Files
sky_phone/frontend/src/components/PhoneLockScreen.vue
T
2026-08-10 17:49:47 +02:00

259 lines
7.3 KiB
Vue

<script setup lang="ts">
import { kFab, kGlass } from 'konsta/vue'
import { Camera, Flashlight, LockKeyhole, X } from 'lucide-vue-next'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import PhoneStatusIndicators from '@/components/PhoneStatusIndicators.vue'
import { getPhoneApp } from '@/config/apps'
import type { PhoneNotification } from '@/stores/notifications'
import { usePhoneStore } from '@/stores/phone'
import { nuiCall } from '@/utils/nui'
import type { PhonePreferencesV1 } from '@/utils/preferences'
const props = defineProps<{
notifications: PhoneNotification[]
preferences?: PhonePreferencesV1
preview?: boolean
}>()
const emit = defineEmits<{
camera: []
clearNotifications: []
dismissNotification: [id: string]
unlock: []
}>()
const phone = usePhoneStore()
const preferences = computed(() => props.preferences ?? phone.preferences)
const now = ref(new Date())
const dragOffset = ref(0)
const dragging = ref(false)
const flashlightActive = ref(false)
const shortcutColors = {
bgIos: 'bg-ios-light-glass dark:bg-ios-dark-glass',
activeBgIos: 'active:bg-white/90 dark:active:bg-white/20',
textIos: 'text-black dark:text-white',
}
let pointerStart = 0
let pointerStartedAt = 0
let clockTicker: number | undefined
const date = computed(() =>
new Intl.DateTimeFormat(phone.lang, {
day: 'numeric',
month: 'long',
weekday: 'long',
}).format(now.value),
)
const time = computed(() =>
new Intl.DateTimeFormat(phone.lang, {
hour: '2-digit',
hourCycle: 'h23',
minute: '2-digit',
})
.formatToParts(now.value)
.filter((part) => part.type !== 'dayPeriod')
.map((part) => part.value)
.join('')
.trim(),
)
const dragStyle = computed(() => ({
'--lock-drag': `${dragOffset.value}px`,
}))
const flashlightShortcutColors = computed(() =>
flashlightActive.value
? {
...shortcutColors,
bgIos: 'bg-white',
textIos: 'text-purple-500',
}
: shortcutColors,
)
function onPointerDown(event: PointerEvent): void {
if (props.preview) return
if (
(event.target as HTMLElement).closest(
'button, .lock-screen__notifications',
)
)
return
pointerStart = event.clientY
pointerStartedAt = Date.now()
dragging.value = true
;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)
}
function onPointerMove(event: PointerEvent): void {
if (!dragging.value) return
dragOffset.value = Math.min(0, event.clientY - pointerStart)
}
function finishPointer(event: PointerEvent): void {
if (!dragging.value) return
const distance = event.clientY - pointerStart
const elapsed = Math.max(1, Date.now() - pointerStartedAt)
const velocity = Math.abs(distance) / elapsed
dragging.value = false
if (distance < -74 || (distance < -22 && velocity > 0.55)) {
emit('unlock')
return
}
dragOffset.value = 0
}
function unlockWithKeyboard(event: KeyboardEvent): void {
if (props.preview) return
if (event.key === 'Enter' || event.key === ' ') emit('unlock')
}
function unlockFromWallpaper(event: MouseEvent): void {
if (props.preview) return
if (
(event.target as HTMLElement).closest(
'button, [role="link"], .lock-screen__notifications',
)
)
return
emit('unlock')
}
async function toggleFlashlight(): Promise<void> {
const enabled = !flashlightActive.value
flashlightActive.value = enabled
const response = await nuiCall('camera:setFlash', { enabled })
if (!response.success) flashlightActive.value = !enabled
}
onMounted(() => {
clockTicker = window.setInterval(() => {
now.value = new Date()
}, 15_000)
})
onBeforeUnmount(() => {
if (clockTicker !== undefined) window.clearInterval(clockTicker)
if (flashlightActive.value)
void nuiCall('camera:setFlash', { enabled: false })
})
</script>
<template>
<section
class="lock-screen"
:class="[
`wallpaper--${preferences.settings.wallpaper}`,
{
'lock-screen--dragging': dragging,
'lock-screen--preview': preview,
},
]"
:style="dragStyle"
:aria-label="phone.t('LockScreen.label')"
:tabindex="preview ? -1 : 0"
@keydown="unlockWithKeyboard"
@pointerdown="onPointerDown"
@pointermove="onPointerMove"
@pointerup="finishPointer"
@pointercancel="finishPointer"
@click="unlockFromWallpaper"
>
<div class="lock-screen__shade" aria-hidden="true"></div>
<header class="lock-screen__status">
<time class="lock-screen__status-time">{{ time }}</time>
<PhoneStatusIndicators class="lock-screen__indicators" />
</header>
<LockKeyhole
class="lock-screen__lock"
:size="14"
:stroke-width="1.8"
aria-hidden="true"
/>
<div class="lock-screen__content">
<time class="lock-screen__date">{{ date }}</time>
<time class="lock-screen__time">{{ time }}</time>
</div>
<section
v-if="props.notifications.length"
class="lock-screen__notifications"
aria-live="polite"
>
<button
v-if="!preview && props.notifications.length > 1"
class="lock-screen__notifications-clear"
type="button"
@click.stop="emit('clearNotifications')"
>
{{ phone.t('Notifications.clearAll') }}
</button>
<k-glass
v-for="notification in props.notifications"
:key="notification.id"
class="lock-screen__notification"
:highlight="false"
>
<img
v-if="getPhoneApp(notification.appId)?.iconImage"
:src="getPhoneApp(notification.appId)?.iconImage"
alt=""
class="lock-screen__notification-icon"
/>
<div class="lock-screen__notification-copy">
<div class="lock-screen__notification-heading">
<strong>{{ notification.title }}</strong>
<span>{{ phone.t('Notifications.now') }}</span>
</div>
<small v-if="notification.subtitle">{{ notification.subtitle }}</small>
<p>{{ notification.text }}</p>
</div>
<button
class="lock-screen__notification-close"
type="button"
:aria-label="phone.t('Common.close')"
@click.stop="emit('dismissNotification', notification.id)"
>
<X :size="14" aria-hidden="true" />
</button>
</k-glass>
</section>
<div v-if="!preview" class="lock-screen__footer">
<nav class="lock-screen__shortcuts">
<k-fab
component="button"
type="button"
class="lock-screen__shortcut"
:colors="flashlightShortcutColors"
:aria-label="phone.t('LockScreen.flashlight')"
@click="toggleFlashlight"
>
<template #icon>
<Flashlight :stroke-width="1.4" aria-hidden="true" />
</template>
</k-fab>
<k-fab
component="button"
type="button"
class="lock-screen__shortcut"
:colors="shortcutColors"
:aria-label="phone.t('LockScreen.camera')"
@click="emit('camera')"
>
<template #icon>
<Camera :stroke-width="1.4" aria-hidden="true" />
</template>
</k-fab>
</nav>
<button class="lock-screen__swipe" type="button" @click="emit('unlock')">
<span class="lock-screen__swipe-chevron" aria-hidden="true"></span>
{{ phone.t('LockScreen.swipeUp') }}
</button>
</div>
</section>
</template>