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:
+19
-4
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user