mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 09:13:24 +00:00
522 lines
15 KiB
Vue
522 lines
15 KiB
Vue
<script setup lang="ts">
|
|
import {
|
|
SkyBlock,
|
|
SkyButton,
|
|
SkyLink,
|
|
SkyList,
|
|
SkyField,
|
|
SkyListItem,
|
|
SkyNavbar,
|
|
SkyAppPage,
|
|
SkyTabBar,
|
|
SkyTabButton,
|
|
SkyToggle,
|
|
} from '@/ui'
|
|
import {
|
|
AlarmClock,
|
|
ChevronRight,
|
|
Globe2,
|
|
Minus,
|
|
Pause,
|
|
Play,
|
|
Plus,
|
|
Timer,
|
|
TimerReset,
|
|
X,
|
|
} from 'lucide-vue-next'
|
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
import { useRoute } from 'vue-router'
|
|
|
|
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 { type Alarm, type AlarmDraft } from '@/utils/alarms'
|
|
import {
|
|
elapsedMilliseconds,
|
|
formatStopwatch,
|
|
formatTimer,
|
|
remainingMilliseconds,
|
|
timerPickerMilliseconds,
|
|
timerPickerValue,
|
|
timerProgressRatio,
|
|
} from '@/utils/clock'
|
|
|
|
const phone = usePhoneStore()
|
|
const clock = useClockStore()
|
|
const route = useRoute()
|
|
const tab = ref<'world' | 'alarm' | 'stopwatch' | 'timer'>(
|
|
route.query.section === 'timer' || route.query.section === 'stopwatch'
|
|
? route.query.section
|
|
: '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(
|
|
clock.stopwatchAccumulated,
|
|
clock.stopwatchStartedAt,
|
|
now.value,
|
|
),
|
|
)
|
|
const timerValue = computed(() =>
|
|
remainingMilliseconds(
|
|
clock.timerRemainingAtStart,
|
|
clock.timerStartedAt,
|
|
now.value,
|
|
),
|
|
)
|
|
const timerProgress = computed(() =>
|
|
timerProgressRatio(timerValue.value, clock.timerDuration),
|
|
)
|
|
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',
|
|
hourCycle: 'h23',
|
|
minute: '2-digit',
|
|
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 stopwatchDecimalSeparator = computed(
|
|
() =>
|
|
new Intl.NumberFormat(phone.lang)
|
|
.formatToParts(1.1)
|
|
.find((part) => part.type === 'decimal')?.value ?? '.',
|
|
)
|
|
const stopwatchDisplay = computed(() =>
|
|
formatStopwatch(stopwatchValue.value).replace(
|
|
'.',
|
|
stopwatchDecimalSeparator.value,
|
|
),
|
|
)
|
|
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: Globe2 },
|
|
{ id: 'alarm', icon: AlarmClock },
|
|
{ id: 'stopwatch', icon: Timer },
|
|
{ id: 'timer', icon: TimerReset },
|
|
] as const
|
|
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
|
|
}
|
|
|
|
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)
|
|
})
|
|
|
|
watch(
|
|
() => route.query.section,
|
|
(section) => {
|
|
if (section === 'timer' || section === 'stopwatch') tab.value = section
|
|
},
|
|
)
|
|
|
|
onMounted(() => {
|
|
ticker = setInterval(() => {
|
|
now.value = Date.now()
|
|
}, 250)
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
clearInterval(ticker)
|
|
if (stopwatchFrame !== undefined) cancelAnimationFrame(stopwatchFrame)
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<sky-app-page
|
|
component="main"
|
|
accent="#ff9f0a"
|
|
accent-soft="rgba(255, 159, 10, 0.16)"
|
|
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)"
|
|
/>
|
|
|
|
<template v-else>
|
|
<sky-navbar
|
|
v-if="tab === 'alarm'"
|
|
:title="phone.t(`Apps.clock.tabs.${tab}`)"
|
|
variant="large"
|
|
transparent
|
|
class="clock-navbar clock-navbar--alarm"
|
|
>
|
|
<template #left>
|
|
<sky-link
|
|
component="button"
|
|
icon-only
|
|
class="clock-navbar-action clock-navbar-action--icon sky-glass-surface"
|
|
:link-props="{ type: 'button' }"
|
|
:aria-label="phone.t('Apps.clock.add')"
|
|
@click="openAlarmEditor()"
|
|
>
|
|
<Plus aria-hidden="true" />
|
|
</sky-link>
|
|
</template>
|
|
<template #right>
|
|
<sky-link
|
|
v-if="clock.alarms.length"
|
|
component="button"
|
|
class="clock-navbar-action clock-navbar-action--edit sky-glass-surface"
|
|
:link-props="{ type: 'button' }"
|
|
@click="toggleAlarmEditing"
|
|
>
|
|
{{ phone.t(alarmsEditing ? 'Common.done' : 'Common.edit') }}
|
|
</sky-link>
|
|
</template>
|
|
</sky-navbar>
|
|
|
|
<sky-block component="section" nested class="clock-content">
|
|
<sky-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>
|
|
</sky-block>
|
|
|
|
<section
|
|
v-else-if="tab === 'alarm'"
|
|
class="clock-alarm-list"
|
|
:aria-label="phone.t('Apps.clock.tabs.alarm')"
|
|
>
|
|
<article
|
|
v-for="alarm in clock.alarms"
|
|
:key="alarm.id"
|
|
class="clock-alarm-item"
|
|
:class="{ 'clock-alarm-item--disabled': !alarm.enabled }"
|
|
>
|
|
<sky-button
|
|
v-if="alarmsEditing"
|
|
rounded
|
|
small
|
|
icon-only
|
|
variant="danger"
|
|
class="clock-alarm-remove"
|
|
:aria-label="phone.t('Apps.clock.alarm.delete')"
|
|
@click="clock.deleteAlarm(alarm.id)"
|
|
>
|
|
<Minus aria-hidden="true" />
|
|
</sky-button>
|
|
|
|
<button
|
|
type="button"
|
|
class="clock-alarm-copy"
|
|
@click="openAlarmEditor(alarm.id)"
|
|
>
|
|
<time class="clock-alarm-time">{{ alarm.time }}</time>
|
|
<span class="clock-alarm-subtitle">{{
|
|
alarmSubtitle(alarm)
|
|
}}</span>
|
|
</button>
|
|
|
|
<ChevronRight
|
|
v-if="alarmsEditing"
|
|
class="clock-alarm-chevron"
|
|
aria-hidden="true"
|
|
/>
|
|
<sky-toggle
|
|
v-else
|
|
:checked="alarm.enabled"
|
|
style="--sky-app-accent: #30d158"
|
|
:aria-label="`${alarm.time}, ${alarmSubtitle(alarm)}`"
|
|
@change="clock.toggleAlarm(alarm.id)"
|
|
/>
|
|
</article>
|
|
</section>
|
|
|
|
<sky-block
|
|
v-else-if="tab === 'stopwatch'"
|
|
nested
|
|
class="clock-tool clock-stopwatch"
|
|
>
|
|
<time class="clock-digits">{{ stopwatchDisplay }}</time>
|
|
<div class="clock-actions clock-stopwatch-actions">
|
|
<sky-button
|
|
rounded
|
|
variant="secondary"
|
|
class="clock-action-button clock-action-button--secondary"
|
|
:disabled="
|
|
clock.stopwatchStartedAt === null && stopwatchValue <= 0
|
|
"
|
|
@click="
|
|
clock.stopwatchStartedAt === null
|
|
? clock.resetStopwatch()
|
|
: clock.addLap(Date.now())
|
|
"
|
|
>
|
|
{{
|
|
phone.t(
|
|
clock.stopwatchStartedAt === null
|
|
? 'Common.reset'
|
|
: 'Apps.clock.lap',
|
|
)
|
|
}}
|
|
</sky-button>
|
|
<sky-button
|
|
rounded
|
|
variant="secondary"
|
|
class="clock-action-button"
|
|
:class="
|
|
clock.stopwatchStartedAt === null
|
|
? 'clock-action-button--start'
|
|
: 'clock-action-button--stop'
|
|
"
|
|
@click="
|
|
clock.stopwatchStartedAt === null
|
|
? clock.startStopwatch(Date.now())
|
|
: clock.pauseStopwatch(Date.now())
|
|
"
|
|
>
|
|
{{
|
|
phone.t(
|
|
clock.stopwatchStartedAt === null
|
|
? 'Common.start'
|
|
: 'Common.stop',
|
|
)
|
|
}}
|
|
</sky-button>
|
|
</div>
|
|
<div class="clock-tool-divider" aria-hidden="true" />
|
|
<sky-list dividers class="clock-konsta-list clock-lap-list">
|
|
<sky-list-item
|
|
v-for="(lap, index) in clock.laps"
|
|
:key="index"
|
|
:title="`${phone.t('Apps.clock.lap')} ${clock.laps.length - index}`"
|
|
:after="formatStopwatch(lap)"
|
|
/>
|
|
</sky-list>
|
|
</sky-block>
|
|
|
|
<section v-else class="clock-tool clock-timer">
|
|
<div
|
|
v-if="clock.timerStartedAt !== null"
|
|
class="timer-ring"
|
|
role="progressbar"
|
|
aria-valuemin="0"
|
|
aria-valuemax="100"
|
|
:aria-valuenow="Math.round(timerProgress * 100)"
|
|
:aria-valuetext="formatTimer(timerValue)"
|
|
>
|
|
<svg viewBox="0 0 210 210" aria-hidden="true">
|
|
<circle class="timer-ring-track" cx="105" cy="105" r="92" />
|
|
<circle
|
|
class="timer-ring-progress"
|
|
cx="105"
|
|
cy="105"
|
|
r="92"
|
|
pathLength="100"
|
|
:style="{ strokeDashoffset: 100 - timerProgress * 100 }"
|
|
/>
|
|
</svg>
|
|
<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')"
|
|
/>
|
|
|
|
<div class="clock-actions clock-timer-actions">
|
|
<sky-button
|
|
rounded
|
|
variant="secondary"
|
|
class="clock-action-button clock-action-button--secondary clock-action-button--icon"
|
|
:aria-label="phone.t('Common.reset')"
|
|
@click="clock.resetTimer()"
|
|
>
|
|
<X aria-hidden="true" />
|
|
</sky-button>
|
|
<sky-button
|
|
rounded
|
|
variant="secondary"
|
|
class="clock-action-button clock-action-button--start clock-action-button--icon"
|
|
:disabled="clock.timerStartedAt === null && timerValue <= 0"
|
|
:aria-label="
|
|
phone.t(
|
|
clock.timerStartedAt === null
|
|
? 'Common.start'
|
|
: 'Common.pause',
|
|
)
|
|
"
|
|
@click="
|
|
clock.timerStartedAt === null
|
|
? clock.startTimer(now)
|
|
: clock.pauseTimer(now)
|
|
"
|
|
>
|
|
<Play v-if="clock.timerStartedAt === null" aria-hidden="true" />
|
|
<Pause v-else aria-hidden="true" />
|
|
</sky-button>
|
|
</div>
|
|
|
|
<sky-list strong inset class="clock-konsta-list clock-timer-settings">
|
|
<sky-field
|
|
:label="phone.t('Apps.clock.timer.description')"
|
|
:placeholder="phone.t('Apps.clock.timer.notePlaceholder')"
|
|
:value="clock.timerNote"
|
|
maxlength="80"
|
|
@input="setTimerNote"
|
|
/>
|
|
<sky-list-item
|
|
link
|
|
:chevron="false"
|
|
:title="phone.t('Apps.clock.timer.sound')"
|
|
@click="timerSoundMenuOpen = true"
|
|
>
|
|
<template #after>
|
|
<span class="clock-sound-selection">
|
|
{{ phone.t(`Apps.clock.alarm.sounds.${clock.timerSound}`) }}
|
|
<ChevronRight aria-hidden="true" />
|
|
</span>
|
|
</template>
|
|
</sky-list-item>
|
|
</sky-list>
|
|
</section>
|
|
</sky-block>
|
|
|
|
<sky-tab-bar
|
|
icons
|
|
labels
|
|
class="clock-tabbar"
|
|
:aria-label="phone.t('Apps.clock.name')"
|
|
>
|
|
<sky-tab-button
|
|
v-for="item in tabs"
|
|
:key="item.id"
|
|
:active="tab === item.id"
|
|
:aria-label="phone.t(`Apps.clock.tabs.${item.id}`)"
|
|
@click="selectTab(item.id)"
|
|
>
|
|
<template #icon>
|
|
<component :is="item.icon" aria-hidden="true" />
|
|
</template>
|
|
<template #label>
|
|
{{ phone.t(`Apps.clock.tabs.${item.id}`) }}
|
|
</template>
|
|
</sky-tab-button>
|
|
</sky-tab-bar>
|
|
|
|
<AlarmEditor
|
|
v-if="alarmEditor"
|
|
:alarm="selectedAlarm"
|
|
@cancel="alarmEditor = null"
|
|
@delete="deleteSelectedAlarm"
|
|
@save="saveAlarm"
|
|
/>
|
|
</template>
|
|
</sky-app-page>
|
|
</template>
|