diff --git a/apps/mobile/src/mobile-platform-adapter.ts b/apps/mobile/src/mobile-platform-adapter.ts index 8eebd253..db07a33a 100644 --- a/apps/mobile/src/mobile-platform-adapter.ts +++ b/apps/mobile/src/mobile-platform-adapter.ts @@ -80,6 +80,9 @@ async function handleHapticCall(method: string, params?: unknown): Promise if (method === 'impact') { const style = (params as { style?: NativeHapticImpactStyle })?.style ?? 'medium'; await Haptics.impact({ style: IMPACT_STYLE_MAP[style] ?? ImpactStyle.Medium }); + } else if (method === 'vibrate') { + const duration = (params as { duration?: number })?.duration ?? 12; + await Haptics.vibrate({ duration }); } else if (method === 'notification') { const type = (params as { type?: NativeHapticNotificationType })?.type ?? 'success'; await Haptics.notification({ type: NOTIFICATION_TYPE_MAP[type] ?? NotificationType.Success }); diff --git a/apps/mobile/tests/mobile-platform-adapter.test.ts b/apps/mobile/tests/mobile-platform-adapter.test.ts index d8691ea9..0444b3a0 100644 --- a/apps/mobile/tests/mobile-platform-adapter.test.ts +++ b/apps/mobile/tests/mobile-platform-adapter.test.ts @@ -13,6 +13,7 @@ import { const mockHaptics = vi.hoisted(() => ({ impact: vi.fn(), + vibrate: vi.fn(), notification: vi.fn(), selectionStart: vi.fn(), selectionChanged: vi.fn() @@ -154,6 +155,9 @@ describe('mobile-platform-adapter', () => { await bridge.callNative('haptic', 'impact', { style: 'heavy' }); expect(mockHaptics.impact).toHaveBeenCalledWith({ style: 'HEAVY' }); + + await bridge.callNative('haptic', 'vibrate', { duration: 12 }); + expect(mockHaptics.vibrate).toHaveBeenCalledWith({ duration: 12 }); }); it('maps notification haptic calls to Capacitor Haptics.notification', async () => { diff --git a/apps/web/src/lib/components/BottomTabBar.svelte b/apps/web/src/lib/components/BottomTabBar.svelte index bdf96d9f..008f6629 100644 --- a/apps/web/src/lib/components/BottomTabBar.svelte +++ b/apps/web/src/lib/components/BottomTabBar.svelte @@ -61,7 +61,7 @@ } function handleTabClick(event: MouseEvent, tab: BottomTabSlotContribution) { - haptic.light(); + haptic.medium(); if (timetableScreen?.state.isEditing && tab.hostPanel !== 'timetable') { timetableScreen.setEditing(false); } diff --git a/apps/web/src/lib/haptic/haptic.test.ts b/apps/web/src/lib/haptic/haptic.test.ts index 35ca7a12..44e0f6d6 100644 --- a/apps/web/src/lib/haptic/haptic.test.ts +++ b/apps/web/src/lib/haptic/haptic.test.ts @@ -142,7 +142,7 @@ describe('haptic feedback service', () => { expect(mockVibrate).toHaveBeenLastCalledWith(15); haptic.light(); - expect(mockVibrate).toHaveBeenLastCalledWith(25); + expect(mockVibrate).toHaveBeenLastCalledWith(12); haptic.medium(); expect(mockVibrate).toHaveBeenLastCalledWith(50); @@ -176,7 +176,7 @@ describe('haptic feedback service', () => { await vi.waitFor(() => expect(callNative).toHaveBeenCalledTimes(6)); expect(callNative).toHaveBeenCalledWith('haptic', 'selection', {}); - expect(callNative).toHaveBeenCalledWith('haptic', 'impact', { style: 'light' }); + expect(callNative).toHaveBeenCalledWith('haptic', 'vibrate', { duration: 12 }); expect(callNative).toHaveBeenCalledWith('haptic', 'impact', { style: 'medium' }); expect(callNative).toHaveBeenCalledWith('haptic', 'impact', { style: 'heavy' }); expect(callNative).toHaveBeenCalledWith('haptic', 'notification', { type: 'success' }); @@ -193,8 +193,8 @@ describe('haptic feedback service', () => { enableHaptic(); expect(haptic.light()).toBe(true); - await vi.waitFor(() => expect(mockVibrate).toHaveBeenCalledWith(25)); - expect(callNative).toHaveBeenCalledWith('haptic', 'impact', { style: 'light' }); + await vi.waitFor(() => expect(mockVibrate).toHaveBeenCalledWith(12)); + expect(callNative).toHaveBeenCalledWith('haptic', 'vibrate', { duration: 12 }); }); it('does not call native bridge when preference is off', () => { diff --git a/apps/web/src/lib/platform/scroll-boundary-feedback.ts b/apps/web/src/lib/platform/scroll-boundary-feedback.ts index f4d2b36a..b44d3bf0 100644 --- a/apps/web/src/lib/platform/scroll-boundary-feedback.ts +++ b/apps/web/src/lib/platform/scroll-boundary-feedback.ts @@ -1,5 +1,8 @@ +import { haptic } from '$lib/haptic/haptic'; + const SCROLL_SURFACE_SELECTOR = '.native-overscroll-y, .secondary-scroll'; const MAX_PULL_PX = 56; +const MIN_HAPTIC_PULL_PX = 12; const INDICATOR_HEIGHT_PX = 32; const MAX_OPACITY = 0.36; const HIDE_DELAY_MS = 180; @@ -117,7 +120,10 @@ export function installScrollBoundaryFeedback(doc: Document = document): () => v const ended = Array.from(event.changedTouches).some( (touch) => touch.identifier === activePull?.identifier ); - if (ended || event.touches.length === 0) hide(); + if (ended || event.touches.length === 0) { + if (activePull.strength >= MIN_HAPTIC_PULL_PX) haptic.light(); + hide(); + } } function onWheel(event: WheelEvent) { diff --git a/packages/core/src/hosts/native-protocol.ts b/packages/core/src/hosts/native-protocol.ts index 8ad63a4a..4509e284 100644 --- a/packages/core/src/hosts/native-protocol.ts +++ b/packages/core/src/hosts/native-protocol.ts @@ -50,6 +50,10 @@ export interface NativeHapticImpactParams { readonly style: NativeHapticImpactStyle; } +export interface NativeHapticVibrateParams { + readonly duration: number; +} + export interface NativeHapticNotificationParams { readonly type: NativeHapticNotificationType; } @@ -71,15 +75,17 @@ export type NativeClipboardMethod = 'readText' | 'writeText'; * | `impact` | `{ style: NativeHapticImpactStyle }` | `void` on success; reject/error | * | `notification` | `{ type: NativeHapticNotificationType }` | `void` on success; reject/error | * | `selection` | `{}` / omitted (optional) | `void` on success; reject/error | + * | `vibrate` | `{ duration: number }` | `void` on success; reject/error | * * Hosts should play feedback immediately and resolve with `undefined` (or omit `result`). * Reject / return `{ error }` when the platform cannot fire haptics. */ -export type NativeHapticMethod = 'impact' | 'notification' | 'selection'; +export type NativeHapticMethod = 'impact' | 'notification' | 'selection' | 'vibrate'; export type NativeHapticRequest = | { readonly method: 'impact'; readonly params: NativeHapticImpactParams } | { readonly method: 'notification'; readonly params: NativeHapticNotificationParams } + | { readonly method: 'vibrate'; readonly params: NativeHapticVibrateParams } | { readonly method: 'selection'; readonly params?: NativeHapticSelectionParams }; /** diff --git a/packages/ui-kit/src/haptic/haptic.ts b/packages/ui-kit/src/haptic/haptic.ts index 438008fa..35a6baa1 100644 --- a/packages/ui-kit/src/haptic/haptic.ts +++ b/packages/ui-kit/src/haptic/haptic.ts @@ -62,6 +62,7 @@ function vibrateFallback(pattern: number | number[]): boolean { type NativeHapticCall = | { method: 'impact'; params: { style: 'light' | 'medium' | 'heavy' } } + | { method: 'vibrate'; params: { duration: number } } | { method: 'notification'; params: { type: 'success' | 'warning' | 'error' } } | { method: 'selection'; params?: Record }; @@ -116,7 +117,7 @@ export function triggerVibrate(pattern: number | number[]): boolean { /** Vibration API fallbacks (restored pre-native durations, slightly strengthened). */ const FALLBACK = { selection: 15, - light: 25, + light: 12, medium: 50, heavy: 80, success: [30, 60, 40] as number[], @@ -129,9 +130,12 @@ export const haptic = { return triggerNativeOrVibrate({ method: 'selection' }, FALLBACK.selection); }, - /** 轻微反馈:Tab 切换、按钮/开关点击、Radio 勾选 (~25ms fallback) */ + /** 轻微反馈:Tab 切换、按钮/开关点击、Radio 勾选 (~12ms) */ light(): boolean { - return triggerNativeOrVibrate({ method: 'impact', params: { style: 'light' } }, FALLBACK.light); + return triggerNativeOrVibrate( + { method: 'vibrate', params: { duration: FALLBACK.light } }, + FALLBACK.light + ); }, /** 中度反馈:周数滑动吸附、胶囊 Tab / 分段切换 (~50ms fallback) */