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 = {
|
export type TimersRow = {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -5,4 +8,32 @@ export type TimersRow = {
|
|||||||
count_pomodoro: number;
|
count_pomodoro: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Тип для хранения результата COUNT-запроса */
|
||||||
export type CountRow = { cnt: number };
|
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