mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-28 17:01:18 +00:00
CLN - share pull to refresh behavior
This commit is contained in:
@@ -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,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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user