mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 03:01:42 +00:00
ENH - clock app
This commit is contained in:
+47
-4
@@ -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
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user