ENH - clock app

This commit is contained in:
Eichenholz
2026-08-03 21:40:56 +02:00
parent e64bddcc15
commit c955e31e63
18 changed files with 1146 additions and 92 deletions
+47 -4
View File
@@ -1,21 +1,35 @@
<script setup lang="ts">
import { kApp } from 'konsta/vue'
import { computed, onBeforeUnmount, onMounted, type CSSProperties } from 'vue'
import {
computed,
onBeforeUnmount,
onMounted,
type CSSProperties,
watch,
} from 'vue'
import { useRoute } from 'vue-router'
import PhoneHomeIndicator from '@/components/PhoneHomeIndicator.vue'
import PhoneNotifications from '@/components/PhoneNotifications.vue'
import PhoneStatusBar from '@/components/PhoneStatusBar.vue'
import { PHONE_FRAME_IMAGES } from '@/config/appearance'
import { useClockStore } from '@/stores/clock'
import {
useNotificationsStore,
type PhoneNotificationInput,
} from '@/stores/notifications'
import { usePhoneStore, type PhoneOpenPayload } from '@/stores/phone'
import { nuiCall } from '@/utils/nui'
import SpringboardView from '@/views/SpringboardView.vue'
type AppMessage = {
type?: string
data?: PhoneOpenPayload
data?: PhoneNotificationInput | PhoneOpenPayload
}
const phone = usePhoneStore()
const clock = useClockStore()
const notifications = useNotificationsStore()
const route = useRoute()
const isAppRoute = computed(() => route.name === 'app')
const systemColorScheme = window.matchMedia('(prefers-color-scheme: dark)')
@@ -25,12 +39,15 @@ const phoneDeviceStyle = computed<CSSProperties>(() => ({
const phoneFrameImage = computed(
() => PHONE_FRAME_IMAGES[phone.preferences.settings.frame],
)
let alarmTicker: ReturnType<typeof setInterval> | undefined
function onMessage(event: MessageEvent<AppMessage>): void {
if (event.data?.type === 'app:open') {
phone.open(event.data.data)
phone.open(event.data.data as PhoneOpenPayload)
} else if (event.data?.type === 'app:close') {
phone.close()
} else if (event.data?.type === 'notification:show' && event.data.data) {
notifications.show(event.data.data as PhoneNotificationInput)
}
}
@@ -50,10 +67,31 @@ onMounted(() => {
systemColorScheme.addEventListener('change', onSystemColorSchemeChange)
phone.setSystemDarkMode(systemColorScheme.matches)
void nuiCall('ui:ready')
alarmTicker = setInterval(() => {
for (const alarm of clock.dueAlarms(Date.now())) {
notifications.show({
appId: 'clock',
critical: true,
persistent: true,
sound: alarm.sound,
subtitle: alarm.time,
text: alarm.note || phone.t('Apps.clock.alarm.ringing'),
title: phone.t('Apps.clock.name'),
})
}
}, 1000)
if (import.meta.env.DEV) phone.open()
})
watch(
() => notifications.requiresAttention,
(active) => {
void nuiCall('notification:focus', { active })
},
)
onBeforeUnmount(() => {
if (alarmTicker) clearInterval(alarmTicker)
window.removeEventListener('message', onMessage)
window.removeEventListener('keydown', onKeydown)
systemColorScheme.removeEventListener('change', onSystemColorSchemeChange)
@@ -61,7 +99,11 @@ onBeforeUnmount(() => {
</script>
<template>
<main v-if="phone.isOpen" class="phone-stage">
<main
v-if="phone.isOpen || notifications.current"
class="phone-stage"
:class="{ 'phone-stage--peek': notifications.isPeeking }"
>
<section
class="phone-device"
:style="phoneDeviceStyle"
@@ -86,6 +128,7 @@ onBeforeUnmount(() => {
</Transition>
</RouterView>
<PhoneHomeIndicator />
<PhoneNotifications />
</k-app>
</div>
<img
+130 -19
View File
@@ -38,6 +38,14 @@ button {
place-items: center;
pointer-events: none;
}
.phone-stage--peek {
place-items: end;
padding: 0 24px;
}
.phone-stage--peek .phone-device {
transform: translateY(calc(100% - 190px)) scale(var(--phone-scale, 1));
transform-origin: right bottom;
}
.phone-device {
position: relative;
width: min(39vh, 390px);
@@ -82,6 +90,17 @@ button {
overflow: hidden;
background: transparent !important;
}
.phone-notification {
right: 8px !important;
left: 8px !important;
width: auto !important;
margin-left: 0 !important;
}
.phone-notification__icon {
width: 38px;
height: 38px;
object-fit: contain;
}
.phone-status-bar {
position: absolute;
z-index: 70;
@@ -697,8 +716,10 @@ button {
padding-left: 27px;
}
.clock-content {
min-height: calc(100% - 113px);
padding: 0 0 88px;
min-height: 0;
flex: 1;
overflow-y: auto;
padding: 0;
}
.clock-konsta-list {
margin-top: 0;
@@ -724,11 +745,114 @@ button {
font-weight: 200;
letter-spacing: -4px;
}
.clock-world-zone {
margin-top: 2px;
color: #8e8e93;
font-size: 14px;
}
.clock-alarm-list {
margin-top: 8px;
}
.clock-alarm-item {
min-height: 72px;
min-height: 86px;
}
.clock-alarm-remove {
width: 28px;
min-width: 28px;
height: 28px;
padding: 0;
}
.clock-alarm-remove svg {
width: 17px;
height: 17px;
stroke-width: 3;
}
.clock-alarm-editor {
min-height: 0;
flex: 1;
margin: 0;
padding: 8px 0 32px;
overflow-y: auto;
}
.time-wheel-picker {
position: relative;
width: calc(100% - 32px);
height: 200px;
margin: 0 16px 16px;
display: grid;
grid-template-columns: 1fr 1fr;
overflow: hidden;
}
.time-wheel-picker__selection {
position: absolute;
z-index: 0;
top: 80px;
right: 0;
left: 0;
height: 40px;
border-radius: 14px;
background: #2c2c2e;
pointer-events: none;
}
.time-wheel-picker__separator {
position: absolute;
z-index: 2;
top: 80px;
left: 50%;
height: 40px;
transform: translateX(-50%);
display: grid;
place-items: center;
color: #fff;
font-size: 31px;
font-weight: 300;
pointer-events: none;
}
.time-wheel-picker__column {
position: relative;
z-index: 1;
height: 200px;
padding: 80px 0;
overflow-x: hidden;
overflow-y: auto;
scrollbar-width: none;
scroll-snap-type: y mandatory;
overscroll-behavior: contain;
mask-image: linear-gradient(
to bottom,
transparent 0,
#000 30%,
#000 70%,
transparent 100%
);
}
.time-wheel-picker__column::-webkit-scrollbar {
display: none;
}
.time-wheel-picker__value {
width: 100%;
height: 40px;
display: block;
padding: 0;
border: 0;
scroll-snap-align: center;
background: transparent;
color: #8e8e93;
font-size: 27px;
font-weight: 300;
line-height: 40px;
opacity: 0.62;
transition:
color 120ms ease,
opacity 120ms ease;
}
.time-wheel-picker__value--selected {
color: #fff;
opacity: 1;
}
.clock-alarm-delete {
width: calc(100% - 32px);
margin: 18px 16px 0;
}
.clock-tool {
margin: 0;
@@ -1211,24 +1335,11 @@ button {
}
.clock-app {
padding: 44px 0 24px;
display: flex;
flex-direction: column;
overflow: hidden;
background: #020202;
}
.clock-tabbar {
position: absolute !important;
right: 0;
bottom: 24px;
left: 0;
}
.clock-tabbar .k-link {
min-width: 0 !important;
padding-right: 4px;
padding-left: 4px;
outline: none;
}
.clock-tab-label {
font-size: 9px;
letter-spacing: -0.1px;
}
.reference-photos {
padding: 0 0 79px;
+150
View File
@@ -0,0 +1,150 @@
<script setup lang="ts">
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<AlarmDraft>({
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]
}
</script>
<template>
<k-navbar :title="phone.t('Apps.clock.tabs.alarm')">
<template #left>
<k-link component="button" @click="emit('cancel')">
{{ phone.t('Common.cancel') }}
</k-link>
</template>
<template #right>
<k-link component="button" @click="save">
{{ phone.t('Common.save') }}
</k-link>
</template>
</k-navbar>
<k-block component="section" class="clock-alarm-editor">
<TimeWheelPicker
v-model="draft.time"
:hours-label="phone.t('Apps.clock.alarm.hours')"
:label="phone.t('Apps.clock.alarm.time')"
:minutes-label="phone.t('Apps.clock.minutes')"
/>
<k-block-title>{{ phone.t('Apps.clock.alarm.repeat') }}</k-block-title>
<k-list strong inset>
<k-list-item
v-for="weekday in WEEKDAY_IDS"
:key="weekday"
link
:chevron="false"
:title="phone.t(`Apps.clock.alarm.days.${weekdayKeys[weekday]}`)"
@click="toggleWeekday(weekday)"
>
<template #after>
<Check
v-if="draft.weekdays.includes(weekday)"
class="w-5 h-5 text-primary"
/>
</template>
</k-list-item>
</k-list>
<k-list strong inset>
<k-list-input
:label="phone.t('Apps.clock.alarm.note')"
:placeholder="phone.t('Apps.clock.alarm.notePlaceholder')"
:value="draft.note"
maxlength="80"
@input="setNote"
/>
</k-list>
<k-block-title>{{ phone.t('Apps.clock.alarm.sound') }}</k-block-title>
<k-list strong inset>
<k-list-item
v-for="sound in ALARM_SOUND_IDS"
:key="sound"
link
:chevron="false"
:title="phone.t(`Apps.clock.alarm.sounds.${sound}`)"
@click="draft.sound = sound"
>
<template #after>
<Check v-if="draft.sound === sound" class="w-5 h-5 text-primary" />
</template>
</k-list-item>
</k-list>
<k-button
v-if="alarm"
large
rounded
tonal
:colors="dangerColors"
class="clock-alarm-delete"
@click="emit('delete')"
>
{{ phone.t('Apps.clock.alarm.delete') }}
</k-button>
</k-block>
</template>
@@ -0,0 +1,36 @@
<script setup lang="ts">
import { kNotification } from 'konsta/vue'
import { computed } from 'vue'
import { getPhoneApp } from '@/config/apps'
import { useNotificationsStore } from '@/stores/notifications'
import { usePhoneStore } from '@/stores/phone'
const notifications = useNotificationsStore()
const phone = usePhoneStore()
const icon = computed(() =>
notifications.current
? getPhoneApp(notifications.current.appId)?.iconImage
: undefined,
)
</script>
<template>
<k-notification
:opened="!!notifications.current"
:title="notifications.current?.title"
:subtitle="notifications.current?.subtitle"
:text="notifications.current?.text"
:title-right-text="phone.t('Notifications.now')"
button="close"
class="phone-notification"
@close="notifications.dismissCurrent()"
>
<template v-if="icon" #icon>
<img :src="icon" alt="" class="phone-notification__icon" />
</template>
<template #button>
<span class="sr-only">{{ phone.t('Common.close') }}</span>
</template>
</k-notification>
</template>
+117
View File
@@ -0,0 +1,117 @@
<script setup lang="ts">
import { nextTick, onMounted, ref } from 'vue'
const props = defineProps<{
hoursLabel: string
label: string
minutesLabel: string
modelValue: string
}>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
const ITEM_HEIGHT = 40
const hours = Array.from({ length: 24 }, (_, index) => index)
const minutes = Array.from({ length: 60 }, (_, index) => index)
const initialTime = props.modelValue.split(':').map(Number)
const selectedHour = ref(initialTime[0] ?? 0)
const selectedMinute = ref(initialTime[1] ?? 0)
const hourWheel = ref<HTMLElement | null>(null)
const minuteWheel = ref<HTMLElement | null>(null)
let updateFrame: number | undefined
function format(value: number): string {
return String(value).padStart(2, '0')
}
function emitTime(): void {
emit(
'update:modelValue',
`${format(selectedHour.value)}:${format(selectedMinute.value)}`,
)
}
function updateFromScroll(type: 'hour' | 'minute', element: HTMLElement): void {
if (updateFrame) cancelAnimationFrame(updateFrame)
updateFrame = requestAnimationFrame(() => {
const maximum = type === 'hour' ? hours.length - 1 : minutes.length - 1
const value = Math.max(
0,
Math.min(maximum, Math.round(element.scrollTop / ITEM_HEIGHT)),
)
if (type === 'hour') selectedHour.value = value
else selectedMinute.value = value
emitTime()
})
}
function select(
type: 'hour' | 'minute',
value: number,
element: HTMLElement | null,
): void {
if (type === 'hour') selectedHour.value = value
else selectedMinute.value = value
element?.scrollTo({ behavior: 'smooth', top: value * ITEM_HEIGHT })
emitTime()
}
onMounted(() => {
void nextTick(() => {
hourWheel.value?.scrollTo({ top: selectedHour.value * ITEM_HEIGHT })
minuteWheel.value?.scrollTo({ top: selectedMinute.value * ITEM_HEIGHT })
})
})
</script>
<template>
<section class="time-wheel-picker" :aria-label="label">
<div class="time-wheel-picker__selection" aria-hidden="true" />
<span class="time-wheel-picker__separator" aria-hidden="true">:</span>
<div
ref="hourWheel"
class="time-wheel-picker__column"
role="listbox"
:aria-label="hoursLabel"
@scroll.passive="updateFromScroll('hour', $event.currentTarget as HTMLElement)"
>
<button
v-for="hour in hours"
:key="hour"
type="button"
role="option"
:aria-selected="selectedHour === hour"
:class="{ 'time-wheel-picker__value--selected': selectedHour === hour }"
class="time-wheel-picker__value"
@click="select('hour', hour, hourWheel)"
>
{{ format(hour) }}
</button>
</div>
<div
ref="minuteWheel"
class="time-wheel-picker__column"
role="listbox"
:aria-label="minutesLabel"
@scroll.passive="
updateFromScroll('minute', $event.currentTarget as HTMLElement)
"
>
<button
v-for="minute in minutes"
:key="minute"
type="button"
role="option"
:aria-selected="selectedMinute === minute"
:class="{
'time-wheel-picker__value--selected': selectedMinute === minute,
}"
class="time-wheel-picker__value"
@click="select('minute', minute, minuteWheel)"
>
{{ format(minute) }}
</button>
</div>
</section>
</template>
+52 -15
View File
@@ -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()
},
},
})
+107
View File
@@ -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<string, ReturnType<typeof setTimeout>>()
const stopToneHandles = new Map<string, () => void>()
export const useNotificationsStore = defineStore('notifications', () => {
const phone = usePhoneStore()
const queue = ref<PhoneNotification[]>([])
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,
}
})
+43 -2
View File
@@ -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',
+36
View File
@@ -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)
})
})
+119
View File
@@ -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<Alarm, 'note' | 'sound' | 'time' | 'weekdays'>
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<Alarm>
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)
}
+4
View File
@@ -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)
})
+8
View File
@@ -30,6 +30,7 @@ export type PhonePreferencesV1 = {
appearanceMode: AppearanceMode
frame: PhoneFrameId
notificationSound: NotificationSoundId
notificationDurationSeconds: number
notificationVolume: number
notifications: Record<PhoneAppId, AppNotificationPreferences>
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,
+79
View File
@@ -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<PhoneToneId, number[]> = {
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<typeof setTimeout> | 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()
}
}
+159 -42
View File
@@ -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<typeof setInterval> | 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))
<template>
<k-page component="main" class="native-app clock-app">
<k-navbar
<AlarmEditor
v-if="alarmEditor"
:alarm="selectedAlarm"
@cancel="alarmEditor = null"
@delete="deleteSelectedAlarm"
@save="saveAlarm"
/>
<template v-else>
<k-navbar
:title="phone.t(`Apps.clock.tabs.${tab}`)"
:large="tab === 'world' || tab === 'alarm'"
:transparent="tab === 'world' || tab === 'alarm'"
@@ -94,8 +194,9 @@ onBeforeUnmount(() => clearInterval(ticker))
<k-link
component="button"
:link-props="{ type: 'button' }"
@click="toggleAlarmEditing"
>
{{ phone.t('Common.edit') }}
{{ phone.t(alarmsEditing ? 'Common.done' : 'Common.edit') }}
</k-link>
</template>
<template #right>
@@ -104,6 +205,7 @@ onBeforeUnmount(() => clearInterval(ticker))
icon-only
:link-props="{ type: 'button' }"
:aria-label="phone.t('Apps.clock.add')"
@click="openAlarmEditor()"
>
<Plus />
</k-link>
@@ -113,9 +215,10 @@ onBeforeUnmount(() => clearInterval(ticker))
<k-block component="section" nested class="clock-content">
<k-block v-if="tab === 'world'" nested class="clock-world-now">
<span class="clock-world-location">{{
phone.t('Apps.clock.location')
browserTimeZone.replace(/_/g, ' ')
}}</span>
<time class="clock-world-time">{{ losSantosTime }}</time>
<time class="clock-world-time">{{ currentTime }}</time>
<span class="clock-world-zone">{{ currentTimeZone }}</span>
</k-block>
<k-list
@@ -127,18 +230,37 @@ onBeforeUnmount(() => clearInterval(ticker))
<k-list-item
v-for="alarm in clock.alarms"
:key="alarm.id"
link
:chevron="alarmsEditing"
:title="alarm.time"
:subtitle="phone.t(alarm.labelKey)"
title-font-size-ios="text-[30px]"
:subtitle="alarmSubtitle(alarm)"
:strong-title="false"
title-font-size-ios="text-[38px] font-light"
class="clock-alarm-item"
@click="openAlarmEditor(alarm.id)"
>
<template v-if="alarmsEditing" #media>
<k-button
rounded
small
inline
:colors="dangerActionColors"
class="clock-alarm-remove"
:aria-label="phone.t('Apps.clock.alarm.delete')"
@click.stop="clock.deleteAlarm(alarm.id)"
>
<Minus aria-hidden="true" />
</k-button>
</template>
<template #after>
<k-toggle
:checked="alarm.enabled"
:colors="toggleColors"
:aria-label="`${alarm.time}, ${phone.t(alarm.labelKey)}`"
@change="clock.toggleAlarm(alarm.id)"
/>
<span v-if="!alarmsEditing" @click.stop>
<k-toggle
:checked="alarm.enabled"
:colors="toggleColors"
:aria-label="`${alarm.time}, ${alarmSubtitle(alarm)}`"
@change="clock.toggleAlarm(alarm.id)"
/>
</span>
</template>
</k-list-item>
</k-list>
@@ -242,30 +364,25 @@ onBeforeUnmount(() => clearInterval(ticker))
</k-block>
</k-block>
<k-tabbar
<k-navbar
component="nav"
labels
icons
class="clock-tabbar"
inner-class="!w-full !gap-0"
:aria-label="phone.t('Apps.clock.name')"
>
<k-tabbar-link
v-for="item in tabs"
:key="item.id"
component="button"
:active="tab === item.id"
:colors="tabColors"
:link-props="{ type: 'button' }"
@click="tab = item.id"
>
<template #icon>
<component :is="item.icon" :size="22" />
</template>
<span class="clock-tab-label">{{
phone.t(`Apps.clock.tabs.${item.id}`)
}}</span>
</k-tabbar-link>
</k-tabbar>
<template #subnavbar>
<k-segmented :key="tab" strong rounded>
<k-segmented-button
v-for="item in tabs"
:key="item.id"
:active="tab === item.id"
:aria-label="phone.t(`Apps.clock.tabs.${item.id}`)"
:aria-pressed="tab === item.id"
@click="selectTab(item.id)"
>
<component :is="item.icon" aria-hidden="true" />
</k-segmented-button>
</k-segmented>
</template>
</k-navbar>
</template>
</k-page>
</template>
+33 -1
View File
@@ -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 {
</template>
<template v-else-if="activeView === 'general'">
<k-block-title>
{{ phone.t('Apps.settings.notificationDuration') }} ·
{{
phone.t('Apps.settings.seconds', {
seconds: String(
phone.preferences.settings.notificationDurationSeconds,
),
})
}}
</k-block-title>
<k-list strong inset>
<k-list-item>
<template #inner>
<k-range
class="w-full"
:value="phone.preferences.settings.notificationDurationSeconds"
:min="3"
:max="30"
:step="1"
:aria-label="phone.t('Apps.settings.notificationDuration')"
@input="
updateNumberPreference('notificationDurationSeconds', $event)
"
/>
</template>
</k-list-item>
</k-list>
<k-block-title>{{ phone.t('Apps.settings.about') }}</k-block-title>
<k-list strong inset>
<k-list-item
+14 -5
View File
@@ -2,10 +2,11 @@ Locales["en"] = {
CommandDescription = "Open your phone.",
Nui = {
Common = {
cancel = "Cancel", close = "Close", edit = "Edit", home = "Home", pause = "Pause",
add = "Add", cancel = "Cancel", close = "Close", delete = "Delete", done = "Done", edit = "Edit", home = "Home", pause = "Pause",
phone = "Phone", phoneStatus = "Phone status", reset = "Reset",
search = "Search", start = "Start", stop = "Stop",
save = "Save", search = "Search", start = "Start", stop = "Stop",
},
Notifications = { now = "now" },
Home = {
appLibrary = "App Library", appLibrarySearch = "Search apps", allApps = "All Apps", apps = "Apps",
dock = "Dock", noApps = "No apps found", page = "Page", pages = "Home screen pages",
@@ -25,9 +26,16 @@ Locales["en"] = {
modes = { timelapse = "Timelapse", slowMo = "Slow-Mo", cinematic = "Cinematic", video = "Video", photo = "Photo", portrait = "Portrait", pano = "Pano" },
},
clock = {
name = "Clock", lap = "Lap", minutes = "Minutes", add = "Add clock", location = "Los Santos",
name = "Clock", lap = "Lap", minutes = "Minutes", add = "Add alarm", location = "Los Santos",
tabs = { world = "World Clock", alarm = "Alarm", 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", searchPlaceholder = "Photos, people, places...", recents = "Recents",
@@ -60,7 +68,8 @@ Locales["en"] = {
accountStorage = "Cloud Storage", accountStorageValue = "On Device", accountPurchases = "Media & Purchases",
accountPurchasesValue = "Available", notifications = "Notifications", sounds = "Sounds & Haptics",
general = "General Settings", appearance = "Appearance", allowNotifications = "Allow Notifications",
notificationSounds = "Sounds", ringtoneVolume = "Ringtone Volume", notificationVolume = "Notification Volume",
notificationSounds = "Sounds", notificationDuration = "Notification Duration", seconds = "{seconds} seconds",
ringtoneVolume = "Ringtone Volume", notificationVolume = "Notification Volume",
ringtone = "Ringtone", notificationSound = "Notification Sound", appearanceMode = "Appearance Mode",
automatic = "Automatic", light = "Light", dark = "Dark", phoneScale = "Phone Scale", phoneFrame = "Phone Frame",
about = "About", deviceName = "Device Name", deviceNameValue = "Sky Phone", softwareVersion = "Software Version",
+10 -2
View File
@@ -1,4 +1,5 @@
local is_open = false
local notification_focus = false
local function get_locale()
return Locales[Sky.Config.locale] or Locales["en"]
@@ -20,6 +21,7 @@ local function open_phone()
end
is_open = true
notification_focus = false
SetNuiFocus(true, true)
send_open_message()
end
@@ -30,7 +32,7 @@ local function close_phone()
end
is_open = false
SetNuiFocus(false, false)
SetNuiFocus(notification_focus, notification_focus)
SendNUIMessage({ type = "app:close" })
end
@@ -56,6 +58,12 @@ RegisterNUICallback("close", function(_, cb)
cb({ success = true })
end)
RegisterNUICallback("notification:focus", function(data, cb)
notification_focus = data.active == true and not is_open
SetNuiFocus(is_open or notification_focus, is_open or notification_focus)
cb({ success = true })
end)
CreateThread(function()
TriggerEvent("chat:addSuggestion", "/" .. Config.Command, get_locale().CommandDescription)
end)
@@ -65,7 +73,7 @@ AddEventHandler("onResourceStop", function(resource_name)
return
end
if is_open then
if is_open or notification_focus then
SetNuiFocus(false, false)
end
+2 -2
View File
@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sky Phone</title>
<script type="module" crossorigin src="./assets/sky-index-C3leT8Qb.js"></script>
<link rel="stylesheet" crossorigin href="./assets/sky-index-BGSbxL7G.css">
<script type="module" crossorigin src="./assets/sky-index-D_Kmg2yn.js"></script>
<link rel="stylesheet" crossorigin href="./assets/sky-index-Ifens1n8.css">
</head>
<body>
<div id="app"></div>