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 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-04 00:55:19 +00:00
co-authored by Claude Opus 4.6
parent 72f93e4e94
commit 6be4fbe081
9 changed files with 532 additions and 0 deletions
@@ -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,
}
}
@@ -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,
}
}
@@ -0,0 +1,73 @@
import { ref, onMounted, onUnmounted, type Ref } from 'vue'
export function usePinchZoom(elementRef: Ref<HTMLElement | null>) {
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)`,
}
}
@@ -0,0 +1,68 @@
import { ref, onMounted, onUnmounted, type Ref } from 'vue'
import { useHaptics } from './useHaptics'
export function usePullToRefresh(
elementRef: Ref<HTMLElement | null>,
onRefresh: () => Promise<void> | 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 }
}
@@ -0,0 +1,41 @@
import { onMounted, onUnmounted, type Ref } from 'vue'
const scrollPositions = new Map<string, number>()
export function useScrollMemory(key: string, elementRef: Ref<HTMLElement | null>) {
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 }
}
@@ -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 }
}
@@ -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,
}
}