mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-02 11:18:57 +00:00
ENH - add swipe-to-clear lock-screen notifications
This commit is contained in:
@@ -734,17 +734,65 @@ button {
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
}
|
||||
.lock-screen__notification-row {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-height: 76px;
|
||||
border-radius: 18px;
|
||||
background: transparent;
|
||||
isolation: isolate;
|
||||
}
|
||||
.lock-screen__notification-clear {
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
width: 76px;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
color: #fff;
|
||||
background: #ff3b30;
|
||||
font-size: 10px;
|
||||
font-weight: 650;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s ease-out;
|
||||
}
|
||||
.lock-screen__notification-row.is-action-visible {
|
||||
background: #ff3b30;
|
||||
}
|
||||
.lock-screen__notification-row.is-action-visible
|
||||
.lock-screen__notification-clear {
|
||||
opacity: 1;
|
||||
}
|
||||
.lock-screen__notification {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 42px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
min-height: 72px;
|
||||
width: 100%;
|
||||
min-height: 76px;
|
||||
border-radius: 18px;
|
||||
padding: 11px 38px 11px 11px;
|
||||
padding: 11px;
|
||||
color: #fff;
|
||||
background-color: rgb(28 28 32 / 72%);
|
||||
background-color: rgb(38 36 40 / 76%);
|
||||
text-shadow: 0 1px 3px #0009;
|
||||
touch-action: pan-y;
|
||||
transform: translate3d(var(--notification-swipe), 0, 0);
|
||||
transition: transform 0.28s cubic-bezier(0.22, 0.78, 0.24, 1);
|
||||
will-change: transform;
|
||||
backdrop-filter: blur(22px) saturate(1.18);
|
||||
-webkit-backdrop-filter: blur(22px) saturate(1.18);
|
||||
}
|
||||
.lock-screen__notification-row.is-swiping .lock-screen__notification {
|
||||
transition: none;
|
||||
}
|
||||
.phone-notification.is-actionable {
|
||||
cursor: pointer;
|
||||
@@ -800,21 +848,6 @@ button {
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
.lock-screen__notification-close {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
display: grid;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
padding: 0;
|
||||
color: #fff;
|
||||
background: #0007;
|
||||
cursor: pointer;
|
||||
}
|
||||
.lock-screen__footer {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { kFab, kGlass } from 'konsta/vue'
|
||||
import { Camera, Flashlight, LockKeyhole, X } from 'lucide-vue-next'
|
||||
import { Camera, Flashlight, LockKeyhole, Trash2 } 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 {
|
||||
clampNotificationSwipeOffset,
|
||||
NOTIFICATION_SWIPE_ACTION_WIDTH,
|
||||
resolveNotificationSwipeAxis,
|
||||
shouldRevealNotificationAction,
|
||||
type NotificationSwipeAxis,
|
||||
} from '@/utils/notificationSwipe'
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
import type { PhonePreferencesV1 } from '@/utils/preferences'
|
||||
|
||||
@@ -29,6 +36,9 @@ const now = ref(new Date())
|
||||
const dragOffset = ref(0)
|
||||
const dragging = ref(false)
|
||||
const flashlightActive = ref(false)
|
||||
const revealedNotificationId = ref<string | null>(null)
|
||||
const swipingNotificationId = ref<string | null>(null)
|
||||
const notificationSwipeOffset = ref(0)
|
||||
const shortcutColors = {
|
||||
bgIos: 'bg-ios-light-glass dark:bg-ios-dark-glass',
|
||||
activeBgIos: 'active:bg-white/90 dark:active:bg-white/20',
|
||||
@@ -37,6 +47,12 @@ const shortcutColors = {
|
||||
let pointerStart = 0
|
||||
let pointerStartedAt = 0
|
||||
let clockTicker: number | undefined
|
||||
let notificationPointerId: number | null = null
|
||||
let notificationPointerStartX = 0
|
||||
let notificationPointerStartY = 0
|
||||
let notificationPointerStartedAt = 0
|
||||
let notificationSwipeAxis: NotificationSwipeAxis | null = null
|
||||
let suppressNotificationClickUntil = 0
|
||||
|
||||
const date = computed(() =>
|
||||
new Intl.DateTimeFormat(phone.lang, {
|
||||
@@ -83,9 +99,130 @@ function onPointerDown(event: PointerEvent): void {
|
||||
}
|
||||
|
||||
function openNotification(notification: PhoneNotification): void {
|
||||
if (performance.now() <= suppressNotificationClickUntil) return
|
||||
if (revealedNotificationId.value === notification.id) {
|
||||
closeNotificationAction()
|
||||
return
|
||||
}
|
||||
if (notification.route) emit('openNotification', notification)
|
||||
}
|
||||
|
||||
function notificationOffset(notificationId: string): number {
|
||||
if (swipingNotificationId.value === notificationId)
|
||||
return notificationSwipeOffset.value
|
||||
return revealedNotificationId.value === notificationId
|
||||
? -NOTIFICATION_SWIPE_ACTION_WIDTH
|
||||
: 0
|
||||
}
|
||||
|
||||
function notificationStyle(notificationId: string): Record<string, string> {
|
||||
return {
|
||||
'--notification-swipe': `${notificationOffset(notificationId)}px`,
|
||||
}
|
||||
}
|
||||
|
||||
function closeNotificationAction(): void {
|
||||
revealedNotificationId.value = null
|
||||
swipingNotificationId.value = null
|
||||
notificationSwipeOffset.value = 0
|
||||
}
|
||||
|
||||
function beginNotificationSwipe(
|
||||
notificationId: string,
|
||||
event: PointerEvent,
|
||||
): void {
|
||||
if (props.preview || event.button !== 0) return
|
||||
|
||||
if (
|
||||
revealedNotificationId.value &&
|
||||
revealedNotificationId.value !== notificationId
|
||||
) {
|
||||
revealedNotificationId.value = null
|
||||
}
|
||||
|
||||
notificationPointerId = event.pointerId
|
||||
notificationPointerStartX = event.clientX
|
||||
notificationPointerStartY = event.clientY
|
||||
notificationPointerStartedAt = performance.now()
|
||||
notificationSwipeAxis = null
|
||||
swipingNotificationId.value = notificationId
|
||||
notificationSwipeOffset.value =
|
||||
revealedNotificationId.value === notificationId
|
||||
? -NOTIFICATION_SWIPE_ACTION_WIDTH
|
||||
: 0
|
||||
}
|
||||
|
||||
function moveNotificationSwipe(
|
||||
notificationId: string,
|
||||
event: PointerEvent,
|
||||
): void {
|
||||
if (
|
||||
swipingNotificationId.value !== notificationId ||
|
||||
notificationPointerId !== event.pointerId
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const deltaX = event.clientX - notificationPointerStartX
|
||||
const deltaY = event.clientY - notificationPointerStartY
|
||||
notificationSwipeAxis ??= resolveNotificationSwipeAxis(deltaX, deltaY)
|
||||
|
||||
if (notificationSwipeAxis === 'vertical') {
|
||||
swipingNotificationId.value = null
|
||||
notificationSwipeOffset.value = 0
|
||||
return
|
||||
}
|
||||
if (notificationSwipeAxis !== 'horizontal') return
|
||||
|
||||
const target = event.currentTarget as HTMLElement
|
||||
if (!target.hasPointerCapture(event.pointerId))
|
||||
target.setPointerCapture(event.pointerId)
|
||||
event.preventDefault()
|
||||
|
||||
const startingOffset =
|
||||
revealedNotificationId.value === notificationId
|
||||
? -NOTIFICATION_SWIPE_ACTION_WIDTH
|
||||
: 0
|
||||
notificationSwipeOffset.value = clampNotificationSwipeOffset(
|
||||
startingOffset + deltaX,
|
||||
)
|
||||
}
|
||||
|
||||
function finishNotificationSwipe(
|
||||
notificationId: string,
|
||||
event: PointerEvent,
|
||||
): void {
|
||||
if (
|
||||
swipingNotificationId.value !== notificationId ||
|
||||
notificationPointerId !== event.pointerId
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const elapsed = Math.max(1, performance.now() - notificationPointerStartedAt)
|
||||
const velocityX = (event.clientX - notificationPointerStartX) / elapsed
|
||||
const wasHorizontal = notificationSwipeAxis === 'horizontal'
|
||||
if (wasHorizontal) {
|
||||
suppressNotificationClickUntil = performance.now() + 120
|
||||
revealedNotificationId.value = shouldRevealNotificationAction(
|
||||
notificationSwipeOffset.value,
|
||||
velocityX,
|
||||
)
|
||||
? notificationId
|
||||
: null
|
||||
}
|
||||
|
||||
swipingNotificationId.value = null
|
||||
notificationSwipeOffset.value = 0
|
||||
notificationPointerId = null
|
||||
notificationSwipeAxis = null
|
||||
}
|
||||
|
||||
function dismissNotification(notificationId: string): void {
|
||||
closeNotificationAction()
|
||||
emit('dismissNotification', notificationId)
|
||||
}
|
||||
|
||||
function onPointerMove(event: PointerEvent): void {
|
||||
if (!dragging.value) return
|
||||
dragOffset.value = Math.min(0, event.clientY - pointerStart)
|
||||
@@ -193,46 +330,64 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
{{ phone.t('Notifications.clearAll') }}
|
||||
</button>
|
||||
<k-glass
|
||||
<div
|
||||
v-for="notification in props.notifications"
|
||||
:key="notification.id"
|
||||
class="lock-screen__notification"
|
||||
:class="{ 'is-actionable': !!notification.route }"
|
||||
:highlight="false"
|
||||
:role="notification.route ? 'button' : undefined"
|
||||
:tabindex="notification.route ? 0 : undefined"
|
||||
:aria-label="
|
||||
notification.route ? phone.t('Notifications.open') : undefined
|
||||
"
|
||||
@click="openNotification(notification)"
|
||||
@keydown.enter.prevent="openNotification(notification)"
|
||||
@keydown.space.prevent="openNotification(notification)"
|
||||
class="lock-screen__notification-row"
|
||||
:class="{
|
||||
'is-revealed': revealedNotificationId === notification.id,
|
||||
'is-swiping': swipingNotificationId === notification.id,
|
||||
'is-action-visible': notificationOffset(notification.id) < -0.5,
|
||||
}"
|
||||
:style="notificationStyle(notification.id)"
|
||||
>
|
||||
<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"
|
||||
class="lock-screen__notification-clear"
|
||||
type="button"
|
||||
:aria-label="phone.t('Common.close')"
|
||||
@click.stop="emit('dismissNotification', notification.id)"
|
||||
:tabindex="revealedNotificationId === notification.id ? 0 : -1"
|
||||
:aria-hidden="revealedNotificationId !== notification.id"
|
||||
:aria-label="phone.t('Notifications.clear')"
|
||||
@click.stop="dismissNotification(notification.id)"
|
||||
>
|
||||
<X :size="14" aria-hidden="true" />
|
||||
<Trash2 :size="18" :stroke-width="1.8" aria-hidden="true" />
|
||||
<span>{{ phone.t('Notifications.clear') }}</span>
|
||||
</button>
|
||||
</k-glass>
|
||||
<k-glass
|
||||
class="lock-screen__notification"
|
||||
:class="{ 'is-actionable': !!notification.route }"
|
||||
:highlight="false"
|
||||
:role="notification.route ? 'button' : undefined"
|
||||
:tabindex="notification.route ? 0 : undefined"
|
||||
:aria-label="
|
||||
notification.route ? phone.t('Notifications.open') : undefined
|
||||
"
|
||||
@click="openNotification(notification)"
|
||||
@keydown.enter.prevent="openNotification(notification)"
|
||||
@keydown.space.prevent="openNotification(notification)"
|
||||
@keydown.delete.prevent="dismissNotification(notification.id)"
|
||||
@pointerdown.stop="beginNotificationSwipe(notification.id, $event)"
|
||||
@pointermove.stop="moveNotificationSwipe(notification.id, $event)"
|
||||
@pointerup.stop="finishNotificationSwipe(notification.id, $event)"
|
||||
@pointercancel.stop="finishNotificationSwipe(notification.id, $event)"
|
||||
>
|
||||
<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>
|
||||
</k-glass>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="!preview" class="lock-screen__footer">
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
clampNotificationSwipeOffset,
|
||||
NOTIFICATION_SWIPE_ACTION_WIDTH,
|
||||
resolveNotificationSwipeAxis,
|
||||
shouldRevealNotificationAction,
|
||||
} from '@/utils/notificationSwipe'
|
||||
|
||||
describe('lock-screen notification swipe gestures', () => {
|
||||
it('waits for a clear horizontal or vertical direction', () => {
|
||||
expect(resolveNotificationSwipeAxis(5, 2)).toBeNull()
|
||||
expect(resolveNotificationSwipeAxis(-24, 5)).toBe('horizontal')
|
||||
expect(resolveNotificationSwipeAxis(-10, 18)).toBe('vertical')
|
||||
})
|
||||
|
||||
it('only allows a resisted swipe to the left', () => {
|
||||
expect(clampNotificationSwipeOffset(40)).toBe(0)
|
||||
expect(clampNotificationSwipeOffset(-60)).toBe(-60)
|
||||
expect(clampNotificationSwipeOffset(-240)).toBeGreaterThan(
|
||||
-NOTIFICATION_SWIPE_ACTION_WIDTH - 17,
|
||||
)
|
||||
})
|
||||
|
||||
it('reveals clear after enough distance or a deliberate flick', () => {
|
||||
expect(shouldRevealNotificationAction(-41, -0.2)).toBe(false)
|
||||
expect(shouldRevealNotificationAction(-42, -0.2)).toBe(true)
|
||||
expect(shouldRevealNotificationAction(-20, -0.5)).toBe(true)
|
||||
expect(shouldRevealNotificationAction(-12, -0.8)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
export type NotificationSwipeAxis = 'horizontal' | 'vertical'
|
||||
|
||||
export const NOTIFICATION_SWIPE_ACTION_WIDTH = 76
|
||||
export const NOTIFICATION_SWIPE_AXIS_LOCK = 8
|
||||
export const NOTIFICATION_SWIPE_TRIGGER = 42
|
||||
|
||||
export function resolveNotificationSwipeAxis(
|
||||
deltaX: number,
|
||||
deltaY: number,
|
||||
): NotificationSwipeAxis | null {
|
||||
const horizontalDistance = Math.abs(deltaX)
|
||||
const verticalDistance = Math.abs(deltaY)
|
||||
if (
|
||||
Math.max(horizontalDistance, verticalDistance) <
|
||||
NOTIFICATION_SWIPE_AXIS_LOCK
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return horizontalDistance > verticalDistance * 1.15
|
||||
? 'horizontal'
|
||||
: 'vertical'
|
||||
}
|
||||
|
||||
export function clampNotificationSwipeOffset(deltaX: number): number {
|
||||
if (deltaX >= 0) return 0
|
||||
|
||||
const distance = Math.abs(deltaX)
|
||||
const resistedDistance =
|
||||
distance <= NOTIFICATION_SWIPE_ACTION_WIDTH
|
||||
? distance
|
||||
: NOTIFICATION_SWIPE_ACTION_WIDTH +
|
||||
(distance - NOTIFICATION_SWIPE_ACTION_WIDTH) * 0.16
|
||||
|
||||
return -Math.min(resistedDistance, NOTIFICATION_SWIPE_ACTION_WIDTH + 16)
|
||||
}
|
||||
|
||||
export function shouldRevealNotificationAction(
|
||||
offset: number,
|
||||
velocityX: number,
|
||||
): boolean {
|
||||
return (
|
||||
offset <= -NOTIFICATION_SWIPE_TRIGGER ||
|
||||
(offset <= -18 && velocityX <= -0.45)
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user