feat: persistent notifications toggle in settings

This commit is contained in:
Your Name
2026-02-20 14:40:50 +02:00
parent 85b39e41f6
commit 3968a818c5
2 changed files with 1411 additions and 61 deletions

View File

@@ -1,16 +1,39 @@
<script setup lang="ts">
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 NavRail from './components/NavRail.vue'
import MiniTimer from './views/MiniTimer.vue'
import ToastNotification from './components/ToastNotification.vue'
import { useSettingsStore } from './stores/settings'
import { useToastStore } from './stores/toast'
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()
// Detect mini timer by Tauri window label — no routing needed
const isMiniTimer = getCurrentWindow().label === 'mini-timer'
const recurringStore = useRecurringStore()
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() {
try {
@@ -53,43 +76,183 @@ function applyTheme() {
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 () => {
await settingsStore.fetchSettings()
const onboardingStore = useOnboardingStore()
await onboardingStore.load()
const timerStore = useTimerStore()
await timerStore.restoreState()
const zoom = parseInt(settingsStore.settings.ui_zoom) || 100
const app = document.getElementById('app')
if (app) {
(app.style as any).zoom = `${zoom}%`
}
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()
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
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], () => {
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], () => {
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>
<template>
<!-- Mini timer: detected by window label, rendered directly (no routing) -->
<div v-if="isMiniTimer" class="h-full w-full bg-bg-base">
<MiniTimer />
</div>
<!-- Normal app shell -->
<template v-else>
<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">
Skip to main content
</a>
<div class="h-full w-full flex flex-col bg-bg-base">
<TitleBar />
<div class="flex-1 flex overflow-hidden">
<NavRail />
<main class="flex-1 overflow-auto">
<main id="main-content" class="flex-1 overflow-auto" tabindex="-1">
<router-view v-slot="{ Component }">
<Transition name="page" mode="out-in" :duration="{ enter: 250, leave: 150 }">
<component :is="Component" :key="$route.path" />
@@ -99,5 +262,19 @@ watch(() => [settingsStore.settings.shortcut_toggle_timer, settingsStore.setting
</div>
</div>
<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>

File diff suppressed because it is too large Load Diff