11 Commits

Author SHA1 Message Date
Eugene
4ec6032197 Update README.en.md 2026-06-22 18:19:31 +03:00
Eugene
e5f490f8b8 Update README.md 2026-06-22 18:18:16 +03:00
Eugene
8b91776a21 Merge pull request #1 from Arduinum/mvp1
Mvp1
2026-06-05 16:43:12 +03:00
Arduinum
80d94e2fa2 docs: new vers in docs 2026-06-05 15:07:53 +03:00
Arduinum
b72f154016 chore: update client dependencies lockfile 2026-06-05 15:07:47 +03:00
Arduinum
f73be8c219 feat: implement timer screens and navigation 2026-06-05 15:06:21 +03:00
Arduinum
632bf8202f feat: add timer CRUD and DML helpers 2026-06-05 15:04:38 +03:00
Arduinum
939be73abf docs: new vers in docs 2026-06-05 15:00:18 +03:00
Arduinum
067f8e066c chore: update client dependencies lockfile 2026-06-05 15:00:11 +03:00
Arduinum
51ebdc8213 feat: implement timer screens and navigation 2026-06-05 14:58:22 +03:00
Arduinum
15fd9ae275 feat: add timer CRUD and DML helpers 2026-06-05 14:38:51 +03:00
18 changed files with 1339 additions and 339 deletions

View File

@@ -19,7 +19,7 @@ The project is currently at the MVP1 development stage:
- **Name and Surname** — Eugene Kaddo - **Name and Surname** — Eugene Kaddo
- **Nickname** — Arduinum628 - **Nickname** — Arduinum628
I share detailed insights about the app's development process in my articles on [Kawai-Focus](https://pressanybutton.ru/category/kawai-focus/) at the website [Kod on napkin](https://pressanybutton.ru). I talk in detail about the process of writing application code in my <a href="https://habr.com/ru/users/Arduinum/articles/">articles on Habr</a>.
<details> <details>
<summary><strong>Branch and Commit Naming Conventions</strong></summary> <summary><strong>Branch and Commit Naming Conventions</strong></summary>

View File

@@ -20,7 +20,7 @@
- **Имя и Фамилия** - Евгений Каддо - **Имя и Фамилия** - Евгений Каддо
- **Никнейм** - Arduinum628 - **Никнейм** - Arduinum628
Подробно рассказываю о процессе написания кода приложения в своих статьях <a href="https://pressanybutton.ru/category/kawai-focus/">Kawai-Focus</a> на сайте <a href="https://pressanybutton.ru">Код на салфетке</a>. Подробно рассказываю о процессе написания кода приложения в своих <a href="https://habr.com/ru/users/Arduinum/articles/">статьях на Habr</a>.
<details> <details>
<summary> <summary>

653
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
import { QueryResult } from "@tauri-apps/plugin-sql";
import { getDb } from "@/db/initDb"; import { getDb } from "@/db/initDb";
import { SELECT_TIMERS, SELECT_TIMER } from "@/db/dml/timerDML"; import { SELECT_TIMERS, SELECT_TIMER, INSERT_TIMER, UPDATE_TIMER, DELETE_TIMER } from "@/db/dml/timerDML";
import { TimersRow, TimerRow } from "@/types/timerType"; import { TimersRow, TimerRow } from "@/types/timerType";
@@ -9,8 +10,58 @@ export async function getTimers(): Promise<TimersRow[]> {
return await db.select<TimersRow[]>(SELECT_TIMERS); return await db.select<TimersRow[]>(SELECT_TIMERS);
} }
/* Получает таймер по его id **/ /** Получает таймер по его id */
export async function getTimer(TimerId: number): Promise<TimerRow> { export async function getTimer(TimerId: number): Promise<TimerRow> {
const db = await getDb(); const db = await getDb();
return await db.select<TimerRow>(SELECT_TIMER, [TimerId]); const rows = await db.select<TimerRow[]>(SELECT_TIMER, [TimerId]);
if (!Array.isArray(rows) || rows.length === 0) {
throw new Error('Таймер не найден');
}
return rows[0];
}
/** Создаёт новый таймер */
export async function createTimer(
title: string,
pomodoroTime: number,
breakTime: number,
breakLongTime: number,
countPomodoro: number
): Promise<QueryResult> {
const db = await getDb();
return await db.execute(INSERT_TIMER, [
title,
pomodoroTime,
breakTime,
breakLongTime,
countPomodoro
]);
}
/** Обновляет таймер */
export async function updateTimer(
TimerId: number,
title: string,
pomodoroTime: number,
breakTime: number,
breakLongTime: number,
countPomodoro: number
): Promise<QueryResult> {
const db = await getDb();
return await db.execute(UPDATE_TIMER, [
title,
pomodoroTime,
breakTime,
breakLongTime,
countPomodoro,
TimerId
]);
}
/** Удаляет таймер */
export async function deleteTimer(TimerId: number): Promise<QueryResult> {
const db = await getDb();
return await db.execute(DELETE_TIMER, [TimerId]);
} }

View File

@@ -5,3 +5,6 @@ export const INSERT_SEED_DB = `
('Timer mini example', 10, 3, 15, 2), ('Timer max example', 90, 10, 40, 8) ('Timer mini example', 10, 3, 15, 2), ('Timer max example', 90, 10, 40, 8)
` `
export const SELECT_TIMER = 'SELECT * FROM timer WHERE id = ?' export const SELECT_TIMER = 'SELECT * FROM timer WHERE id = ?'
export const INSERT_TIMER = 'INSERT INTO timer (title, pomodoro_time, break_time, break_long_time, count_pomodoro) VALUES (?, ?, ?, ?, ?)'
export const UPDATE_TIMER = 'UPDATE timer SET title = ?, pomodoro_time = ?, break_time = ?, break_long_time = ?, count_pomodoro = ? WHERE id = ?'
export const DELETE_TIMER = 'DELETE FROM timer WHERE id = ?'

View File

@@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from '@ionic/vue-router';
import type { RouteRecordRaw } from 'vue-router'; import type { RouteRecordRaw } from 'vue-router';
import TimersList from '@/views/TimersList/TimersList.vue'; import TimersList from '@/views/TimersList/TimersList.vue';
import Timer from '@/views/Timer/Timer.vue'; import Timer from '@/views/Timer/Timer.vue';
import TimerUpdate from '@/views/TimerUpdate/TimerUpdate.vue';
const routes: RouteRecordRaw[] = [ const routes: RouteRecordRaw[] = [
{ {
@@ -17,6 +18,11 @@ const routes: RouteRecordRaw[] = [
path: '/timer/:id', path: '/timer/:id',
name: 'Timer', name: 'Timer',
component: Timer component: Timer
},
{
path: '/timer-update/:id',
name: 'TimerUpdate',
component: TimerUpdate
} }
]; ];

View File

@@ -10,8 +10,29 @@
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
'Helvetica Neue', Arial, sans-serif; 'Helvetica Neue', Arial, sans-serif;
display: flex; display: flex;
align-items: center; flex-direction: column;
justify-content: center; }
.page-header {
background-color: #ffffff;
padding: 16px;
border-bottom: 1px solid #e0e0e0;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.04);
width: 100%;
min-height: 72px;
position: relative;
}
.page-header h1 {
font-size: 32px;
font-weight: 400;
color: #212121;
letter-spacing: -0.5px;
margin: 0;
position: absolute;
left: 16px;
top: 50%;
transform: translateY(-50%);
} }
.timer-screen { .timer-screen {
@@ -21,6 +42,47 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
margin: 0 auto;
flex: 1;
justify-content: center;
}
.confirm-overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.35);
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
z-index: 1000;
}
.confirm-card {
width: 100%;
max-width: 380px;
background: #ffffff;
border-radius: 16px;
padding: 18px;
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.18);
}
.confirm-card h3 {
font-size: 18px;
color: #212121;
margin-bottom: 8px;
}
.confirm-card p {
font-size: 14px;
color: #666;
margin-bottom: 16px;
}
.confirm-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
} }
.timer-display-card { .timer-display-card {
@@ -87,6 +149,31 @@
height: 48px; height: 48px;
} }
.btn-back {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 40px;
min-width: 96px;
padding: 0 14px;
position: absolute;
right: 16px;
top: 50%;
transform: translateY(-50%);
border-radius: 999px;
border: 2px solid #f44336;
background-color: transparent;
color: #f44336;
font-size: 13px;
font-weight: 500;
cursor: pointer;
}
.btn-back:hover {
background-color: rgba(244, 67, 54, 0.10);
}
.btn-timer { .btn-timer {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -1,11 +1,34 @@
<ion-page> <ion-page>
<ion-content> <ion-content>
<div class="timer-page-container"> <div class="timer-page-container">
<div class="page-header">
<button
v-if="showBackButton"
class="btn-back"
type="button"
@click="askLeaveTimer"
>
<ion-icon :icon="arrowBackOutline"></ion-icon>
Назад
</button>
<h1>Таймер</h1>
</div>
<div v-if="confirmLeave" class="confirm-overlay" @click.self="closeLeaveModal">
<div class="confirm-card">
<h3>Выйти из таймера?</h3>
<p>Текущий прогресс будет сброшен, если вы уйдёте из этого экрана.</p>
<div class="confirm-actions">
<button class="btn-timer btn-timer-pause" type="button" @click="closeLeaveModal">Нет</button>
<button class="btn-timer btn-timer-stop" type="button" @click="confirmLeaveTimer">Да</button>
</div>
</div>
</div>
<div class="timer-screen"> <div class="timer-screen">
<!-- Timer Card --> <!-- Timer Card -->
<div class="timer-display-card"> <div class="timer-display-card">
<!-- Card top labels --> <!-- Card top labels -->
<div class="timer-card-labels"> <div class="timer-card-labels">
<span class="label-left">{{ countdown?.timerNow.value?.title }}</span> <span class="label-left">{{ countdown?.timerNow.value?.title }}</span>

View File

@@ -1,11 +1,12 @@
import { defineComponent, onMounted, onUnmounted, ref, shallowRef } from 'vue'; import { computed, defineComponent, onMounted, onUnmounted, ref, shallowRef } from 'vue';
import { IonIcon, IonPage, IonContent } from '@ionic/vue'; import { IonIcon, IonPage, IonContent } from '@ionic/vue';
import { useRoute } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { import {
stopOutline, stopOutline,
playOutline, playOutline,
pauseOutline pauseOutline,
arrowBackOutline
} from 'ionicons/icons'; } from 'ionicons/icons';
import type { TimerRow } from '@/types/timerType'; import type { TimerRow } from '@/types/timerType';
@@ -27,6 +28,7 @@ export default defineComponent({
/** Инициализирует данные и логику компонента таймера */ /** Инициализирует данные и логику компонента таймера */
setup() { setup() {
const route = useRoute(); const route = useRoute();
const router = useRouter();
const id = Number(route.params.id); const id = Number(route.params.id);
/** Реактивные данные загруженного таймера */ /** Реактивные данные загруженного таймера */
@@ -37,6 +39,7 @@ export default defineComponent({
/** Текущее состояние кнопок управления */ /** Текущее состояние кнопок управления */
const state = ref<TimerState>('idle'); const state = ref<TimerState>('idle');
const confirmLeave = ref(false);
/** Обновляет текущее состояние таймера */ /** Обновляет текущее состояние таймера */
const setState = (newState: TimerState) => { const setState = (newState: TimerState) => {
@@ -46,6 +49,39 @@ export default defineComponent({
/** Shallow-ссылка на экземпляр composable обратного отсчёта */ /** Shallow-ссылка на экземпляр composable обратного отсчёта */
const countdown = shallowRef<CountdownReturn | null>(null); const countdown = shallowRef<CountdownReturn | null>(null);
/** Определяет, нужно ли показывать кнопку "Назад". */
const showBackButton = computed(() => {
return (
state.value === 'paused' ||
(state.value === 'idle' &&
countdown.value?.isRunning.value === false &&
countdown.value?.timerNow.value !== undefined)
);
});
/** Обрабатывает попытку выхода из таймера. */
const askLeaveTimer = (): void => {
if (state.value === 'paused') {
confirmLeave.value = true;
return;
}
router.back();
};
/** Закрывает модальное окно подтверждения выхода. */
const closeLeaveModal = (): void => {
confirmLeave.value = false;
};
/** Подтверждает выход из таймера. */
const confirmLeaveTimer = (): void => {
confirmLeave.value = false;
countdown.value?.stop();
state.value = 'idle';
router.back();
};
/** Чтение конфига */ /** Чтение конфига */
const config = loadConfig() const config = loadConfig()
@@ -78,9 +114,15 @@ export default defineComponent({
error, error,
state, state,
setState, setState,
showBackButton,
confirmLeave,
askLeaveTimer,
closeLeaveModal,
confirmLeaveTimer,
stopOutline, stopOutline,
playOutline, playOutline,
pauseOutline, pauseOutline,
arrowBackOutline,
countdown countdown
}; };
} }

View File

@@ -0,0 +1,269 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
.timer-update-container {
background-color: #f5f5f5;
min-height: 100vh;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
.page-header {
background-color: #ffffff;
padding: 16px;
border-bottom: 1px solid #e0e0e0;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.04);
}
.back-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
width: auto;
min-height: 44px;
padding: 0 16px;
border-radius: 999px;
border: 2px solid #f44336;
background-color: transparent;
color: #f44336;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
margin-bottom: 8px;
}
.back-button:hover {
background-color: rgba(244, 67, 54, 0.10);
}
.page-header h1 {
font-size: 28px;
font-weight: 400;
color: #212121;
letter-spacing: -0.5px;
}
.page-header p {
font-size: 13px;
color: #757575;
margin-top: 4px;
}
.form-shell {
max-width: 860px;
margin: 0 auto;
padding: 16px;
}
.state-card {
background-color: #ffffff;
border-radius: 12px;
padding: 24px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
color: #212121;
}
.state-card.error {
color: #c62828;
}
.timer-form {
display: flex;
flex-direction: column;
gap: 12px;
}
.field-card {
background-color: #ffffff;
border-radius: 12px;
padding: 14px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.field-head {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 8px;
margin-bottom: 10px;
}
.field-label {
font-size: 14px;
font-weight: 600;
color: #212121;
}
.field-caption {
font-size: 12px;
color: #757575;
}
.field-card input[type="text"] {
width: 100%;
border: 1px solid #e0e0e0;
border-radius: 10px;
padding: 12px 14px;
font-size: 15px;
color: #212121;
background-color: #fff;
}
.field-error {
margin-top: 6px;
color: #d32f2f;
font-size: 12px;
line-height: 1.4;
}
.title-input--error {
border-color: #d32f2f !important;
box-shadow: 0 0 0 2px rgba(211, 47, 47, 0.12) !important;
}
.field-card input[type="text"]:focus,
.title-input:focus,
.title-input:focus-visible {
outline: none;
border-color: #f44336;
box-shadow: 0 0 0 2px rgba(244, 67, 54, 0.12);
}
.title-input:hover {
border-color: #f44336;
}
.tomato-card {
display: flex;
flex-direction: column;
gap: 8px;
}
.tomato-stage {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.tomato-display {
width: 92px;
height: 92px;
border-radius: 50%;
display: grid;
place-items: center;
text-align: center;
background: radial-gradient(circle at 30% 30%, #ff6b6b 0%, #f44336 65%, #d32f2f 100%);
color: #ffffff;
box-shadow: 0 8px 18px rgba(244, 67, 54, 0.22);
}
.tomato-display strong {
font-size: 24px;
line-height: 1;
}
.tomato-emoji {
font-size: 18px;
line-height: 1;
}
.slider-card .slider-wrap {
display: flex;
align-items: center;
gap: 12px;
}
.value-pill {
width: 78px;
height: 78px;
border-radius: 50%;
display: grid;
place-items: center;
text-align: center;
background: linear-gradient(145deg, #f44336, #ff7043);
color: #ffffff;
font-size: 18px;
font-weight: 700;
box-shadow: 0 6px 14px rgba(244, 67, 54, 0.25);
flex-shrink: 0;
border: 1px solid rgba(255, 255, 255, 0.35);
}
.value-pill small {
display: block;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.08em;
opacity: 0.9;
}
.slider-card input[type="range"] {
flex: 1;
accent-color: #f44336;
}
.slider-card input[type="range"]::-webkit-slider-thumb {
background: #f44336;
}
.slider-card input[type="range"]::-moz-range-thumb {
background: #f44336;
}
.range-note {
margin-top: 8px;
color: #757575;
font-size: 12px;
}
.save-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
min-height: 44px;
border: 2px solid #4caf50;
border-radius: 999px;
background-color: transparent;
color: #4caf50;
padding: 0 16px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.save-button:hover {
background-color: rgba(76, 175, 80, 0.10);
}
@media (max-width: 600px) {
.page-header {
padding: 12px;
}
.page-header h1 {
font-size: 24px;
}
.form-shell {
padding: 12px;
}
.slider-card .slider-wrap {
flex-direction: column;
align-items: stretch;
}
.value-pill {
width: 68px;
height: 68px;
margin: 0 auto;
}
}

View File

@@ -0,0 +1,91 @@
<ion-page>
<ion-content>
<div class="timer-update-container">
<div class="page-header">
<button class="back-button" type="button" @click="goBack">
<ion-icon :icon="arrowBackOutline"></ion-icon>
Назад
</button>
<h1>{{ modeLabel }}</h1>
<p>Подстрой параметры под нужный режим работы.</p>
</div>
<div class="form-shell">
<div v-if="loading" class="state-card">Загрузка таймера...</div>
<div v-else-if="error" class="state-card error">{{ error }}</div>
<form v-else class="timer-form" @submit.prevent="saveTimer">
<label class="field-card">
<span class="field-label">Название</span>
<input
class="title-input"
:class="{ 'title-input--error': titleError }"
v-model="form.title"
type="text"
maxlength="100"
placeholder="Например, Утренний фокус"
/>
<p v-if="titleError" class="field-error">{{ titleError }}</p>
</label>
<section class="field-card tomato-card">
<div class="field-head">
<span class="field-label">Количество помидоров</span>
</div>
<div class="tomato-stage">
<div class="tomato-display">
<span class="tomato-emoji">🍅</span>
<strong>{{ form.count_pomodoro }}</strong>
</div>
<input
v-model.number="form.count_pomodoro"
type="range"
:min="LIMITS.countPomodoro.min"
:max="LIMITS.countPomodoro.max"
@input="onSliderInput('count_pomodoro', LIMITS.countPomodoro.min, LIMITS.countPomodoro.max, $event)"
/>
</div>
</section>
<section class="field-card slider-card">
<div class="field-head">
<span class="field-label">Длительность помидора</span>
</div>
<div class="slider-wrap">
<div class="value-pill">{{ form.pomodoro_time }}<small>мин</small></div>
<input v-model.number="form.pomodoro_time" type="range" :min="LIMITS.pomodoroTime.min" :max="LIMITS.pomodoroTime.max" @input="onSliderInput('pomodoro_time', LIMITS.pomodoroTime.min, LIMITS.pomodoroTime.max, $event)" />
</div>
<p class="range-note">Пределы: {{ LIMITS.pomodoroTime.min }} — {{ LIMITS.pomodoroTime.max }} минут</p>
</section>
<section class="field-card slider-card">
<div class="field-head">
<span class="field-label">Короткий перерыв</span>
</div>
<div class="slider-wrap">
<div class="value-pill">{{ form.break_time }}<small>мин</small></div>
<input v-model.number="form.break_time" type="range" :min="LIMITS.breakTime.min" :max="LIMITS.breakTime.max" @input="onSliderInput('break_time', LIMITS.breakTime.min, LIMITS.breakTime.max, $event)" />
</div>
<p class="range-note">Пределы: {{ LIMITS.breakTime.min }} — {{ LIMITS.breakTime.max }} минут</p>
</section>
<section class="field-card slider-card">
<div class="field-head">
<span class="field-label">Длинный перерыв</span>
</div>
<div class="slider-wrap">
<div class="value-pill">{{ form.break_long_time }}<small>мин</small></div>
<input v-model.number="form.break_long_time" type="range" :min="LIMITS.breakLongTime.min" :max="LIMITS.breakLongTime.max" @input="onSliderInput('break_long_time', LIMITS.breakLongTime.min, LIMITS.breakLongTime.max, $event)" />
</div>
<p class="range-note">Пределы: {{ LIMITS.breakLongTime.min }} — {{ LIMITS.breakLongTime.max }} минут</p>
</section>
<button class="save-button" type="submit">
<ion-icon :icon="checkmarkOutline"></ion-icon>
Сохранить и открыть
</button>
</form>
</div>
</div>
</ion-content>
</ion-page>

View File

@@ -0,0 +1,226 @@
import { computed, defineComponent, onMounted, ref, watch } from 'vue';
import { IonIcon, IonPage, IonContent } from '@ionic/vue';
import { useRoute, useRouter } from 'vue-router';
import { arrowBackOutline, checkmarkOutline } from 'ionicons/icons';
import { createTimer, getTimer, updateTimer } from '@/db/crud/timerCrud';
import type { TimerRow } from '@/types/timerType';
const LIMITS = {
pomodoroTime: { min: 10, max: 90 },
breakTime: { min: 3, max: 10 },
breakLongTime: { min: 15, max: 40 },
countPomodoro: { min: 2, max: 8 },
} as const;
export default defineComponent({
name: 'TimerUpdate',
components: { IonIcon, IonPage, IonContent },
setup() {
const route = useRoute();
const router = useRouter();
const isCreateMode = computed(() => route.params.id === 'new');
const currentId = computed(() => (isCreateMode.value ? 0 : Number(route.params.id)));
const loading = ref(false);
const error = ref<string | null>(null);
const titleError = ref('');
const modeLabel = computed(() => (isCreateMode.value ? 'Создать таймер' : 'Редактировать таймер'));
const form = ref<TimerRow>({
id: currentId.value,
title: '',
pomodoro_time: LIMITS.pomodoroTime.min,
break_time: LIMITS.breakTime.min,
break_long_time: LIMITS.breakLongTime.min,
count_pomodoro: LIMITS.countPomodoro.min,
});
/**
* Безопасно преобразует значение в число.
* Если преобразование невозможно, возвращает значение по умолчанию.
*/
const safeNumber = (value: unknown, fallback: number): number => {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
};
/**
* Ограничивает число диапазоном min-max.
*/
const clamp = (value: number, min: number, max: number): number => {
return Math.min(max, Math.max(min, value));
};
/**
* Нормализует объект таймера и приводит числовые поля
* к допустимым диапазонам значений.
*/
const normalizeTimer = (timer?: Partial<TimerRow>): TimerRow => {
return {
id: currentId.value,
title: timer?.title ?? '',
pomodoro_time: clamp(safeNumber(timer?.pomodoro_time, LIMITS.pomodoroTime.min), LIMITS.pomodoroTime.min, LIMITS.pomodoroTime.max),
break_time: clamp(safeNumber(timer?.break_time, LIMITS.breakTime.min), LIMITS.breakTime.min, LIMITS.breakTime.max),
break_long_time: clamp(safeNumber(timer?.break_long_time, LIMITS.breakLongTime.min), LIMITS.breakLongTime.min, LIMITS.breakLongTime.max),
count_pomodoro: clamp(safeNumber(timer?.count_pomodoro, LIMITS.countPomodoro.min), LIMITS.countPomodoro.min, LIMITS.countPomodoro.max),
};
};
/**
* Ограничивает значение поля формы указанным диапазоном.
*/
const clampField = (field: keyof TimerRow, min: number, max: number): void => {
if (typeof form.value[field] === 'number') {
form.value[field] = clamp(safeNumber(form.value[field], min), min, max) as never;
}
};
/**
* Обрабатывает изменение значения слайдера.
*/
const onSliderInput = (field: keyof TimerRow, min: number, max: number, value: Event): void => {
const nextValue = safeNumber((value.target as HTMLInputElement | null)?.value, min);
if (typeof form.value[field] === 'number') {
form.value[field] = clamp(nextValue, min, max) as never;
}
};
/**
* Загружает таймер из базы данных либо
* создаёт форму со значениями по умолчанию.
*/
const loadTimer = async (): Promise<void> => {
if (isCreateMode.value) {
form.value = normalizeTimer({
id: 0,
title: '',
pomodoro_time: LIMITS.pomodoroTime.min,
break_time: LIMITS.breakTime.min,
break_long_time: LIMITS.breakLongTime.min,
count_pomodoro: LIMITS.countPomodoro.min,
});
return;
}
loading.value = true;
error.value = null;
try {
const timer = await getTimer(currentId.value);
form.value = normalizeTimer(timer);
} catch (e) {
error.value = e instanceof Error ? e.message : 'Ошибка загрузки таймера';
} finally {
loading.value = false;
}
};
/**
* Проверяет корректность названия таймера.
*/
const validateTitle = (): boolean => {
const trimmedTitle = form.value.title.trim();
if (!trimmedTitle) {
titleError.value = 'Введите название таймера';
return false;
}
titleError.value = '';
return true;
};
/**
* Создаёт новый таймер или обновляет существующий.
*/
const saveTimer = async (): Promise<void> => {
if (!validateTitle()) {
return;
}
try {
const trimmedTitle = form.value.title.trim();
form.value = normalizeTimer({
id: isCreateMode.value ? 0 : currentId.value,
title: trimmedTitle,
pomodoro_time: form.value.pomodoro_time,
break_time: form.value.break_time,
break_long_time: form.value.break_long_time,
count_pomodoro: form.value.count_pomodoro,
});
if (isCreateMode.value) {
const result = await createTimer(
form.value.title,
form.value.pomodoro_time,
form.value.break_time,
form.value.break_long_time,
form.value.count_pomodoro,
);
const newId = Number((result as { lastInsertId?: number }).lastInsertId ?? 0);
if (!newId) {
throw new Error('Не удалось получить id созданного таймера');
}
router.push(`/timer/${newId}`);
return;
}
await updateTimer(
currentId.value,
form.value.title,
form.value.pomodoro_time,
form.value.break_time,
form.value.break_long_time,
form.value.count_pomodoro,
);
router.push(`/timer/${currentId.value}`);
} catch (e) {
error.value = e instanceof Error ? e.message : 'Ошибка сохранения таймера';
}
};
/**
* Возвращает пользователя на предыдущую страницу.
*/
const goBack = (): void => {
router.back();
};
onMounted(loadTimer);
watch(() => route.params.id, () => {
loadTimer();
});
watch(() => form.value.title, () => {
if (titleError.value && form.value.title.trim()) {
titleError.value = '';
}
});
return {
form,
loading,
error,
titleError,
isCreateMode,
modeLabel,
LIMITS,
arrowBackOutline,
checkmarkOutline,
clampField,
onSliderInput,
saveTimer,
validateTitle,
goBack,
};
},
});

View File

@@ -0,0 +1,4 @@
<!-- TimerUpdate.vue -->
<template src="@/views/TimerUpdate/TimerUpdate.html"></template>
<script lang="ts" src="@/views/TimerUpdate/TimerUpdate.ts"></script>
<style src="@/views/TimerUpdate/TimerUpdate.css" scoped></style>

View File

@@ -31,6 +31,44 @@
margin: 0 auto; margin: 0 auto;
} }
.confirm-overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.35);
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
z-index: 1000;
}
.confirm-card {
width: 100%;
max-width: 380px;
background: #ffffff;
border-radius: 16px;
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.18);
padding: 18px;
}
.confirm-card h3 {
font-size: 18px;
color: #212121;
margin-bottom: 8px;
}
.confirm-card p {
font-size: 14px;
color: #666;
margin-bottom: 16px;
}
.confirm-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.loading, .loading,
.error-message, .error-message,
.empty-state { .empty-state {
@@ -179,6 +217,27 @@
font-weight: 400; font-weight: 400;
} }
.add-timer-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
min-height: 48px;
margin-bottom: 12px;
border: 2px solid #1976d2;
border-radius: 999px;
background-color: transparent;
color: #1976d2;
font-size: 14px;
font-weight: 600;
cursor: pointer;
}
.add-timer-button:hover {
background-color: rgba(25, 118, 210, 0.10);
}
.card-actions { .card-actions {
display: grid; display: grid;
grid-template-columns: 1fr 1fr 1fr; grid-template-columns: 1fr 1fr 1fr;
@@ -191,8 +250,8 @@
justify-content: center; justify-content: center;
gap: 6px; gap: 6px;
padding: 10px 12px; padding: 10px 12px;
border: none; border: 2px solid transparent;
border-radius: 4px; border-radius: 999px;
font-size: 13px; font-size: 13px;
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
@@ -207,43 +266,46 @@
} }
.btn-primary { .btn-primary {
background-color: #1976d2; background-color: transparent;
color: #ffffff; color: #4caf50;
border: 2px solid #4caf50;
} }
.btn-primary:hover { .btn-primary:hover {
background-color: #1565c0; background-color: rgba(76, 175, 80, 0.10);
box-shadow: 0 4px 8px rgba(25, 118, 210, 0.3); box-shadow: 0 4px 8px rgba(76, 175, 80, 0.18);
} }
.btn-primary:active { .btn-primary:active {
background-color: #0d47a1; background-color: rgba(76, 175, 80, 0.20);
} }
.btn-secondary { .btn-secondary {
background-color: #e0e0e0; background-color: transparent;
color: #212121; color: #f9a825;
border: 2px solid #f9a825;
} }
.btn-secondary:hover { .btn-secondary:hover {
background-color: #d0d0d0; background-color: rgba(249, 168, 37, 0.10);
} }
.btn-secondary:active { .btn-secondary:active {
background-color: #c0c0c0; background-color: rgba(249, 168, 37, 0.20);
} }
.btn-danger { .btn-danger {
background-color: #fff3e0; background-color: transparent;
color: #e65100; color: #f44336;
border: 2px solid #f44336;
} }
.btn-danger:hover { .btn-danger:hover {
background-color: #ffe0b2; background-color: rgba(244, 67, 54, 0.10);
} }
.btn-danger:active { .btn-danger:active {
background-color: #ffcc80; background-color: rgba(244, 67, 54, 0.20);
} }
@media (max-width: 600px) { @media (max-width: 600px) {

View File

@@ -6,6 +6,21 @@
</div> </div>
<div class="timers-list"> <div class="timers-list">
<div v-if="confirmDeleteId !== null" class="confirm-overlay" @click.self="cancelDeleteTimer">
<div class="confirm-card">
<h3>Удалить таймер?</h3>
<p>Это действие нельзя будет отменить.</p>
<div class="confirm-actions">
<button class="btn btn-secondary" type="button" @click="cancelDeleteTimer">Отмена</button>
<button class="btn btn-danger" type="button" @click="removeTimer">Удалить</button>
</div>
</div>
</div>
<button @click="goToCreateTimer" class="add-timer-button" type="button">
<ion-icon :icon="addCircleOutline"></ion-icon>
Создать таймер
</button>
<!-- Loading state --> <!-- Loading state -->
<div v-if="loading" class="loading"> <div v-if="loading" class="loading">
<p>Загрузка таймеров...</p> <p>Загрузка таймеров...</p>
@@ -63,15 +78,15 @@
</div> </div>
<div class="card-actions"> <div class="card-actions">
<button @click="goToTimer(timer.id)" class="btn btn-secondary"> <button @click="goToTimer(timer.id)" class="btn btn-primary">
<ion-icon :icon="playCircleOutline"></ion-icon> <ion-icon :icon="playCircleOutline"></ion-icon>
Открыть Открыть
</button> </button>
<button class="btn btn-secondary"> <button @click="goToEditTimer(timer.id)" class="btn btn-secondary">
<ion-icon :icon="pencilOutline"></ion-icon> <ion-icon :icon="pencilOutline"></ion-icon>
Редактировать Редактировать
</button> </button>
<button class="btn btn-danger"> <button @click="askDeleteTimer(timer.id)" class="btn btn-danger">
<ion-icon :icon="trashOutline"></ion-icon> <ion-icon :icon="trashOutline"></ion-icon>
Удалить Удалить
</button> </button>

View File

@@ -1,6 +1,6 @@
import { defineComponent, onMounted, ref } from 'vue' import { defineComponent, onMounted, ref, watch } from 'vue'
import { IonIcon, IonPage, IonContent } from '@ionic/vue' import { IonIcon, IonPage, IonContent } from '@ionic/vue'
import { useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router'
import { import {
timerOutline, timerOutline,
chevronUp, chevronUp,
@@ -8,26 +8,76 @@ import {
playCircleOutline, playCircleOutline,
pencilOutline, pencilOutline,
trashOutline, trashOutline,
addCircleOutline,
} from 'ionicons/icons' } from 'ionicons/icons'
import { getTimers } from '@/db/crud/timerCrud' import { deleteTimer, getTimers } from '@/db/crud/timerCrud'
import type { TimersRow } from '@/types/timerType' import type { TimersRow } from '@/types/timerType'
export default defineComponent({ export default defineComponent({
name: 'TimersList', name: 'TimersList',
components: { IonIcon, IonPage, IonContent }, components: { IonIcon, IonPage, IonContent },
setup() { setup() {
/** Список всех таймеров */
const timers = ref<TimersRow[]>([]) const timers = ref<TimersRow[]>([])
/** Флаг загрузки списка таймеров */
const loading = ref<boolean>(true) const loading = ref<boolean>(true)
/** Ошибка загрузки или операций */
const error = ref<string | null>(null) const error = ref<string | null>(null)
/** ID раскрытого таймера в списке */
const expandedId = ref<number | null>(null) const expandedId = ref<number | null>(null)
const router = useRouter(); /** ID таймера, ожидающего удаления */
const confirmDeleteId = ref<number | null>(null)
const router = useRouter()
const route = useRoute()
/** Переход на страницу конкретного таймера */
const goToTimer = (id: number) => { const goToTimer = (id: number) => {
router.push(`/timer/${id}`); router.push(`/timer/${id}`)
}; }
/** Переход на создание нового таймера */
const goToCreateTimer = () => {
router.push('/timer-update/new')
}
/** Переход на редактирование таймера */
const goToEditTimer = (id: number) => {
router.push(`/timer-update/${id}`)
}
/** Открывает подтверждение удаления таймера */
const askDeleteTimer = (id: number): void => {
confirmDeleteId.value = id
}
/** Отмена удаления таймера */
const cancelDeleteTimer = (): void => {
confirmDeleteId.value = null
}
/** Удаляет таймер и обновляет список */
const removeTimer = async (): Promise<void> => {
if (confirmDeleteId.value === null) {
return
}
try {
await deleteTimer(confirmDeleteId.value)
confirmDeleteId.value = null
await loadTimers()
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : 'Ошибка удаления таймера'
}
}
/** Загружает список таймеров из базы данных */
const loadTimers = async (): Promise<void> => { const loadTimers = async (): Promise<void> => {
loading.value = true loading.value = true
error.value = null error.value = null
@@ -42,17 +92,31 @@ export default defineComponent({
} }
} }
/** Переключает раскрытие карточки таймера */
const toggleExpand = (id: number): void => { const toggleExpand = (id: number): void => {
expandedId.value = expandedId.value === id ? null : id expandedId.value = expandedId.value === id ? null : id
} }
/** Загружает таймеры при первом рендере */
onMounted(loadTimers) onMounted(loadTimers)
/** Перезагружает список при возврате на страницу /timers */
watch(
() => route.fullPath,
(path) => {
if (path === '/timers') {
loadTimers()
}
},
{ immediate: false }
)
return { return {
timers, timers,
loading, loading,
error, error,
expandedId, expandedId,
confirmDeleteId,
toggleExpand, toggleExpand,
@@ -62,7 +126,13 @@ export default defineComponent({
playCircleOutline, playCircleOutline,
pencilOutline, pencilOutline,
trashOutline, trashOutline,
goToTimer addCircleOutline,
goToTimer,
goToCreateTimer,
goToEditTimer,
askDeleteTimer,
cancelDeleteTimer,
removeTimer,
} }
}, },
}) })

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "kawai-focus" name = "kawai-focus"
version = "0.2.0" version = "0.3.0"
description = "Кавай-Фокус (Kawai-Focus) - приложение для фокусировки внимания на основе таймера Pomodoro." description = "Кавай-Фокус (Kawai-Focus) - приложение для фокусировки внимания на основе таймера Pomodoro."
authors = ["Arduinum"] authors = ["Arduinum"]
license = "MIT" license = "MIT"

View File

@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Kawai-Focus", "productName": "Kawai-Focus",
"version": "0.2.0", "version": "0.3.0",
"identifier": "io.github.arduinum.KawaiFocus", "identifier": "io.github.arduinum.KawaiFocus",
"build": { "build": {
"frontendDist": "../../client/dist", "frontendDist": "../../client/dist",