feat: persistent notifications toggle in settings
This commit is contained in:
203
src/App.vue
203
src/App.vue
@@ -1,16 +1,39 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, watch } from 'vue'
|
import { onMounted, watch } from 'vue'
|
||||||
import { getCurrentWindow } from '@tauri-apps/api/window'
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
import TitleBar from './components/TitleBar.vue'
|
import TitleBar from './components/TitleBar.vue'
|
||||||
import NavRail from './components/NavRail.vue'
|
import NavRail from './components/NavRail.vue'
|
||||||
import MiniTimer from './views/MiniTimer.vue'
|
|
||||||
import ToastNotification from './components/ToastNotification.vue'
|
import ToastNotification from './components/ToastNotification.vue'
|
||||||
import { useSettingsStore } from './stores/settings'
|
import { useSettingsStore } from './stores/settings'
|
||||||
|
import { useToastStore } from './stores/toast'
|
||||||
import { useTimerStore } from './stores/timer'
|
import { useTimerStore } from './stores/timer'
|
||||||
|
import { useRecurringStore } from './stores/recurring'
|
||||||
|
import { loadAndApplyTimerFont } from './utils/fonts'
|
||||||
|
import { loadAndApplyUIFont } from './utils/uiFonts'
|
||||||
|
import { useAnnouncer } from './composables/useAnnouncer'
|
||||||
|
import { audioEngine, DEFAULT_EVENTS } from './utils/audio'
|
||||||
|
import type { SoundEvent } from './utils/audio'
|
||||||
|
import TourOverlay from './components/TourOverlay.vue'
|
||||||
|
import RecurringPromptDialog from './components/RecurringPromptDialog.vue'
|
||||||
|
import { useOnboardingStore } from './stores/onboarding'
|
||||||
|
import { useProjectsStore } from './stores/projects'
|
||||||
|
import { useInvoicesStore } from './stores/invoices'
|
||||||
|
|
||||||
const settingsStore = useSettingsStore()
|
const settingsStore = useSettingsStore()
|
||||||
// Detect mini timer by Tauri window label — no routing needed
|
const recurringStore = useRecurringStore()
|
||||||
const isMiniTimer = getCurrentWindow().label === 'mini-timer'
|
const { announcement } = useAnnouncer()
|
||||||
|
|
||||||
|
function getProjectName(projectId?: number): string {
|
||||||
|
if (!projectId) return ''
|
||||||
|
const projectsStore = useProjectsStore()
|
||||||
|
return projectsStore.projects.find(p => p.id === projectId)?.name || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProjectColor(projectId?: number): string {
|
||||||
|
if (!projectId) return '#6B7280'
|
||||||
|
const projectsStore = useProjectsStore()
|
||||||
|
return projectsStore.projects.find(p => p.id === projectId)?.color || '#6B7280'
|
||||||
|
}
|
||||||
|
|
||||||
async function registerShortcuts() {
|
async function registerShortcuts() {
|
||||||
try {
|
try {
|
||||||
@@ -53,43 +76,183 @@ function applyTheme() {
|
|||||||
el.setAttribute('data-accent', accent)
|
el.setAttribute('data-accent', accent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyMotion() {
|
||||||
|
const setting = settingsStore.settings.reduce_motion || 'system'
|
||||||
|
const el = document.documentElement
|
||||||
|
if (setting === 'on') {
|
||||||
|
el.classList.add('reduce-motion')
|
||||||
|
} else if (setting === 'off') {
|
||||||
|
el.classList.remove('reduce-motion')
|
||||||
|
} else {
|
||||||
|
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||||
|
el.classList.add('reduce-motion')
|
||||||
|
} else {
|
||||||
|
el.classList.remove('reduce-motion')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await settingsStore.fetchSettings()
|
await settingsStore.fetchSettings()
|
||||||
|
|
||||||
|
const onboardingStore = useOnboardingStore()
|
||||||
|
await onboardingStore.load()
|
||||||
|
|
||||||
|
const timerStore = useTimerStore()
|
||||||
|
await timerStore.restoreState()
|
||||||
|
|
||||||
const zoom = parseInt(settingsStore.settings.ui_zoom) || 100
|
const zoom = parseInt(settingsStore.settings.ui_zoom) || 100
|
||||||
const app = document.getElementById('app')
|
const app = document.getElementById('app')
|
||||||
if (app) {
|
if (app) {
|
||||||
(app.style as any).zoom = `${zoom}%`
|
(app.style as any).zoom = `${zoom}%`
|
||||||
}
|
}
|
||||||
applyTheme()
|
applyTheme()
|
||||||
|
applyMotion()
|
||||||
|
loadAndApplyTimerFont(settingsStore.settings.timer_font || 'JetBrains Mono')
|
||||||
|
|
||||||
|
const dyslexiaMode = settingsStore.settings.dyslexia_mode === 'true'
|
||||||
|
if (dyslexiaMode) {
|
||||||
|
loadAndApplyUIFont('OpenDyslexic')
|
||||||
|
} else {
|
||||||
|
const uiFont = settingsStore.settings.ui_font
|
||||||
|
if (uiFont && uiFont !== 'Inter') {
|
||||||
|
loadAndApplyUIFont(uiFont)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load audio settings
|
||||||
|
const soundEnabled = settingsStore.settings.sound_enabled === 'true'
|
||||||
|
const soundMode = (settingsStore.settings.sound_mode || 'synthesized') as 'synthesized' | 'system' | 'custom'
|
||||||
|
const soundVolume = parseInt(settingsStore.settings.sound_volume) || 70
|
||||||
|
let soundEvents: Record<string, boolean> = {}
|
||||||
|
try {
|
||||||
|
soundEvents = JSON.parse(settingsStore.settings.sound_events || '{}')
|
||||||
|
} catch { /* use defaults */ }
|
||||||
|
|
||||||
|
audioEngine.updateSettings({
|
||||||
|
enabled: soundEnabled,
|
||||||
|
mode: soundMode,
|
||||||
|
volume: soundVolume,
|
||||||
|
events: { ...DEFAULT_EVENTS, ...soundEvents } as Record<SoundEvent, boolean>,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Initialize persistent notifications setting
|
||||||
|
const toastStore = useToastStore()
|
||||||
|
toastStore.setPersistentNotifications(settingsStore.settings.persistent_notifications === 'true')
|
||||||
|
|
||||||
|
await recurringStore.fetchEntries()
|
||||||
|
recurringStore.checkRecurrences()
|
||||||
|
setInterval(() => recurringStore.checkRecurrences(), 60000)
|
||||||
|
|
||||||
|
// Background calendar sync
|
||||||
|
async function syncCalendars() {
|
||||||
|
try {
|
||||||
|
const sources = await invoke<any[]>('get_calendar_sources')
|
||||||
|
for (const source of sources) {
|
||||||
|
if (source.source_type === 'url' && source.enabled && source.url) {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(source.url)
|
||||||
|
if (resp.ok) {
|
||||||
|
const content = await resp.text()
|
||||||
|
await invoke('import_ics_file', { sourceId: source.id, content })
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Calendar sync failed for', source.name, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to sync calendars:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
syncCalendars()
|
||||||
|
setInterval(syncCalendars, 30 * 60000)
|
||||||
|
|
||||||
|
const invoicesStore = useInvoicesStore()
|
||||||
|
await invoicesStore.fetchInvoices()
|
||||||
|
await invoicesStore.checkOverdue()
|
||||||
|
|
||||||
registerShortcuts()
|
registerShortcuts()
|
||||||
|
|
||||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
||||||
if (settingsStore.settings.theme_mode === 'system') applyTheme()
|
if (settingsStore.settings.theme_mode === 'system') applyTheme()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
window.matchMedia('(prefers-reduced-motion: reduce)').addEventListener('change', () => {
|
||||||
|
if (settingsStore.settings.reduce_motion === 'system' || !settingsStore.settings.reduce_motion) {
|
||||||
|
applyMotion()
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(() => [settingsStore.settings.theme_mode, settingsStore.settings.accent_color], () => {
|
watch(() => [settingsStore.settings.theme_mode, settingsStore.settings.accent_color], () => {
|
||||||
applyTheme()
|
applyTheme()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(() => settingsStore.settings.reduce_motion, () => {
|
||||||
|
applyMotion()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => settingsStore.settings.timer_font, (newFont) => {
|
||||||
|
if (newFont) loadAndApplyTimerFont(newFont)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => settingsStore.settings.dyslexia_mode, (val) => {
|
||||||
|
if (val === 'true') {
|
||||||
|
loadAndApplyUIFont('OpenDyslexic')
|
||||||
|
} else {
|
||||||
|
const uiFont = settingsStore.settings.ui_font || 'Inter'
|
||||||
|
loadAndApplyUIFont(uiFont)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => settingsStore.settings.ui_font, (newFont) => {
|
||||||
|
if (settingsStore.settings.dyslexia_mode !== 'true' && newFont) {
|
||||||
|
loadAndApplyUIFont(newFont)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
watch(() => [settingsStore.settings.shortcut_toggle_timer, settingsStore.settings.shortcut_show_app], () => {
|
watch(() => [settingsStore.settings.shortcut_toggle_timer, settingsStore.settings.shortcut_show_app], () => {
|
||||||
registerShortcuts()
|
registerShortcuts()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(() => settingsStore.settings.sound_enabled, (val) => {
|
||||||
|
audioEngine.updateSettings({ enabled: val === 'true' })
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => settingsStore.settings.sound_mode, (val) => {
|
||||||
|
if (val) audioEngine.updateSettings({ mode: val as 'synthesized' | 'system' | 'custom' })
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => settingsStore.settings.sound_volume, (val) => {
|
||||||
|
audioEngine.updateSettings({ volume: parseInt(val) || 70 })
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => settingsStore.settings.sound_events, (val) => {
|
||||||
|
if (val) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(val)
|
||||||
|
audioEngine.updateSettings({ events: { ...DEFAULT_EVENTS, ...parsed } as Record<SoundEvent, boolean> })
|
||||||
|
} catch { /* ignore parse errors */ }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => settingsStore.settings.persistent_notifications, (val) => {
|
||||||
|
const toastStore = useToastStore()
|
||||||
|
toastStore.setPersistentNotifications(val === 'true')
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<!-- Mini timer: detected by window label, rendered directly (no routing) -->
|
<a href="#main-content" class="sr-only sr-only-focusable fixed top-0 left-0 z-[200] bg-accent text-white px-4 py-2 rounded-br-lg">
|
||||||
<div v-if="isMiniTimer" class="h-full w-full bg-bg-base">
|
Skip to main content
|
||||||
<MiniTimer />
|
</a>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Normal app shell -->
|
|
||||||
<template v-else>
|
|
||||||
<div class="h-full w-full flex flex-col bg-bg-base">
|
<div class="h-full w-full flex flex-col bg-bg-base">
|
||||||
<TitleBar />
|
<TitleBar />
|
||||||
<div class="flex-1 flex overflow-hidden">
|
<div class="flex-1 flex overflow-hidden">
|
||||||
<NavRail />
|
<NavRail />
|
||||||
<main class="flex-1 overflow-auto">
|
<main id="main-content" class="flex-1 overflow-auto" tabindex="-1">
|
||||||
<router-view v-slot="{ Component }">
|
<router-view v-slot="{ Component }">
|
||||||
<Transition name="page" mode="out-in" :duration="{ enter: 250, leave: 150 }">
|
<Transition name="page" mode="out-in" :duration="{ enter: 250, leave: 150 }">
|
||||||
<component :is="Component" :key="$route.path" />
|
<component :is="Component" :key="$route.path" />
|
||||||
@@ -99,5 +262,19 @@ watch(() => [settingsStore.settings.shortcut_toggle_timer, settingsStore.setting
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ToastNotification />
|
<ToastNotification />
|
||||||
</template>
|
<RecurringPromptDialog
|
||||||
|
:show="recurringStore.pendingPrompt !== null"
|
||||||
|
:project-name="getProjectName(recurringStore.pendingPrompt?.project_id)"
|
||||||
|
:project-color="getProjectColor(recurringStore.pendingPrompt?.project_id)"
|
||||||
|
:task-name="''"
|
||||||
|
:description="recurringStore.pendingPrompt?.description || ''"
|
||||||
|
:duration="recurringStore.pendingPrompt?.duration || 0"
|
||||||
|
:time-of-day="recurringStore.pendingPrompt?.time_of_day || ''"
|
||||||
|
@confirm="recurringStore.confirmPrompt()"
|
||||||
|
@snooze="recurringStore.snoozePrompt()"
|
||||||
|
@skip="recurringStore.skipPrompt()"
|
||||||
|
/>
|
||||||
|
<div id="route-announcer" class="sr-only" aria-live="polite" aria-atomic="true"></div>
|
||||||
|
<div id="announcer" class="sr-only" aria-live="assertive" aria-atomic="true">{{ announcement }}</div>
|
||||||
|
<TourOverlay />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user