diff --git a/client/src/composables/useTimer.ts b/client/src/composables/useTimer.ts new file mode 100644 index 0000000..f373c03 --- /dev/null +++ b/client/src/composables/useTimer.ts @@ -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(totalSeconds); + + /** Флаг активного состояния таймера */ + const isRunning = ref(false); + + let interval: ReturnType | null = null; + + /** Форматирует оставшееся время в строку ЧЧ:ММ:СС */ + const formatted = computed(() => { + 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(() => { + 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 + }; +} diff --git a/client/src/types/timerType.ts b/client/src/types/timerType.ts index 3ceedca..9b8a5f9 100644 --- a/client/src/types/timerType.ts +++ b/client/src/types/timerType.ts @@ -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; + isRunning: Ref; + formatted: ComputedRef; // ЧЧ:ММ:СС + progress: Ref; + start: () => void; + stop: () => void; + pause: () => void; +} diff --git a/client/src/views/Timer/Timer.css b/client/src/views/Timer/Timer.css new file mode 100644 index 0000000..b859379 --- /dev/null +++ b/client/src/views/Timer/Timer.css @@ -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; + } +} diff --git a/client/src/views/Timer/Timer.html b/client/src/views/Timer/Timer.html new file mode 100644 index 0000000..d9719cb --- /dev/null +++ b/client/src/views/Timer/Timer.html @@ -0,0 +1,70 @@ + + +
+
+ + +
+ + +
+ {{ timer.title }} + Помидор +
+ + +
+ {{ countdown?.formatted }} +
+ + +
+
+
+ + +
+ + + + + + + + + + +
+
+ +
+
+
+
diff --git a/client/src/views/Timer/Timer.ts b/client/src/views/Timer/Timer.ts new file mode 100644 index 0000000..a46a94c --- /dev/null +++ b/client/src/views/Timer/Timer.ts @@ -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(); + + /** Сообщение об ошибке при загрузке */ + const error = ref(null); + + /** Текущее состояние кнопок управления */ + const state = ref('idle'); + + /** Обновляет текущее состояние таймера */ + const setState = (newState: TimerState) => { + state.value = newState; + }; + + /** Shallow-ссылка на экземпляр composable обратного отсчёта */ + const countdown = shallowRef(null); + + /** Останавливает таймер при размонтировании компонента */ + onUnmounted(() => { + countdown.value?.stop(); + }); + + /** Загружает данные таймера из БД и инициализирует отсчёт */ + const loadTimer = async (): Promise => { + 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 + }; + } +}); diff --git a/client/src/views/Timer/Timer.vue b/client/src/views/Timer/Timer.vue new file mode 100644 index 0000000..27ce468 --- /dev/null +++ b/client/src/views/Timer/Timer.vue @@ -0,0 +1,4 @@ + + + +