Merge pull request #11 from Arduinum/mvp1

Mvp1
This commit is contained in:
Arduinum
2026-05-15 15:18:45 +03:00
committed by GitHub
18 changed files with 1215 additions and 926 deletions

View File

@@ -16,8 +16,10 @@
"@ionic/vue": "^8.0.0", "@ionic/vue": "^8.0.0",
"@ionic/vue-router": "^8.0.0", "@ionic/vue-router": "^8.0.0",
"@tauri-apps/api": "^2.9.1", "@tauri-apps/api": "^2.9.1",
"@tauri-apps/plugin-fs": "^2.5.1",
"@tauri-apps/plugin-sql": "^2.3.1", "@tauri-apps/plugin-sql": "^2.3.1",
"ionicons": "^7.0.0", "ionicons": "^7.0.0",
"toml": "^4.1.1",
"vue": "^3.3.0", "vue": "^3.3.0",
"vue-router": "^4.2.0" "vue-router": "^4.2.0"
}, },
@@ -3146,15 +3148,24 @@
} }
}, },
"node_modules/@tauri-apps/api": { "node_modules/@tauri-apps/api": {
"version": "2.9.1", "version": "2.11.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.9.1.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.0.tgz",
"integrity": "sha512-IGlhP6EivjXHepbBic618GOmiWe4URJiIeZFlB7x3czM0yDHHYviH1Xvoiv4FefdkQtn6v7TuwWCRfOGdnVUGw==", "integrity": "sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==",
"license": "Apache-2.0 OR MIT", "license": "Apache-2.0 OR MIT",
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
"url": "https://opencollective.com/tauri" "url": "https://opencollective.com/tauri"
} }
}, },
"node_modules/@tauri-apps/plugin-fs": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-fs/-/plugin-fs-2.5.1.tgz",
"integrity": "sha512-9Lz+Jopp6QyeEWhlpkMx4R/+P9HgR+AVAI4vOZhlT8Xaymtz8iVI/Ov984/XTqgJz/5gz5NretqPB/XEMS3NhQ==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.11.0"
}
},
"node_modules/@tauri-apps/plugin-sql": { "node_modules/@tauri-apps/plugin-sql": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-sql/-/plugin-sql-2.3.1.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-sql/-/plugin-sql-2.3.1.tgz",
@@ -8971,6 +8982,15 @@
"node": ">=8.0" "node": ">=8.0"
} }
}, },
"node_modules/toml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/toml/-/toml-4.1.1.tgz",
"integrity": "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw==",
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/tough-cookie": { "node_modules/tough-cookie": {
"version": "5.1.2", "version": "5.1.2",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",

View File

@@ -20,8 +20,10 @@
"@ionic/vue": "^8.0.0", "@ionic/vue": "^8.0.0",
"@ionic/vue-router": "^8.0.0", "@ionic/vue-router": "^8.0.0",
"@tauri-apps/api": "^2.9.1", "@tauri-apps/api": "^2.9.1",
"@tauri-apps/plugin-fs": "^2.5.1",
"@tauri-apps/plugin-sql": "^2.3.1", "@tauri-apps/plugin-sql": "^2.3.1",
"ionicons": "^7.0.0", "ionicons": "^7.0.0",
"toml": "^4.1.1",
"vue": "^3.3.0", "vue": "^3.3.0",
"vue-router": "^4.2.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

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

View File

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

View File

@@ -23,7 +23,7 @@ export type TimerRow = {
/** Параметры инициализации обратного отсчёта */ /** Параметры инициализации обратного отсчёта */
export type CountdownOptions = { export type CountdownOptions = {
seconds: number; timers: TypeTimer[];
onFinish?: () => void; onFinish?: () => void;
} }
@@ -36,4 +36,13 @@ export type CountdownReturn = {
start: () => void; start: () => void;
stop: () => void; stop: () => void;
pause: () => void; pause: () => void;
timerNow: Ref<TypeTimer | undefined>;
isFinished: ComputedRef<boolean>;
}
/** Тип таймеров */
export type TypeTimer = {
title: string;
time: number;
typeTimer: string;
} }

View File

@@ -8,8 +8,8 @@
<!-- Card top labels --> <!-- Card top labels -->
<div class="timer-card-labels"> <div class="timer-card-labels">
<span v-if="timer" class="label-left">{{ timer.title }}</span> <span class="label-left">{{ countdown?.timerNow.value?.title }}</span>
<span class="label-right">Помидор</span> <span class="label-right">{{ countdown?.timerNow.value?.typeTimer }}</span>
</div> </div>
<!-- Time display --> <!-- Time display -->
@@ -18,7 +18,7 @@
</div> </div>
<!-- Progress bar --> <!-- Progress bar -->
<div class="timer-progress-bar"> <div v-show="!countdown?.isFinished.value" class="timer-progress-bar">
<div class="timer-progress-fill" :style="{ width: `${countdown?.progress.value}%` }"></div> <div class="timer-progress-fill" :style="{ width: `${countdown?.progress.value}%` }"></div>
</div> </div>
@@ -42,7 +42,7 @@
class="btn-timer class="btn-timer
btn-timer-start" btn-timer-start"
id="btn-start" id="btn-start"
v-show="state === 'idle' || state === 'paused'" v-show="state === 'idle' && !countdown?.isFinished.value || state === 'paused'"
@click="() => { setState('running'); countdown?.start(); }" @click="() => { setState('running'); countdown?.start(); }"
> >
<ion-icon :icon="playOutline"></ion-icon> <ion-icon :icon="playOutline"></ion-icon>
@@ -54,7 +54,7 @@
class="btn-timer class="btn-timer
btn-timer-pause" btn-timer-pause"
id="btn-pause" id="btn-pause"
v-show="state === 'running'" v-show="state === 'running' && !countdown?.isFinished.value"
@click="() => { setState('paused'); countdown?.pause(); }" @click="() => { setState('paused'); countdown?.pause(); }"
> >
<ion-icon :icon="pauseOutline"></ion-icon> <ion-icon :icon="pauseOutline"></ion-icon>

View File

@@ -8,9 +8,12 @@ import {
pauseOutline pauseOutline
} from 'ionicons/icons'; } from 'ionicons/icons';
import { getTimer } from '@/db/crud/timerCrud';
import type { TimerRow } from '@/types/timerType'; import type { TimerRow } from '@/types/timerType';
import { useCountdown } from '@/composables/useTimer'; 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'; import { CountdownReturn } from '@/types/timerType';
/** Возможные состояния таймера */ /** Возможные состояния таймера */
@@ -43,6 +46,9 @@ export default defineComponent({
/** Shallow-ссылка на экземпляр composable обратного отсчёта */ /** Shallow-ссылка на экземпляр composable обратного отсчёта */
const countdown = shallowRef<CountdownReturn | null>(null); const countdown = shallowRef<CountdownReturn | null>(null);
/** Чтение конфига */
const config = loadConfig()
/** Останавливает таймер при размонтировании компонента */ /** Останавливает таймер при размонтировании компонента */
onUnmounted(() => { onUnmounted(() => {
countdown.value?.stop(); countdown.value?.stop();
@@ -51,15 +57,14 @@ export default defineComponent({
/** Загружает данные таймера из БД и инициализирует отсчёт */ /** Загружает данные таймера из БД и инициализирует отсчёт */
const loadTimer = async (): Promise<void> => { const loadTimer = async (): Promise<void> => {
try { try {
const result = await getTimer(id); const timers = await queueTimer(id);
const timerData = Array.isArray(result) ? result[0] : result;
timer.value = timerData;
countdown.value = useCountdown({ countdown.value = useCountdown({
seconds: timerData.pomodoro_time ?? 0, timers: timers,
onFinish: () => alert('Время вышло!') onFinish: async () => {
await playSound(getSoundPathById((await config).sound.sound_id));
}
}); });
} catch (e) { } catch (e) {
error.value = 'Ошибка загрузки таймера'; error.value = 'Ошибка загрузки таймера';
console.error(e); console.error(e);

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "kawai-focus" name = "kawai-focus"
version = "0.1.0" version = "0.2.0"
description = "Kawai-Focus - приложение для фокусировки внимания на основе таймера Pomodoro." description = "Kawai-Focus - приложение для фокусировки внимания на основе таймера Pomodoro."
authors = ["Arduinum"] authors = ["Arduinum"]
license = "MIT" license = "MIT"
@@ -24,3 +24,4 @@ log = "0.4"
tauri = { version = "2.9.5", features = [] } tauri = { version = "2.9.5", features = [] }
tauri-plugin-log = "2" tauri-plugin-log = "2"
tauri-plugin-sql = { version = "2.3.1", features = ["sqlite"] } tauri-plugin-sql = { version = "2.3.1", features = ["sqlite"] }
tauri-plugin-fs = "2.5.1"

View File

@@ -5,6 +5,20 @@
"permissions": [ "permissions": [
"core:default", "core:default",
"sql: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

@@ -13,6 +13,7 @@ pub fn run() {
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_sql::Builder::default().build()) .plugin(tauri_plugin_sql::Builder::default().build())
.plugin(tauri_plugin_fs::init())
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("Ошибка при запуске приложения Tauri."); .expect("Ошибка при запуске приложения Tauri.");
} }

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.1.0", "version": "0.2.0",
"identifier": "io.github.arduinum.KawaiFocus", "identifier": "io.github.arduinum.KawaiFocus",
"build": { "build": {
"frontendDist": "../../client/dist", "frontendDist": "../../client/dist",