24 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
Arduinum
79e87dbbe8 docs: added ru, eng docs 2026-05-15 18:43:28 +03:00
Arduinum
5f004ddcbc docs: added new version 2026-05-14 23:46:47 +03:00
Arduinum
23239e303a fix: fixed permissions for read write 2026-05-14 23:45:50 +03:00
Arduinum
196f3c6cb4 feat: updated timer view 2026-05-14 23:38:48 +03:00
Arduinum
f45e23b440 feat: added connected chain ad sound into timer 2026-05-14 23:23:57 +03:00
Arduinum
35061a44f8 feat: added use audio 2026-05-14 23:02:55 +03:00
Arduinum
d56c983901 feat: added work with config toml 2026-05-14 22:49:08 +03:00
Arduinum
5b52549404 feat: added router for Timer 2026-04-23 22:18:54 +03:00
Arduinum
46b8b181da feat: added Timer screen 2026-04-23 22:17:15 +03:00
Arduinum
a431f70c25 feat: added dml and crud for get timer 2026-04-23 22:12:29 +03:00
Arduinum
e7b768c926 docs: updated .gitignore for db 2026-04-23 22:10:25 +03:00
Arduinum
1716306e86 fix: rename app and lib 2026-03-12 20:21:50 +03:00
Arduinum
4d2bb6597a feat: added functions for flags 2026-03-12 20:21:05 +03:00
35 changed files with 3163 additions and 1327 deletions

1
.gitignore vendored
View File

@@ -22,6 +22,7 @@ gen/
# Database
*.db
*.db*
*.sqlite
*.sqlite3

42
README.en.md Normal file
View File

@@ -0,0 +1,42 @@
# Kawai-Focus
A focus app based on the Pomodoro technique.
The project is currently at the MVP1 development stage:
- **Pomodoro** — timers for the Pomodoro technique to manage your work sessions and rest breaks;
- **Guide** — information on how to use the timers and the app, as well as details about the Pomodoro technique itself;
- **App Limiter** — block or limit distracting applications during your work blocks.
## Tech Stack
- **Vue.js** — handles the user interface and SQLite operations (frontend);
- **Ionic** — provides native UI components and the mobile version;
- **Tauri** — the engine powering the desktop version of the application.
## Author
**Project Developer:**
- **Name and Surname** — Eugene Kaddo
- **Nickname** — Arduinum628
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>
<summary><strong>Branch and Commit Naming Conventions</strong></summary>
Branch example: `nickname/name_task`
- **nickname** — your GitHub username;
- **name_task** — task name (matches the card title on YouGile).
Commit example: `refactor: renaming a variable`
- **feat:** — a new feature for the application code (excluding build configurations);
- **devops:** — build configuration changes (adding, removing, or fixing configurations);
- **fix:** — a bug fix in the application code;
- **docs:** — documentation changes only;
- **style:** — changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc.);
- **refactor:** — a code change that neither fixes a bug nor adds a feature (e.g., renaming a variable);
- **test:** — adding missing tests or correcting existing tests;
- **chore:** — changes to the build process or auxiliary tools and libraries such as documentation generation.
Conventions are based on [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/).
</details>

View File

@@ -1,7 +1,7 @@
# kawai-focus-v2
Kawai-Focus - приложение для фокусировки внимания на основе таймера Pomodoro.
# Кавай-Фокус
Приложение для фокусировки внимания на основе таймера Pomodoro.
роект находится на стадии разработки MVP1:
Проект находится на стадии разработки MVP1:
- **Pomodoro** - таймеры для техники Pomodoro, которая управляет вашим временем работы и перерывами на отдых;
- **Гайд** - информация о том как использовать таймеры и приложение, а так же о самой технике Pomodoro;
@@ -20,7 +20,7 @@ Kawai-Focus - приложение для фокусировки внимани
- **Имя и Фамилия** - Евгений Каддо
- **Никнейм** - 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>
<summary>

679
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -20,8 +20,10 @@
"@ionic/vue": "^8.0.0",
"@ionic/vue-router": "^8.0.0",
"@tauri-apps/api": "^2.9.1",
"@tauri-apps/plugin-fs": "^2.5.1",
"@tauri-apps/plugin-sql": "^2.3.1",
"ionicons": "^7.0.0",
"toml": "^4.1.1",
"vue": "^3.3.0",
"vue-router": "^4.2.0"
},

View File

@@ -0,0 +1,4 @@
# Конфиг таймера
[sound]
sound_id = "alarm_beep"

Binary file not shown.

View File

@@ -0,0 +1,35 @@
import { TypeTimer } from '@/types/timerType';
import { getTimer } from '@/db/crud/timerCrud';
/** Фомирует цепочку таймеров */
export async function queueTimer(TimerId: number): Promise<TypeTimer[]> {
let timers: TypeTimer[] = [];
const result = await getTimer(TimerId);
const timerData = Array.isArray(result) ? result[0] : result;
for (let i = 0; i < timerData.count_pomodoro; i++) {
timers.push({
title: timerData.title,
time: timerData.pomodoro_time,
typeTimer: "Помидор"
});
if (i === timerData.count_pomodoro - 1) {
timers.push({
title: timerData.title,
time: timerData.break_long_time,
typeTimer: "Перерывище"
});
} else {
timers.push({
title: timerData.title,
time: timerData.break_time,
typeTimer: "Перерыв"
});
}
}
return timers;
}

View File

@@ -0,0 +1,16 @@
/** Получает путь к аудио по id */
export function getSoundPathById(id: string): string {
const sound = SOUND_LIBRARY.find(s => s.id === id);
if (!sound) {
throw new Error("Sound not found");
}
return sound.file;
}
const base_path = "/sounds"
const SOUND_LIBRARY = [
{ id: "alarm_beep", file: `${base_path}/alarm-beep.mp3`, name: "Alarm Beep" }
];

View File

@@ -0,0 +1,49 @@
let audioContext: AudioContext | null = null;
let currentSource: AudioBufferSourceNode | null = null;
/** Получить AudioContext */
function getAudioContext(): AudioContext {
if (!audioContext) {
audioContext = new AudioContext();
}
return audioContext;
}
/** Воспроизводит звук */
export async function playSound(url: string): Promise<void> {
const context = getAudioContext();
if (context.state === 'suspended') {
await context.resume();
}
// Если уже играет звук — остановит
stopSound();
const response = await fetch(url);
const arrayBuffer = await response.arrayBuffer();
const audioBuffer = await context.decodeAudioData(arrayBuffer);
const source = context.createBufferSource();
source.buffer = audioBuffer;
source.connect(context.destination);
currentSource = source;
source.start(0);
source.onended = () => {
source.disconnect();
if (currentSource === source) {
currentSource = null;
}
};
}
/** Останавливает звук */
export function stopSound(): void {
if (currentSource) {
currentSource.stop();
currentSource.disconnect();
currentSource = null;
}
}

View File

@@ -0,0 +1,36 @@
import { readTextFile, writeTextFile, BaseDirectory } from '@tauri-apps/plugin-fs';
import TOML from 'toml';
import { Config } from '@/types/configType';
/**
* Загрузит или создаст TOML-конфиг
*/
export async function loadConfig(): Promise<Config> {
let text: string | null = null;
const configName = 'config.toml';
// 1. пробуем прочитать пользовательский конфиг
try {
text = await readTextFile(configName, {
baseDir: BaseDirectory.AppConfig
});
} catch {
text = null;
}
// 2. если нет — создаст из дефолта
if (!text) {
const defaultConfig = await fetch(`/${configName}`)
.then(r => r.text());
await writeTextFile(configName, defaultConfig, {
baseDir: BaseDirectory.AppConfig
});
text = defaultConfig;
}
// 3. парсим TOML
return TOML.parse(text) as Config;
}

View File

@@ -0,0 +1,120 @@
import { ref, computed } from 'vue';
import { CountdownOptions, CountdownReturn } from '@/types/timerType';
import { stopSound } from '@/composables/useAudio';
import { TypeTimer } from '@/types/timerType';
/** Composable для управления обратным отсчётом времени */
export function useCountdown({ timers, onFinish }: CountdownOptions): CountdownReturn {
const totalSeconds = ref<number>(0);
const timerNow = ref<TypeTimer | undefined>(timers.shift());
if (timerNow.value) {
totalSeconds.value = timerNow.value.time * 60;
}
/** Реактивное оставшееся время в секундах */
const timeLeft = ref<number>(totalSeconds.value);
/** Флаг активного состояния таймера */
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;
if (timerNow.value) {
return `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
}
return "Завершён!";
});
/** Вычисляет процент прошедшего времени от общего */
const progress = computed<number>(() => {
if (totalSeconds.value === 0) return 0;
return ((totalSeconds.value - timeLeft.value) / totalSeconds.value) * 100;
});
/** Отслеживает завершённость таймера */
const isFinished = computed<boolean>(() => {
return !timerNow.value || timeLeft.value <= 0;
});
/** Задаёт следующий таймер */
const setNextTimer = (): void => {
timerNow.value = timers.shift();
if (timerNow.value) {
totalSeconds.value = timerNow.value.time * 60;
timeLeft.value = totalSeconds.value;
}
};
//** Заканчивает таймер */
const finish = (): void => {
if (interval) {
clearInterval(interval);
interval = null;
}
isRunning.value = false;
timeLeft.value = 0;
onFinish?.();
};
/** Запускает таймер */
const start = (): void => {
if (isRunning.value) return
isRunning.value = true;
interval = setInterval(() => {
if (timeLeft.value > 0) {
timeLeft.value--;
} else {
finish();
}
}, 1000);
}
/** Останавливает таймер и сбрасывает время до следующего таймера */
const stop = (): void => {
if (interval) {
clearInterval(interval);
interval = null;
}
isRunning.value = false;
stopSound();
setNextTimer()
}
/** Приостанавливает таймер без сброса оставшегося времени */
const pause = (): void => {
if (!isRunning.value) return
if (interval) {
clearInterval(interval);
interval = null;
}
isRunning.value = false;
}
return {
timeLeft,
isRunning,
isFinished,
formatted,
progress,
timerNow,
start,
stop,
pause
};
}

View File

@@ -1,6 +1,7 @@
import { QueryResult } from "@tauri-apps/plugin-sql";
import { getDb } from "@/db/initDb";
import { SELECT_TIMERS } from "@/db/dml/timerDML";
import { TimersRow } from "@/types/timerType"
import { SELECT_TIMERS, SELECT_TIMER, INSERT_TIMER, UPDATE_TIMER, DELETE_TIMER } from "@/db/dml/timerDML";
import { TimersRow, TimerRow } from "@/types/timerType";
/** Получает список таймеров */
@@ -8,3 +9,59 @@ export async function getTimers(): Promise<TimersRow[]> {
const db = await getDb();
return await db.select<TimersRow[]>(SELECT_TIMERS);
}
/** Получает таймер по его id */
export async function getTimer(TimerId: number): Promise<TimerRow> {
const db = await getDb();
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

@@ -4,3 +4,7 @@ export const INSERT_SEED_DB = `
INSERT INTO timer (title, pomodoro_time, break_time, break_long_time, count_pomodoro) VALUES
('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 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

@@ -1,6 +1,8 @@
import { createRouter, createWebHistory } from '@ionic/vue-router';
import type { RouteRecordRaw } from 'vue-router';
import TimersList from '@/views/TimersList/TimersList.vue';
import Timer from '@/views/Timer/Timer.vue';
import TimerUpdate from '@/views/TimerUpdate/TimerUpdate.vue';
const routes: RouteRecordRaw[] = [
{
@@ -11,6 +13,16 @@ const routes: RouteRecordRaw[] = [
path: '/timers',
name: 'Timers',
component: TimersList
},
{
path: '/timer/:id',
name: 'Timer',
component: Timer
},
{
path: '/timer-update/:id',
name: 'TimerUpdate',
component: TimerUpdate
}
];

View File

@@ -0,0 +1,6 @@
//** Тип для конфига */
export type Config = {
sound: {
sound_id: string;
}
}

View File

@@ -1,3 +1,6 @@
import type { Ref, ComputedRef } from 'vue';
/** Строка списка таймеров с основными полями */
export type TimersRow = {
id: number;
title: string;
@@ -5,4 +8,41 @@ 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 = {
timers: TypeTimer[];
onFinish?: () => void;
}
/** Возвращаемые значения и методы composable обратного отсчёта */
export type CountdownReturn = {
timeLeft: Ref<number>;
isRunning: Ref<boolean>;
formatted: ComputedRef<string>; // ЧЧ:ММ:СС
progress: Ref<number>;
start: () => void;
stop: () => void;
pause: () => void;
timerNow: Ref<TypeTimer | undefined>;
isFinished: ComputedRef<boolean>;
}
/** Тип таймеров */
export type TypeTimer = {
title: string;
time: number;
typeTimer: string;
}

View File

@@ -0,0 +1,260 @@
* {
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;
flex-direction: column;
}
.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 {
padding: 24px 16px;
width: 100%;
max-width: 500px;
display: flex;
flex-direction: column;
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 {
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-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 {
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,93 @@
<ion-page>
<ion-content>
<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">
<!-- Timer Card -->
<div class="timer-display-card">
<!-- Card top labels -->
<div class="timer-card-labels">
<span class="label-left">{{ countdown?.timerNow.value?.title }}</span>
<span class="label-right">{{ countdown?.timerNow.value?.typeTimer }}</span>
</div>
<!-- Time display -->
<div class="timer-time">
<span class="time-text">{{ countdown?.formatted }}</span>
</div>
<!-- Progress bar -->
<div v-show="!countdown?.isFinished.value" 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' && !countdown?.isFinished.value || 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' && !countdown?.isFinished.value"
@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,129 @@
import { computed, defineComponent, onMounted, onUnmounted, ref, shallowRef } from 'vue';
import { IonIcon, IonPage, IonContent } from '@ionic/vue';
import { useRoute, useRouter } from 'vue-router';
import {
stopOutline,
playOutline,
pauseOutline,
arrowBackOutline
} from 'ionicons/icons';
import type { TimerRow } from '@/types/timerType';
import { useCountdown } from '@/composables/useTimer';
import { playSound } from '@/composables/useAudio';
import { getSoundPathById } from '@/composables/libAudio';
import { loadConfig } from '@/composables/useConfig';
import { queueTimer } from '@/composables/chainTimer';
import { CountdownReturn } from '@/types/timerType';
/** Возможные состояния таймера */
type TimerState = 'idle' | 'running' | 'paused';
/** Компонент страницы таймера с управлением обратным отсчётом */
export default defineComponent({
name: 'Timer',
components: { IonIcon, IonPage, IonContent },
/** Инициализирует данные и логику компонента таймера */
setup() {
const route = useRoute();
const router = useRouter();
const id = Number(route.params.id);
/** Реактивные данные загруженного таймера */
const timer = ref<TimerRow>();
/** Сообщение об ошибке при загрузке */
const error = ref<string | null>(null);
/** Текущее состояние кнопок управления */
const state = ref<TimerState>('idle');
const confirmLeave = ref(false);
/** Обновляет текущее состояние таймера */
const setState = (newState: TimerState) => {
state.value = newState;
};
/** Shallow-ссылка на экземпляр composable обратного отсчёта */
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()
/** Останавливает таймер при размонтировании компонента */
onUnmounted(() => {
countdown.value?.stop();
});
/** Загружает данные таймера из БД и инициализирует отсчёт */
const loadTimer = async (): Promise<void> => {
try {
const timers = await queueTimer(id);
countdown.value = useCountdown({
timers: timers,
onFinish: async () => {
await playSound(getSoundPathById((await config).sound.sound_id));
}
});
} catch (e) {
error.value = 'Ошибка загрузки таймера';
console.error(e);
}
};
onMounted(loadTimer);
return {
timer,
error,
state,
setState,
showBackButton,
confirmLeave,
askLeaveTimer,
closeLeaveModal,
confirmLeaveTimer,
stopOutline,
playOutline,
pauseOutline,
arrowBackOutline,
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>

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;
}
.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,
.error-message,
.empty-state {
@@ -179,6 +217,27 @@
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 {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
@@ -191,8 +250,8 @@
justify-content: center;
gap: 6px;
padding: 10px 12px;
border: none;
border-radius: 4px;
border: 2px solid transparent;
border-radius: 999px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
@@ -207,43 +266,46 @@
}
.btn-primary {
background-color: #1976d2;
color: #ffffff;
background-color: transparent;
color: #4caf50;
border: 2px solid #4caf50;
}
.btn-primary:hover {
background-color: #1565c0;
box-shadow: 0 4px 8px rgba(25, 118, 210, 0.3);
background-color: rgba(76, 175, 80, 0.10);
box-shadow: 0 4px 8px rgba(76, 175, 80, 0.18);
}
.btn-primary:active {
background-color: #0d47a1;
background-color: rgba(76, 175, 80, 0.20);
}
.btn-secondary {
background-color: #e0e0e0;
color: #212121;
background-color: transparent;
color: #f9a825;
border: 2px solid #f9a825;
}
.btn-secondary:hover {
background-color: #d0d0d0;
background-color: rgba(249, 168, 37, 0.10);
}
.btn-secondary:active {
background-color: #c0c0c0;
background-color: rgba(249, 168, 37, 0.20);
}
.btn-danger {
background-color: #fff3e0;
color: #e65100;
background-color: transparent;
color: #f44336;
border: 2px solid #f44336;
}
.btn-danger:hover {
background-color: #ffe0b2;
background-color: rgba(244, 67, 54, 0.10);
}
.btn-danger:active {
background-color: #ffcc80;
background-color: rgba(244, 67, 54, 0.20);
}
@media (max-width: 600px) {

View File

@@ -1,81 +1,100 @@
<div class="timers-container">
<div class="page-header">
<h1>Таймеры</h1>
</div>
<ion-page>
<ion-content>
<div class="timers-container">
<div class="page-header">
<h1>Таймеры</h1>
</div>
<div class="timers-list">
<!-- Loading state -->
<div v-if="loading" class="loading">
<p>Загрузка таймеров...</p>
</div>
<!-- Error state -->
<div v-else-if="error" class="error-message">
<p>{{ error }}</p>
</div>
<!-- Empty state -->
<div v-else-if="timers.length === 0" class="empty-state">
<p>Нет таймеров</p>
</div>
<!-- Timers list -->
<div v-else class="timer-cards">
<div
v-for="timer in timers"
:key="timer.id"
class="timer-card"
:class="{ expanded: expandedId === timer.id }"
>
<!-- Card header -->
<div class="card-header" @click="toggleExpand(timer.id)">
<div class="timer-icon">
<ion-icon :icon="timerOutline"></ion-icon>
</div>
<div class="timer-info">
<h3 class="timer-title">{{ timer.title }}</h3>
<p class="timer-subtitle">{{ timer.count_pomodoro }} помидоров по {{ timer.pomodoro_time }} минут</p>
</div>
<div class="expand-icon">
<ion-icon
:icon="expandedId === timer.id ? chevronUp : chevronDown"
></ion-icon>
<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>
<!-- Card content -->
<div v-show="expandedId === timer.id" class="card-content">
<div class="timer-details">
<div class="detail-row">
<span class="detail-label">Название:</span>
<span class="detail-value">{{ timer.title }}</span>
</div>
<div class="detail-row">
<span class="detail-label">Длительность помидора:</span>
<span class="detail-value">{{ timer.pomodoro_time }} минут</span>
</div>
<div class="detail-row">
<span class="detail-label">Количество помидоров:</span>
<span class="detail-value">{{ timer.count_pomodoro }}</span>
</div>
</div>
<button @click="goToCreateTimer" class="add-timer-button" type="button">
<ion-icon :icon="addCircleOutline"></ion-icon>
Создать таймер
</button>
<!-- Loading state -->
<div v-if="loading" class="loading">
<p>Загрузка таймеров...</p>
</div>
<div class="card-actions">
<button class="btn btn-primary">
<ion-icon :icon="playCircleOutline"></ion-icon>
Открыть
</button>
<button class="btn btn-secondary">
<ion-icon :icon="pencilOutline"></ion-icon>
Редактировать
</button>
<button class="btn btn-danger">
<ion-icon :icon="trashOutline"></ion-icon>
Удалить
</button>
<!-- Error state -->
<div v-else-if="error" class="error-message">
<p>{{ error }}</p>
</div>
<!-- Empty state -->
<div v-else-if="timers.length === 0" class="empty-state">
<p>Нет таймеров</p>
</div>
<!-- Timers list -->
<div v-else class="timer-cards">
<div
v-for="timer in timers"
:key="timer.id"
class="timer-card"
:class="{ expanded: expandedId === timer.id }"
>
<!-- Card header -->
<div class="card-header" @click="toggleExpand(timer.id)">
<div class="timer-icon">
<ion-icon :icon="timerOutline"></ion-icon>
</div>
<div class="timer-info">
<h3 class="timer-title">{{ timer.title }}</h3>
<p class="timer-subtitle">{{ timer.count_pomodoro }} помидоров по {{ timer.pomodoro_time }} минут</p>
</div>
<div class="expand-icon">
<ion-icon
:icon="expandedId === timer.id ? chevronUp : chevronDown"
></ion-icon>
</div>
</div>
<!-- Card content -->
<div v-show="expandedId === timer.id" class="card-content">
<div class="timer-details">
<div class="detail-row">
<span class="detail-label">Название:</span>
<span class="detail-value">{{ timer.title }}</span>
</div>
<div class="detail-row">
<span class="detail-label">Длительность помидора:</span>
<span class="detail-value">{{ timer.pomodoro_time }} минут</span>
</div>
<div class="detail-row">
<span class="detail-label">Количество помидоров:</span>
<span class="detail-value">{{ timer.count_pomodoro }}</span>
</div>
</div>
<div class="card-actions">
<button @click="goToTimer(timer.id)" class="btn btn-primary">
<ion-icon :icon="playCircleOutline"></ion-icon>
Открыть
</button>
<button @click="goToEditTimer(timer.id)" class="btn btn-secondary">
<ion-icon :icon="pencilOutline"></ion-icon>
Редактировать
</button>
<button @click="askDeleteTimer(timer.id)" class="btn btn-danger">
<ion-icon :icon="trashOutline"></ion-icon>
Удалить
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</ion-content>
</ion-page>

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
[package]
name = "app"
version = "0.1.0"
description = "Kawai-Focus - приложение для фокусировки внимания на основе таймера Pomodoro."
name = "kawai-focus"
version = "0.3.0"
description = "Кавай-Фокус (Kawai-Focus) - приложение для фокусировки внимания на основе таймера Pomodoro."
authors = ["Arduinum"]
license = "MIT"
repository = ""
@@ -11,7 +11,7 @@ rust-version = "1.77.2"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
name = "app_lib"
name = "kawai_focus"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
@@ -24,3 +24,4 @@ log = "0.4"
tauri = { version = "2.9.5", features = [] }
tauri-plugin-log = "2"
tauri-plugin-sql = { version = "2.3.1", features = ["sqlite"] }
tauri-plugin-fs = "2.5.1"

View File

@@ -5,6 +5,20 @@
"permissions": [
"core:default",
"sql:default",
"sql:allow-execute"
]
"sql:allow-execute",
"fs:allow-appconfig-read",
"fs:allow-appconfig-write"
],
"fs": {
"read": {
"scope": [
"$APPCONFIG/**"
]
},
"write": {
"scope": [
"$APPCONFIG/**"
]
}
}
}

View File

@@ -0,0 +1,44 @@
#[cfg(target_os = "linux")]
pub fn apply_linux_cli_flags() {
let disable_webkit_compositing_mode = std::env::args().any(|a| a == "--webkit-disable-compositing-mode");
if disable_webkit_compositing_mode {
unsafe { std::env::set_var("WEBKIT_DISABLE_COMPOSITING_MODE", "1"); }
}
let webkit_disable_dmabuf_renderer = std::env::args().any(|a| a == "--webkit-disable-dmabuf-renderer");
if webkit_disable_dmabuf_renderer {
unsafe { std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1"); }
}
let nv_disable_explicit_sync = std::env::args().any(|a| a == "--nv-disable-explicit-sync");
if nv_disable_explicit_sync {
unsafe { std::env::set_var("__NV_DISABLE_EXPLICIT_SYNC", "1"); }
}
}
#[cfg(target_os = "linux")]
pub fn print_help() {
println!(
r#"Kawai Focus — Pomodoro таймер
Использование:
kawai-focus [FLAGS]
Флаги (Linux):
--webkit-disable-compositing-mode
Отключает WebKit compositing mode.
Помогает при графических артефактах на некоторых GPU.
--webkit-disable-dmabuf-renderer
Отключает DMA-BUF renderer WebKit.
Полезно при проблемах с Wayland и с отсутствием GBM.
--nv-disable-explicit-sync
Отключает explicit sync драйвера NVIDIA.
Иногда исправляет: зависания интерфейса, проблемы с отсутствием GBM.
--help
Показать эту справку и выйти.
"#);
}

View File

@@ -1,17 +1,19 @@
#[cfg(target_os = "linux")]
fn apply_linux_webkit_cli_flags() {
let disable = std::env::args().any(|a| a == "--disable-webkit-compositing");
if disable {
unsafe { std::env::set_var("WEBKIT_DISABLE_COMPOSITING_MODE", "1"); }
}
}
mod flags;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
apply_linux_webkit_cli_flags();
#[cfg(target_os = "linux")]
if std::env::args().any(|a| a == "--help" || a == "-h") {
flags::print_help();
std::process::exit(0);
}
#[cfg(target_os = "linux")]
flags::apply_linux_cli_flags();
tauri::Builder::default()
.plugin(tauri_plugin_sql::Builder::default().build())
.plugin(tauri_plugin_fs::init())
.run(tauri::generate_context!())
.expect("Ошибка при запуске приложения Tauri.");
}

View File

@@ -3,5 +3,5 @@
fn main() {
println!("Запуск приложения Tauri...");
app_lib::run();
kawai_focus::run();
}

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Kawai-Focus",
"version": "0.1.0",
"version": "0.3.0",
"identifier": "io.github.arduinum.KawaiFocus",
"build": {
"frontendDist": "../../client/dist",
@@ -14,7 +14,7 @@
"windows": [
{
"label": "main",
"title": "Kawai-Focus",
"title": "Кавай-Фокус",
"width": 800,
"height": 600,
"resizable": true,
@@ -37,7 +37,8 @@
}
}
},
"shortDescription": "Kawai-Focus - приложение для фокусировки внимания на основе таймера Pomodoro.",
"shortDescription": "Кавай-Фокус (Kawai-Focus) - приложение для фокусировки внимания на основе таймера Pomodoro.",
"copyright": "© 2026 Eugene Kaddo",
"icon": [
"icons/32x32.png",
"icons/128x128.png",