CLN - share pull to refresh behavior

This commit is contained in:
DerEchteAlec
2026-08-17 05:14:39 +02:00
parent a888f206ff
commit dd6b9eb350
5 changed files with 266 additions and 178 deletions
@@ -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<void>((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()
})
})
@@ -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> | 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<typeof setTimeout> | undefined
const isBusy = (): boolean =>
isRefreshing.value || options.isBusy?.() === true
async function refresh(): Promise<boolean> {
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,
}
}
+13 -56
View File
@@ -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<HTMLElement | null>(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<typeof setTimeout> | undefined
let cooldownToastTimer: ReturnType<typeof setTimeout> | 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<void> {
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)
})
</script>
+36 -72
View File
@@ -1,17 +1,4 @@
<script setup lang="ts">
import {
SkyBlockTitle,
SkyButton,
SkyCard,
SkyDialog,
SkyDialogButton,
SkyList,
SkyListItem,
SkyNavbar,
SkyAppPage,
SkySpinner,
SkySheet,
} from '@/ui'
import {
Camera,
CarFront,
@@ -30,15 +17,29 @@ import {
} from 'lucide-vue-next'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useHousingStore } from '@/stores/housing'
import { usePullToRefresh } from '@/composables/usePullToRefresh'
import { useEasyShareStore } from '@/stores/easyshare'
import { useHousingStore } from '@/stores/housing'
import { usePhoneStore } from '@/stores/phone'
import type {
HousingKey,
HousingKeyCandidate,
HousingProperty,
} from '@/types/housing'
import { SkyToast } from '@/ui'
import {
SkyAppPage,
SkyBlockTitle,
SkyButton,
SkyCard,
SkyDialog,
SkyDialogButton,
SkyList,
SkyListItem,
SkyNavbar,
SkySheet,
SkySpinner,
SkyToast,
} from '@/ui'
const phone = usePhoneStore()
const housing = useHousingStore()
@@ -49,15 +50,26 @@ const revokeCandidate = ref<HousingKey | null>(null)
const toastOpened = ref(false)
const toastText = ref('')
const houseScroll = ref<HTMLElement | null>(null)
const isRefreshing = ref(false)
const pullDistance = ref(0)
const pullThreshold = 56
let pullStartY = 0
let isPulling = false
let wheelRefreshTimeout: ReturnType<typeof setTimeout> | undefined
let toastTimer: number | undefined
const {
finishPull,
movePull,
pullDistance,
pullThreshold,
pullWithWheel: updatePullWithWheel,
refresh,
startPull,
} = usePullToRefresh({
isAtTop: () => (houseScroll.value?.scrollTop ?? 0) <= 0,
refresh: async () => {
const loaded = await housing.load(true)
if (!loaded && housing.error === 'reload_cooldown') {
showToast(translatedError(housing.error))
}
},
})
const properties = computed(() => housing.overview?.properties ?? [])
const ownedCount = computed(
() =>
@@ -93,57 +105,9 @@ function isPending(action: string, property: HousingProperty): boolean {
return housing.pendingAction === `${action}:${property.id}`
}
async function refresh(): Promise<void> {
if (isRefreshing.value) return
isRefreshing.value = true
pullDistance.value = pullThreshold
const loaded = await housing.load(true)
isRefreshing.value = false
pullDistance.value = 0
if (!loaded && housing.error === 'reload_cooldown') {
showToast(translatedError(housing.error))
}
}
function atTop(): boolean {
return (houseScroll.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)
if (!updatePullWithWheel(event)) return
if (toastTimer) clearTimeout(toastTimer)
wheelRefreshTimeout = setTimeout(finishPull, 130)
}
async function runCommand(
@@ -233,7 +197,7 @@ onMounted(() => {
})
onBeforeUnmount(() => {
if (wheelRefreshTimeout) clearTimeout(wheelRefreshTimeout)
if (toastTimer) clearTimeout(toastTimer)
})
</script>
+15 -50
View File
@@ -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<typeof setTimeout> | undefined
let cooldownToastTimer: ReturnType<typeof setTimeout> | 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<void> {
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)
})