ENH - clock app

This commit is contained in:
Eichenholz
2026-08-04 15:06:30 +02:00
parent 5d2906adba
commit 723eec5ef9
9 changed files with 160 additions and 39 deletions
+14 -1
View File
@@ -921,9 +921,22 @@ button {
.clock-digits {
margin: 70px 0 54px;
font-variant-numeric: tabular-nums;
font-size: 58px;
font-size: min(52px, 14cqw);
font-weight: 200;
}
.clock-sound-menu {
margin-top: 8px;
}
.clock-sound-selection {
display: inline-flex;
align-items: center;
gap: 5px;
color: #8e8e93;
}
.clock-sound-selection svg {
width: 17px;
height: 17px;
}
.clock-actions {
display: flex;
justify-content: space-between;
+24 -11
View File
@@ -9,13 +9,13 @@ import {
kListItem,
kNavbar,
} from 'konsta/vue'
import { Check, X } from 'lucide-vue-next'
import { reactive } from 'vue'
import { ChevronRight, Check, X } from 'lucide-vue-next'
import { reactive, ref } from 'vue'
import AlarmSoundMenu from '@/components/AlarmSoundMenu.vue'
import { usePhoneStore } from '@/stores/phone'
import TimeWheelPicker from '@/components/TimeWheelPicker.vue'
import {
ALARM_SOUND_IDS,
type Alarm,
type AlarmDraft,
WEEKDAY_IDS,
@@ -28,6 +28,7 @@ const emit = defineEmits<{
save: [draft: AlarmDraft]
}>()
const phone = usePhoneStore()
const soundMenuOpen = ref(false)
const weekdayKeys = [
'sunday',
'monday',
@@ -72,7 +73,15 @@ function toggleWeekday(weekday: number): void {
</script>
<template>
<k-navbar :title="phone.t('Apps.clock.tabs.alarm')">
<AlarmSoundMenu
v-if="soundMenuOpen"
:back-label="phone.t('Apps.clock.tabs.alarm')"
:selected-sound="draft.sound"
@close="soundMenuOpen = false"
@select="draft.sound = $event"
/>
<k-navbar v-show="!soundMenuOpen" :title="phone.t('Apps.clock.tabs.alarm')">
<template #left>
<k-link
component="button"
@@ -98,7 +107,11 @@ function toggleWeekday(weekday: number): void {
</template>
</k-navbar>
<k-block component="section" class="clock-alarm-editor">
<k-block
v-show="!soundMenuOpen"
component="section"
class="clock-alarm-editor"
>
<TimeWheelPicker
v-model="draft.time"
:hours-label="phone.t('Apps.clock.alarm.hours')"
@@ -135,18 +148,18 @@ function toggleWeekday(weekday: number): void {
/>
</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"
:title="phone.t('Apps.clock.alarm.sound')"
@click="soundMenuOpen = true"
>
<template #after>
<Check v-if="draft.sound === sound" class="w-5 h-5 text-primary" />
<span class="clock-sound-selection">
{{ phone.t(`Apps.clock.alarm.sounds.${draft.sound}`) }}
<ChevronRight aria-hidden="true" />
</span>
</template>
</k-list-item>
</k-list>
@@ -0,0 +1,61 @@
<script setup lang="ts">
import { kList, kListItem, kNavbar, kNavbarBackLink } from 'konsta/vue'
import { Check } from 'lucide-vue-next'
import { onBeforeUnmount } from 'vue'
import { usePhoneStore } from '@/stores/phone'
import { ALARM_SOUND_IDS, type AlarmSoundId } from '@/utils/alarms'
import { playPhoneTone } from '@/utils/tones'
defineProps<{ backLabel: string; selectedSound: AlarmSoundId }>()
const emit = defineEmits<{
close: []
select: [sound: AlarmSoundId]
}>()
const phone = usePhoneStore()
let stopPreview: (() => void) | undefined
let previewTimer: ReturnType<typeof setTimeout> | undefined
function stopSoundPreview(): void {
if (previewTimer) clearTimeout(previewTimer)
previewTimer = undefined
stopPreview?.()
stopPreview = undefined
}
function selectSound(sound: AlarmSoundId): void {
stopSoundPreview()
emit('select', sound)
stopPreview = playPhoneTone(
sound,
phone.preferences.settings.ringtoneVolume,
false,
)
previewTimer = setTimeout(stopSoundPreview, 1800)
}
onBeforeUnmount(stopSoundPreview)
</script>
<template>
<k-navbar :title="phone.t('Apps.clock.alarm.sound')">
<template #left>
<k-navbar-back-link :text="backLabel" @click="emit('close')" />
</template>
</k-navbar>
<k-list strong inset class="clock-sound-menu">
<k-list-item
v-for="sound in ALARM_SOUND_IDS"
:key="sound"
link
:chevron="false"
:title="phone.t(`Apps.clock.alarm.sounds.${sound}`)"
@click="selectSound(sound)"
>
<template #after>
<Check v-if="selectedSound === sound" class="h-5 w-5 text-primary" />
</template>
</k-list-item>
</k-list>
</template>
+1 -1
View File
@@ -82,7 +82,7 @@ const defaultLocales: LocaleTree = {
add: 'Add alarm',
location: 'Los Santos',
tabs: {
world: 'World Clock',
world: 'Clock',
alarm: 'Alarm',
stopwatch: 'Stopwatch',
timer: 'Timer',
+5
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import {
elapsedMilliseconds,
formatStopwatch,
formatTimer,
remainingMilliseconds,
timerPickerMilliseconds,
@@ -18,4 +19,8 @@ describe('timestamp clocks', () => {
expect(timerPickerValue(3_661_000)).toBe('01:01:01')
expect(timerPickerMilliseconds('01:01:01')).toBe(3_661_000)
})
it('formats stopwatch durations with actual milliseconds', () => {
expect(formatStopwatch(61_234)).toBe('01:01.234')
})
})
+5 -5
View File
@@ -18,11 +18,11 @@ export function remainingMilliseconds(
}
export function formatStopwatch(milliseconds: number): string {
const totalCentiseconds = Math.floor(milliseconds / 10)
const minutes = Math.floor(totalCentiseconds / 6000)
const seconds = Math.floor((totalCentiseconds % 6000) / 100)
const centiseconds = totalCentiseconds % 100
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}.${String(centiseconds).padStart(2, '0')}`
const totalMilliseconds = Math.floor(milliseconds)
const minutes = Math.floor(totalMilliseconds / 60_000)
const seconds = Math.floor((totalMilliseconds % 60_000) / 1000)
const millisecondsPart = totalMilliseconds % 1000
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}.${String(millisecondsPart).padStart(3, '0')}`
}
export function formatTimer(milliseconds: number): string {
+47 -18
View File
@@ -1,7 +1,6 @@
<script setup lang="ts">
import {
kBlock,
kBlockTitle,
kButton,
kLink,
kList,
@@ -15,20 +14,21 @@ import {
} from 'konsta/vue'
import {
AlarmClock,
Check,
ChevronRight,
Clock3,
Minus,
Plus,
Timer,
TimerReset,
} from 'lucide-vue-next'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import AlarmEditor from '@/components/AlarmEditor.vue'
import AlarmSoundMenu from '@/components/AlarmSoundMenu.vue'
import TimeWheelPicker from '@/components/TimeWheelPicker.vue'
import { useClockStore } from '@/stores/clock'
import { usePhoneStore } from '@/stores/phone'
import { ALARM_SOUND_IDS, type Alarm, type AlarmDraft } from '@/utils/alarms'
import { type Alarm, type AlarmDraft } from '@/utils/alarms'
import {
elapsedMilliseconds,
formatStopwatch,
@@ -44,10 +44,12 @@ const tab = ref<'world' | 'alarm' | 'stopwatch' | 'timer'>('world')
const alarmEditor = ref<
{ mode: 'create' } | { id: string; mode: 'edit' } | null
>(null)
const timerSoundMenuOpen = ref(false)
const alarmsEditing = ref(false)
const now = ref(Date.now())
const browserTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone
let ticker: ReturnType<typeof setInterval> | undefined
let stopwatchFrame: number | undefined
const stopwatchValue = computed(() =>
elapsedMilliseconds(
@@ -180,19 +182,47 @@ function selectTab(nextTab: (typeof tabs)[number]['id']): void {
function setTimerNote(event: Event): void {
clock.setTimerNote((event.target as HTMLInputElement).value.slice(0, 80))
}
function updateStopwatchFrame(): void {
now.value = Date.now()
stopwatchFrame = requestAnimationFrame(updateStopwatchFrame)
}
watch(
[() => clock.stopwatchStartedAt, tab],
([startedAt, activeTab]) => {
if (stopwatchFrame !== undefined) cancelAnimationFrame(stopwatchFrame)
stopwatchFrame =
startedAt === null || activeTab !== 'stopwatch'
? undefined
: requestAnimationFrame(updateStopwatchFrame)
},
)
onMounted(() => {
ticker = setInterval(() => {
now.value = Date.now()
}, 250)
})
onBeforeUnmount(() => clearInterval(ticker))
onBeforeUnmount(() => {
clearInterval(ticker)
if (stopwatchFrame !== undefined) cancelAnimationFrame(stopwatchFrame)
})
</script>
<template>
<k-page component="main" class="native-app clock-app">
<AlarmSoundMenu
v-if="timerSoundMenuOpen"
:back-label="phone.t('Apps.clock.tabs.timer')"
:selected-sound="clock.timerSound"
@close="timerSoundMenuOpen = false"
@select="clock.setTimerSound($event)"
/>
<AlarmEditor
v-if="alarmEditor"
v-else-if="alarmEditor"
:alarm="selectedAlarm"
@cancel="alarmEditor = null"
@delete="deleteSelectedAlarm"
@@ -209,6 +239,7 @@ onBeforeUnmount(() => clearInterval(ticker))
>
<template #left>
<k-link
v-if="tab === 'alarm'"
component="button"
:link-props="{ type: 'button' }"
@click="toggleAlarmEditing"
@@ -218,6 +249,7 @@ onBeforeUnmount(() => clearInterval(ticker))
</template>
<template #right>
<k-link
v-if="tab === 'alarm'"
component="button"
icon-only
:link-props="{ type: 'button' }"
@@ -291,7 +323,7 @@ onBeforeUnmount(() => clearInterval(ticker))
@click="
clock.stopwatchStartedAt === null
? clock.resetStopwatch()
: clock.addLap(now)
: clock.addLap(Date.now())
"
>
{{
@@ -308,8 +340,8 @@ onBeforeUnmount(() => clearInterval(ticker))
class="clock-action-button"
@click="
clock.stopwatchStartedAt === null
? clock.startStopwatch(now)
: clock.pauseStopwatch(now)
? clock.startStopwatch(Date.now())
: clock.pauseStopwatch(Date.now())
"
>
{{
@@ -362,21 +394,18 @@ onBeforeUnmount(() => clearInterval(ticker))
/>
</k-list>
<k-block-title>{{ phone.t('Apps.clock.timer.sound') }}</k-block-title>
<k-list strong inset class="clock-konsta-list clock-timer-settings">
<k-list-item
v-for="sound in ALARM_SOUND_IDS"
:key="sound"
link
:chevron="false"
:title="phone.t(`Apps.clock.alarm.sounds.${sound}`)"
@click="clock.setTimerSound(sound)"
:title="phone.t('Apps.clock.timer.sound')"
@click="timerSoundMenuOpen = true"
>
<template #after>
<Check
v-if="clock.timerSound === sound"
class="h-5 w-5 text-primary"
/>
<span class="clock-sound-selection">
{{ phone.t(`Apps.clock.alarm.sounds.${clock.timerSound}`) }}
<ChevronRight aria-hidden="true" />
</span>
</template>
</k-list-item>
</k-list>
+1 -1
View File
@@ -27,7 +27,7 @@ Locales["en"] = {
},
clock = {
name = "Clock", lap = "Lap", minutes = "Minutes", add = "Add alarm", location = "Los Santos",
tabs = { world = "World Clock", alarm = "Alarm", stopwatch = "Stopwatch", timer = "Timer" },
tabs = { world = "Clock", alarm = "Alarm", stopwatch = "Stopwatch", timer = "Timer" },
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",
+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-DgnAE74E.js"></script>
<link rel="stylesheet" crossorigin href="./assets/sky-index-CCqdfChw.css">
<script type="module" crossorigin src="./assets/sky-index-CHcZ50p1.js"></script>
<link rel="stylesheet" crossorigin href="./assets/sky-index-BQVZ7udM.css">
</head>
<body>
<div id="app"></div>