mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 09:13:24 +00:00
ENH - clock app
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user