feat: added Timer screen
This commit is contained in:
73
client/src/composables/useTimer.ts
Normal file
73
client/src/composables/useTimer.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { ref, computed } from 'vue';
|
||||
import { CountdownOptions, CountdownReturn } from '@/types/timerType';
|
||||
|
||||
/** Composable для управления обратным отсчётом времени */
|
||||
export function useCountdown({ seconds, onFinish }: CountdownOptions): CountdownReturn {
|
||||
const totalSeconds = seconds * 60;
|
||||
|
||||
/** Реактивное оставшееся время в секундах */
|
||||
const timeLeft = ref<number>(totalSeconds);
|
||||
|
||||
/** Флаг активного состояния таймера */
|
||||
const isRunning = ref<boolean>(false);
|
||||
|
||||
let interval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
/** Форматирует оставшееся время в строку ЧЧ:ММ:СС */
|
||||
const formatted = computed<string>(() => {
|
||||
const hours = Math.floor(timeLeft.value / 3600);
|
||||
const mins = Math.floor((timeLeft.value % 3600) / 60);
|
||||
const secs = timeLeft.value % 60;
|
||||
return `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
|
||||
});
|
||||
|
||||
/** Вычисляет процент прошедшего времени от общего */
|
||||
const progress = computed<number>(() => {
|
||||
return ((totalSeconds - timeLeft.value) / totalSeconds) * 100;
|
||||
});
|
||||
|
||||
/** Запускает таймер */
|
||||
const start = (): void => {
|
||||
if (isRunning.value) return
|
||||
isRunning.value = true;
|
||||
|
||||
interval = setInterval(() => {
|
||||
if (timeLeft.value > 0) {
|
||||
timeLeft.value--;
|
||||
} else {
|
||||
stop();
|
||||
onFinish?.();
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
/** Останавливает таймер и сбрасывает время до начального */
|
||||
const stop = (): void => {
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
interval = null;
|
||||
}
|
||||
isRunning.value = false;
|
||||
timeLeft.value = totalSeconds;
|
||||
}
|
||||
|
||||
/** Приостанавливает таймер без сброса оставшегося времени */
|
||||
const pause = (): void => {
|
||||
if (!isRunning.value) return
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
interval = null;
|
||||
}
|
||||
isRunning.value = false;
|
||||
}
|
||||
|
||||
return {
|
||||
timeLeft,
|
||||
isRunning,
|
||||
formatted,
|
||||
progress,
|
||||
start,
|
||||
stop,
|
||||
pause
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
import type { Ref, ComputedRef } from 'vue';
|
||||
|
||||
/** Строка списка таймеров с основными полями */
|
||||
export type TimersRow = {
|
||||
id: number;
|
||||
title: string;
|
||||
@@ -5,4 +8,32 @@ export type TimersRow = {
|
||||
count_pomodoro: number;
|
||||
};
|
||||
|
||||
/** Тип для хранения результата COUNT-запроса */
|
||||
export type CountRow = { cnt: number };
|
||||
|
||||
/** Полная строка таймера со всеми временными параметрами */
|
||||
export type TimerRow = {
|
||||
id: number;
|
||||
title: string;
|
||||
pomodoro_time: number;
|
||||
break_time: number;
|
||||
break_long_time: number;
|
||||
count_pomodoro: number;
|
||||
}
|
||||
|
||||
/** Параметры инициализации обратного отсчёта */
|
||||
export type CountdownOptions = {
|
||||
seconds: number;
|
||||
onFinish?: () => void;
|
||||
}
|
||||
|
||||
/** Возвращаемые значения и методы composable обратного отсчёта */
|
||||
export type CountdownReturn = {
|
||||
timeLeft: Ref<number>;
|
||||
isRunning: Ref<boolean>;
|
||||
formatted: ComputedRef<string>; // ЧЧ:ММ:СС
|
||||
progress: Ref<number>;
|
||||
start: () => void;
|
||||
stop: () => void;
|
||||
pause: () => void;
|
||||
}
|
||||
|
||||
173
client/src/views/Timer/Timer.css
Normal file
173
client/src/views/Timer/Timer.css
Normal file
@@ -0,0 +1,173 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.timer-page-container {
|
||||
background-color: #f5f5f5;
|
||||
min-height: 100vh;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
||||
'Helvetica Neue', Arial, sans-serif;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.timer-screen {
|
||||
padding: 24px 16px;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.timer-display-card {
|
||||
background-color: #ffffff;
|
||||
border-radius: 16px;
|
||||
padding: 28px 24px 24px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.timer-card-labels {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.label-left,
|
||||
.label-right {
|
||||
font-size: 13px;
|
||||
color: #9e9e9e;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.timer-time {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.time-text {
|
||||
font-size: 56px;
|
||||
font-weight: 400;
|
||||
color: #212121;
|
||||
letter-spacing: 2px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.timer-progress-bar {
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background-color: #e0e0e0;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.timer-progress-fill {
|
||||
height: 100%;
|
||||
background-color: #1976d2;
|
||||
border-radius: 2px;
|
||||
transition: width 1s linear;
|
||||
}
|
||||
|
||||
.timer-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.btn-timer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
width: 120px;
|
||||
height: 44px;
|
||||
flex-shrink: 0; /* запрет на сжатие */
|
||||
background-color: transparent;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
border-radius: 50px;
|
||||
cursor: pointer;
|
||||
letter-spacing: 0.5px;
|
||||
border: 2px solid;
|
||||
}
|
||||
|
||||
.btn-timer ion-icon {
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.btn-timer-start {
|
||||
border-color: #4caf50;
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
.btn-timer-start:hover {
|
||||
background-color: rgba(76, 175, 80, 0.10);
|
||||
}
|
||||
|
||||
.btn-timer-start:active {
|
||||
background-color: rgba(76, 175, 80, 0.22);
|
||||
}
|
||||
|
||||
.btn-timer-stop {
|
||||
border-color: #f44336;
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
.btn-timer-stop:hover {
|
||||
background-color: rgba(244, 67, 54, 0.10);
|
||||
}
|
||||
|
||||
.btn-timer-stop:active {
|
||||
background-color: rgba(244, 67, 54, 0.22);
|
||||
}
|
||||
|
||||
.btn-timer-pause {
|
||||
border-color: #ffc107;
|
||||
color: #f9a825;
|
||||
}
|
||||
|
||||
.btn-timer-pause:hover {
|
||||
background-color: rgba(255, 193, 7, 0.10);
|
||||
}
|
||||
|
||||
.btn-timer-pause:active {
|
||||
background-color: rgba(255, 193, 7, 0.22);
|
||||
}
|
||||
|
||||
.btn-timer {
|
||||
transition: all 0.2s ease, opacity 0.25s ease, transform 0.25s ease;
|
||||
}
|
||||
|
||||
.btn-timer.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.timer-display-card {
|
||||
padding: 20px 16px;
|
||||
border-radius: 12px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.time-text {
|
||||
font-size: 44px;
|
||||
}
|
||||
|
||||
.btn-timer {
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
70
client/src/views/Timer/Timer.html
Normal file
70
client/src/views/Timer/Timer.html
Normal file
@@ -0,0 +1,70 @@
|
||||
<ion-page>
|
||||
<ion-content>
|
||||
<div class="timer-page-container">
|
||||
<div class="timer-screen">
|
||||
|
||||
<!-- Timer Card -->
|
||||
<div class="timer-display-card">
|
||||
|
||||
<!-- Card top labels -->
|
||||
<div class="timer-card-labels">
|
||||
<span v-if="timer" class="label-left">{{ timer.title }}</span>
|
||||
<span class="label-right">Помидор</span>
|
||||
</div>
|
||||
|
||||
<!-- Time display -->
|
||||
<div class="timer-time">
|
||||
<span class="time-text">{{ countdown?.formatted }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Progress bar -->
|
||||
<div class="timer-progress-bar">
|
||||
<div class="timer-progress-fill" :style="{ width: `${countdown?.progress.value}%` }"></div>
|
||||
</div>
|
||||
|
||||
<!-- Buttons -->
|
||||
<div class="timer-controls">
|
||||
|
||||
<!-- Видна только при running и paused -->
|
||||
<button
|
||||
class="btn-timer
|
||||
btn-timer-stop"
|
||||
id="btn-stop"
|
||||
v-show="state === 'running' || state === 'paused'"
|
||||
@click="() => { setState('idle'); countdown?.stop(); }"
|
||||
>
|
||||
<ion-icon :icon="stopOutline"></ion-icon>
|
||||
Стоп
|
||||
</button>
|
||||
|
||||
<!-- Видна при idle и paused -->
|
||||
<button
|
||||
class="btn-timer
|
||||
btn-timer-start"
|
||||
id="btn-start"
|
||||
v-show="state === 'idle' || state === 'paused'"
|
||||
@click="() => { setState('running'); countdown?.start(); }"
|
||||
>
|
||||
<ion-icon :icon="playOutline"></ion-icon>
|
||||
Старт
|
||||
</button>
|
||||
|
||||
<!-- Видна только при running -->
|
||||
<button
|
||||
class="btn-timer
|
||||
btn-timer-pause"
|
||||
id="btn-pause"
|
||||
v-show="state === 'running'"
|
||||
@click="() => { setState('paused'); countdown?.pause(); }"
|
||||
>
|
||||
<ion-icon :icon="pauseOutline"></ion-icon>
|
||||
Пауза
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</ion-content>
|
||||
</ion-page>
|
||||
82
client/src/views/Timer/Timer.ts
Normal file
82
client/src/views/Timer/Timer.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { defineComponent, onMounted, onUnmounted, ref, shallowRef } from 'vue';
|
||||
import { IonIcon, IonPage, IonContent } from '@ionic/vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
import {
|
||||
stopOutline,
|
||||
playOutline,
|
||||
pauseOutline
|
||||
} from 'ionicons/icons';
|
||||
|
||||
import { getTimer } from '@/db/crud/timerCrud';
|
||||
import type { TimerRow } from '@/types/timerType';
|
||||
import { useCountdown } from '@/composables/useTimer';
|
||||
import { CountdownReturn } from '@/types/timerType';
|
||||
|
||||
/** Возможные состояния таймера */
|
||||
type TimerState = 'idle' | 'running' | 'paused';
|
||||
|
||||
/** Компонент страницы таймера с управлением обратным отсчётом */
|
||||
export default defineComponent({
|
||||
name: 'Timer',
|
||||
components: { IonIcon, IonPage, IonContent },
|
||||
|
||||
/** Инициализирует данные и логику компонента таймера */
|
||||
setup() {
|
||||
const route = useRoute();
|
||||
const id = Number(route.params.id);
|
||||
|
||||
/** Реактивные данные загруженного таймера */
|
||||
const timer = ref<TimerRow>();
|
||||
|
||||
/** Сообщение об ошибке при загрузке */
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
/** Текущее состояние кнопок управления */
|
||||
const state = ref<TimerState>('idle');
|
||||
|
||||
/** Обновляет текущее состояние таймера */
|
||||
const setState = (newState: TimerState) => {
|
||||
state.value = newState;
|
||||
};
|
||||
|
||||
/** Shallow-ссылка на экземпляр composable обратного отсчёта */
|
||||
const countdown = shallowRef<CountdownReturn | null>(null);
|
||||
|
||||
/** Останавливает таймер при размонтировании компонента */
|
||||
onUnmounted(() => {
|
||||
countdown.value?.stop();
|
||||
});
|
||||
|
||||
/** Загружает данные таймера из БД и инициализирует отсчёт */
|
||||
const loadTimer = async (): Promise<void> => {
|
||||
try {
|
||||
const result = await getTimer(id);
|
||||
const timerData = Array.isArray(result) ? result[0] : result;
|
||||
timer.value = timerData;
|
||||
|
||||
countdown.value = useCountdown({
|
||||
seconds: timerData.pomodoro_time ?? 0,
|
||||
onFinish: () => alert('Время вышло!')
|
||||
});
|
||||
|
||||
} catch (e) {
|
||||
error.value = 'Ошибка загрузки таймера';
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(loadTimer);
|
||||
|
||||
return {
|
||||
timer,
|
||||
error,
|
||||
state,
|
||||
setState,
|
||||
stopOutline,
|
||||
playOutline,
|
||||
pauseOutline,
|
||||
countdown
|
||||
};
|
||||
}
|
||||
});
|
||||
4
client/src/views/Timer/Timer.vue
Normal file
4
client/src/views/Timer/Timer.vue
Normal file
@@ -0,0 +1,4 @@
|
||||
<!-- Timer.vue -->
|
||||
<template src="@/views/Timer/Timer.html"></template>
|
||||
<script lang="ts" src="@/views/Timer/Timer.ts"></script>
|
||||
<style src="@/views/Timer/Timer.css" scoped></style>
|
||||
Reference in New Issue
Block a user