// Multi-sensory scan feedback for the mobile/warehouse pages: sound + screen flash + native haptics. // // Used by 10+ of the app/pages/mobile/* pages on their scan success/error paths, so the public API // (success/error + playSuccessSound/playErrorSound/showSuccessFlash/showErrorFlash) is kept // backward-compatible — existing call sites gain haptics for free. // // Cross-build safe: this composable also runs in the WEB build's /mobile pages, so @capacitor/haptics // is DYNAMICALLY imported and only ever touched on a real native platform (window.Capacitor) — the web // bundle never executes it. navigator.vibrate is the web fallback. A single shared (lazily-created, // auto-resumed) AudioContext is reused across all beeps instead of leaking one per call. // // Feedback can be silenced per-device (e.g. quiet shifts) via a sound/haptic preference persisted in // localStorage (same store the app already uses for theme/lang/scanner prefs). The screen flash stays // on even when sound+haptic are off, so a silenced device still gets a visual scan confirmation. const SOUND_KEY = 'logship_feedback_sound' const HAPTIC_KEY = 'logship_feedback_haptic' // --- preference (localStorage = sync source of truth for the hot scan path) --- const readBool = (key, def = true) => { if (typeof window === 'undefined') return def try { const v = window.localStorage.getItem(key) return v === null ? def : v === 'true' } catch { return def } } const writeBool = (key, val) => { if (typeof window === 'undefined') return try { window.localStorage.setItem(key, String(!!val)) } catch { /* private mode / quota */ } } // --- native detection without a static @capacitor/core import (keeps it out of the web bundle) --- const isNative = () => typeof window !== 'undefined' && !!window.Capacitor && typeof window.Capacitor.isNativePlatform === 'function' && window.Capacitor.isNativePlatform() === true // --- shared AudioContext (one per app, resumed on demand for the autoplay policy) --- let _audioCtx = null const getCtx = () => { if (typeof window === 'undefined') return null const AC = window.AudioContext || window.webkitAudioContext if (!AC) return null try { if (!_audioCtx) _audioCtx = new AC() if (_audioCtx.state === 'suspended') _audioCtx.resume().catch(() => {}) return _audioCtx } catch { return null } } const tone = (freq, duration = 0.2, type = 'sine', volume = 0.3, startOffset = 0) => { const ctx = getCtx() if (!ctx) return try { const osc = ctx.createOscillator() const gain = ctx.createGain() osc.connect(gain) gain.connect(ctx.destination) osc.frequency.value = freq osc.type = type const t0 = ctx.currentTime + startOffset gain.gain.setValueAtTime(volume, t0) gain.gain.exponentialRampToValueAtTime(0.01, t0 + duration) osc.start(t0) osc.stop(t0 + duration) } catch { /* audio unavailable */ } } // --- haptics (native Capacitor on device, navigator.vibrate on web) --- const WEB_VIBRATE = { success: 40, error: [60, 40, 60], warning: [30, 30, 30], tap: 15, complete: [40, 30, 40], allComplete: [60, 40, 60, 40, 120], } const hapticNative = async (kind) => { try { const { Haptics, ImpactStyle, NotificationType } = await import('@capacitor/haptics') switch (kind) { case 'error': return await Haptics.notification({ type: NotificationType.Error }) case 'warning': return await Haptics.notification({ type: NotificationType.Warning }) case 'tap': return await Haptics.impact({ style: ImpactStyle.Light }) case 'allComplete': return await Haptics.vibrate({ duration: 130 }) // success + complete default: return await Haptics.notification({ type: NotificationType.Success }) } } catch { /* plugin missing / unsupported → silent */ } } const fireHaptic = (kind = 'success') => { if (!readBool(HAPTIC_KEY, true)) return if (isNative()) { hapticNative(kind); return } try { navigator.vibrate?.(WEB_VIBRATE[kind] || 40) } catch { /* unsupported */ } } // --- screen flash (full-viewport tint; keyframes injected once) --- let _flashStyleInjected = false const ensureFlashStyle = () => { if (_flashStyleInjected || typeof document === 'undefined') return const style = document.createElement('style') style.id = 'lf-flash-style' style.textContent = '@keyframes lf-flash{0%{opacity:.5}100%{opacity:0}}' document.head.appendChild(style) _flashStyleInjected = true } const flash = (color) => { if (typeof document === 'undefined') return ensureFlashStyle() const el = document.createElement('div') Object.assign(el.style, { position: 'fixed', top: '0', left: '0', width: '100%', height: '100%', backgroundColor: color, opacity: '0.5', zIndex: '9999', pointerEvents: 'none', animation: 'lf-flash 0.45s ease-out', }) document.body.appendChild(el) setTimeout(() => { try { el.remove() } catch { /* already gone */ } }, 450) } export const useFeedback = () => { const soundOn = () => readBool(SOUND_KEY, true) // --- low-level (kept for backward compatibility with existing call sites) --- const playSuccessSound = () => { if (soundOn()) tone(800, 0.2, 'sine', 0.3) } const playErrorSound = () => { if (soundOn()) tone(300, 0.3, 'sawtooth', 0.3) } const showSuccessFlash = () => flash('#4caf50') const showErrorFlash = () => flash('#f44336') // --- semantic feedback (sound + flash + haptic) --- const success = () => { playSuccessSound(); showSuccessFlash(); fireHaptic('success') } const error = () => { playErrorSound(); showErrorFlash(); fireHaptic('error') } const warning = () => { if (soundOn()) tone(520, 0.18, 'triangle', 0.3) flash('#ff9800') fireHaptic('warning') } // Light per-scan tick (no flash) — for confirming each item in a high-volume scan loop. const tap = () => { if (soundOn()) tone(1000, 0.05, 'square', 0.14); fireHaptic('tap') } // Rising two-tone — a product/line is fully picked. const complete = () => { if (soundOn()) { tone(880, 0.12, 'sine', 0.3, 0); tone(1320, 0.16, 'sine', 0.3, 0.12) } showSuccessFlash() fireHaptic('complete') } // Celebratory arpeggio — the whole order/shipment is done. const allComplete = () => { if (soundOn()) [660, 880, 1100, 1320].forEach((f, i) => tone(f, 0.14, 'sine', 0.3, i * 0.12)) showSuccessFlash() fireHaptic('allComplete') } return { // semantic success, error, warning, tap, complete, allComplete, // low-level (back-compat) playSuccessSound, playErrorSound, showSuccessFlash, showErrorFlash, // raw haptic, if a caller wants just the buzz haptic: fireHaptic, // preference predicates, so pages with their own custom tones can gate them on the same toggle isSoundEnabled: () => readBool(SOUND_KEY, true), isHapticEnabled: () => readBool(HAPTIC_KEY, true), } } // Reactive sound/haptic preference for the settings toggle. Separate from useFeedback so the hot // scan path stays a plain localStorage read (no reactivity overhead per beep). export const useFeedbackPrefs = () => { const soundEnabled = useState('lf_sound_enabled', () => readBool(SOUND_KEY, true)) const hapticEnabled = useState('lf_haptic_enabled', () => readBool(HAPTIC_KEY, true)) // Re-sync from localStorage on the client (covers the web build's SSR default). const syncFromStore = () => { soundEnabled.value = readBool(SOUND_KEY, true) hapticEnabled.value = readBool(HAPTIC_KEY, true) } const setSoundEnabled = (v) => { soundEnabled.value = !!v; writeBool(SOUND_KEY, v) } const setHapticEnabled = (v) => { hapticEnabled.value = !!v; writeBool(HAPTIC_KEY, v) } const toggleSound = () => setSoundEnabled(!soundEnabled.value) const toggleHaptic = () => setHapticEnabled(!hapticEnabled.value) return { soundEnabled, hapticEnabled, syncFromStore, setSoundEnabled, setHapticEnabled, toggleSound, toggleHaptic } }