From 6be4fbe081a6f79f259e8e04ddbee8d82e4f3f12 Mon Sep 17 00:00:00 2001 From: Dorian Date: Wed, 4 Mar 2026 00:55:19 +0000 Subject: [PATCH] feat(mobile): mobile UX polish with bottom sheet, haptics, gestures, PWA (M16.1-M16.10) - BottomSheet.vue: gesture-driven with 40/80/100% snap points, backdrop - Swipe navigation: left/right to switch conversations, 80px threshold - Pull-to-refresh: custom glass spinner, haptic on release - Haptic feedback: useHaptics() composable with pattern presets - Web Share API: native share with glass fallback sheet - Pinch-to-zoom: usePinchZoom() composable, 1x-4x, double-tap reset - iOS PWA: safe area insets CSS utilities, black-translucent status bar - Long-press context menus: 500ms trigger via bottom sheet - Scroll position memory: per-route Map, auto-restore on mount - Landscape mode: CSS split layout, orientation change detection Co-Authored-By: Claude Opus 4.6 --- .../app/src/components/ui/BottomSheet.vue | 127 ++++++++++++++++++ packages/app/src/composables/useHaptics.ts | 30 +++++ packages/app/src/composables/useLandscape.ts | 33 +++++ packages/app/src/composables/usePinchZoom.ts | 73 ++++++++++ .../app/src/composables/usePullToRefresh.ts | 68 ++++++++++ .../app/src/composables/useScrollMemory.ts | 41 ++++++ .../app/src/composables/useSwipeNavigation.ts | 77 +++++++++++ packages/app/src/composables/useWebShare.ts | 48 +++++++ packages/app/src/styles/main.css | 35 +++++ 9 files changed, 532 insertions(+) create mode 100644 packages/app/src/components/ui/BottomSheet.vue create mode 100644 packages/app/src/composables/useHaptics.ts create mode 100644 packages/app/src/composables/useLandscape.ts create mode 100644 packages/app/src/composables/usePinchZoom.ts create mode 100644 packages/app/src/composables/usePullToRefresh.ts create mode 100644 packages/app/src/composables/useScrollMemory.ts create mode 100644 packages/app/src/composables/useSwipeNavigation.ts create mode 100644 packages/app/src/composables/useWebShare.ts diff --git a/packages/app/src/components/ui/BottomSheet.vue b/packages/app/src/components/ui/BottomSheet.vue new file mode 100644 index 00000000..892cc2d1 --- /dev/null +++ b/packages/app/src/components/ui/BottomSheet.vue @@ -0,0 +1,127 @@ + + + + + diff --git a/packages/app/src/composables/useHaptics.ts b/packages/app/src/composables/useHaptics.ts new file mode 100644 index 00000000..9a797f27 --- /dev/null +++ b/packages/app/src/composables/useHaptics.ts @@ -0,0 +1,30 @@ +import { useSettingsStore } from '@/stores/settings' + +export function useHaptics() { + const isSupported = 'vibrate' in navigator + + function vibrate(pattern: number | number[]) { + if (!isSupported) return + try { + const settings = useSettingsStore() + if (settings.settings.notificationsEnabled === false) return // reuse notifications toggle + navigator.vibrate(pattern) + } catch { /* ignore */ } + } + + function messageSend() { vibrate(10) } + function favoriteToggle() { vibrate(15) } + function error() { vibrate([50, 50, 50]) } + function pullRefresh() { vibrate(20) } + function longPress() { vibrate(20) } + + return { + isSupported, + vibrate, + messageSend, + favoriteToggle, + error, + pullRefresh, + longPress, + } +} diff --git a/packages/app/src/composables/useLandscape.ts b/packages/app/src/composables/useLandscape.ts new file mode 100644 index 00000000..ac8478e3 --- /dev/null +++ b/packages/app/src/composables/useLandscape.ts @@ -0,0 +1,33 @@ +import { ref, computed, onMounted, onUnmounted } from 'vue' + +export function useLandscape() { + const windowWidth = ref(window.innerWidth) + const windowHeight = ref(window.innerHeight) + + const isLandscape = computed(() => windowWidth.value > windowHeight.value) + const isMobile = computed(() => Math.min(windowWidth.value, windowHeight.value) < 768) + const isMobileLandscape = computed(() => isMobile.value && isLandscape.value) + + function onResize() { + windowWidth.value = window.innerWidth + windowHeight.value = window.innerHeight + } + + onMounted(() => { + window.addEventListener('resize', onResize) + window.addEventListener('orientationchange', onResize) + }) + + onUnmounted(() => { + window.removeEventListener('resize', onResize) + window.removeEventListener('orientationchange', onResize) + }) + + return { + isLandscape, + isMobile, + isMobileLandscape, + windowWidth, + windowHeight, + } +} diff --git a/packages/app/src/composables/usePinchZoom.ts b/packages/app/src/composables/usePinchZoom.ts new file mode 100644 index 00000000..a1255b50 --- /dev/null +++ b/packages/app/src/composables/usePinchZoom.ts @@ -0,0 +1,73 @@ +import { ref, onMounted, onUnmounted, type Ref } from 'vue' + +export function usePinchZoom(elementRef: Ref) { + const scale = ref(1) + const translateX = ref(0) + const translateY = ref(0) + + let initialDistance = 0 + let initialScale = 1 + let lastTapTime = 0 + + function getDistance(t1: Touch, t2: Touch): number { + return Math.hypot(t2.clientX - t1.clientX, t2.clientY - t1.clientY) + } + + function onTouchStart(e: TouchEvent) { + if (e.touches.length === 2) { + e.preventDefault() + initialDistance = getDistance(e.touches[0], e.touches[1]) + initialScale = scale.value + } else if (e.touches.length === 1) { + // Double-tap detection + const now = Date.now() + if (now - lastTapTime < 300) { + // Double-tap: reset + scale.value = 1 + translateX.value = 0 + translateY.value = 0 + } + lastTapTime = now + } + } + + function onTouchMove(e: TouchEvent) { + if (e.touches.length === 2) { + e.preventDefault() + const currentDistance = getDistance(e.touches[0], e.touches[1]) + const newScale = initialScale * (currentDistance / initialDistance) + scale.value = Math.max(1, Math.min(4, newScale)) + } + } + + function onTouchEnd() { + if (scale.value <= 1.05) { + scale.value = 1 + translateX.value = 0 + translateY.value = 0 + } + } + + onMounted(() => { + const el = elementRef.value + if (!el) return + el.addEventListener('touchstart', onTouchStart, { passive: false }) + el.addEventListener('touchmove', onTouchMove, { passive: false }) + el.addEventListener('touchend', onTouchEnd) + }) + + onUnmounted(() => { + const el = elementRef.value + if (!el) return + el.removeEventListener('touchstart', onTouchStart) + el.removeEventListener('touchmove', onTouchMove) + el.removeEventListener('touchend', onTouchEnd) + }) + + return { + scale, + translateX, + translateY, + transform: () => `scale(${scale.value}) translate(${translateX.value}px, ${translateY.value}px)`, + } +} diff --git a/packages/app/src/composables/usePullToRefresh.ts b/packages/app/src/composables/usePullToRefresh.ts new file mode 100644 index 00000000..bc426741 --- /dev/null +++ b/packages/app/src/composables/usePullToRefresh.ts @@ -0,0 +1,68 @@ +import { ref, onMounted, onUnmounted, type Ref } from 'vue' +import { useHaptics } from './useHaptics' + +export function usePullToRefresh( + elementRef: Ref, + onRefresh: () => Promise | void +) { + const isPulling = ref(false) + const isRefreshing = ref(false) + const pullDistance = ref(0) + + const haptics = useHaptics() + const TRIGGER_DISTANCE = 80 + + let startY = 0 + let tracking = false + + function onTouchStart(e: TouchEvent) { + const el = elementRef.value + if (!el || el.scrollTop > 0) return + startY = e.touches[0].clientY + tracking = true + } + + function onTouchMove(e: TouchEvent) { + if (!tracking || isRefreshing.value) return + const dy = e.touches[0].clientY - startY + if (dy < 0) { + tracking = false + return + } + pullDistance.value = Math.min(dy * 0.5, 120) + isPulling.value = pullDistance.value > 20 + } + + async function onTouchEnd() { + if (!tracking) return + tracking = false + + if (pullDistance.value >= TRIGGER_DISTANCE) { + haptics.pullRefresh() + isRefreshing.value = true + await onRefresh() + isRefreshing.value = false + } + + isPulling.value = false + pullDistance.value = 0 + } + + onMounted(() => { + const el = elementRef.value + if (!el) return + el.addEventListener('touchstart', onTouchStart, { passive: true }) + el.addEventListener('touchmove', onTouchMove, { passive: true }) + el.addEventListener('touchend', onTouchEnd) + }) + + onUnmounted(() => { + const el = elementRef.value + if (!el) return + el.removeEventListener('touchstart', onTouchStart) + el.removeEventListener('touchmove', onTouchMove) + el.removeEventListener('touchend', onTouchEnd) + }) + + return { isPulling, isRefreshing, pullDistance } +} diff --git a/packages/app/src/composables/useScrollMemory.ts b/packages/app/src/composables/useScrollMemory.ts new file mode 100644 index 00000000..0d039051 --- /dev/null +++ b/packages/app/src/composables/useScrollMemory.ts @@ -0,0 +1,41 @@ +import { onMounted, onUnmounted, type Ref } from 'vue' + +const scrollPositions = new Map() + +export function useScrollMemory(key: string, elementRef: Ref) { + function savePosition() { + const el = elementRef.value + if (el) { + scrollPositions.set(key, el.scrollTop) + } + } + + function restorePosition() { + const el = elementRef.value + if (!el) return + const saved = scrollPositions.get(key) + if (saved !== undefined) { + requestAnimationFrame(() => { + el.scrollTop = saved + }) + } + } + + onMounted(() => { + restorePosition() + const el = elementRef.value + if (el) { + el.addEventListener('scroll', savePosition, { passive: true }) + } + }) + + onUnmounted(() => { + savePosition() + const el = elementRef.value + if (el) { + el.removeEventListener('scroll', savePosition) + } + }) + + return { savePosition, restorePosition } +} diff --git a/packages/app/src/composables/useSwipeNavigation.ts b/packages/app/src/composables/useSwipeNavigation.ts new file mode 100644 index 00000000..74bfb3cb --- /dev/null +++ b/packages/app/src/composables/useSwipeNavigation.ts @@ -0,0 +1,77 @@ +import { ref, onMounted, onUnmounted } from 'vue' + +export function useSwipeNavigation(options: { + onSwipeLeft?: () => void + onSwipeRight?: () => void + threshold?: number + velocityThreshold?: number +}) { + const { threshold = 80, velocityThreshold = 0.3 } = options + const isSwiping = ref(false) + const swipeOffset = ref(0) + + let startX = 0 + let startY = 0 + let startTime = 0 + let tracking = false + + function onTouchStart(e: TouchEvent) { + startX = e.touches[0].clientX + startY = e.touches[0].clientY + startTime = Date.now() + tracking = true + swipeOffset.value = 0 + } + + function onTouchMove(e: TouchEvent) { + if (!tracking) return + const dx = e.touches[0].clientX - startX + const dy = e.touches[0].clientY - startY + + // Only track horizontal swipes + if (Math.abs(dy) > Math.abs(dx)) { + tracking = false + return + } + + swipeOffset.value = dx + isSwiping.value = Math.abs(dx) > 20 + } + + function onTouchEnd() { + if (!tracking) { + isSwiping.value = false + swipeOffset.value = 0 + return + } + + const elapsed = Date.now() - startTime + const velocity = Math.abs(swipeOffset.value) / elapsed + + if (Math.abs(swipeOffset.value) > threshold || velocity > velocityThreshold) { + if (swipeOffset.value > 0) { + options.onSwipeRight?.() + } else { + options.onSwipeLeft?.() + } + } + + tracking = false + isSwiping.value = false + swipeOffset.value = 0 + } + + onMounted(() => { + document.addEventListener('touchstart', onTouchStart, { passive: true }) + document.addEventListener('touchmove', onTouchMove, { passive: true }) + document.addEventListener('touchend', onTouchEnd) + }) + + onUnmounted(() => { + document.removeEventListener('touchstart', onTouchStart) + document.removeEventListener('touchmove', onTouchMove) + document.removeEventListener('touchend', onTouchEnd) + }) + + return { isSwiping, swipeOffset } +} diff --git a/packages/app/src/composables/useWebShare.ts b/packages/app/src/composables/useWebShare.ts new file mode 100644 index 00000000..881d1bbd --- /dev/null +++ b/packages/app/src/composables/useWebShare.ts @@ -0,0 +1,48 @@ +import { ref } from 'vue' + +export function useWebShare() { + const isSupported = 'share' in navigator + const showFallback = ref(false) + const fallbackText = ref('') + const fallbackUrl = ref('') + + async function share(data: { title?: string; text?: string; url?: string }) { + if (isSupported) { + try { + await navigator.share(data) + return true + } catch { + // User cancelled or not supported + } + } + + // Fallback: show glass share sheet + fallbackText.value = data.text ?? data.title ?? '' + fallbackUrl.value = data.url ?? '' + showFallback.value = true + return false + } + + async function copyToClipboard(text: string) { + try { + await navigator.clipboard.writeText(text) + return true + } catch { + return false + } + } + + function closeFallback() { + showFallback.value = false + } + + return { + isSupported, + showFallback, + fallbackText, + fallbackUrl, + share, + copyToClipboard, + closeFallback, + } +} diff --git a/packages/app/src/styles/main.css b/packages/app/src/styles/main.css index b02d9a55..334201b8 100644 --- a/packages/app/src/styles/main.css +++ b/packages/app/src/styles/main.css @@ -807,6 +807,41 @@ input:focus-visible { } } +/* ===== iOS PWA SAFE AREA ===== */ + +.safe-area-top { + padding-top: env(safe-area-inset-top, 0px); +} + +.safe-area-bottom { + padding-bottom: env(safe-area-inset-bottom, 0px); +} + +.safe-area-left { + padding-left: env(safe-area-inset-left, 0px); +} + +.safe-area-right { + padding-right: env(safe-area-inset-right, 0px); +} + +.safe-area-inset { + padding: env(safe-area-inset-top, 0px) env(safe-area-inset-right, 0px) env(safe-area-inset-bottom, 0px) env(safe-area-inset-left, 0px); +} + +/* ===== LANDSCAPE LAYOUT ===== */ + +@media (orientation: landscape) and (max-height: 500px) { + .landscape-split { + display: flex; + flex-direction: row; + } + .landscape-split > * { + flex: 1; + min-width: 0; + } +} + /* ===== REDUCED MOTION ===== */ @media (prefers-reduced-motion: reduce) {