ENH - clock app

This commit is contained in:
Eichenholz
2026-08-04 10:58:04 +02:00
parent c955e31e63
commit dfc358f85a
11 changed files with 707 additions and 284 deletions
+19 -4
View File
@@ -20,6 +20,7 @@ import {
} from '@/stores/notifications'
import { usePhoneStore, type PhoneOpenPayload } from '@/stores/phone'
import { nuiCall } from '@/utils/nui'
import { formatTimer } from '@/utils/clock'
import SpringboardView from '@/views/SpringboardView.vue'
type AppMessage = {
@@ -39,7 +40,7 @@ const phoneDeviceStyle = computed<CSSProperties>(() => ({
const phoneFrameImage = computed(
() => PHONE_FRAME_IMAGES[phone.preferences.settings.frame],
)
let alarmTicker: ReturnType<typeof setInterval> | undefined
let clockTicker: ReturnType<typeof setInterval> | undefined
function onMessage(event: MessageEvent<AppMessage>): void {
if (event.data?.type === 'app:open') {
@@ -67,8 +68,9 @@ onMounted(() => {
systemColorScheme.addEventListener('change', onSystemColorSchemeChange)
phone.setSystemDarkMode(systemColorScheme.matches)
void nuiCall('ui:ready')
alarmTicker = setInterval(() => {
for (const alarm of clock.dueAlarms(Date.now())) {
clockTicker = setInterval(() => {
const now = Date.now()
for (const alarm of clock.dueAlarms(now)) {
notifications.show({
appId: 'clock',
critical: true,
@@ -79,6 +81,19 @@ onMounted(() => {
title: phone.t('Apps.clock.name'),
})
}
const timer = clock.consumeDueTimer(now)
if (timer) {
notifications.show({
appId: 'clock',
critical: true,
persistent: true,
sound: timer.sound,
subtitle: formatTimer(clock.timerDuration),
text: timer.note || phone.t('Apps.clock.timer.ringing'),
title: phone.t('Apps.clock.name'),
})
}
}, 1000)
if (import.meta.env.DEV) phone.open()
})
@@ -91,7 +106,7 @@ watch(
)
onBeforeUnmount(() => {
if (alarmTicker) clearInterval(alarmTicker)
if (clockTicker) clearInterval(clockTicker)
window.removeEventListener('message', onMessage)
window.removeEventListener('keydown', onKeydown)
systemColorScheme.removeEventListener('change', onSystemColorSchemeChange)
+55 -20
View File
@@ -780,9 +780,23 @@ button {
height: 200px;
margin: 0 16px 16px;
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-columns: 64px 64px;
justify-content: center;
gap: 8px;
overflow: hidden;
}
.time-wheel-picker--duration {
grid-template-columns: repeat(3, 64px);
gap: 0;
}
.time-wheel-picker__field {
position: relative;
width: 64px;
height: 200px;
}
.time-wheel-picker--duration .time-wheel-picker__field {
width: 64px;
}
.time-wheel-picker__selection {
position: absolute;
z-index: 0;
@@ -794,20 +808,6 @@ button {
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;
@@ -818,6 +818,9 @@ button {
scrollbar-width: none;
scroll-snap-type: y mandatory;
overscroll-behavior: contain;
touch-action: none;
cursor: grab;
user-select: none;
mask-image: linear-gradient(
to bottom,
transparent 0,
@@ -826,6 +829,23 @@ button {
transparent 100%
);
}
.time-wheel-picker--unit-labels .time-wheel-picker__column {
width: 36px;
}
.time-wheel-picker__unit {
position: absolute;
z-index: 2;
top: 80px;
left: 34px;
height: 40px;
color: #8e8e93;
font-size: 9px;
line-height: 40px;
pointer-events: none;
}
.time-wheel-picker__column:active {
cursor: grabbing;
}
.time-wheel-picker__column::-webkit-scrollbar {
display: none;
}
@@ -835,6 +855,7 @@ button {
display: block;
padding: 0;
border: 0;
outline: none;
scroll-snap-align: center;
background: transparent;
color: #8e8e93;
@@ -846,6 +867,11 @@ button {
color 120ms ease,
opacity 120ms ease;
}
.time-wheel-picker__value:focus-visible {
border-radius: 8px;
outline: 2px solid #ff9f0a;
outline-offset: -2px;
}
.time-wheel-picker__value--selected {
color: #fff;
opacity: 1;
@@ -859,11 +885,20 @@ button {
padding: 0 16px;
text-align: center;
}
.clock-section-heading {
margin: 0;
padding: 4px 0 10px;
text-align: left;
font-size: 34px;
font-weight: 700;
line-height: 41px;
letter-spacing: -0.8px;
}
.clock-digits {
margin: 70px 0 54px;
font-variant-numeric: tabular-nums;
font-size: 45px;
font-weight: 250;
font-size: 58px;
font-weight: 200;
}
.clock-actions {
display: flex;
@@ -890,12 +925,12 @@ button {
border-radius: 50%;
font-size: 34px;
}
.clock-timer-input {
.clock-timer-settings {
margin-right: 0;
margin-left: 0;
}
.clock-timer-input input {
text-align: center;
.clock-timer {
padding-bottom: 28px;
}
.camera-app {
padding: 45px 0 25px;
+21 -5
View File
@@ -9,7 +9,7 @@ import {
kListItem,
kNavbar,
} from 'konsta/vue'
import { Check } from 'lucide-vue-next'
import { Check, X } from 'lucide-vue-next'
import { reactive } from 'vue'
import { usePhoneStore } from '@/stores/phone'
@@ -47,6 +47,9 @@ const dangerColors = {
tonalBgIos: 'bg-red-500/15 active:bg-red-500/25',
tonalTextIos: 'text-red-500',
}
const saveLinkColors = {
navbarTextIos: 'text-[#ff9f0a]',
}
function setNote(event: Event): void {
draft.note = (event.target as HTMLInputElement).value.slice(0, 80)
@@ -71,13 +74,26 @@ function toggleWeekday(weekday: number): void {
<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
component="button"
icon-only
:link-props="{ type: 'button' }"
:aria-label="phone.t('Common.cancel')"
@click="emit('cancel')"
>
<X aria-hidden="true" />
</k-link>
</template>
<template #right>
<k-link component="button" @click="save">
{{ phone.t('Common.save') }}
<k-link
component="button"
icon-only
:colors="saveLinkColors"
:link-props="{ type: 'button' }"
:aria-label="phone.t('Common.save')"
@click="save"
>
<Check aria-hidden="true" />
</k-link>
</template>
</k-navbar>
+283 -58
View File
@@ -1,117 +1,342 @@
<script setup lang="ts">
import { nextTick, onMounted, ref } from 'vue'
import { nextTick, onMounted, ref, watch } from 'vue'
const props = defineProps<{
hoursLabel: string
hoursUnitLabel?: string
label: string
minutesLabel: string
minutesUnitLabel?: string
modelValue: string
secondsLabel?: string
secondsUnitLabel?: string
showUnitLabels?: boolean
}>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
type WheelType = 'hour' | 'minute' | 'second'
type DragState = {
element: HTMLElement
moved: boolean
pointerId: number
startScrollTop: number
startY: number
}
const ITEM_HEIGHT = 40
const LOOP_CYCLES = 5
const CENTER_CYCLE = Math.floor(LOOP_CYCLES / 2)
const hours = Array.from({ length: 24 }, (_, index) => index)
const minutes = Array.from({ length: 60 }, (_, index) => index)
const seconds = Array.from({ length: 60 }, (_, index) => index)
const loopedHours = Array.from(
{ length: hours.length * LOOP_CYCLES },
(_, index) => ({ index, value: index % hours.length }),
)
const loopedMinutes = Array.from(
{ length: minutes.length * LOOP_CYCLES },
(_, index) => ({ index, value: index % minutes.length }),
)
const loopedSeconds = Array.from(
{ length: seconds.length * LOOP_CYCLES },
(_, index) => ({ index, value: index % seconds.length }),
)
const initialTime = props.modelValue.split(':').map(Number)
const selectedHour = ref(initialTime[0] ?? 0)
const selectedMinute = ref(initialTime[1] ?? 0)
const selectedHour = ref(normalize(initialTime[0] ?? 0, hours.length))
const selectedMinute = ref(normalize(initialTime[1] ?? 0, minutes.length))
const selectedSecond = ref(normalize(initialTime[2] ?? 0, seconds.length))
const selectedHourIndex = ref(CENTER_CYCLE * hours.length + selectedHour.value)
const selectedMinuteIndex = ref(
CENTER_CYCLE * minutes.length + selectedMinute.value,
)
const selectedSecondIndex = ref(
CENTER_CYCLE * seconds.length + selectedSecond.value,
)
const hourWheel = ref<HTMLElement | null>(null)
const minuteWheel = ref<HTMLElement | null>(null)
const secondWheel = ref<HTMLElement | null>(null)
let dragState: DragState | null = null
let suppressClick = false
let updateFrame: number | undefined
function normalize(value: number, length: number): number {
return ((value % length) + length) % length
}
function format(value: number): string {
return String(value).padStart(2, '0')
}
function emitTime(): void {
const time = `${format(selectedHour.value)}:${format(selectedMinute.value)}`
emit(
'update:modelValue',
`${format(selectedHour.value)}:${format(selectedMinute.value)}`,
props.secondsLabel ? `${time}:${format(selectedSecond.value)}` : time,
)
}
function updateFromScroll(type: 'hour' | 'minute', element: HTMLElement): void {
function updateFromScroll(type: WheelType, element: HTMLElement): void {
if (updateFrame) cancelAnimationFrame(updateFrame)
updateFrame = requestAnimationFrame(() => {
const maximum = type === 'hour' ? hours.length - 1 : minutes.length - 1
const value = Math.max(
const values =
type === 'hour' ? hours : type === 'minute' ? minutes : seconds
let index = Math.max(
0,
Math.min(maximum, Math.round(element.scrollTop / ITEM_HEIGHT)),
Math.min(
values.length * LOOP_CYCLES - 1,
Math.round(element.scrollTop / ITEM_HEIGHT),
),
)
if (type === 'hour') selectedHour.value = value
else selectedMinute.value = value
const value = index % values.length
if (
!dragState &&
(index < values.length || index >= values.length * (LOOP_CYCLES - 1))
) {
index = CENTER_CYCLE * values.length + value
element.scrollTop = index * ITEM_HEIGHT
}
if (type === 'hour') {
selectedHour.value = value
selectedHourIndex.value = index
} else if (type === 'minute') {
selectedMinute.value = value
selectedMinuteIndex.value = index
} else {
selectedSecond.value = value
selectedSecondIndex.value = index
}
emitTime()
})
}
function select(
type: 'hour' | 'minute',
type: WheelType,
index: number,
value: number,
element: HTMLElement | null,
): void {
if (type === 'hour') selectedHour.value = value
else selectedMinute.value = value
element?.scrollTo({ behavior: 'smooth', top: value * ITEM_HEIGHT })
if (suppressClick) {
suppressClick = false
return
}
if (type === 'hour') {
selectedHour.value = value
selectedHourIndex.value = index
} else if (type === 'minute') {
selectedMinute.value = value
selectedMinuteIndex.value = index
} else {
selectedSecond.value = value
selectedSecondIndex.value = index
}
element?.scrollTo({ top: index * ITEM_HEIGHT })
emitTime()
}
function beginDrag(event: PointerEvent, element: HTMLElement): void {
if (
!event.isPrimary ||
(event.pointerType === 'mouse' && event.button !== 0)
) {
return
}
dragState = {
element,
moved: false,
pointerId: event.pointerId,
startScrollTop: element.scrollTop,
startY: event.clientY,
}
element.setPointerCapture(event.pointerId)
}
function drag(event: PointerEvent): void {
if (!dragState || dragState.pointerId !== event.pointerId) return
const distance = dragState.startY - event.clientY
if (Math.abs(distance) > 3) dragState.moved = true
dragState.element.scrollTop = dragState.startScrollTop + distance
event.preventDefault()
}
function endDrag(event: PointerEvent): void {
if (!dragState || dragState.pointerId !== event.pointerId) return
const { element, moved, pointerId } = dragState
dragState = null
if (element.hasPointerCapture(pointerId))
element.releasePointerCapture(pointerId)
const index = Math.round(element.scrollTop / ITEM_HEIGHT)
element.scrollTo({ behavior: 'smooth', top: index * ITEM_HEIGHT })
suppressClick = moved
if (moved) setTimeout(() => (suppressClick = false))
}
onMounted(() => {
void nextTick(() => {
hourWheel.value?.scrollTo({ top: selectedHour.value * ITEM_HEIGHT })
minuteWheel.value?.scrollTo({ top: selectedMinute.value * ITEM_HEIGHT })
hourWheel.value?.scrollTo({
top: selectedHourIndex.value * ITEM_HEIGHT,
})
minuteWheel.value?.scrollTo({
top: selectedMinuteIndex.value * ITEM_HEIGHT,
})
secondWheel.value?.scrollTo({
top: selectedSecondIndex.value * ITEM_HEIGHT,
})
})
})
watch(
() => props.modelValue,
(value) => {
const [hour = 0, minute = 0, second = 0] = value.split(':').map(Number)
const normalizedHour = normalize(hour, hours.length)
const normalizedMinute = normalize(minute, minutes.length)
const normalizedSecond = normalize(second, seconds.length)
if (
normalizedHour === selectedHour.value &&
normalizedMinute === selectedMinute.value &&
normalizedSecond === selectedSecond.value
) {
return
}
selectedHour.value = normalizedHour
selectedMinute.value = normalizedMinute
selectedSecond.value = normalizedSecond
selectedHourIndex.value = CENTER_CYCLE * hours.length + normalizedHour
selectedMinuteIndex.value = CENTER_CYCLE * minutes.length + normalizedMinute
selectedSecondIndex.value = CENTER_CYCLE * seconds.length + normalizedSecond
hourWheel.value?.scrollTo({ top: selectedHourIndex.value * ITEM_HEIGHT })
minuteWheel.value?.scrollTo({
top: selectedMinuteIndex.value * ITEM_HEIGHT,
})
secondWheel.value?.scrollTo({
top: selectedSecondIndex.value * ITEM_HEIGHT,
})
},
)
</script>
<template>
<section class="time-wheel-picker" :aria-label="label">
<section
class="time-wheel-picker"
:class="{
'time-wheel-picker--duration': secondsLabel,
'time-wheel-picker--unit-labels': showUnitLabels,
}"
: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)"
<div class="time-wheel-picker__field">
<div
ref="hourWheel"
class="time-wheel-picker__column"
role="listbox"
:aria-label="hoursLabel"
@pointercancel="endDrag"
@pointerdown="beginDrag($event, $event.currentTarget as HTMLElement)"
@pointermove="drag"
@pointerup="endDrag"
@scroll.passive="
updateFromScroll('hour', $event.currentTarget as HTMLElement)
"
>
{{ format(hour) }}
</button>
<button
v-for="item in loopedHours"
:key="item.index"
type="button"
role="option"
:aria-selected="selectedHourIndex === item.index"
:class="{
'time-wheel-picker__value--selected':
selectedHourIndex === item.index,
}"
class="time-wheel-picker__value"
@click="select('hour', item.index, item.value, hourWheel)"
>
{{ format(item.value) }}
</button>
</div>
<span v-if="showUnitLabels" class="time-wheel-picker__unit">{{
hoursUnitLabel ?? hoursLabel
}}</span>
</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)"
<div class="time-wheel-picker__field">
<div
ref="minuteWheel"
class="time-wheel-picker__column"
role="listbox"
:aria-label="minutesLabel"
@pointercancel="endDrag"
@pointerdown="beginDrag($event, $event.currentTarget as HTMLElement)"
@pointermove="drag"
@pointerup="endDrag"
@scroll.passive="
updateFromScroll('minute', $event.currentTarget as HTMLElement)
"
>
{{ format(minute) }}
</button>
<button
v-for="item in loopedMinutes"
:key="item.index"
type="button"
role="option"
:aria-selected="selectedMinuteIndex === item.index"
:class="{
'time-wheel-picker__value--selected':
selectedMinuteIndex === item.index,
}"
class="time-wheel-picker__value"
@click="select('minute', item.index, item.value, minuteWheel)"
>
{{ format(item.value) }}
</button>
</div>
<span v-if="showUnitLabels" class="time-wheel-picker__unit">{{
minutesUnitLabel ?? minutesLabel
}}</span>
</div>
<div v-if="secondsLabel" class="time-wheel-picker__field">
<div
ref="secondWheel"
class="time-wheel-picker__column"
role="listbox"
:aria-label="secondsLabel"
@pointercancel="endDrag"
@pointerdown="beginDrag($event, $event.currentTarget as HTMLElement)"
@pointermove="drag"
@pointerup="endDrag"
@scroll.passive="
updateFromScroll('second', $event.currentTarget as HTMLElement)
"
>
<button
v-for="item in loopedSeconds"
:key="item.index"
type="button"
role="option"
:aria-selected="selectedSecondIndex === item.index"
:class="{
'time-wheel-picker__value--selected':
selectedSecondIndex === item.index,
}"
class="time-wheel-picker__value"
@click="select('second', item.index, item.value, secondWheel)"
>
{{ format(item.value) }}
</button>
</div>
<span v-if="showUnitLabels" class="time-wheel-picker__unit">{{
secondsUnitLabel ?? secondsLabel
}}</span>
</div>
</section>
</template>
+30 -3
View File
@@ -4,6 +4,7 @@ import {
alarmMinuteKey,
type Alarm,
type AlarmDraft,
type AlarmSoundId,
isAlarmDue,
readAlarms,
writeAlarms,
@@ -17,7 +18,9 @@ export const useClockStore = defineStore('clock', {
stopwatchAccumulated: 0,
stopwatchStartedAt: null as number | null,
timerDuration: 5 * 60 * 1000,
timerNote: '',
timerRemainingAtStart: 5 * 60 * 1000,
timerSound: 'radar' as AlarmSoundId,
timerStartedAt: null as number | null,
}),
actions: {
@@ -89,11 +92,19 @@ export const useClockStore = defineStore('clock', {
this.timerRemainingAtStart = this.timerDuration
this.timerStartedAt = null
},
setTimerMinutes(minutes: number): void {
this.timerDuration =
Math.max(1, Math.min(60, Math.round(minutes))) * 60 * 1000
setTimerDuration(milliseconds: number): void {
this.timerDuration = Math.max(
0,
Math.min(24 * 60 * 60 * 1000 - 1000, milliseconds),
)
this.resetTimer()
},
setTimerNote(note: string): void {
this.timerNote = note
},
setTimerSound(sound: AlarmSoundId): void {
this.timerSound = sound
},
startStopwatch(now: number): void {
if (this.stopwatchStartedAt !== null) return
this.stopwatchStartedAt = now
@@ -103,6 +114,22 @@ export const useClockStore = defineStore('clock', {
return
this.timerStartedAt = now
},
consumeDueTimer(now: number): { note: string; sound: AlarmSoundId } | null {
if (
this.timerStartedAt === null ||
remainingMilliseconds(
this.timerRemainingAtStart,
this.timerStartedAt,
now,
) > 0
) {
return null
}
this.timerRemainingAtStart = 0
this.timerStartedAt = null
return { note: this.timerNote, sound: this.timerSound }
},
toggleAlarm(id: string): void {
const alarm = this.alarms.find((candidate) => candidate.id === id)
if (!alarm) return
+13
View File
@@ -122,6 +122,19 @@ const defaultLocales: LocaleTree = {
},
sounds: { radar: 'Radar', beacon: 'Beacon', chimes: 'Chimes' },
},
timer: {
time: 'Timer duration',
hours: 'Hours',
hoursShort: 'hr',
minutes: 'Minutes',
minutesShort: 'min',
seconds: 'Seconds',
secondsShort: 'sec',
note: 'Note',
notePlaceholder: 'Timer',
sound: 'Sound',
ringing: 'Timer',
},
},
photos: {
name: 'Photos',
+13 -1
View File
@@ -1,9 +1,21 @@
import { describe, expect, it } from 'vitest'
import { elapsedMilliseconds, remainingMilliseconds } from './clock'
import {
elapsedMilliseconds,
formatTimer,
remainingMilliseconds,
timerPickerMilliseconds,
timerPickerValue,
} from './clock'
describe('timestamp clocks', () => {
it('derives elapsed and remaining time without an interval state', () => {
expect(elapsedMilliseconds(500, 1000, 2500)).toBe(2000)
expect(remainingMilliseconds(5000, 1000, 2500)).toBe(3500)
expect(remainingMilliseconds(100, 1000, 2500)).toBe(0)
})
it('formats and parses timer durations with hours, minutes, and seconds', () => {
expect(formatTimer(3_661_000)).toBe('01:01:01')
expect(timerPickerValue(3_661_000)).toBe('01:01:01')
expect(timerPickerMilliseconds('01:01:01')).toBe(3_661_000)
})
})
+18 -2
View File
@@ -27,7 +27,23 @@ export function formatStopwatch(milliseconds: number): string {
export function formatTimer(milliseconds: number): string {
const totalSeconds = Math.ceil(milliseconds / 1000)
const minutes = Math.floor(totalSeconds / 60)
const hours = Math.floor(totalSeconds / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
const seconds = totalSeconds % 60
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
}
export function timerPickerValue(milliseconds: number): string {
const totalSeconds = Math.floor(milliseconds / 1000)
const hours = Math.floor(totalSeconds / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
const seconds = totalSeconds % 60
return [hours, minutes, seconds]
.map((value) => String(value).padStart(2, '0'))
.join(':')
}
export function timerPickerMilliseconds(value: string): number {
const [hours = 0, minutes = 0, seconds = 0] = value.split(':').map(Number)
return (hours * 3600 + minutes * 60 + seconds) * 1000
}
+248 -189
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import {
kBlock,
kBlockTitle,
kButton,
kLink,
kList,
@@ -14,6 +15,7 @@ import {
} from 'konsta/vue'
import {
AlarmClock,
Check,
Clock3,
Minus,
Plus,
@@ -23,14 +25,17 @@ import {
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import AlarmEditor from '@/components/AlarmEditor.vue'
import TimeWheelPicker from '@/components/TimeWheelPicker.vue'
import { useClockStore } from '@/stores/clock'
import { usePhoneStore } from '@/stores/phone'
import type { Alarm, AlarmDraft } from '@/utils/alarms'
import { ALARM_SOUND_IDS, type Alarm, type AlarmDraft } from '@/utils/alarms'
import {
elapsedMilliseconds,
formatStopwatch,
formatTimer,
remainingMilliseconds,
timerPickerMilliseconds,
timerPickerValue,
} from '@/utils/clock'
const phone = usePhoneStore()
@@ -58,6 +63,11 @@ const timerValue = computed(() =>
now.value,
),
)
const timerPicker = computed({
get: () => timerPickerValue(clock.timerRemainingAtStart),
set: (value: string) =>
clock.setTimerDuration(timerPickerMilliseconds(value)),
})
const currentTime = computed(() =>
new Intl.DateTimeFormat(phone.lang, {
hour: '2-digit',
@@ -72,8 +82,7 @@ const currentTimeZone = computed(
timeZoneName: 'short',
})
.formatToParts(now.value)
.find((part) => part.type === 'timeZoneName')?.value ??
browserTimeZone,
.find((part) => part.type === 'timeZoneName')?.value ?? browserTimeZone,
)
const selectedAlarm = computed(() => {
const editor = alarmEditor.value
@@ -87,6 +96,9 @@ const tabs = [
{ id: 'stopwatch', icon: Timer },
{ id: 'timer', icon: TimerReset },
] as const
const tabBarColors = {
strongHighlightBgIos: 'bg-[#2c2c2e]',
}
const secondaryActionColors = {
tonalBgIos: 'bg-[#2c2c2e] active:bg-[#3a3a3c]',
tonalTextIos: 'text-white',
@@ -164,6 +176,10 @@ function selectTab(nextTab: (typeof tabs)[number]['id']): void {
tab.value = nextTab
if (nextTab !== 'alarm') alarmsEditing.value = false
}
function setTimerNote(event: Event): void {
clock.setTimerNote((event.target as HTMLInputElement).value.slice(0, 80))
}
onMounted(() => {
ticker = setInterval(() => {
now.value = Date.now()
@@ -185,204 +201,247 @@ onBeforeUnmount(() => clearInterval(ticker))
<template v-else>
<k-navbar
:title="phone.t(`Apps.clock.tabs.${tab}`)"
:large="tab === 'world' || tab === 'alarm'"
:transparent="tab === 'world' || tab === 'alarm'"
class="clock-navbar"
>
<template #left>
<k-link
component="button"
:link-props="{ type: 'button' }"
@click="toggleAlarmEditing"
>
{{ phone.t(alarmsEditing ? 'Common.done' : 'Common.edit') }}
</k-link>
</template>
<template #right>
<k-link
component="button"
icon-only
:link-props="{ type: 'button' }"
:aria-label="phone.t('Apps.clock.add')"
@click="openAlarmEditor()"
>
<Plus />
</k-link>
</template>
</k-navbar>
<k-block component="section" nested class="clock-content">
<k-block v-if="tab === 'world'" nested class="clock-world-now">
<span class="clock-world-location">{{
browserTimeZone.replace(/_/g, ' ')
}}</span>
<time class="clock-world-time">{{ currentTime }}</time>
<span class="clock-world-zone">{{ currentTimeZone }}</span>
</k-block>
<k-list
v-else-if="tab === 'alarm'"
strong
inset
class="clock-konsta-list clock-alarm-list"
v-if="tab === 'world' || tab === 'alarm'"
:title="phone.t(`Apps.clock.tabs.${tab}`)"
:large="tab === 'world' || tab === 'alarm'"
:transparent="tab === 'world' || tab === 'alarm'"
class="clock-navbar"
>
<k-list-item
v-for="alarm in clock.alarms"
:key="alarm.id"
link
:chevron="alarmsEditing"
:title="alarm.time"
:subtitle="alarmSubtitle(alarm)"
:strong-title="false"
title-font-size-ios="text-[38px] font-light"
class="clock-alarm-item"
@click="openAlarmEditor(alarm.id)"
<template #left>
<k-link
component="button"
:link-props="{ type: 'button' }"
@click="toggleAlarmEditing"
>
{{ phone.t(alarmsEditing ? 'Common.done' : 'Common.edit') }}
</k-link>
</template>
<template #right>
<k-link
component="button"
icon-only
:link-props="{ type: 'button' }"
:aria-label="phone.t('Apps.clock.add')"
@click="openAlarmEditor()"
>
<Plus />
</k-link>
</template>
</k-navbar>
<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')
}}</span>
<time class="clock-world-time">{{ currentTime }}</time>
<span class="clock-world-zone">{{ currentTimeZone }}</span>
</k-block>
<k-list
v-else-if="tab === 'alarm'"
class="clock-konsta-list clock-alarm-list"
>
<template v-if="alarmsEditing" #media>
<k-list-item
v-for="alarm in clock.alarms"
:key="alarm.id"
link
:chevron="alarmsEditing"
:title="alarm.time"
: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>
<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>
<k-block v-else-if="tab === 'stopwatch'" nested class="clock-tool">
<div class="clock-digits">{{ formatStopwatch(stopwatchValue) }}</div>
<div class="clock-actions">
<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)"
tonal
:colors="secondaryActionColors"
class="clock-action-button"
@click="
clock.stopwatchStartedAt === null
? clock.resetStopwatch()
: clock.addLap(now)
"
>
<Minus aria-hidden="true" />
{{
phone.t(
clock.stopwatchStartedAt === null
? 'Common.reset'
: 'Apps.clock.lap',
)
}}
</k-button>
</template>
<template #after>
<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>
<k-button
rounded
:colors="positiveActionColors"
class="clock-action-button"
@click="
clock.stopwatchStartedAt === null
? clock.startStopwatch(now)
: clock.pauseStopwatch(now)
"
>
{{
phone.t(
clock.stopwatchStartedAt === null
? 'Common.start'
: 'Common.stop',
)
}}
</k-button>
</div>
<k-list dividers class="clock-konsta-list clock-lap-list">
<k-list-item
v-for="(lap, index) in clock.laps"
:key="index"
:title="`${phone.t('Apps.clock.lap')} ${clock.laps.length - index}`"
:after="formatStopwatch(lap)"
/>
</k-list>
</k-block>
<k-block v-else-if="tab === 'stopwatch'" nested class="clock-tool">
<div class="clock-digits">{{ formatStopwatch(stopwatchValue) }}</div>
<div class="clock-actions">
<k-button
rounded
tonal
:colors="secondaryActionColors"
class="clock-action-button"
@click="
clock.stopwatchStartedAt === null
? clock.resetStopwatch()
: clock.addLap(now)
"
>
{{
phone.t(
clock.stopwatchStartedAt === null
? 'Common.reset'
: 'Apps.clock.lap',
)
}}
</k-button>
<k-button
rounded
:colors="positiveActionColors"
class="clock-action-button"
@click="
clock.stopwatchStartedAt === null
? clock.startStopwatch(now)
: clock.pauseStopwatch(now)
"
>
{{
phone.t(
clock.stopwatchStartedAt === null
? 'Common.start'
: 'Common.stop',
)
}}
</k-button>
</div>
<k-list dividers class="clock-konsta-list clock-lap-list">
<k-list-item
v-for="(lap, index) in clock.laps"
:key="index"
:title="`${phone.t('Apps.clock.lap')} ${clock.laps.length - index}`"
:after="formatStopwatch(lap)"
<k-block v-else nested class="clock-tool clock-timer">
<h1 class="clock-section-heading">
{{ phone.t('Apps.clock.tabs.timer') }}
</h1>
<div v-if="clock.timerStartedAt !== null" class="timer-ring">
<span>{{ formatTimer(timerValue) }}</span>
</div>
<TimeWheelPicker
v-else
v-model="timerPicker"
show-unit-labels
:hours-label="phone.t('Apps.clock.timer.hours')"
:hours-unit-label="phone.t('Apps.clock.timer.hoursShort')"
:label="phone.t('Apps.clock.timer.time')"
:minutes-label="phone.t('Apps.clock.timer.minutes')"
:minutes-unit-label="phone.t('Apps.clock.timer.minutesShort')"
:seconds-label="phone.t('Apps.clock.timer.seconds')"
:seconds-unit-label="phone.t('Apps.clock.timer.secondsShort')"
/>
</k-list>
<k-list strong inset class="clock-konsta-list clock-timer-settings">
<k-list-input
:label="phone.t('Apps.clock.timer.note')"
:placeholder="phone.t('Apps.clock.timer.notePlaceholder')"
:value="clock.timerNote"
maxlength="80"
@input="setTimerNote"
/>
</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)"
>
<template #after>
<Check
v-if="clock.timerSound === sound"
class="h-5 w-5 text-primary"
/>
</template>
</k-list-item>
</k-list>
<div class="clock-actions">
<k-button
rounded
tonal
:colors="secondaryActionColors"
class="clock-action-button"
@click="clock.resetTimer()"
>
{{ phone.t('Common.reset') }}
</k-button>
<k-button
rounded
:colors="positiveActionColors"
class="clock-action-button"
:disabled="clock.timerStartedAt === null && timerValue <= 0"
@click="
clock.timerStartedAt === null
? clock.startTimer(now)
: clock.pauseTimer(now)
"
>
{{
phone.t(
clock.timerStartedAt === null
? 'Common.start'
: 'Common.pause',
)
}}
</k-button>
</div>
</k-block>
</k-block>
<k-block v-else nested class="clock-tool">
<div class="timer-ring">
<span>{{ formatTimer(timerValue) }}</span>
</div>
<k-list strong inset class="clock-konsta-list clock-timer-input">
<k-list-input
:label="phone.t('Apps.clock.minutes')"
type="number"
min="1"
max="60"
:value="clock.timerDuration / 60000"
@change="
clock.setTimerMinutes(
Number(($event.target as HTMLInputElement).value),
)
"
/>
</k-list>
<div class="clock-actions">
<k-button
<k-navbar component="nav" :aria-label="phone.t('Apps.clock.name')">
<template #subnavbar>
<k-segmented
strong
rounded
tonal
:colors="secondaryActionColors"
class="clock-action-button"
@click="clock.resetTimer()"
:colors="tabBarColors"
:data-active-tab="tab"
>
{{ phone.t('Common.reset') }}
</k-button>
<k-button
rounded
:colors="positiveActionColors"
class="clock-action-button"
@click="
clock.timerStartedAt === null
? clock.startTimer(now)
: clock.pauseTimer(now)
"
>
{{
phone.t(
clock.timerStartedAt === null ? 'Common.start' : 'Common.pause',
)
}}
</k-button>
</div>
</k-block>
</k-block>
<k-navbar
component="nav"
:aria-label="phone.t('Apps.clock.name')"
>
<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>
<k-segmented-button
v-for="item in tabs"
:key="item.id"
large
:active="tab === item.id"
:class="tab === item.id ? 'text-[#ff9f0a]' : 'text-[#8e8e93]'"
:aria-label="phone.t(`Apps.clock.tabs.${item.id}`)"
:aria-pressed="tab === item.id"
@click="selectTab(item.id)"
>
<span
class="flex flex-col items-center gap-0.5 text-[10px] leading-none"
>
<component :is="item.icon" class="h-5 w-5" aria-hidden="true" />
<span>{{ phone.t(`Apps.clock.tabs.${item.id}`) }}</span>
</span>
</k-segmented-button>
</k-segmented>
</template>
</k-navbar>
</template>
</k-page>
</template>
+5
View File
@@ -36,6 +36,11 @@ Locales["en"] = {
daysShort = { sunday = "Sun", monday = "Mon", tuesday = "Tue", wednesday = "Wed", thursday = "Thu", friday = "Fri", saturday = "Sat" },
sounds = { radar = "Radar", beacon = "Beacon", chimes = "Chimes" },
},
timer = {
time = "Timer duration", hours = "Hours", hoursShort = "hr", minutes = "Minutes", minutesShort = "min",
seconds = "Seconds", secondsShort = "sec",
note = "Note", notePlaceholder = "Timer", sound = "Sound", ringing = "Timer",
},
},
photos = {
name = "Photos", searchPlaceholder = "Photos, people, places...", recents = "Recents",
+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-D_Kmg2yn.js"></script>
<link rel="stylesheet" crossorigin href="./assets/sky-index-Ifens1n8.css">
<script type="module" crossorigin src="./assets/sky-index-Brc5i4FT.js"></script>
<link rel="stylesheet" crossorigin href="./assets/sky-index-DkBC7Qz5.css">
</head>
<body>
<div id="app"></div>