From dd6b9eb350047e912cc33ad53bcb21b9de13755f Mon Sep 17 00:00:00 2001 From: DerEchteAlec Date: Mon, 17 Aug 2026 05:14:39 +0200 Subject: [PATCH] CLN - share pull to refresh behavior --- .../src/composables/usePullToRefresh.test.ts | 105 +++++++++++++++++ frontend/src/composables/usePullToRefresh.ts | 97 ++++++++++++++++ frontend/src/views/apps/BankingApp.vue | 69 +++-------- frontend/src/views/apps/HouseApp.vue | 108 ++++++------------ frontend/src/views/apps/WeatherApp.vue | 65 +++-------- 5 files changed, 266 insertions(+), 178 deletions(-) create mode 100644 frontend/src/composables/usePullToRefresh.test.ts create mode 100644 frontend/src/composables/usePullToRefresh.ts diff --git a/frontend/src/composables/usePullToRefresh.test.ts b/frontend/src/composables/usePullToRefresh.test.ts new file mode 100644 index 0000000..366f769 --- /dev/null +++ b/frontend/src/composables/usePullToRefresh.test.ts @@ -0,0 +1,105 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { usePullToRefresh } from '@/composables/usePullToRefresh' + +function touchEvent(clientY: number): TouchEvent { + return { touches: [{ clientY }] } as unknown as TouchEvent +} + +function wheelEvent(deltaY: number): WheelEvent { + return { deltaY } as WheelEvent +} + +afterEach(() => { + vi.useRealTimers() +}) + +describe('usePullToRefresh', () => { + it('ignores gestures while away from the top or busy', () => { + const refresh = vi.fn() + const pull = usePullToRefresh({ + isAtTop: () => false, + isBusy: () => true, + refresh, + }) + + expect(pull.startPull(touchEvent(40))).toBe(false) + expect(pull.pullWithWheel(wheelEvent(-100))).toBe(false) + expect(pull.pullDistance.value).toBe(0) + expect(refresh).not.toHaveBeenCalled() + }) + + it('applies touch resistance and resets a short pull', () => { + const pull = usePullToRefresh({ + isAtTop: () => true, + refresh: vi.fn(), + }) + + expect(pull.startPull(touchEvent(100))).toBe(true) + pull.movePull(touchEvent(180)) + expect(pull.pullDistance.value).toBe(36) + + pull.finishPull() + expect(pull.pullDistance.value).toBe(0) + }) + + it('runs one refresh after crossing the threshold and always resets', async () => { + let releaseRefresh: (() => void) | undefined + const refresh = vi.fn( + () => + new Promise((resolve) => { + releaseRefresh = resolve + }), + ) + const pull = usePullToRefresh({ + isAtTop: () => true, + refresh, + }) + + pull.startPull(touchEvent(0)) + pull.movePull(touchEvent(200)) + expect(pull.pullDistance.value).toBe(76) + + pull.finishPull() + expect(refresh).toHaveBeenCalledTimes(1) + expect(pull.isRefreshing.value).toBe(true) + expect(pull.pullDistance.value).toBe(56) + expect(await pull.refresh()).toBe(false) + + releaseRefresh?.() + await vi.waitFor(() => expect(pull.isRefreshing.value).toBe(false)) + expect(pull.pullDistance.value).toBe(0) + }) + + it('settles wheel input before deciding whether to refresh', async () => { + vi.useFakeTimers() + const refresh = vi.fn() + const pull = usePullToRefresh({ + isAtTop: () => true, + refresh, + }) + + expect(pull.pullWithWheel(wheelEvent(-400))).toBe(true) + expect(pull.pullDistance.value).toBe(72) + expect(refresh).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(130) + expect(refresh).toHaveBeenCalledTimes(1) + expect(pull.pullDistance.value).toBe(0) + }) + + it('cancels pending wheel work when disposed', async () => { + vi.useFakeTimers() + const refresh = vi.fn() + const pull = usePullToRefresh({ + isAtTop: () => true, + refresh, + }) + + pull.pullWithWheel(wheelEvent(-400)) + pull.dispose() + await vi.advanceTimersByTimeAsync(130) + + expect(refresh).not.toHaveBeenCalled() + }) +}) diff --git a/frontend/src/composables/usePullToRefresh.ts b/frontend/src/composables/usePullToRefresh.ts new file mode 100644 index 0000000..64c0ea0 --- /dev/null +++ b/frontend/src/composables/usePullToRefresh.ts @@ -0,0 +1,97 @@ +import { getCurrentScope, onScopeDispose, readonly, ref } from 'vue' + +const DEFAULT_THRESHOLD = 56 +const DEFAULT_OVERSHOOT = 20 +const DEFAULT_TOUCH_RESISTANCE = 0.45 +const DEFAULT_WHEEL_RESISTANCE = 0.18 +const DEFAULT_WHEEL_SETTLE_MS = 130 + +type PullToRefreshOptions = { + isAtTop: (event: Event) => boolean + isBusy?: () => boolean + refresh: () => Promise | unknown + threshold?: number +} + +export function usePullToRefresh(options: PullToRefreshOptions) { + const pullDistance = ref(0) + const isRefreshing = ref(false) + const pullThreshold = options.threshold ?? DEFAULT_THRESHOLD + const maximumDistance = pullThreshold + DEFAULT_OVERSHOOT + let pullStartY = 0 + let isPulling = false + let wheelRefreshTimeout: ReturnType | undefined + + const isBusy = (): boolean => + isRefreshing.value || options.isBusy?.() === true + + async function refresh(): Promise { + if (isBusy()) return false + isRefreshing.value = true + pullDistance.value = pullThreshold + try { + await options.refresh() + return true + } finally { + isRefreshing.value = false + pullDistance.value = 0 + } + } + + function startPull(event: TouchEvent): boolean { + if (!options.isAtTop(event) || isBusy()) return false + pullStartY = event.touches[0]?.clientY ?? 0 + isPulling = true + return true + } + + function movePull(event: TouchEvent): void { + if (!isPulling || isBusy()) return + const distance = (event.touches[0]?.clientY ?? pullStartY) - pullStartY + pullDistance.value = + distance > 0 + ? Math.min(maximumDistance, distance * DEFAULT_TOUCH_RESISTANCE) + : 0 + } + + function finishPull(): void { + if (!isPulling && pullDistance.value === 0) return + isPulling = false + if (pullDistance.value >= pullThreshold) { + void refresh() + return + } + pullDistance.value = 0 + } + + function pullWithWheel(event: WheelEvent): boolean { + if (!options.isAtTop(event) || isBusy() || event.deltaY >= 0) return false + pullDistance.value = Math.min( + maximumDistance, + pullDistance.value + Math.abs(event.deltaY) * DEFAULT_WHEEL_RESISTANCE, + ) + if (wheelRefreshTimeout) clearTimeout(wheelRefreshTimeout) + wheelRefreshTimeout = setTimeout(finishPull, DEFAULT_WHEEL_SETTLE_MS) + return true + } + + function dispose(): void { + if (wheelRefreshTimeout) clearTimeout(wheelRefreshTimeout) + wheelRefreshTimeout = undefined + isPulling = false + } + + if (getCurrentScope()) onScopeDispose(dispose) + + return { + dispose, + finishPull, + isRefreshing: readonly(isRefreshing), + movePull, + pullDistance, + pullThreshold, + pullWithWheel, + refresh, + startPull, + } +} diff --git a/frontend/src/views/apps/BankingApp.vue b/frontend/src/views/apps/BankingApp.vue index 60278e3..88ecd45 100644 --- a/frontend/src/views/apps/BankingApp.vue +++ b/frontend/src/views/apps/BankingApp.vue @@ -13,6 +13,7 @@ import { } from 'lucide-vue-next' import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' +import { usePullToRefresh } from '@/composables/usePullToRefresh' import { useBankingStore } from '@/stores/banking' import { useCallsStore } from '@/stores/calls' import { usePhoneStore } from '@/stores/phone' @@ -58,19 +59,25 @@ const amount = ref('') const target = ref('') const formError = ref('') const bankingScroll = ref(null) -const isRefreshing = ref(false) -const pullDistance = ref(0) const cooldownToastOpened = ref(false) const overlayOpened = computed(() => Boolean(action.value || selectedTransaction.value), ) -const pullThreshold = 56 -let pullStartY = 0 -let isPulling = false -let wheelRefreshTimeout: ReturnType | undefined let cooldownToastTimer: ReturnType | undefined +const { + finishPull, + movePull, + pullDistance, + pullThreshold, + pullWithWheel, + startPull, +} = usePullToRefresh({ + isAtTop: () => (bankingScroll.value?.scrollTop ?? 0) <= 0, + refresh: () => banking.load(true), +}) + function closeCooldownToast(): void { cooldownToastOpened.value = false if (banking.error === 'reload_cooldown') banking.error = '' @@ -211,55 +218,6 @@ function updateAmount(event: Event): void { formError.value = '' } -async function refresh(): Promise { - if (isRefreshing.value) return - isRefreshing.value = true - pullDistance.value = pullThreshold - await banking.load(true) - isRefreshing.value = false - pullDistance.value = 0 -} - -function atTop(): boolean { - return (bankingScroll.value?.scrollTop ?? 0) <= 0 -} - -function startPull(event: TouchEvent): void { - if (!atTop() || isRefreshing.value) return - pullStartY = event.touches[0]?.clientY ?? 0 - isPulling = true -} - -function movePull(event: TouchEvent): void { - if (!isPulling || isRefreshing.value) return - const distance = (event.touches[0]?.clientY ?? pullStartY) - pullStartY - if (distance <= 0) { - pullDistance.value = 0 - return - } - pullDistance.value = Math.min(pullThreshold + 20, distance * 0.45) -} - -function finishPull(): void { - if (!isPulling && pullDistance.value === 0) return - isPulling = false - if (pullDistance.value >= pullThreshold) { - void refresh() - return - } - pullDistance.value = 0 -} - -function pullWithWheel(event: WheelEvent): void { - if (!atTop() || isRefreshing.value || event.deltaY >= 0) return - pullDistance.value = Math.min( - pullThreshold + 20, - pullDistance.value + Math.abs(event.deltaY) * 0.18, - ) - if (wheelRefreshTimeout) clearTimeout(wheelRefreshTimeout) - wheelRefreshTimeout = setTimeout(finishPull, 130) -} - function errorMessage(code: string): string { return phone.t(`Apps.banking.errors.${code}`) === `Apps.banking.errors.${code}` @@ -310,7 +268,6 @@ watch(action, async (currentAction) => { }) onBeforeUnmount(() => { - if (wheelRefreshTimeout) clearTimeout(wheelRefreshTimeout) if (cooldownToastTimer) clearTimeout(cooldownToastTimer) }) diff --git a/frontend/src/views/apps/HouseApp.vue b/frontend/src/views/apps/HouseApp.vue index 1f015c1..266d1ca 100644 --- a/frontend/src/views/apps/HouseApp.vue +++ b/frontend/src/views/apps/HouseApp.vue @@ -1,17 +1,4 @@ diff --git a/frontend/src/views/apps/WeatherApp.vue b/frontend/src/views/apps/WeatherApp.vue index 04fb723..164f2de 100644 --- a/frontend/src/views/apps/WeatherApp.vue +++ b/frontend/src/views/apps/WeatherApp.vue @@ -11,6 +11,7 @@ import { import { computed, onBeforeUnmount, ref, watch } from 'vue' import WeatherConditionIcon from '@/components/WeatherConditionIcon.vue' +import { usePullToRefresh } from '@/composables/usePullToRefresh' import { usePhoneStore } from '@/stores/phone' import { useWeatherStore } from '@/stores/weather' import type { WeatherConditionId } from '@/types/weather' @@ -27,14 +28,23 @@ import { const phone = usePhoneStore() const weather = useWeatherStore() const forecast = computed(() => weather.forecast) -const pullDistance = ref(0) const cooldownToastOpened = ref(false) -const pullThreshold = 56 -let pullStartY = 0 -let isPulling = false -let wheelRefreshTimeout: ReturnType | undefined let cooldownToastTimer: ReturnType | undefined +const { + finishPull, + movePull, + pullDistance, + pullThreshold, + pullWithWheel, + startPull, +} = usePullToRefresh({ + isAtTop: (event) => + (event.currentTarget as HTMLElement | null)?.scrollTop === 0, + isBusy: () => weather.isLoading, + refresh: () => weather.refresh(true, true), +}) + function closeCooldownToast(): void { cooldownToastOpened.value = false if (weather.error === 'reload_cooldown') weather.error = null @@ -114,52 +124,7 @@ function formatHour(timestamp: number, index: number): string { }).format(timestamp) } -async function refresh(): Promise { - if (weather.isLoading) return - pullDistance.value = pullThreshold - await weather.refresh(true, true) - pullDistance.value = 0 -} - -function atTop(event: Event): boolean { - return (event.currentTarget as HTMLElement | null)?.scrollTop === 0 -} - -function startPull(event: TouchEvent): void { - if (!atTop(event) || weather.isLoading) return - pullStartY = event.touches[0]?.clientY ?? 0 - isPulling = true -} - -function movePull(event: TouchEvent): void { - if (!isPulling || weather.isLoading) return - const distance = (event.touches[0]?.clientY ?? pullStartY) - pullStartY - pullDistance.value = - distance > 0 ? Math.min(pullThreshold + 20, distance * 0.45) : 0 -} - -function finishPull(): void { - if (!isPulling && pullDistance.value === 0) return - isPulling = false - if (pullDistance.value >= pullThreshold) { - void refresh() - return - } - pullDistance.value = 0 -} - -function pullWithWheel(event: WheelEvent): void { - if (!atTop(event) || weather.isLoading || event.deltaY >= 0) return - pullDistance.value = Math.min( - pullThreshold + 20, - pullDistance.value + Math.abs(event.deltaY) * 0.18, - ) - if (wheelRefreshTimeout) clearTimeout(wheelRefreshTimeout) - wheelRefreshTimeout = setTimeout(finishPull, 130) -} - onBeforeUnmount(() => { - if (wheelRefreshTimeout) clearTimeout(wheelRefreshTimeout) if (cooldownToastTimer) clearTimeout(cooldownToastTimer) })