Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/mobile/src/mobile-platform-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ async function handleHapticCall(method: string, params?: unknown): Promise<void>
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 });
Expand Down
4 changes: 4 additions & 0 deletions apps/mobile/tests/mobile-platform-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {

const mockHaptics = vi.hoisted(() => ({
impact: vi.fn(),
vibrate: vi.fn(),
notification: vi.fn(),
selectionStart: vi.fn(),
selectionChanged: vi.fn()
Expand Down Expand Up @@ -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 () => {
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/lib/components/BottomTabBar.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@
}

function handleTabClick(event: MouseEvent, tab: BottomTabSlotContribution) {
haptic.light();
haptic.medium();
if (timetableScreen?.state.isEditing && tab.hostPanel !== 'timetable') {
timetableScreen.setEditing(false);
}
Expand Down
8 changes: 4 additions & 4 deletions apps/web/src/lib/haptic/haptic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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' });
Expand All @@ -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', () => {
Expand Down
8 changes: 7 additions & 1 deletion apps/web/src/lib/platform/scroll-boundary-feedback.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
8 changes: 7 additions & 1 deletion packages/core/src/hosts/native-protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ export interface NativeHapticImpactParams {
readonly style: NativeHapticImpactStyle;
}

export interface NativeHapticVibrateParams {
readonly duration: number;
}

export interface NativeHapticNotificationParams {
readonly type: NativeHapticNotificationType;
}
Expand All @@ -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 };

/**
Expand Down
10 changes: 7 additions & 3 deletions packages/ui-kit/src/haptic/haptic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, never> };

Expand Down Expand Up @@ -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[],
Expand All @@ -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) */
Expand Down
Loading