26
client/package-lock.json
generated
26
client/package-lock.json
generated
@@ -16,8 +16,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"
|
||||
},
|
||||
@@ -3146,15 +3148,24 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/api": {
|
||||
"version": "2.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.9.1.tgz",
|
||||
"integrity": "sha512-IGlhP6EivjXHepbBic618GOmiWe4URJiIeZFlB7x3czM0yDHHYviH1Xvoiv4FefdkQtn6v7TuwWCRfOGdnVUGw==",
|
||||
"version": "2.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.0.tgz",
|
||||
"integrity": "sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==",
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"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": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-sql/-/plugin-sql-2.3.1.tgz",
|
||||
@@ -8971,6 +8982,15 @@
|
||||
"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": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
4
client/public/config.toml
Normal file
4
client/public/config.toml
Normal file
@@ -0,0 +1,4 @@
|
||||
# Конфиг таймера
|
||||
|
||||
[sound]
|
||||
sound_id = "alarm_beep"
|
||||
BIN
client/public/sounds/alarm-beep.mp3
Normal file
BIN
client/public/sounds/alarm-beep.mp3
Normal file
Binary file not shown.
35
client/src/composables/chainTimer.ts
Normal file
35
client/src/composables/chainTimer.ts
Normal 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;
|
||||
}
|
||||
16
client/src/composables/libAudio.ts
Normal file
16
client/src/composables/libAudio.ts
Normal 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" }
|
||||
];
|
||||
49
client/src/composables/useAudio.ts
Normal file
49
client/src/composables/useAudio.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
36
client/src/composables/useConfig.ts
Normal file
36
client/src/composables/useConfig.ts
Normal 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;
|
||||
}
|
||||
@@ -1,31 +1,74 @@
|
||||
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({ seconds, onFinish }: CountdownOptions): CountdownReturn {
|
||||
const totalSeconds = seconds * 60;
|
||||
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);
|
||||
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>(() => {
|
||||
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 => {
|
||||
if (isRunning.value) return
|
||||
@@ -35,20 +78,22 @@ export function useCountdown({ seconds, onFinish }: CountdownOptions): Countdown
|
||||
if (timeLeft.value > 0) {
|
||||
timeLeft.value--;
|
||||
} else {
|
||||
stop();
|
||||
onFinish?.();
|
||||
finish();
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
/** Останавливает таймер и сбрасывает время до начального */
|
||||
/** Останавливает таймер и сбрасывает время до следующего таймера */
|
||||
const stop = (): void => {
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
interval = null;
|
||||
}
|
||||
|
||||
isRunning.value = false;
|
||||
timeLeft.value = totalSeconds;
|
||||
|
||||
stopSound();
|
||||
setNextTimer()
|
||||
}
|
||||
|
||||
/** Приостанавливает таймер без сброса оставшегося времени */
|
||||
@@ -64,8 +109,10 @@ export function useCountdown({ seconds, onFinish }: CountdownOptions): Countdown
|
||||
return {
|
||||
timeLeft,
|
||||
isRunning,
|
||||
isFinished,
|
||||
formatted,
|
||||
progress,
|
||||
timerNow,
|
||||
start,
|
||||
stop,
|
||||
pause
|
||||
|
||||
6
client/src/types/configType.ts
Normal file
6
client/src/types/configType.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
//** Тип для конфига */
|
||||
export type Config = {
|
||||
sound: {
|
||||
sound_id: string;
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ export type TimerRow = {
|
||||
|
||||
/** Параметры инициализации обратного отсчёта */
|
||||
export type CountdownOptions = {
|
||||
seconds: number;
|
||||
timers: TypeTimer[];
|
||||
onFinish?: () => void;
|
||||
}
|
||||
|
||||
@@ -36,4 +36,13 @@ export type CountdownReturn = {
|
||||
start: () => void;
|
||||
stop: () => void;
|
||||
pause: () => void;
|
||||
timerNow: Ref<TypeTimer | undefined>;
|
||||
isFinished: ComputedRef<boolean>;
|
||||
}
|
||||
|
||||
/** Тип таймеров */
|
||||
export type TypeTimer = {
|
||||
title: string;
|
||||
time: number;
|
||||
typeTimer: string;
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
|
||||
<!-- Card top labels -->
|
||||
<div class="timer-card-labels">
|
||||
<span v-if="timer" class="label-left">{{ timer.title }}</span>
|
||||
<span class="label-right">Помидор</span>
|
||||
<span class="label-left">{{ countdown?.timerNow.value?.title }}</span>
|
||||
<span class="label-right">{{ countdown?.timerNow.value?.typeTimer }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Time display -->
|
||||
@@ -18,7 +18,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
class="btn-timer
|
||||
btn-timer-start"
|
||||
id="btn-start"
|
||||
v-show="state === 'idle' || state === 'paused'"
|
||||
v-show="state === 'idle' && !countdown?.isFinished.value || state === 'paused'"
|
||||
@click="() => { setState('running'); countdown?.start(); }"
|
||||
>
|
||||
<ion-icon :icon="playOutline"></ion-icon>
|
||||
@@ -54,7 +54,7 @@
|
||||
class="btn-timer
|
||||
btn-timer-pause"
|
||||
id="btn-pause"
|
||||
v-show="state === 'running'"
|
||||
v-show="state === 'running' && !countdown?.isFinished.value"
|
||||
@click="() => { setState('paused'); countdown?.pause(); }"
|
||||
>
|
||||
<ion-icon :icon="pauseOutline"></ion-icon>
|
||||
|
||||
@@ -8,9 +8,12 @@ import {
|
||||
pauseOutline
|
||||
} from 'ionicons/icons';
|
||||
|
||||
import { getTimer } from '@/db/crud/timerCrud';
|
||||
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';
|
||||
|
||||
/** Возможные состояния таймера */
|
||||
@@ -43,6 +46,9 @@ export default defineComponent({
|
||||
/** Shallow-ссылка на экземпляр composable обратного отсчёта */
|
||||
const countdown = shallowRef<CountdownReturn | null>(null);
|
||||
|
||||
/** Чтение конфига */
|
||||
const config = loadConfig()
|
||||
|
||||
/** Останавливает таймер при размонтировании компонента */
|
||||
onUnmounted(() => {
|
||||
countdown.value?.stop();
|
||||
@@ -51,15 +57,14 @@ export default defineComponent({
|
||||
/** Загружает данные таймера из БД и инициализирует отсчёт */
|
||||
const loadTimer = async (): Promise<void> => {
|
||||
try {
|
||||
const result = await getTimer(id);
|
||||
const timerData = Array.isArray(result) ? result[0] : result;
|
||||
timer.value = timerData;
|
||||
const timers = await queueTimer(id);
|
||||
|
||||
countdown.value = useCountdown({
|
||||
seconds: timerData.pomodoro_time ?? 0,
|
||||
onFinish: () => alert('Время вышло!')
|
||||
timers: timers,
|
||||
onFinish: async () => {
|
||||
await playSound(getSoundPathById((await config).sound.sound_id));
|
||||
}
|
||||
});
|
||||
|
||||
} catch (e) {
|
||||
error.value = 'Ошибка загрузки таймера';
|
||||
console.error(e);
|
||||
|
||||
1836
desktop/src-tauri/Cargo.lock
generated
1836
desktop/src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "kawai-focus"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
description = "Kawai-Focus - приложение для фокусировки внимания на основе таймера Pomodoro."
|
||||
authors = ["Arduinum"]
|
||||
license = "MIT"
|
||||
@@ -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"
|
||||
|
||||
@@ -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/**"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ pub fn run() {
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_sql::Builder::default().build())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.run(tauri::generate_context!())
|
||||
.expect("Ошибка при запуске приложения Tauri.");
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Kawai-Focus",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"identifier": "io.github.arduinum.KawaiFocus",
|
||||
"build": {
|
||||
"frontendDist": "../../client/dist",
|
||||
|
||||
Reference in New Issue
Block a user