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