feat: added Timer screen

This commit is contained in:
Arduinum
2026-04-23 22:17:15 +03:00
committed by Arduinum628
parent a431f70c25
commit 46b8b181da
6 changed files with 433 additions and 0 deletions

View 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
};
}

View File

@@ -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;
}

View 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;
}
}

View 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>

View 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
};
}
});

View 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>