diff --git a/frontend/src/App.vue b/frontend/src/App.vue index a24c1a4..0464047 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,21 +1,35 @@ - + { + +import { + kBlock, + kBlockTitle, + kButton, + kLink, + kList, + kListInput, + kListItem, + kNavbar, +} from 'konsta/vue' +import { Check } from 'lucide-vue-next' +import { reactive } from 'vue' + +import { usePhoneStore } from '@/stores/phone' +import TimeWheelPicker from '@/components/TimeWheelPicker.vue' +import { + ALARM_SOUND_IDS, + type Alarm, + type AlarmDraft, + WEEKDAY_IDS, +} from '@/utils/alarms' + +const props = defineProps<{ alarm?: Alarm }>() +const emit = defineEmits<{ + cancel: [] + delete: [] + save: [draft: AlarmDraft] +}>() +const phone = usePhoneStore() +const weekdayKeys = [ + 'sunday', + 'monday', + 'tuesday', + 'wednesday', + 'thursday', + 'friday', + 'saturday', +] as const +const draft = reactive({ + note: props.alarm?.note ?? '', + sound: props.alarm?.sound ?? 'radar', + time: props.alarm?.time ?? '07:00', + weekdays: [...(props.alarm?.weekdays ?? [])], +}) +const dangerColors = { + tonalBgIos: 'bg-red-500/15 active:bg-red-500/25', + tonalTextIos: 'text-red-500', +} + +function setNote(event: Event): void { + draft.note = (event.target as HTMLInputElement).value.slice(0, 80) +} + +function save(): void { + emit('save', { + note: draft.note, + sound: draft.sound, + time: draft.time, + weekdays: [...draft.weekdays], + }) +} + +function toggleWeekday(weekday: number): void { + draft.weekdays = draft.weekdays.includes(weekday) + ? draft.weekdays.filter((candidate) => candidate !== weekday) + : [...draft.weekdays, weekday] +} + + + + + + + {{ phone.t('Common.cancel') }} + + + + + {{ phone.t('Common.save') }} + + + + + + + + {{ phone.t('Apps.clock.alarm.repeat') }} + + + + + + + + + + + + + {{ phone.t('Apps.clock.alarm.sound') }} + + + + + + + + + + {{ phone.t('Apps.clock.alarm.delete') }} + + + diff --git a/frontend/src/components/PhoneNotifications.vue b/frontend/src/components/PhoneNotifications.vue new file mode 100644 index 0000000..3a6aa78 --- /dev/null +++ b/frontend/src/components/PhoneNotifications.vue @@ -0,0 +1,36 @@ + + + + + + + + + {{ phone.t('Common.close') }} + + + diff --git a/frontend/src/components/TimeWheelPicker.vue b/frontend/src/components/TimeWheelPicker.vue new file mode 100644 index 0000000..30b50d8 --- /dev/null +++ b/frontend/src/components/TimeWheelPicker.vue @@ -0,0 +1,117 @@ + + + + + + : + + + + {{ format(hour) }} + + + + + + {{ format(minute) }} + + + + diff --git a/frontend/src/stores/clock.ts b/frontend/src/stores/clock.ts index ecfd311..2895764 100644 --- a/frontend/src/stores/clock.ts +++ b/frontend/src/stores/clock.ts @@ -1,23 +1,18 @@ import { defineStore } from 'pinia' +import { + alarmMinuteKey, + type Alarm, + type AlarmDraft, + isAlarmDue, + readAlarms, + writeAlarms, +} from '@/utils/alarms' import { elapsedMilliseconds, remainingMilliseconds } from '@/utils/clock' export const useClockStore = defineStore('clock', { state: () => ({ - alarms: [ - { - enabled: true, - id: 'weekday', - labelKey: 'Apps.clock.alarm.weekday', - time: '07:30', - }, - { - enabled: false, - id: 'weekend', - labelKey: 'Apps.clock.alarm.weekend', - time: '09:00', - }, - ], + alarms: readAlarms(), laps: [] as number[], stopwatchAccumulated: 0, stopwatchStartedAt: null as number | null, @@ -26,6 +21,34 @@ export const useClockStore = defineStore('clock', { timerStartedAt: null as number | null, }), actions: { + createAlarm(draft: AlarmDraft): Alarm { + const alarm: Alarm = { + ...structuredClone(draft), + enabled: true, + id: `alarm-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, + lastTriggeredMinute: null, + } + this.alarms.push(alarm) + this.persistAlarms() + return alarm + }, + deleteAlarm(id: string): void { + this.alarms = this.alarms.filter((alarm) => alarm.id !== id) + this.persistAlarms() + }, + dueAlarms(now: number): Alarm[] { + const date = new Date(now) + const due = this.alarms.filter((alarm) => isAlarmDue(alarm, date)) + if (!due.length) return due + + const minuteKey = alarmMinuteKey(date) + for (const alarm of due) { + alarm.lastTriggeredMinute = minuteKey + if (!alarm.weekdays.length) alarm.enabled = false + } + this.persistAlarms() + return due + }, addLap(now: number): void { if (this.stopwatchStartedAt === null) return this.laps.unshift( @@ -54,6 +77,9 @@ export const useClockStore = defineStore('clock', { ) this.timerStartedAt = null }, + persistAlarms(): void { + writeAlarms(this.alarms) + }, resetStopwatch(): void { this.stopwatchAccumulated = 0 this.stopwatchStartedAt = null @@ -79,7 +105,18 @@ export const useClockStore = defineStore('clock', { }, toggleAlarm(id: string): void { const alarm = this.alarms.find((candidate) => candidate.id === id) - if (alarm) alarm.enabled = !alarm.enabled + if (!alarm) return + alarm.enabled = !alarm.enabled + alarm.lastTriggeredMinute = null + this.persistAlarms() + }, + updateAlarm(id: string, draft: AlarmDraft): void { + const alarm = this.alarms.find((candidate) => candidate.id === id) + if (!alarm) return + Object.assign(alarm, structuredClone(draft), { + lastTriggeredMinute: null, + }) + this.persistAlarms() }, }, }) diff --git a/frontend/src/stores/notifications.ts b/frontend/src/stores/notifications.ts new file mode 100644 index 0000000..5a3126a --- /dev/null +++ b/frontend/src/stores/notifications.ts @@ -0,0 +1,107 @@ +import { computed, ref } from 'vue' +import { defineStore } from 'pinia' + +import { usePhoneStore } from '@/stores/phone' +import type { PhoneAppId } from '@/types/apps' +import { playPhoneTone, type PhoneToneId } from '@/utils/tones' + +export type PhoneNotificationInput = { + appId: PhoneAppId + critical?: boolean + persistent?: boolean + sound?: PhoneToneId + subtitle?: string + text: string + title: string +} + +export type PhoneNotification = PhoneNotificationInput & { + id: string +} + +const timeoutHandles = new Map>() +const stopToneHandles = new Map void>() + +export const useNotificationsStore = defineStore('notifications', () => { + const phone = usePhoneStore() + const queue = ref([]) + const current = computed(() => queue.value[0] ?? null) + const isPeeking = computed(() => !!current.value && !phone.isOpen) + const requiresAttention = computed( + () => !!current.value?.persistent && !phone.isOpen, + ) + + function activate(notification: PhoneNotification): void { + const appPreferences = + phone.preferences.settings.notifications[notification.appId] + if (appPreferences.sounds || notification.critical) { + const sound = + notification.sound ?? phone.preferences.settings.notificationSound + const volume = notification.critical + ? phone.preferences.settings.ringtoneVolume + : phone.preferences.settings.notificationVolume + stopToneHandles.set( + notification.id, + playPhoneTone(sound, volume, !!notification.persistent), + ) + } + + if (!notification.persistent) { + timeoutHandles.set( + notification.id, + setTimeout( + () => dismiss(notification.id), + phone.preferences.settings.notificationDurationSeconds * 1000, + ), + ) + } + } + + function dismiss(id: string): void { + const index = queue.value.findIndex((notification) => notification.id === id) + if (index < 0) return + const wasCurrent = index === 0 + const timeout = timeoutHandles.get(id) + if (timeout) clearTimeout(timeout) + timeoutHandles.delete(id) + stopToneHandles.get(id)?.() + stopToneHandles.delete(id) + queue.value.splice(index, 1) + if (wasCurrent && current.value) activate(current.value) + } + + function dismissCurrent(): void { + if (current.value) dismiss(current.value.id) + } + + function show(input: PhoneNotificationInput): string | null { + const appPreferences = phone.preferences.settings.notifications[input.appId] + if (!appPreferences) { + console.error(`[Phone notifications] Unknown app: ${input.appId}`) + return null + } + if (!input.critical && !appPreferences.enabled) { + return null + } + if (input.critical) { + for (const notification of [...queue.value]) dismiss(notification.id) + } + const notification: PhoneNotification = { + ...input, + id: `notification-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, + } + queue.value.push(notification) + if (queue.value.length === 1) activate(notification) + return notification.id + } + + return { + current, + dismiss, + dismissCurrent, + isPeeking, + queue, + requiresAttention, + show, + } +}) diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index 590e189..690fc50 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -79,7 +79,7 @@ const defaultLocales: LocaleTree = { name: 'Clock', lap: 'Lap', minutes: 'Minutes', - add: 'Add clock', + add: 'Add alarm', location: 'Los Santos', tabs: { world: 'World Clock', @@ -87,7 +87,41 @@ const defaultLocales: LocaleTree = { stopwatch: 'Stopwatch', timer: 'Timer', }, - alarm: { weekday: 'Weekdays', weekend: 'Weekend' }, + alarm: { + add: 'Add Alarm', + edit: 'Edit Alarm', + ringing: 'Alarm', + time: 'Time', + hours: 'Hours', + repeat: 'Repeat', + note: 'Note', + notePlaceholder: 'Alarm', + sound: 'Sound', + delete: 'Delete Alarm', + never: 'Never', + everyDay: 'Every Day', + weekdays: 'Weekdays', + weekends: 'Weekends', + days: { + sunday: 'Sunday', + monday: 'Monday', + tuesday: 'Tuesday', + wednesday: 'Wednesday', + thursday: 'Thursday', + friday: 'Friday', + saturday: 'Saturday', + }, + daysShort: { + sunday: 'Sun', + monday: 'Mon', + tuesday: 'Tue', + wednesday: 'Wed', + thursday: 'Thu', + friday: 'Fri', + saturday: 'Sat', + }, + sounds: { radar: 'Radar', beacon: 'Beacon', chimes: 'Chimes' }, + }, }, photos: { name: 'Photos', @@ -148,6 +182,8 @@ const defaultLocales: LocaleTree = { appearance: 'Appearance', allowNotifications: 'Allow Notifications', notificationSounds: 'Sounds', + notificationDuration: 'Notification Duration', + seconds: '{seconds} seconds', ringtoneVolume: 'Ringtone Volume', notificationVolume: 'Notification Volume', ringtone: 'Ringtone', @@ -199,8 +235,11 @@ const defaultLocales: LocaleTree = { }, }, Common: { + add: 'Add', cancel: 'Cancel', close: 'Close', + delete: 'Delete', + done: 'Done', edit: 'Edit', home: 'Home', pause: 'Pause', @@ -208,9 +247,11 @@ const defaultLocales: LocaleTree = { phoneStatus: 'Phone status', reset: 'Reset', search: 'Search', + save: 'Save', start: 'Start', stop: 'Stop', }, + Notifications: { now: 'now' }, Home: { appLibrary: 'App Library', appLibrarySearch: 'Search apps', diff --git a/frontend/src/utils/alarms.test.ts b/frontend/src/utils/alarms.test.ts new file mode 100644 index 0000000..820216b --- /dev/null +++ b/frontend/src/utils/alarms.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' + +import { alarmMinuteKey, isAlarmDue, type Alarm } from './alarms' + +const alarm: Alarm = { + enabled: true, + id: 'test', + lastTriggeredMinute: null, + note: '', + sound: 'radar', + time: '07:30', + weekdays: [1, 2, 3, 4, 5], +} + +describe('alarm scheduling', () => { + it('fires once in the matching local minute and weekday', () => { + const monday = new Date(2026, 7, 3, 7, 30) + expect(isAlarmDue(alarm, monday)).toBe(true) + expect( + isAlarmDue( + { ...alarm, lastTriggeredMinute: alarmMinuteKey(monday) }, + monday, + ), + ).toBe(false) + expect(isAlarmDue(alarm, new Date(2026, 7, 2, 7, 30))).toBe(false) + }) + + it('treats an empty repeat selection as a one-time daily match', () => { + expect( + isAlarmDue( + { ...alarm, time: '09:00', weekdays: [] }, + new Date(2026, 7, 2, 9, 0), + ), + ).toBe(true) + }) +}) diff --git a/frontend/src/utils/alarms.ts b/frontend/src/utils/alarms.ts new file mode 100644 index 0000000..3e30955 --- /dev/null +++ b/frontend/src/utils/alarms.ts @@ -0,0 +1,119 @@ +export const ALARMS_STORAGE_KEY = 'sky_phone.clock.alarms.v1' + +export const ALARM_SOUND_IDS = ['radar', 'beacon', 'chimes'] as const +export const WEEKDAY_IDS = [1, 2, 3, 4, 5, 6, 0] as const + +export type AlarmSoundId = (typeof ALARM_SOUND_IDS)[number] + +export type Alarm = { + enabled: boolean + id: string + lastTriggeredMinute: string | null + note: string + sound: AlarmSoundId + time: string + weekdays: number[] +} + +export type AlarmDraft = Pick + +const DEFAULT_ALARMS: Alarm[] = [ + { + enabled: true, + id: 'weekday', + lastTriggeredMinute: null, + note: '', + sound: 'radar', + time: '07:30', + weekdays: [1, 2, 3, 4, 5], + }, + { + enabled: false, + id: 'weekend', + lastTriggeredMinute: null, + note: '', + sound: 'chimes', + time: '09:00', + weekdays: [0, 6], + }, +] + +function isAlarmSound(value: unknown): value is AlarmSoundId { + return ( + typeof value === 'string' && + ALARM_SOUND_IDS.includes(value as AlarmSoundId) + ) +} + +function readAlarm(value: unknown): Alarm | null { + if (!value || typeof value !== 'object') return null + const alarm = value as Partial + if ( + typeof alarm.id !== 'string' || + typeof alarm.enabled !== 'boolean' || + typeof alarm.time !== 'string' || + !/^([01]\d|2[0-3]):[0-5]\d$/.test(alarm.time) || + typeof alarm.note !== 'string' || + !isAlarmSound(alarm.sound) || + !Array.isArray(alarm.weekdays) + ) { + return null + } + + return { + enabled: alarm.enabled, + id: alarm.id, + lastTriggeredMinute: + typeof alarm.lastTriggeredMinute === 'string' + ? alarm.lastTriggeredMinute + : null, + note: alarm.note.slice(0, 80), + sound: alarm.sound, + time: alarm.time, + weekdays: [ + ...new Set( + alarm.weekdays.filter( + (weekday): weekday is number => + Number.isInteger(weekday) && weekday >= 0 && weekday <= 6, + ), + ), + ], + } +} + +export function readAlarms(): Alarm[] { + const raw = window.localStorage.getItem(ALARMS_STORAGE_KEY) + if (!raw) return structuredClone(DEFAULT_ALARMS) + + try { + const parsed = JSON.parse(raw) as unknown + if (!Array.isArray(parsed)) return structuredClone(DEFAULT_ALARMS) + return parsed.map(readAlarm).filter((alarm): alarm is Alarm => !!alarm) + } catch { + return structuredClone(DEFAULT_ALARMS) + } +} + +export function writeAlarms(alarms: Alarm[]): void { + window.localStorage.setItem(ALARMS_STORAGE_KEY, JSON.stringify(alarms)) +} + +export function alarmMinuteKey(date: Date): string { + return [ + date.getFullYear(), + String(date.getMonth() + 1).padStart(2, '0'), + String(date.getDate()).padStart(2, '0'), + String(date.getHours()).padStart(2, '0'), + String(date.getMinutes()).padStart(2, '0'), + ].join('-') +} + +export function isAlarmDue(alarm: Alarm, date: Date): boolean { + if (!alarm.enabled) return false + const currentTime = `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}` + if (alarm.time !== currentTime) return false + if (alarm.weekdays.length && !alarm.weekdays.includes(date.getDay())) { + return false + } + return alarm.lastTriggeredMinute !== alarmMinuteKey(date) +} diff --git a/frontend/src/utils/preferences.test.ts b/frontend/src/utils/preferences.test.ts index 5a2a007..e67e753 100644 --- a/frontend/src/utils/preferences.test.ts +++ b/frontend/src/utils/preferences.test.ts @@ -14,6 +14,7 @@ describe('preferences', () => { settings: { appearanceMode: 'light', notificationVolume: 45, + notificationDurationSeconds: 14, notifications: { camera: { enabled: false, sounds: false }, }, @@ -24,6 +25,7 @@ describe('preferences', () => { ) expect(value.settings.appearanceMode).toBe('light') expect(value.settings.notificationVolume).toBe(45) + expect(value.settings.notificationDurationSeconds).toBe(14) expect(value.settings.notifications.camera).toEqual({ enabled: false, sounds: false, @@ -41,6 +43,7 @@ describe('preferences', () => { appearanceMode: 'neon', frame: 'gold', notificationVolume: -10, + notificationDurationSeconds: 100, phoneScale: 500, ringtoneVolume: 120, }, @@ -50,6 +53,7 @@ describe('preferences', () => { expect(value.settings.appearanceMode).toBe('automatic') expect(value.settings.frame).toBe('black') expect(value.settings.notificationVolume).toBe(0) + expect(value.settings.notificationDurationSeconds).toBe(30) expect(value.settings.phoneScale).toBe(115) expect(value.settings.ringtoneVolume).toBe(100) }) diff --git a/frontend/src/utils/preferences.ts b/frontend/src/utils/preferences.ts index 8deaa77..1cedb76 100644 --- a/frontend/src/utils/preferences.ts +++ b/frontend/src/utils/preferences.ts @@ -30,6 +30,7 @@ export type PhonePreferencesV1 = { appearanceMode: AppearanceMode frame: PhoneFrameId notificationSound: NotificationSoundId + notificationDurationSeconds: number notificationVolume: number notifications: Record phoneScale: number @@ -59,6 +60,7 @@ export const DEFAULT_PHONE_PREFERENCES: PhonePreferencesV1 = { appearanceMode: 'automatic', frame: 'black', notificationSound: 'chime', + notificationDurationSeconds: 10, notificationVolume: 70, notifications: DEFAULT_APP_NOTIFICATIONS, phoneScale: 100, @@ -147,6 +149,12 @@ export function parsePhonePreferences(raw: string | null): PhonePreferencesV1 { NOTIFICATION_SOUND_IDS, defaults.notificationSound, ), + notificationDurationSeconds: readNumber( + settings.notificationDurationSeconds, + defaults.notificationDurationSeconds, + 3, + 30, + ), notificationVolume: readNumber( settings.notificationVolume, defaults.notificationVolume, diff --git a/frontend/src/utils/tones.ts b/frontend/src/utils/tones.ts new file mode 100644 index 0000000..1e37465 --- /dev/null +++ b/frontend/src/utils/tones.ts @@ -0,0 +1,79 @@ +import type { AlarmSoundId } from '@/utils/alarms' +import type { NotificationSoundId } from '@/utils/preferences' + +export type PhoneToneId = AlarmSoundId | NotificationSoundId + +const TONE_PATTERNS: Record = { + beacon: [660, 880, 660, 880], + chime: [784, 1047], + chimes: [523, 659, 784, 1047], + radar: [880, 0, 880, 0, 1175], + signal: [740, 988, 740], + soft: [523, 659], +} + +export function playPhoneTone( + tone: PhoneToneId, + volumePercent: number, + loop: boolean, +): () => void { + const AudioContextConstructor = + window.AudioContext ?? + (window as typeof window & { webkitAudioContext?: typeof AudioContext }) + .webkitAudioContext + if (!AudioContextConstructor) { + console.error('[Phone audio] Web Audio is unavailable') + return () => undefined + } + + const context = new AudioContextConstructor() + const frequencies = TONE_PATTERNS[tone] + const volume = Math.max(0, Math.min(1, volumePercent / 100)) * 0.16 + let stopped = false + let nextPatternTimer: ReturnType | undefined + let oscillators: OscillatorNode[] = [] + + const schedulePattern = (): void => { + if (stopped) return + const startAt = context.currentTime + 0.02 + oscillators = frequencies.flatMap((frequency, index) => { + if (!frequency) return [] + const oscillator = context.createOscillator() + const gain = context.createGain() + const toneStart = startAt + index * 0.24 + oscillator.type = 'sine' + oscillator.frequency.value = frequency + gain.gain.setValueAtTime(0, toneStart) + gain.gain.linearRampToValueAtTime(volume, toneStart + 0.025) + gain.gain.exponentialRampToValueAtTime(0.001, toneStart + 0.19) + oscillator.connect(gain).connect(context.destination) + oscillator.start(toneStart) + oscillator.stop(toneStart + 0.2) + return [oscillator] + }) + + if (loop) { + nextPatternTimer = setTimeout( + schedulePattern, + frequencies.length * 240 + 520, + ) + } + } + + void context.resume().then(schedulePattern).catch((error: unknown) => { + console.error('[Phone audio] Failed to start tone', error) + }) + + return () => { + stopped = true + if (nextPatternTimer) clearTimeout(nextPatternTimer) + for (const oscillator of oscillators) { + try { + oscillator.stop() + } catch { + // The oscillator already completed its scheduled note. + } + } + void context.close() + } +} diff --git a/frontend/src/views/apps/ClockApp.vue b/frontend/src/views/apps/ClockApp.vue index 04f6fac..b097834 100644 --- a/frontend/src/views/apps/ClockApp.vue +++ b/frontend/src/views/apps/ClockApp.vue @@ -8,15 +8,24 @@ import { kListItem, kNavbar, kPage, - kTabbar, - kTabbarLink, + kSegmented, + kSegmentedButton, kToggle, } from 'konsta/vue' -import { AlarmClock, Clock3, Plus, Timer, TimerReset } from 'lucide-vue-next' +import { + AlarmClock, + Clock3, + Minus, + Plus, + Timer, + TimerReset, +} from 'lucide-vue-next' import { computed, onBeforeUnmount, onMounted, ref } from 'vue' +import AlarmEditor from '@/components/AlarmEditor.vue' import { useClockStore } from '@/stores/clock' import { usePhoneStore } from '@/stores/phone' +import type { Alarm, AlarmDraft } from '@/utils/alarms' import { elapsedMilliseconds, formatStopwatch, @@ -27,7 +36,12 @@ import { const phone = usePhoneStore() const clock = useClockStore() const tab = ref<'world' | 'alarm' | 'stopwatch' | 'timer'>('world') +const alarmEditor = ref< + { mode: 'create' } | { id: string; mode: 'edit' } | null +>(null) +const alarmsEditing = ref(false) const now = ref(Date.now()) +const browserTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone let ticker: ReturnType | undefined const stopwatchValue = computed(() => @@ -44,13 +58,29 @@ const timerValue = computed(() => now.value, ), ) -const losSantosTime = computed(() => +const currentTime = computed(() => new Intl.DateTimeFormat(phone.lang, { hour: '2-digit', minute: '2-digit', - timeZone: 'America/Los_Angeles', + timeZone: browserTimeZone, }).format(now.value), ) +const currentTimeZone = computed( + () => + new Intl.DateTimeFormat(phone.lang, { + timeZone: browserTimeZone, + timeZoneName: 'short', + }) + .formatToParts(now.value) + .find((part) => part.type === 'timeZoneName')?.value ?? + browserTimeZone, +) +const selectedAlarm = computed(() => { + const editor = alarmEditor.value + return editor?.mode === 'edit' + ? clock.alarms.find((alarm) => alarm.id === editor.id) + : undefined +}) const tabs = [ { id: 'world', icon: Clock3 }, { id: 'alarm', icon: AlarmClock }, @@ -68,11 +98,72 @@ const positiveActionColors = { const toggleColors = { checkedBgIos: 'bg-[#30d158]', } -const tabColors = { - textActiveIos: 'text-[#d99900]', - textIos: 'text-[#77777c]', +const dangerActionColors = { + fillBgIos: 'bg-red-500 active:bg-red-600', + fillTextIos: 'text-white', +} +const weekdayKeys = [ + 'sunday', + 'monday', + 'tuesday', + 'wednesday', + 'thursday', + 'friday', + 'saturday', +] as const + +function alarmRepeat(alarm: Alarm): string { + const weekdays = [...alarm.weekdays].sort() + if (!weekdays.length) return phone.t('Apps.clock.alarm.never') + if (weekdays.length === 7) return phone.t('Apps.clock.alarm.everyDay') + if (weekdays.join(',') === '1,2,3,4,5') { + return phone.t('Apps.clock.alarm.weekdays') + } + if (weekdays.join(',') === '0,6') { + return phone.t('Apps.clock.alarm.weekends') + } + return weekdays + .map((weekday) => + phone.t(`Apps.clock.alarm.daysShort.${weekdayKeys[weekday]}`), + ) + .join(', ') } +function alarmSubtitle(alarm: Alarm): string { + const repeat = alarmRepeat(alarm) + return alarm.note ? `${repeat} · ${alarm.note}` : repeat +} + +function openAlarmEditor(id?: string): void { + tab.value = 'alarm' + alarmEditor.value = id ? { id, mode: 'edit' } : { mode: 'create' } +} + +function saveAlarm(draft: AlarmDraft): void { + if (alarmEditor.value?.mode === 'edit') { + clock.updateAlarm(alarmEditor.value.id, draft) + } else { + clock.createAlarm(draft) + } + alarmEditor.value = null +} + +function deleteSelectedAlarm(): void { + if (alarmEditor.value?.mode === 'edit') { + clock.deleteAlarm(alarmEditor.value.id) + } + alarmEditor.value = null +} + +function toggleAlarmEditing(): void { + tab.value = 'alarm' + alarmsEditing.value = !alarmsEditing.value +} + +function selectTab(nextTab: (typeof tabs)[number]['id']): void { + tab.value = nextTab + if (nextTab !== 'alarm') alarmsEditing.value = false +} onMounted(() => { ticker = setInterval(() => { now.value = Date.now() @@ -84,7 +175,16 @@ onBeforeUnmount(() => clearInterval(ticker)) - + + + clearInterval(ticker)) - {{ phone.t('Common.edit') }} + {{ phone.t(alarmsEditing ? 'Common.done' : 'Common.edit') }} @@ -104,6 +205,7 @@ onBeforeUnmount(() => clearInterval(ticker)) icon-only :link-props="{ type: 'button' }" :aria-label="phone.t('Apps.clock.add')" + @click="openAlarmEditor()" > @@ -113,9 +215,10 @@ onBeforeUnmount(() => clearInterval(ticker)) {{ - phone.t('Apps.clock.location') + browserTimeZone.replace(/_/g, ' ') }} - {{ losSantosTime }} + {{ currentTime }} + {{ currentTimeZone }} clearInterval(ticker)) + + + + + - + + + @@ -242,30 +364,25 @@ onBeforeUnmount(() => clearInterval(ticker)) - - - - - - {{ - phone.t(`Apps.clock.tabs.${item.id}`) - }} - - + + + + + + + + + diff --git a/frontend/src/views/apps/SettingsApp.vue b/frontend/src/views/apps/SettingsApp.vue index 432a9c8..ddf388d 100644 --- a/frontend/src/views/apps/SettingsApp.vue +++ b/frontend/src/views/apps/SettingsApp.vue @@ -177,7 +177,11 @@ function toggleRootSetting(key: RootToggleKey): void { } function updateNumberPreference( - key: 'notificationVolume' | 'phoneScale' | 'ringtoneVolume', + key: + | 'notificationDurationSeconds' + | 'notificationVolume' + | 'phoneScale' + | 'ringtoneVolume', event: Event, ): void { phone.setPreference( @@ -539,6 +543,34 @@ function selectNotificationSound(sound: NotificationSoundId): void { + + {{ phone.t('Apps.settings.notificationDuration') }} · + {{ + phone.t('Apps.settings.seconds', { + seconds: String( + phone.preferences.settings.notificationDurationSeconds, + ), + }) + }} + + + + + + + + + {{ phone.t('Apps.settings.about') }} Sky Phone - - + +