ENH - clock app

This commit is contained in:
Eichenholz
2026-08-03 21:40:56 +02:00
parent e64bddcc15
commit c955e31e63
18 changed files with 1146 additions and 92 deletions
+150
View File
@@ -0,0 +1,150 @@
<script setup lang="ts">
import {
kBlock,
kBlockTitle,
kButton,
kLink,
kList,
kListInput,
kListItem,
kNavbar,
} from 'konsta/vue'
import { Check } from 'lucide-vue-next'
import { reactive } from 'vue'
import { usePhoneStore } from '@/stores/phone'
import TimeWheelPicker from '@/components/TimeWheelPicker.vue'
import {
ALARM_SOUND_IDS,
type Alarm,
type AlarmDraft,
WEEKDAY_IDS,
} from '@/utils/alarms'
const props = defineProps<{ alarm?: Alarm }>()
const emit = defineEmits<{
cancel: []
delete: []
save: [draft: AlarmDraft]
}>()
const phone = usePhoneStore()
const weekdayKeys = [
'sunday',
'monday',
'tuesday',
'wednesday',
'thursday',
'friday',
'saturday',
] as const
const draft = reactive<AlarmDraft>({
note: props.alarm?.note ?? '',
sound: props.alarm?.sound ?? 'radar',
time: props.alarm?.time ?? '07:00',
weekdays: [...(props.alarm?.weekdays ?? [])],
})
const dangerColors = {
tonalBgIos: 'bg-red-500/15 active:bg-red-500/25',
tonalTextIos: 'text-red-500',
}
function setNote(event: Event): void {
draft.note = (event.target as HTMLInputElement).value.slice(0, 80)
}
function save(): void {
emit('save', {
note: draft.note,
sound: draft.sound,
time: draft.time,
weekdays: [...draft.weekdays],
})
}
function toggleWeekday(weekday: number): void {
draft.weekdays = draft.weekdays.includes(weekday)
? draft.weekdays.filter((candidate) => candidate !== weekday)
: [...draft.weekdays, weekday]
}
</script>
<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>
</template>
<template #right>
<k-link component="button" @click="save">
{{ phone.t('Common.save') }}
</k-link>
</template>
</k-navbar>
<k-block component="section" class="clock-alarm-editor">
<TimeWheelPicker
v-model="draft.time"
:hours-label="phone.t('Apps.clock.alarm.hours')"
:label="phone.t('Apps.clock.alarm.time')"
:minutes-label="phone.t('Apps.clock.minutes')"
/>
<k-block-title>{{ phone.t('Apps.clock.alarm.repeat') }}</k-block-title>
<k-list strong inset>
<k-list-item
v-for="weekday in WEEKDAY_IDS"
:key="weekday"
link
:chevron="false"
:title="phone.t(`Apps.clock.alarm.days.${weekdayKeys[weekday]}`)"
@click="toggleWeekday(weekday)"
>
<template #after>
<Check
v-if="draft.weekdays.includes(weekday)"
class="w-5 h-5 text-primary"
/>
</template>
</k-list-item>
</k-list>
<k-list strong inset>
<k-list-input
:label="phone.t('Apps.clock.alarm.note')"
:placeholder="phone.t('Apps.clock.alarm.notePlaceholder')"
:value="draft.note"
maxlength="80"
@input="setNote"
/>
</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"
>
<template #after>
<Check v-if="draft.sound === sound" class="w-5 h-5 text-primary" />
</template>
</k-list-item>
</k-list>
<k-button
v-if="alarm"
large
rounded
tonal
:colors="dangerColors"
class="clock-alarm-delete"
@click="emit('delete')"
>
{{ phone.t('Apps.clock.alarm.delete') }}
</k-button>
</k-block>
</template>
@@ -0,0 +1,36 @@
<script setup lang="ts">
import { kNotification } from 'konsta/vue'
import { computed } from 'vue'
import { getPhoneApp } from '@/config/apps'
import { useNotificationsStore } from '@/stores/notifications'
import { usePhoneStore } from '@/stores/phone'
const notifications = useNotificationsStore()
const phone = usePhoneStore()
const icon = computed(() =>
notifications.current
? getPhoneApp(notifications.current.appId)?.iconImage
: undefined,
)
</script>
<template>
<k-notification
:opened="!!notifications.current"
:title="notifications.current?.title"
:subtitle="notifications.current?.subtitle"
:text="notifications.current?.text"
:title-right-text="phone.t('Notifications.now')"
button="close"
class="phone-notification"
@close="notifications.dismissCurrent()"
>
<template v-if="icon" #icon>
<img :src="icon" alt="" class="phone-notification__icon" />
</template>
<template #button>
<span class="sr-only">{{ phone.t('Common.close') }}</span>
</template>
</k-notification>
</template>
+117
View File
@@ -0,0 +1,117 @@
<script setup lang="ts">
import { nextTick, onMounted, ref } from 'vue'
const props = defineProps<{
hoursLabel: string
label: string
minutesLabel: string
modelValue: string
}>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
const ITEM_HEIGHT = 40
const hours = Array.from({ length: 24 }, (_, index) => index)
const minutes = Array.from({ length: 60 }, (_, index) => index)
const initialTime = props.modelValue.split(':').map(Number)
const selectedHour = ref(initialTime[0] ?? 0)
const selectedMinute = ref(initialTime[1] ?? 0)
const hourWheel = ref<HTMLElement | null>(null)
const minuteWheel = ref<HTMLElement | null>(null)
let updateFrame: number | undefined
function format(value: number): string {
return String(value).padStart(2, '0')
}
function emitTime(): void {
emit(
'update:modelValue',
`${format(selectedHour.value)}:${format(selectedMinute.value)}`,
)
}
function updateFromScroll(type: 'hour' | 'minute', element: HTMLElement): void {
if (updateFrame) cancelAnimationFrame(updateFrame)
updateFrame = requestAnimationFrame(() => {
const maximum = type === 'hour' ? hours.length - 1 : minutes.length - 1
const value = Math.max(
0,
Math.min(maximum, Math.round(element.scrollTop / ITEM_HEIGHT)),
)
if (type === 'hour') selectedHour.value = value
else selectedMinute.value = value
emitTime()
})
}
function select(
type: 'hour' | 'minute',
value: number,
element: HTMLElement | null,
): void {
if (type === 'hour') selectedHour.value = value
else selectedMinute.value = value
element?.scrollTo({ behavior: 'smooth', top: value * ITEM_HEIGHT })
emitTime()
}
onMounted(() => {
void nextTick(() => {
hourWheel.value?.scrollTo({ top: selectedHour.value * ITEM_HEIGHT })
minuteWheel.value?.scrollTo({ top: selectedMinute.value * ITEM_HEIGHT })
})
})
</script>
<template>
<section class="time-wheel-picker" :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)"
>
{{ format(hour) }}
</button>
</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)"
>
{{ format(minute) }}
</button>
</div>
</section>
</template>