diff --git a/apps/expo/app/(app)/(tabs)/(home)/index.tsx b/apps/expo/app/(app)/(tabs)/(home)/index.tsx index 7dd7da5100..994bef2923 100644 --- a/apps/expo/app/(app)/(tabs)/(home)/index.tsx +++ b/apps/expo/app/(app)/(tabs)/(home)/index.tsx @@ -29,6 +29,7 @@ import { ShoppingListTile } from 'expo-app/features/packs/components/ShoppingLis import { WeightAnalysisTile } from 'expo-app/features/packs/components/WeightAnalysisTile'; import { TrailConditionsTile } from 'expo-app/features/trips/components/TrailConditionsTile'; import { UpcomingTripsTile } from 'expo-app/features/trips/components/UpcomingTripsTile'; +import { VoiceCommandsTile } from 'expo-app/features/voice/components/VoiceCommandsTile'; import { WeatherAlertsTile } from 'expo-app/features/weather/components/WeatherAlertsTile'; import { WeatherTile } from 'expo-app/features/weather/components/WeatherTile'; import { WildlifeTile } from 'expo-app/features/wildlife/components/WildlifeTile'; @@ -154,6 +155,11 @@ const tileInfo = { ], component: WildlifeTile, }, + 'voice-commands': { + title: 'Voice Commands', + keywords: ['voice', 'microphone', 'hands-free', 'navigation', 'gps', 'speak', 'commands'], + component: VoiceCommandsTile, + }, }; type TileName = keyof typeof tileInfo; @@ -246,6 +252,7 @@ export default function DashboardScreen() { 'gap 4', 'guides', ...(featureFlags.enableWildlifeIdentification ? ['wildlife'] : []), + ...(featureFlags.enableVoiceCommands ? ['voice-commands'] : []), ]).current; const filteredTiles = useMemo(() => { diff --git a/apps/expo/app/(app)/_layout.tsx b/apps/expo/app/(app)/_layout.tsx index aade62b523..d9bd18c2e3 100644 --- a/apps/expo/app/(app)/_layout.tsx +++ b/apps/expo/app/(app)/_layout.tsx @@ -237,6 +237,13 @@ export default function AppLayout() { getPackTemplateItemDetailOptions((route.params as { id: string })?.id) } /> + ); diff --git a/apps/expo/app/(app)/voice-commands/index.tsx b/apps/expo/app/(app)/voice-commands/index.tsx new file mode 100644 index 0000000000..6d29753659 --- /dev/null +++ b/apps/expo/app/(app)/voice-commands/index.tsx @@ -0,0 +1,17 @@ +import { VoiceCommandScreen } from 'expo-app/features/voice/screens/VoiceCommandScreen'; +import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; +import { StatusBar } from 'expo-status-bar'; +import { Platform } from 'react-native'; + +export default function VoiceCommandsRoute() { + const { colorScheme } = useColorScheme(); + + return ( + <> + + + + ); +} diff --git a/apps/expo/config.ts b/apps/expo/config.ts index e2c23aa59e..8ec393d0c9 100644 --- a/apps/expo/config.ts +++ b/apps/expo/config.ts @@ -8,4 +8,5 @@ export const featureFlags = { enableTrailConditions: false, enableFeed: false, enableWildlifeIdentification: false, + enableVoiceCommands: false, }; diff --git a/apps/expo/features/catalog/types.ts b/apps/expo/features/catalog/types.ts index 61f4b7f4a7..6c86cb7679 100644 --- a/apps/expo/features/catalog/types.ts +++ b/apps/expo/features/catalog/types.ts @@ -1,5 +1,5 @@ import type { WeightUnit } from 'expo-app/types'; -import type { PackItemInput } from '../packs'; +import type { PackItemInput } from '../packs/input'; export interface CatalogItemLink { id: string; diff --git a/apps/expo/features/packs/components/PackCard.tsx b/apps/expo/features/packs/components/PackCard.tsx index d167c76834..5e41ae5b8d 100644 --- a/apps/expo/features/packs/components/PackCard.tsx +++ b/apps/expo/features/packs/components/PackCard.tsx @@ -145,10 +145,14 @@ export function PackCard({ size="icon" disabled={isDuplicating} onPress={() => - Alert.alert('Duplicate pack?', 'This will create a copy of this pack in your collection.', [ - { text: 'Cancel', style: 'cancel' }, - { text: 'Duplicate', onPress: () => duplicatePack(pack.id) }, - ]) + Alert.alert( + 'Duplicate pack?', + 'This will create a copy of this pack in your collection.', + [ + { text: 'Cancel', style: 'cancel' }, + { text: 'Duplicate', onPress: () => duplicatePack(pack.id) }, + ], + ) } > {isDuplicating ? ( @@ -165,10 +169,14 @@ export function PackCard({ variant="plain" size="icon" onPress={() => - Alert.alert('Delete pack?', 'Are you sure you want to delete this pack? This action cannot be undone.', [ - { text: 'Cancel', style: 'cancel' }, - { text: 'OK', style: 'destructive', onPress: () => deletePack(pack.id) }, - ]) + Alert.alert( + 'Delete pack?', + 'Are you sure you want to delete this pack? This action cannot be undone.', + [ + { text: 'Cancel', style: 'cancel' }, + { text: 'OK', style: 'destructive', onPress: () => deletePack(pack.id) }, + ], + ) } > diff --git a/apps/expo/features/packs/hooks/useCreatePackFromPack.ts b/apps/expo/features/packs/hooks/useCreatePackFromPack.ts index 32f99abe1d..ec31f09cc9 100644 --- a/apps/expo/features/packs/hooks/useCreatePackFromPack.ts +++ b/apps/expo/features/packs/hooks/useCreatePackFromPack.ts @@ -23,7 +23,7 @@ export function useCreatePackFromPack() { deleted: false, }; - // @ts-ignore: Safe because Legend-State uses Proxy + // @ts-expect-error: Safe because Legend-State uses Proxy packsStore[newPackId].set(newPack); // Copy each item from the source pack @@ -40,7 +40,7 @@ export function useCreatePackFromPack() { updatedAt: undefined, }; - // @ts-ignore: Safe because Legend-State uses Proxy + // @ts-expect-error: Safe because Legend-State uses Proxy packItemsStore[newItemId].set(newItem); } } diff --git a/apps/expo/features/packs/input.ts b/apps/expo/features/packs/input.ts new file mode 100644 index 0000000000..8d9a4895e0 --- /dev/null +++ b/apps/expo/features/packs/input.ts @@ -0,0 +1,15 @@ +import type { WeightUnit } from 'expo-app/types'; + +export interface PackItemInput { + name: string; + description?: string; + weight: number; + weightUnit: WeightUnit; + quantity: number; + category?: string; + consumable: boolean; + worn: boolean; + notes?: string; + image?: string | null; + catalogItemId?: number; +} diff --git a/apps/expo/features/profile/types.ts b/apps/expo/features/profile/types.ts index 16645c2203..f487d58a38 100644 --- a/apps/expo/features/profile/types.ts +++ b/apps/expo/features/profile/types.ts @@ -1,4 +1,4 @@ -import type { WeightUnit } from '../packs'; +import type { WeightUnit } from '../packs/types'; export interface User { id: number; diff --git a/apps/expo/features/voice/components/VoiceCommandPanel.tsx b/apps/expo/features/voice/components/VoiceCommandPanel.tsx new file mode 100644 index 0000000000..e9bab0c527 --- /dev/null +++ b/apps/expo/features/voice/components/VoiceCommandPanel.tsx @@ -0,0 +1,144 @@ +import { Text } from '@packrat/ui/nativewindui'; +import { Icon, type MaterialIconName } from '@roninoss/icons'; +import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; +import { Pressable, View } from 'react-native'; +import type { VoiceCommand, VoiceListeningState } from '../types'; + +interface VoiceCommandPanelProps { + listeningState: VoiceListeningState; + lastTranscript: string; + lastCommand: string | null; + isTracking: boolean; + waypointCount: number; + availableCommands: VoiceCommand[]; + onStartListening: () => void; + onStopListening: () => void; + onTestCommand?: (transcript: string) => void; +} + +const STATE_COLORS: Record = { + idle: 'bg-muted', + listening: 'bg-red-500', + processing: 'bg-amber-500', + error: 'bg-destructive', +}; + +const STATE_ICONS: Record = { + idle: 'microphone-outline', + listening: 'microphone', + processing: 'dots-horizontal', + error: 'information', +}; + +/** + * Main voice command control panel. + * Shows the microphone button, current status, last transcript, + * and a reference list of available commands. + */ +export function VoiceCommandPanel({ + listeningState, + lastTranscript, + lastCommand, + isTracking, + waypointCount, + availableCommands, + onStartListening, + onStopListening, + onTestCommand, +}: VoiceCommandPanelProps) { + const { t } = useTranslation(); + + const isListening = listeningState === 'listening'; + const micBgClass = STATE_COLORS[listeningState]; + const micIcon = STATE_ICONS[listeningState]; + + return ( + + {/* Status Row */} + + + + {isTracking ? t('voice.trackingActive') : t('voice.trackingInactive')} + + + + + {t('voice.waypointsCount', { count: waypointCount })} + + + + + {/* Microphone Button */} + + + + + + {listeningState === 'idle' && t('voice.holdToSpeak')} + {listeningState === 'listening' && t('voice.listening')} + {listeningState === 'processing' && t('voice.processing')} + {listeningState === 'error' && t('voice.error')} + + + {t('voice.holdMicDescription')} + + + + {/* Last Transcript */} + {lastTranscript ? ( + + + {t('voice.lastHeard')} + + "{lastTranscript}" + {lastCommand && ( + + ✓ {t('voice.executedCommand', { command: lastCommand.replace(/_/g, ' ') })} + + )} + + ) : null} + + {/* Available Commands Reference */} + + + {t('voice.availableCommands')} + + + {availableCommands.map((cmd, index) => ( + onTestCommand?.(cmd.patterns[0] ?? '')} + className={`p-3 flex-row items-center gap-3 ${ + index < availableCommands.length - 1 ? 'border-b border-border' : '' + }`} + accessibilityRole="button" + accessibilityLabel={`${t('voice.testCommand')} ${cmd.patterns[0]}`} + > + + + + + "{cmd.patterns[0]}" + {cmd.description} + + + + ))} + + + {t('voice.tapCommandToTest')} + + + + ); +} diff --git a/apps/expo/features/voice/components/VoiceCommandsTile.tsx b/apps/expo/features/voice/components/VoiceCommandsTile.tsx new file mode 100644 index 0000000000..bdf42c303d --- /dev/null +++ b/apps/expo/features/voice/components/VoiceCommandsTile.tsx @@ -0,0 +1,47 @@ +import { ListItem } from '@packrat/ui/nativewindui'; +import { Icon } from '@roninoss/icons'; +import { featureFlags } from 'expo-app/config'; +import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; +import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; +import { useRouter } from 'expo-router'; +import { Platform, View } from 'react-native'; + +/** + * Dashboard tile that navigates to the voice commands screen. + */ +export function VoiceCommandsTile() { + const router = useRouter(); + const { colors } = useColorScheme(); + const { t } = useTranslation(); + + if (!featureFlags.enableVoiceCommands) return null; + + return ( + + + + + + + } + rightView={ + + + + } + item={{ + title: t('voice.voiceCommands'), + subTitle: t('voice.handsFreeNavigation'), + }} + onPress={() => router.push('/voice-commands')} + target="Cell" + index={0} + removeSeparator={Platform.OS === 'ios'} + /> + + ); +} diff --git a/apps/expo/features/voice/hooks/__tests__/useVoiceCommands.test.ts b/apps/expo/features/voice/hooks/__tests__/useVoiceCommands.test.ts new file mode 100644 index 0000000000..13aab9076d --- /dev/null +++ b/apps/expo/features/voice/hooks/__tests__/useVoiceCommands.test.ts @@ -0,0 +1,251 @@ +/** + * Unit tests for voice-command logic in useVoiceCommands. + * + * Tests the pure helper functions (matchCommand, extractNavigationTarget) and + * timer-cleanup behaviour that is independent of the React / Expo environment. + * + * Run with: cd apps/expo && bun test features/voice + */ + +import { describe, expect, it, mock } from 'bun:test'; + +// --------------------------------------------------------------------------- +// Mock Expo and React Native modules before importing the module under test. +// bun:test hoists mock.module calls so they take effect before ESM imports. +// --------------------------------------------------------------------------- + +mock.module('expo-av', () => ({ + Audio: { + Recording: { + createAsync: async () => ({ + recording: { + stopAndUnloadAsync: async () => {}, + getURI: () => null, + }, + }), + }, + RecordingOptionsPresets: { HIGH_QUALITY: {} }, + requestPermissionsAsync: async () => ({ granted: true }), + setAudioModeAsync: async () => {}, + }, +})); + +mock.module('expo-speech', () => ({ + stop: () => {}, + speak: () => {}, + isSpeakingAsync: async () => false, +})); + +mock.module('expo-location', () => ({ + Accuracy: { BestForNavigation: 6, High: 4 }, + requestForegroundPermissionsAsync: async () => ({ status: 'granted' }), + watchPositionAsync: async () => ({ remove: () => {} }), + getCurrentPositionAsync: async () => ({ + coords: { + latitude: 0, + longitude: 0, + altitude: null, + accuracy: null, + speed: null, + heading: null, + }, + timestamp: 0, + }), +})); + +mock.module('react', () => ({ + useCallback: (fn: unknown) => fn, + useEffect: () => {}, + useRef: (initial: unknown) => ({ current: initial }), + useState: (initial: unknown) => [initial, () => {}], +})); + +// Use dynamic import so the mock.module calls above take effect first +const { matchCommand, extractNavigationTarget } = await import('../useVoiceCommands'); + +// --------------------------------------------------------------------------- +// Command matching — disambiguation tests (Issue 4) +// --------------------------------------------------------------------------- + +describe('matchCommand – where_am_i vs navigate_to disambiguation', () => { + it('"navigate to my location" does NOT match where_am_i', () => { + const result = matchCommand('navigate to my location'); + expect(result?.name).not.toBe('where_am_i'); + }); + + it('"navigate to my location" matches navigate_to', () => { + const result = matchCommand('navigate to my location'); + expect(result?.name).toBe('navigate_to'); + }); + + it('"where am i" still matches where_am_i', () => { + const result = matchCommand('where am i'); + expect(result?.name).toBe('where_am_i'); + }); + + it('"what\'s my location" still matches where_am_i', () => { + const result = matchCommand("what's my location"); + expect(result?.name).toBe('where_am_i'); + }); + + it('"current location" still matches where_am_i', () => { + const result = matchCommand('current location'); + expect(result?.name).toBe('where_am_i'); + }); + + it('"navigate to summit" matches navigate_to', () => { + const result = matchCommand('navigate to summit'); + expect(result?.name).toBe('navigate_to'); + }); + + it('"go to lake" matches navigate_to', () => { + const result = matchCommand('go to lake'); + expect(result?.name).toBe('navigate_to'); + }); + + it('unknown phrase returns null', () => { + expect(matchCommand('play some music')).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// extractNavigationTarget +// --------------------------------------------------------------------------- + +describe('extractNavigationTarget', () => { + it('extracts target after "navigate to"', () => { + expect(extractNavigationTarget('navigate to my location')).toBe('my location'); + }); + + it('extracts target after "go to"', () => { + expect(extractNavigationTarget('go to the summit')).toBe('the summit'); + }); + + it('extracts target after "take me to"', () => { + expect(extractNavigationTarget('take me to camp alpha')).toBe('camp alpha'); + }); + + it('extracts target after "directions to"', () => { + expect(extractNavigationTarget('directions to base camp')).toBe('base camp'); + }); + + it('returns trimmed input when no known prefix found', () => { + expect(extractNavigationTarget(' unknown input ')).toBe('unknown input'); + }); +}); + +// --------------------------------------------------------------------------- +// Timer cleanup tests (Issues 2 & 3) — verified through simulated hook logic +// --------------------------------------------------------------------------- + +describe('startListening timer cleanup (Issue 2)', () => { + it('calling startListening twice clears the first timeout before setting a second', () => { + const clearedIds: ReturnType[] = []; + const scheduledIds: ReturnType[] = []; + + let idCounter = 1000; + const originalSetTimeout = globalThis.setTimeout; + const originalClearTimeout = globalThis.clearTimeout; + + // Install spies + (globalThis as any).setTimeout = (_fn: () => void, _ms: number) => { + const id = ++idCounter as unknown as ReturnType; + scheduledIds.push(id); + return id; + }; + (globalThis as any).clearTimeout = (id: ReturnType) => { + clearedIds.push(id); + }; + + // Replicate the fixed startListening logic (Issue 2 fix) + let listenTimeoutRef: ReturnType | null = null; + + function simulateStartListening() { + if (listenTimeoutRef !== null) { + clearTimeout(listenTimeoutRef); + listenTimeoutRef = null; // <-- the fix: null out after clearing + } + listenTimeoutRef = setTimeout(() => {}, 10000); + } + + simulateStartListening(); // first call — no prior timer + expect(scheduledIds).toHaveLength(1); + expect(clearedIds).toHaveLength(0); + + simulateStartListening(); // second call — must clear the first + expect(scheduledIds).toHaveLength(2); + expect(clearedIds).toHaveLength(1); + expect(clearedIds[0]).toBe(scheduledIds[0]); + + // Restore originals + (globalThis as any).setTimeout = originalSetTimeout; + (globalThis as any).clearTimeout = originalClearTimeout; + }); +}); + +describe('processTranscript timer cleanup (Issue 3)', () => { + it('when a transcript arrives before the 10s timeout, the timeout is cleared', () => { + const clearedIds: ReturnType[] = []; + const scheduledIds: ReturnType[] = []; + + let idCounter = 2000; + const originalSetTimeout = globalThis.setTimeout; + const originalClearTimeout = globalThis.clearTimeout; + + (globalThis as any).setTimeout = (_fn: () => void, _ms: number) => { + const id = ++idCounter as unknown as ReturnType; + scheduledIds.push(id); + return id; + }; + (globalThis as any).clearTimeout = (id: ReturnType) => { + clearedIds.push(id); + }; + + let listenTimeoutRef: ReturnType | null = null; + + function simulateStartListening() { + if (listenTimeoutRef !== null) { + clearTimeout(listenTimeoutRef); + listenTimeoutRef = null; + } + listenTimeoutRef = setTimeout(() => {}, 10000); + } + + function simulateProcessTranscript() { + // Issue 3 fix: cancel auto-timeout when transcript arrives + if (listenTimeoutRef !== null) { + clearTimeout(listenTimeoutRef); + listenTimeoutRef = null; + } + } + + simulateStartListening(); + const autoTimeoutId = scheduledIds.at(0); + expect(autoTimeoutId).toBeDefined(); + expect(clearedIds).toHaveLength(0); + + // Transcript arrives — the auto-timeout must be cancelled + simulateProcessTranscript(); + expect(clearedIds).toContain(autoTimeoutId); + expect(listenTimeoutRef).toBeNull(); + + // Restore originals + (globalThis as any).setTimeout = originalSetTimeout; + (globalThis as any).clearTimeout = originalClearTimeout; + }); + + it('after transcript clears the timer, the "timed out" callback does not fire', async () => { + let timedOutCalled = false; + + const timerId = setTimeout(() => { + timedOutCalled = true; + }, 50); // short delay + + // Transcript arrives before timeout fires + clearTimeout(timerId); + + // Wait beyond the delay; the callback must NOT have fired + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(timedOutCalled).toBe(false); + }); +}); diff --git a/apps/expo/features/voice/hooks/index.ts b/apps/expo/features/voice/hooks/index.ts new file mode 100644 index 0000000000..2626019ffd --- /dev/null +++ b/apps/expo/features/voice/hooks/index.ts @@ -0,0 +1,3 @@ +export * from './useGPSTracking'; +export * from './useSpeech'; +export * from './useVoiceCommands'; diff --git a/apps/expo/features/voice/hooks/useGPSTracking.ts b/apps/expo/features/voice/hooks/useGPSTracking.ts new file mode 100644 index 0000000000..edae8f4555 --- /dev/null +++ b/apps/expo/features/voice/hooks/useGPSTracking.ts @@ -0,0 +1,160 @@ +import * as Location from 'expo-location'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { GPSPosition, Waypoint } from '../types'; + +/** + * Hook for GPS tracking and waypoint management. + * Uses expo-location for on-device GPS (no internet required). + */ +export function useGPSTracking() { + const [isTracking, setIsTracking] = useState(false); + const [currentPosition, setCurrentPosition] = useState(null); + const [waypoints, setWaypoints] = useState([]); + const [permissionGranted, setPermissionGranted] = useState(false); + const watchRef = useRef(null); + // Prevents concurrent permission requests racing each other (#12) + const permissionRequestInFlightRef = useRef(false); + // Stable counter for auto-generated waypoint names — avoids waypoints.length dep + const waypointCountRef = useRef(0); + + useEffect(() => { + if (permissionRequestInFlightRef.current) return; + permissionRequestInFlightRef.current = true; + Location.requestForegroundPermissionsAsync() + .then(({ status }) => { + setPermissionGranted(status === 'granted'); + }) + .finally(() => { + permissionRequestInFlightRef.current = false; + }); + }, []); + + const startTracking = useCallback(async () => { + // Guard: prevent creating a second subscription while already tracking (#3). + // Return true because tracking is already active — the caller's intent is satisfied. + if (watchRef.current) return true; + + if (!permissionGranted) { + if (permissionRequestInFlightRef.current) return false; + permissionRequestInFlightRef.current = true; + const { status } = await Location.requestForegroundPermissionsAsync(); + permissionRequestInFlightRef.current = false; + if (status !== 'granted') return false; + setPermissionGranted(true); + } + + try { + const subscription = await Location.watchPositionAsync( + { + accuracy: Location.Accuracy.BestForNavigation, + timeInterval: 5000, + distanceInterval: 10, + }, + (location) => { + setCurrentPosition({ + latitude: location.coords.latitude, + longitude: location.coords.longitude, + altitude: location.coords.altitude, + accuracy: location.coords.accuracy, + speed: location.coords.speed, + heading: location.coords.heading, + timestamp: location.timestamp, + }); + }, + ); + watchRef.current = subscription; + setIsTracking(true); + return true; + } catch (err) { + console.warn('[useGPSTracking] watchPositionAsync failed:', err); + return false; + } + }, [permissionGranted]); + + const stopTracking = useCallback(() => { + if (watchRef.current) { + watchRef.current.remove(); + watchRef.current = null; + } + setIsTracking(false); + }, []); + + const getCurrentPosition = useCallback(async (): Promise => { + if (!permissionGranted) return null; + try { + const location = await Location.getCurrentPositionAsync({ + accuracy: Location.Accuracy.High, + }); + const pos: GPSPosition = { + latitude: location.coords.latitude, + longitude: location.coords.longitude, + altitude: location.coords.altitude, + accuracy: location.coords.accuracy, + speed: location.coords.speed, + heading: location.coords.heading, + timestamp: location.timestamp, + }; + setCurrentPosition(pos); + return pos; + } catch { + return null; + } + }, [permissionGranted]); + + const markWaypoint = useCallback( + async (name?: string): Promise => { + const pos = currentPosition ?? (await getCurrentPosition()); + if (!pos) return null; + + // Increment counter via ref so we don't need waypoints.length in deps (#6) + waypointCountRef.current += 1; + const waypoint: Waypoint = { + id: `wp_${Date.now()}`, + name: name ?? `Waypoint ${waypointCountRef.current}`, + latitude: pos.latitude, + longitude: pos.longitude, + createdAt: new Date().toISOString(), + }; + setWaypoints((prev) => [...prev, waypoint]); + return waypoint; + }, + [currentPosition, getCurrentPosition], + ); + + const getDistanceTo = useCallback( + (target: { latitude: number; longitude: number }): number | null => { + if (!currentPosition) return null; + const R = 6371e3; // metres + const φ1 = (currentPosition.latitude * Math.PI) / 180; + const φ2 = (target.latitude * Math.PI) / 180; + const Δφ = ((target.latitude - currentPosition.latitude) * Math.PI) / 180; + const Δλ = ((target.longitude - currentPosition.longitude) * Math.PI) / 180; + const a = + Math.sin(Δφ / 2) * Math.sin(Δφ / 2) + + Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ / 2) * Math.sin(Δλ / 2); + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return R * c; + }, + [currentPosition], + ); + + useEffect(() => { + return () => { + if (watchRef.current) { + watchRef.current.remove(); + } + }; + }, []); + + return { + isTracking, + currentPosition, + waypoints, + permissionGranted, + startTracking, + stopTracking, + getCurrentPosition, + markWaypoint, + getDistanceTo, + }; +} diff --git a/apps/expo/features/voice/hooks/useSpeech.ts b/apps/expo/features/voice/hooks/useSpeech.ts new file mode 100644 index 0000000000..32e7b62e1f --- /dev/null +++ b/apps/expo/features/voice/hooks/useSpeech.ts @@ -0,0 +1,27 @@ +import * as Speech from 'expo-speech'; +import { useCallback } from 'react'; + +/** + * Hook for text-to-speech voice feedback. + * Uses expo-speech for on-device TTS (no internet required). + */ +export function useSpeech() { + const speak = useCallback((text: string) => { + Speech.stop(); + Speech.speak(text, { + language: 'en-US', + pitch: 1.0, + rate: 0.9, + }); + }, []); + + const stop = useCallback(() => { + Speech.stop(); + }, []); + + const isSpeaking = useCallback(async (): Promise => { + return Speech.isSpeakingAsync(); + }, []); + + return { speak, stop, isSpeaking }; +} diff --git a/apps/expo/features/voice/hooks/useVoiceCommands.ts b/apps/expo/features/voice/hooks/useVoiceCommands.ts new file mode 100644 index 0000000000..65ca02991f --- /dev/null +++ b/apps/expo/features/voice/hooks/useVoiceCommands.ts @@ -0,0 +1,358 @@ +import { Audio } from 'expo-av'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { VoiceCommand, VoiceCommandName, VoiceListeningState } from '../types'; +import { useGPSTracking } from './useGPSTracking'; +import { useSpeech } from './useSpeech'; + +/** + * Registered voice commands with their trigger patterns. + * Pattern matching is case-insensitive substring match, + * enabling future integration with any speech-to-text backend + * (Vosk, Picovoice, Whisper, Web Speech API, etc.). + */ +export const VOICE_COMMANDS: VoiceCommand[] = [ + { + name: 'start_tracking', + patterns: ['start tracking', 'begin tracking', 'start gps', 'track me'], + description: 'Begin GPS location tracking', + }, + { + name: 'stop_tracking', + patterns: ['stop tracking', 'end tracking', 'stop gps', 'pause tracking'], + description: 'End GPS location tracking', + }, + { + name: 'mark_waypoint', + patterns: ['mark waypoint', 'save location', 'add waypoint', 'mark location', 'drop pin'], + description: 'Create a waypoint at the current location', + }, + { + name: 'where_am_i', + patterns: ['where am i', "what's my location", 'current location'], + description: 'Announce current GPS coordinates', + }, + { + name: 'how_far', + patterns: [ + 'how far', + 'distance to', + 'how far to destination', + 'distance remaining', + 'how much further', + ], + description: 'Announce distance to last waypoint', + }, + { + name: 'navigate_to', + patterns: ['navigate to', 'go to', 'take me to', 'directions to'], + description: 'Start navigation to a named waypoint', + }, +]; + +/** Ordered longest-first so longer prefixes match before shorter ones (e.g. "directions to" before "go to"). */ +const NAVIGATE_TO_PREFIXES = ['directions to', 'navigate to', 'take me to', 'go to'] as const; + +/** + * Match a transcript against registered command patterns. + * Exported for testing purposes. + */ +export function matchCommand(transcript: string): VoiceCommand | null { + const lower = transcript.toLowerCase().trim(); + for (const cmd of VOICE_COMMANDS) { + if (cmd.patterns.some((p) => lower.includes(p))) { + return cmd; + } + } + return null; +} + +/** + * Extract the destination noun phrase from a navigate_to transcript. + * Strips the first matching command prefix for reliable extraction (#8). + * Exported for testing purposes. + */ +export function extractNavigationTarget(transcript: string): string { + const lower = transcript.toLowerCase(); + for (const prefix of NAVIGATE_TO_PREFIXES) { + const idx = lower.indexOf(prefix); + if (idx !== -1) { + return transcript.slice(idx + prefix.length).trim(); + } + } + return transcript.trim(); +} + +/** + * Format metres into a human-readable distance string. + */ +function formatDistance(metres: number): string { + if (metres < 1000) { + return `${Math.round(metres)} meters`; + } + return `${(metres / 1000).toFixed(1)} kilometers`; +} + +/** + * Main hook that wires together voice recognition, GPS tracking, and TTS. + * + * Voice input is accepted via the `processTranscript` function, which lets + * any speech-to-text backend (Vosk, Picovoice, Web Speech API, etc.) feed + * recognised text into the command pipeline. + * + * The `startListening` / `stopListening` lifecycle hooks are provided so UI + * components can trigger recording on button press — the actual microphone + * work is delegated to the chosen STT integration. + */ +export function useVoiceCommands() { + const { speak } = useSpeech(); + + // Destructure only the values we need so that useCallback deps are stable (#2) + const { + startTracking, + stopTracking, + markWaypoint, + getCurrentPosition, + getDistanceTo, + currentPosition, + waypoints, + isTracking, + } = useGPSTracking(); + + const [listeningState, setListeningState] = useState('idle'); + const [lastCommand, setLastCommand] = useState(null); + const [lastTranscript, setLastTranscript] = useState(''); + const listeningTimeoutRef = useRef | null>(null); + const recordingRef = useRef(null); + // Stable ref so startListening's timeout can call stopListening without a dep + const stopListeningRef = useRef<(() => Promise) | null>(null); + + // Clear the auto-timeout and any active recording when the hook unmounts (#4) + useEffect(() => { + return () => { + if (listeningTimeoutRef.current) { + clearTimeout(listeningTimeoutRef.current); + } + if (recordingRef.current) { + recordingRef.current.stopAndUnloadAsync().catch(() => {}); + recordingRef.current = null; + } + }; + }, []); + + /** Called by a speech-to-text backend with the recognised text. */ + const processTranscript = useCallback( + async (transcript: string) => { + // Cancel the auto-timeout so it doesn't fire after command processing (#3) + if (listeningTimeoutRef.current) { + clearTimeout(listeningTimeoutRef.current); + listeningTimeoutRef.current = null; + } + setLastTranscript(transcript); + setListeningState('processing'); + + const command = matchCommand(transcript); + if (!command) { + speak("I didn't recognise that command. Please try again."); + setListeningState('error'); + return; + } + + setLastCommand(command.name); + + try { + switch (command.name) { + case 'start_tracking': { + const started = await startTracking(); + if (!started) { + speak('Unable to start tracking. Check permissions.'); + setListeningState('error'); + return; + } + speak('GPS tracking started.'); + break; + } + + case 'stop_tracking': { + stopTracking(); + speak('GPS tracking stopped.'); + break; + } + + case 'mark_waypoint': { + const waypoint = await markWaypoint(); + if (waypoint) { + speak(`Waypoint ${waypoint.name} marked at your current location.`); + } else { + speak('Unable to mark waypoint. GPS position not available.'); + setListeningState('error'); + return; + } + break; + } + + case 'where_am_i': { + const pos = currentPosition ?? (await getCurrentPosition()); + if (pos) { + speak( + `You are at latitude ${pos.latitude.toFixed(4)}, longitude ${pos.longitude.toFixed(4)}.`, + ); + } else { + speak('Unable to determine your location. Check GPS permissions.'); + setListeningState('error'); + return; + } + break; + } + + case 'how_far': { + const lastWaypoint = waypoints[waypoints.length - 1]; + if (!lastWaypoint) { + speak('No waypoints saved. Mark a waypoint first.'); + } else { + const dist = getDistanceTo(lastWaypoint); + if (dist !== null) { + speak(`You are ${formatDistance(dist)} from ${lastWaypoint.name}.`); + } else { + speak('Unable to calculate distance. GPS position not available.'); + setListeningState('error'); + return; + } + } + break; + } + + case 'navigate_to': { + const target = extractNavigationTarget(transcript); + const found = waypoints.find((w) => + w.name.toLowerCase().includes(target.toLowerCase()), + ); + if (found) { + const dist = getDistanceTo(found); + if (dist !== null) { + speak(`Navigating to ${found.name}. Distance is ${formatDistance(dist)}.`); + } else { + speak(`Navigating to ${found.name}.`); + } + } else { + speak( + target + ? `Waypoint "${target}" not found. Try marking it first.` + : 'Please say the waypoint name after "navigate to".', + ); + } + break; + } + } + } catch { + speak('An error occurred while processing your command.'); + setListeningState('error'); + return; + } + + setListeningState('idle'); + }, + [ + speak, + startTracking, + stopTracking, + markWaypoint, + getCurrentPosition, + getDistanceTo, + currentPosition, + waypoints, + ], + ); + + /** Called when user presses the microphone button to start recording. */ + const startListening = useCallback(async () => { + // Bail out if already recording to avoid a second subscription (#3 pattern) + if (recordingRef.current) return; + + // Clear any previously scheduled timeout before starting a new one + if (listeningTimeoutRef.current) { + clearTimeout(listeningTimeoutRef.current); + listeningTimeoutRef.current = null; + } + + try { + // Request mic permission and configure the audio session + const { granted } = await Audio.requestPermissionsAsync(); + if (!granted) { + speak('Microphone permission is required for voice commands.'); + return; + } + + await Audio.setAudioModeAsync({ + allowsRecordingIOS: true, + playsInSilentModeIOS: true, + }); + + const { recording } = await Audio.Recording.createAsync( + Audio.RecordingOptionsPresets.HIGH_QUALITY, + ); + recordingRef.current = recording; + } catch (err) { + console.warn('[useVoiceCommands] Failed to start recording:', err); + speak('Unable to start microphone. Please try again.'); + return; + } + + setListeningState('listening'); + + // Auto-timeout after 10 seconds if no transcript arrives + listeningTimeoutRef.current = setTimeout(() => { + stopListeningRef.current?.(); + speak('Listening timed out. Please try again.'); + }, 10000); + }, [speak]); + + /** Called when user releases the microphone button or recording ends. */ + const stopListening = useCallback(async () => { + if (listeningTimeoutRef.current) { + clearTimeout(listeningTimeoutRef.current); + listeningTimeoutRef.current = null; + } + // Use functional update to avoid stale closure on listeningState (#5) + setListeningState((prev) => (prev === 'listening' ? 'idle' : prev)); + + if (recordingRef.current) { + try { + await recordingRef.current.stopAndUnloadAsync(); + // The URI of the recorded audio file (for STT transcription) + const uri = recordingRef.current.getURI(); + recordingRef.current = null; + + // TODO: wire to real STT backend (e.g., OpenAI Whisper, Google Speech-to-Text) + // Send `uri` to the STT service and call processTranscript() with the result. + // Example: const transcript = await transcribeAudio(uri); + // if (transcript) processTranscript(transcript); + console.debug('[useVoiceCommands] Recording saved to:', uri); + } catch (err) { + console.warn('[useVoiceCommands] Failed to stop recording:', err); + recordingRef.current = null; + } + } + }, []); + // Keep ref in sync so startListening's timeout can call stopListening without a circular dep + stopListeningRef.current = stopListening; + + return { + // State + listeningState, + lastCommand, + lastTranscript, + + // GPS state passthrough + isTracking, + currentPosition, + waypoints, + + // Actions + startListening, + stopListening, + processTranscript, + + // Available commands reference + availableCommands: VOICE_COMMANDS, + }; +} diff --git a/apps/expo/features/voice/index.ts b/apps/expo/features/voice/index.ts new file mode 100644 index 0000000000..a9a0a249f5 --- /dev/null +++ b/apps/expo/features/voice/index.ts @@ -0,0 +1,2 @@ +export * from './hooks'; +export * from './types'; diff --git a/apps/expo/features/voice/screens/VoiceCommandScreen.tsx b/apps/expo/features/voice/screens/VoiceCommandScreen.tsx new file mode 100644 index 0000000000..ce9f7a14af --- /dev/null +++ b/apps/expo/features/voice/screens/VoiceCommandScreen.tsx @@ -0,0 +1,59 @@ +import { Text } from '@packrat/ui/nativewindui'; +import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; +import { ScrollView, View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { VoiceCommandPanel } from '../components/VoiceCommandPanel'; +import { useVoiceCommands } from '../hooks/useVoiceCommands'; + +export function VoiceCommandScreen() { + const { t } = useTranslation(); + const { + listeningState, + lastCommand, + lastTranscript, + isTracking, + waypoints, + availableCommands, + startListening, + stopListening, + processTranscript, + } = useVoiceCommands(); + + return ( + + + {/* Header */} + + {t('voice.voiceCommands')} + + {t('voice.screenDescription')} + + + + {/* Offline badge */} + + + + {t('voice.offlineCapable')} + + {t('voice.offlineDescription')} + + + + + + ); +} diff --git a/apps/expo/features/voice/types.ts b/apps/expo/features/voice/types.ts new file mode 100644 index 0000000000..91e1e5790a --- /dev/null +++ b/apps/expo/features/voice/types.ts @@ -0,0 +1,42 @@ +export type VoiceCommandName = + | 'start_tracking' + | 'stop_tracking' + | 'mark_waypoint' + | 'where_am_i' + | 'how_far' + | 'navigate_to'; + +export interface VoiceCommand { + name: VoiceCommandName; + patterns: string[]; + description: string; +} + +export interface Waypoint { + id: string; + name: string; + latitude: number; + longitude: number; + createdAt: string; +} + +export interface GPSPosition { + latitude: number; + longitude: number; + altitude: number | null; + accuracy: number | null; + speed: number | null; + heading: number | null; + timestamp: number; +} + +export type VoiceListeningState = 'idle' | 'listening' | 'processing' | 'error'; + +export interface VoiceCommandsState { + listeningState: VoiceListeningState; + lastCommand: VoiceCommandName | null; + lastTranscript: string; + isTracking: boolean; + currentPosition: GPSPosition | null; + waypoints: Waypoint[]; +} diff --git a/apps/expo/lib/i18n/locales/en.json b/apps/expo/lib/i18n/locales/en.json index ae4205a0fa..40c1091a72 100644 --- a/apps/expo/lib/i18n/locales/en.json +++ b/apps/expo/lib/i18n/locales/en.json @@ -1142,5 +1142,27 @@ "fish": "fish", "other": "other" } + }, + "voice": { + "voiceCommands": "Voice Commands", + "handsFreeNavigation": "Hands-free trail navigation", + "screenDescription": "Control navigation hands-free using your voice.", + "offlineCapable": "Works Offline", + "offlineDescription": "No internet required", + "holdToSpeak": "Hold to Speak", + "holdMicDescription": "Hold the button and speak a command", + "startListening": "Start listening", + "stopListening": "Stop listening", + "listening": "Listening…", + "processing": "Processing…", + "error": "Error", + "trackingActive": "GPS Tracking Active", + "trackingInactive": "GPS Tracking Off", + "waypointsCount": "{{count}} waypoints", + "lastHeard": "Last heard", + "executedCommand": "Executed: {{command}}", + "availableCommands": "Available Commands", + "tapCommandToTest": "Tap a command to test it", + "testCommand": "Test command" } } diff --git a/apps/expo/lib/utils/itemCalculations.ts b/apps/expo/lib/utils/itemCalculations.ts index ed2d30a59e..72139e71c8 100644 --- a/apps/expo/lib/utils/itemCalculations.ts +++ b/apps/expo/lib/utils/itemCalculations.ts @@ -1,6 +1,6 @@ import { makeEnumGuard } from '@packrat/guards'; import type { CatalogItem } from 'expo-app/features/catalog/types'; -import type { PackTemplateItem } from 'expo-app/features/pack-templates'; +import type { PackTemplateItem } from 'expo-app/features/pack-templates/types'; import type { PackItem, WeightUnit } from 'expo-app/features/packs'; type Item = CatalogItem | PackItem | PackTemplateItem; diff --git a/apps/expo/package.json b/apps/expo/package.json index 848f961879..454bb1a7be 100644 --- a/apps/expo/package.json +++ b/apps/expo/package.json @@ -77,13 +77,15 @@ "@stardazed/streams-text-encoding": "^1.0.2", "@tanstack/react-form": "^1.0.5", "@tanstack/react-query": "^5.70.0", - "ai": "^5.0.136", - "axios": "^1.8.4", + "ai": "catalog:", + "axios": "catalog:", "burnt": "^0.13.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "expo": "^54.0.0", "expo-apple-authentication": "~8.0.8", + "expo-av": "~15.1.7", "expo-blur": "~15.0.8", "expo-clipboard": "~8.0.8", "expo-constants": "~18.0.13", @@ -100,6 +102,7 @@ "expo-navigation-bar": "~5.0.10", "expo-router": "~6.0.23", "expo-secure-store": "~15.0.8", + "expo-speech": "~13.1.0", "expo-sqlite": "~16.0.10", "expo-status-bar": "~3.0.9", "expo-store-review": "~9.0.9", @@ -112,9 +115,9 @@ "jotai": "^2.12.2", "llama.rn": "0.10.1", "nativewind": "^4.2.3", - "radash": "^12.1.1", - "react": "19.1.0", - "react-dom": "19.1.0", + "radash": "catalog:", + "react": "catalog:", + "react-dom": "catalog:", "react-i18next": "^16.5.6", "react-native": "0.81.5", "react-native-gesture-handler": "~2.28.0", @@ -130,8 +133,7 @@ "react-native-web": "^0.21.0", "tailwind-merge": "^2.5.5", "use-debounce": "^10.0.5", - "zod": "^3.24.2", - "date-fns": "^4.1.0" + "zod": "catalog:" }, "devDependencies": { "@babel/core": "^7.20.0", @@ -147,8 +149,8 @@ "prettier": "^3.2.5", "prettier-plugin-tailwindcss": "^0.5.11", "rimraf": "^6.0.1", - "tailwindcss": "^3.4.17", - "typescript": "^5.8.2", + "tailwindcss": "catalog:", + "typescript": "catalog:", "vitest": "~3.1.0" } } diff --git a/package.json b/package.json index 1c28b79f59..e33a348c1d 100644 --- a/package.json +++ b/package.json @@ -28,9 +28,9 @@ "ios": "cd apps/expo && bun ios", "lefthook": "lefthook install", "lint": "biome check --write", - "lint-unsafe": "biome check --write --unsafe", "lint:custom": "bun run scripts/lint/no-raw-typeof.ts && bun run scripts/lint/no-raw-regex.ts", "lint:strict": "biome check && bun run lint:custom", + "lint-unsafe": "biome check --write --unsafe", "test:api:unit": "vitest run --config packages/api/vitest.unit.config.ts", "test:e2e:android": "bash .github/scripts/e2e.sh android", "test:e2e:ios": "bash .github/scripts/e2e.sh ios", @@ -47,9 +47,6 @@ "lefthook": "^1.11.14", "sort-package-json": "^3.6.1" }, - "patchedDependencies": { - "@packrat-ai/nativewindui@1.1.0": "patches/@packrat-ai+nativewindui@1.1.0.patch" - }, "catalog": { "ai": "^5.0.136", "axios": "^1.12.0", @@ -88,6 +85,9 @@ "@radix-ui/react-toggle-group": "^1.1.10", "@radix-ui/react-tooltip": "^1.2.7" }, + "patchedDependencies": { + "@packrat-ai/nativewindui@1.1.0": "patches/@packrat-ai+nativewindui@1.1.0.patch" + }, "trustedDependencies": [ "@sentry/cli" ] diff --git a/packages/api/src/db/schema.ts b/packages/api/src/db/schema.ts index aa8f4ec46e..ecf0c617a0 100644 --- a/packages/api/src/db/schema.ts +++ b/packages/api/src/db/schema.ts @@ -15,7 +15,7 @@ import { varchar, vector, } from 'drizzle-orm/pg-core'; -import type { ValidationError } from '../types/etl'; +import type { ValidationError } from '../types/validation'; const availabilityEnum = pgEnum('availability', ['in_stock', 'out_of_stock', 'preorder']); diff --git a/packages/api/src/types/etl.ts b/packages/api/src/types/etl.ts index e1a323fb39..6d31e97fca 100644 --- a/packages/api/src/types/etl.ts +++ b/packages/api/src/types/etl.ts @@ -1,10 +1,7 @@ import type { NewCatalogItem } from '@packrat/api/db/schema'; +import type { ValidationError } from './validation'; -export interface ValidationError { - field: string; - reason: string; - value?: string | number | boolean | null | undefined; -} +export type { ValidationError } from './validation'; export interface ValidatedCatalogItem { item: Partial; diff --git a/packages/api/src/types/validation.ts b/packages/api/src/types/validation.ts new file mode 100644 index 0000000000..e2d273875b --- /dev/null +++ b/packages/api/src/types/validation.ts @@ -0,0 +1,5 @@ +export interface ValidationError { + field: string; + reason: string; + value?: string | number | boolean | null | undefined; +} diff --git a/scripts/lint/no-circular-deps.ts b/scripts/lint/no-circular-deps.ts index e72a48ecc8..214c4eb6e9 100644 --- a/scripts/lint/no-circular-deps.ts +++ b/scripts/lint/no-circular-deps.ts @@ -157,21 +157,31 @@ function collectFiles(): string[] { const IMPORT_RE = /(?:import|export)\s+(?:[^'"]*\s+from\s+)?['"]([^'"]+)['"]/g; const REQUIRE_RE = /(?:require|import)\s*\(\s*['"]([^'"]+)['"]\s*\)/g; +// Strip block comments (/* … */) and line comments (// …) so that example +// import paths in JSDoc comments are not mistaken for real imports. +const BLOCK_COMMENT_RE = /\/\*[\s\S]*?\*\//g; +const LINE_COMMENT_RE = /\/\/[^\n]*/g; + +function stripComments(source: string): string { + return source.replace(BLOCK_COMMENT_RE, '').replace(LINE_COMMENT_RE, ''); +} + function extractImports(source: string): string[] { const specifiers: string[] = []; + const stripped = stripComments(source); IMPORT_RE.lastIndex = 0; - let importMatch = IMPORT_RE.exec(source); + let importMatch = IMPORT_RE.exec(stripped); while (importMatch !== null) { if (importMatch[1]) specifiers.push(importMatch[1]); - importMatch = IMPORT_RE.exec(source); + importMatch = IMPORT_RE.exec(stripped); } REQUIRE_RE.lastIndex = 0; - let requireMatch = REQUIRE_RE.exec(source); + let requireMatch = REQUIRE_RE.exec(stripped); while (requireMatch !== null) { if (requireMatch[1]) specifiers.push(requireMatch[1]); - requireMatch = REQUIRE_RE.exec(source); + requireMatch = REQUIRE_RE.exec(stripped); } return specifiers;