Skip to content
Closed
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
7 changes: 7 additions & 0 deletions apps/expo/app/(app)/(tabs)/(home)/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -246,6 +252,7 @@ export default function DashboardScreen() {
'gap 4',
'guides',
...(featureFlags.enableWildlifeIdentification ? ['wildlife'] : []),
...(featureFlags.enableVoiceCommands ? ['voice-commands'] : []),
]).current;

const filteredTiles = useMemo(() => {
Expand Down
7 changes: 7 additions & 0 deletions apps/expo/app/(app)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,13 @@ export default function AppLayout() {
getPackTemplateItemDetailOptions((route.params as { id: string })?.id)
}
/>
<Stack.Screen
name="voice-commands/index"
options={{
title: 'Voice Commands',
headerLargeTitle: true,
}}
/>
</Stack>
</>
);
Expand Down
17 changes: 17 additions & 0 deletions apps/expo/app/(app)/voice-commands/index.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<StatusBar
style={Platform.OS === 'ios' ? 'light' : colorScheme === 'dark' ? 'light' : 'dark'}
/>
<VoiceCommandScreen />
</>
);
}
1 change: 1 addition & 0 deletions apps/expo/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ export const featureFlags = {
enableTrailConditions: false,
enableFeed: false,
enableWildlifeIdentification: false,
enableVoiceCommands: false,
};
2 changes: 1 addition & 1 deletion apps/expo/features/catalog/types.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
24 changes: 16 additions & 8 deletions apps/expo/features/packs/components/PackCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ? (
Expand All @@ -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) },
],
)
}
>
<Icon name="trash-can" size={21} color={colors.grey2} />
Expand Down
4 changes: 2 additions & 2 deletions apps/expo/features/packs/hooks/useCreatePackFromPack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
}
}
Expand Down
15 changes: 15 additions & 0 deletions apps/expo/features/packs/input.ts
Original file line number Diff line number Diff line change
@@ -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;
}
2 changes: 1 addition & 1 deletion apps/expo/features/profile/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { WeightUnit } from '../packs';
import type { WeightUnit } from '../packs/types';

export interface User {
id: number;
Expand Down
144 changes: 144 additions & 0 deletions apps/expo/features/voice/components/VoiceCommandPanel.tsx
Original file line number Diff line number Diff line change
@@ -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<VoiceListeningState, string> = {
idle: 'bg-muted',
listening: 'bg-red-500',
processing: 'bg-amber-500',
error: 'bg-destructive',
};

const STATE_ICONS: Record<VoiceListeningState, MaterialIconName> = {
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 (
<View className="gap-6">
{/* Status Row */}
<View className="flex-row items-center gap-4 rounded-2xl bg-card p-4 border border-border">
<View
className={`h-3 w-3 rounded-full ${isTracking ? 'bg-green-500' : 'bg-muted-foreground'}`}
/>
<Text className="text-sm text-foreground font-medium">
{isTracking ? t('voice.trackingActive') : t('voice.trackingInactive')}
</Text>
<View className="ml-auto flex-row items-center gap-1">
<Icon name="map-marker-outline" size={14} color="#6b7280" />
<Text className="text-sm text-muted-foreground">
{t('voice.waypointsCount', { count: waypointCount })}
</Text>
</View>
</View>

{/* Microphone Button */}
<View className="items-center gap-3">
<Pressable
onPressIn={onStartListening}
onPressOut={onStopListening}
className={`h-24 w-24 items-center justify-center rounded-full ${micBgClass}`}
accessibilityRole="button"
accessibilityLabel={isListening ? t('voice.stopListening') : t('voice.startListening')}
accessibilityState={{ selected: isListening }}
>
<Icon name={micIcon} size={40} color="white" />
</Pressable>
<Text className="text-base font-semibold text-foreground">
{listeningState === 'idle' && t('voice.holdToSpeak')}
{listeningState === 'listening' && t('voice.listening')}
{listeningState === 'processing' && t('voice.processing')}
{listeningState === 'error' && t('voice.error')}
</Text>
<Text className="text-xs text-muted-foreground text-center">
{t('voice.holdMicDescription')}
</Text>
</View>

{/* Last Transcript */}
{lastTranscript ? (
<View className="rounded-xl bg-card border border-border p-4 gap-1">
<Text className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t('voice.lastHeard')}
</Text>
<Text className="text-base text-foreground">"{lastTranscript}"</Text>
{lastCommand && (
<Text className="text-xs text-primary mt-1">
✓ {t('voice.executedCommand', { command: lastCommand.replace(/_/g, ' ') })}
</Text>
)}
</View>
) : null}

{/* Available Commands Reference */}
<View className="gap-2">
<Text className="text-sm font-semibold uppercase tracking-wide text-muted-foreground px-1">
{t('voice.availableCommands')}
</Text>
<View className="rounded-xl bg-card border border-border overflow-hidden">
{availableCommands.map((cmd, index) => (
<Pressable
key={cmd.name}
onPress={() => 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]}`}
>
<View className="h-8 w-8 items-center justify-center rounded-full bg-violet-500/10">
<Icon name="microphone-outline" size={14} color="#7c3aed" />
</View>
<View className="flex-1">
<Text className="text-sm font-medium text-foreground">"{cmd.patterns[0]}"</Text>
<Text className="text-xs text-muted-foreground">{cmd.description}</Text>
</View>
<Icon name="chevron-right" size={14} color="#9ca3af" />
</Pressable>
))}
</View>
<Text className="text-xs text-muted-foreground text-center px-4">
{t('voice.tapCommandToTest')}
</Text>
</View>
</View>
);
}
47 changes: 47 additions & 0 deletions apps/expo/features/voice/components/VoiceCommandsTile.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { ListItem } from '@packrat/ui/nativewindui';
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Text component is imported from @packrat/ui/nativewindui but is never used in this file. The component only uses ListItem. This unused import should be removed.

Copilot uses AI. Check for mistakes.
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 (
<View>
<ListItem
className="ios:pl-0 pl-2"
titleClassName="text-lg"
leftView={
<View className="px-3">
<View className="h-6 w-6 items-center justify-center rounded-md bg-violet-500">
<Icon name="microphone" size={15} color="white" />
</View>
</View>
}
rightView={
<View className="flex-1 flex-row items-center justify-center gap-2 px-4">
<Icon name="chevron-right" size={17} color={colors.grey} />
</View>
}
item={{
title: t('voice.voiceCommands'),
subTitle: t('voice.handsFreeNavigation'),
}}
onPress={() => router.push('/voice-commands')}
target="Cell"
index={0}
removeSeparator={Platform.OS === 'ios'}
/>
</View>
);
}
Loading