From 584865932c03512e62989217aab9a372b531e7b6 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Wed, 21 Jan 2026 09:49:02 -0800 Subject: [PATCH 01/14] RD-T39 Fixed some styling issues --- src/app/(app)/home.tsx | 9 ++++++++- src/app/(app)/home.web.tsx | 11 ++++++++++- src/app/onboarding.tsx | 2 +- .../dispatch-console/active-calls-panel.tsx | 14 +++----------- .../dispatch-console/activity-log-panel.tsx | 13 +++---------- src/components/dispatch-console/notes-panel.tsx | 14 +++----------- .../dispatch-console/personnel-panel.tsx | 14 +++----------- src/components/dispatch-console/ptt-interface.tsx | 5 ++--- src/components/dispatch-console/stats-header.tsx | 4 +++- src/components/dispatch-console/units-panel.tsx | 14 +++----------- src/lib/hooks/__tests__/use-selected-theme.test.ts | 4 ++-- src/lib/hooks/use-selected-theme.tsx | 7 ++++--- src/lib/hooks/use-selected-theme.web.tsx | 11 +++++++++-- 13 files changed, 54 insertions(+), 68 deletions(-) diff --git a/src/app/(app)/home.tsx b/src/app/(app)/home.tsx index dabee6f..a97f679 100644 --- a/src/app/(app)/home.tsx +++ b/src/app/(app)/home.tsx @@ -574,8 +574,10 @@ export default function DispatchConsole() { ); }; + const containerStyle = colorScheme === 'dark' ? [styles.container, styles.containerDark] : [styles.container, styles.containerLight]; + return ( - + {/* Stats Header */} @@ -606,8 +608,13 @@ export default function DispatchConsole() { const styles = StyleSheet.create({ container: { flex: 1, + }, + containerLight: { backgroundColor: '#f3f4f6', }, + containerDark: { + backgroundColor: '#030712', + }, column: { minWidth: 0, }, diff --git a/src/app/(app)/home.web.tsx b/src/app/(app)/home.web.tsx index 8af27d2..0fb8eba 100644 --- a/src/app/(app)/home.web.tsx +++ b/src/app/(app)/home.web.tsx @@ -1,5 +1,6 @@ import { useFocusEffect } from '@react-navigation/native'; import { type Href, router } from 'expo-router'; +import { useColorScheme } from 'nativewind'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ScrollView, StyleSheet, useWindowDimensions, View } from 'react-native'; @@ -30,6 +31,7 @@ import { useUnitsStore } from '@/stores/units/store'; export default function DispatchConsoleWeb() { const { t } = useTranslation(); const { trackEvent } = useAnalytics(); + const { colorScheme } = useColorScheme(); const { width, height } = useWindowDimensions(); const isLandscape = width > height; const isTablet = Math.min(width, height) >= 600; @@ -659,8 +661,10 @@ export default function DispatchConsoleWeb() { ); }; + const containerStyle = colorScheme === 'dark' ? [styles.container, styles.containerDark] : [styles.container, styles.containerLight]; + return ( - + {/* Stats Header */} @@ -691,8 +695,13 @@ export default function DispatchConsoleWeb() { const styles = StyleSheet.create({ container: { flex: 1, + }, + containerLight: { backgroundColor: '#f3f4f6', }, + containerDark: { + backgroundColor: '#030712', + }, column: { minWidth: 0, }, diff --git a/src/app/onboarding.tsx b/src/app/onboarding.tsx index 3a849bb..6ff4fca 100644 --- a/src/app/onboarding.tsx +++ b/src/app/onboarding.tsx @@ -23,7 +23,7 @@ type OnboardingItemProps = { const onboardingData: OnboardingItemProps[] = [ { - title: 'Command Your Operations', + title: 'Resgrid Dispatch', description: 'Create, dispatch, and manage emergency calls with a powerful mobile command center at your fingertips', icon: , }, diff --git a/src/components/dispatch-console/active-calls-panel.tsx b/src/components/dispatch-console/active-calls-panel.tsx index 219a008..02bab1b 100644 --- a/src/components/dispatch-console/active-calls-panel.tsx +++ b/src/components/dispatch-console/active-calls-panel.tsx @@ -1,5 +1,6 @@ import { type Href, router } from 'expo-router'; import { AlertTriangle, Clock, ExternalLink, MapPin, Plus, Search, X } from 'lucide-react-native'; +import { useColorScheme } from 'nativewind'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Pressable, ScrollView, StyleSheet, Text as RNText, TextInput, View } from 'react-native'; @@ -230,7 +231,7 @@ export const ActiveCallsPanel: React.FC = ({ selectedCall {!isCollapsed ? ( {/* Search Input */} - + = ({ selectedCall ) : null} - + {error ? ( @@ -288,15 +289,6 @@ const styles = StyleSheet.create({ contentWrapper: { flex: 1, }, - searchContainer: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: 8, - paddingVertical: 6, - borderBottomWidth: 1, - borderBottomColor: '#e5e7eb', - gap: 8, - }, searchInput: { flex: 1, paddingVertical: 4, diff --git a/src/components/dispatch-console/activity-log-panel.tsx b/src/components/dispatch-console/activity-log-panel.tsx index a3184db..879e366 100644 --- a/src/components/dispatch-console/activity-log-panel.tsx +++ b/src/components/dispatch-console/activity-log-panel.tsx @@ -1,4 +1,5 @@ import { AlertTriangle, ArrowRight, Clock, Filter, Info, Mic, Phone, Plus, Radio, Settings, Truck, User, Zap } from 'lucide-react-native'; +import { useColorScheme } from 'nativewind'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Pressable, ScrollView, StyleSheet, View } from 'react-native'; @@ -476,10 +477,10 @@ export const ActivityLogPanel: React.FC = ({ {/* Context hint */} {!hasSelectedCall && !hasSelectedUnit && !hasSelectedPersonnel ? ( - + {t('dispatch.select_items_for_actions')} - + ) : null} ); @@ -621,12 +622,4 @@ const styles = StyleSheet.create({ alignItems: 'center', justifyContent: 'center', }, - contextHint: { - flexDirection: 'row', - alignItems: 'center', - padding: 12, - backgroundColor: 'rgba(251, 191, 36, 0.1)', - borderRadius: 8, - marginTop: 8, - }, }); diff --git a/src/components/dispatch-console/notes-panel.tsx b/src/components/dispatch-console/notes-panel.tsx index cc25c90..855755e 100644 --- a/src/components/dispatch-console/notes-panel.tsx +++ b/src/components/dispatch-console/notes-panel.tsx @@ -1,4 +1,5 @@ import { AlertTriangle, FileText, Filter, Plus, Search, Send, X } from 'lucide-react-native'; +import { useColorScheme } from 'nativewind'; import React, { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Pressable, ScrollView, StyleSheet, TextInput, View } from 'react-native'; @@ -150,7 +151,7 @@ export const NotesPanel: React.FC = ({ notes, isLoading, onRefr {!isCollapsed ? ( {/* Search Input */} - + = ({ notes, isLoading, onRefr ) : null} - + {/* Add note input for call notes */} {isCallFilterActive && onAddCallNote ? ( @@ -216,15 +217,6 @@ export const NotesPanel: React.FC = ({ notes, isLoading, onRefr }; const styles = StyleSheet.create({ - searchContainer: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: 8, - paddingVertical: 6, - borderBottomWidth: 1, - borderBottomColor: '#e5e7eb', - gap: 8, - }, searchInput: { flex: 1, paddingVertical: 4, diff --git a/src/components/dispatch-console/personnel-panel.tsx b/src/components/dispatch-console/personnel-panel.tsx index 5fb3bc9..56e9091 100644 --- a/src/components/dispatch-console/personnel-panel.tsx +++ b/src/components/dispatch-console/personnel-panel.tsx @@ -1,4 +1,5 @@ import { Building2, Circle, Filter, Phone, Plus, Search, User, Users, X } from 'lucide-react-native'; +import { useColorScheme } from 'nativewind'; import React, { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Pressable, ScrollView, StyleSheet, TextInput, View } from 'react-native'; @@ -209,7 +210,7 @@ export const PersonnelPanel: React.FC = ({ {!isCollapsed ? ( {/* Search Input */} - + = ({ ) : null} - + {displayedPersonnel.length === 0 ? ( @@ -256,15 +257,6 @@ export const PersonnelPanel: React.FC = ({ }; const styles = StyleSheet.create({ - searchContainer: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: 8, - paddingVertical: 6, - borderBottomWidth: 1, - borderBottomColor: '#e5e7eb', - gap: 8, - }, searchInput: { flex: 1, paddingVertical: 4, diff --git a/src/components/dispatch-console/ptt-interface.tsx b/src/components/dispatch-console/ptt-interface.tsx index 3d8eda5..bd91fbf 100644 --- a/src/components/dispatch-console/ptt-interface.tsx +++ b/src/components/dispatch-console/ptt-interface.tsx @@ -62,12 +62,12 @@ export const PTTInterface: React.FC = ({ onPTTPress, onPTTRel {/* Compact Controls */} {/* Audio Streams Button */} - + {/* Mute Button */} - setIsMuted(!isMuted)} style={StyleSheet.flatten([styles.compactControlButton, isMuted && styles.mutedButton])}> + setIsMuted(!isMuted)} style={StyleSheet.flatten([styles.compactControlButton, { backgroundColor: colorScheme === 'dark' ? '#374151' : '#e5e7eb' }, isMuted && styles.mutedButton])}> @@ -86,7 +86,6 @@ const styles = StyleSheet.create({ width: 36, height: 36, borderRadius: 18, - backgroundColor: '#e5e7eb', alignItems: 'center', justifyContent: 'center', }, diff --git a/src/components/dispatch-console/stats-header.tsx b/src/components/dispatch-console/stats-header.tsx index f1b3890..9fbc886 100644 --- a/src/components/dispatch-console/stats-header.tsx +++ b/src/components/dispatch-console/stats-header.tsx @@ -55,6 +55,8 @@ interface StatsHeaderProps { export const StatsHeader: React.FC = ({ activeCalls, pendingCalls, scheduledCalls, unitsAvailable, unitsOnScene, personnelOnDuty, currentTime, weatherLatitude, weatherLongitude }) => { const { t } = useTranslation(); + const { colorScheme } = useColorScheme(); + const isDark = colorScheme === 'dark'; return ( @@ -83,7 +85,7 @@ export const StatsHeader: React.FC = ({ activeCalls, pendingCa {currentTime} - + diff --git a/src/components/dispatch-console/units-panel.tsx b/src/components/dispatch-console/units-panel.tsx index c089ed1..bbd6901 100644 --- a/src/components/dispatch-console/units-panel.tsx +++ b/src/components/dispatch-console/units-panel.tsx @@ -1,4 +1,5 @@ import { Building2, Circle, Filter, MapPin, Phone, Plus, Search, Truck, X } from 'lucide-react-native'; +import { useColorScheme } from 'nativewind'; import React, { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Pressable, ScrollView, StyleSheet, TextInput, View } from 'react-native'; @@ -193,7 +194,7 @@ export const UnitsPanel: React.FC = ({ units, isLoading, onRefr {!isCollapsed ? ( {/* Search Input */} - + = ({ units, isLoading, onRefr ) : null} - + {displayedUnits.length === 0 ? ( @@ -240,15 +241,6 @@ const styles = StyleSheet.create({ contentWrapper: { flex: 1, }, - searchContainer: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: 8, - paddingVertical: 6, - borderBottomWidth: 1, - borderBottomColor: '#e5e7eb', - gap: 8, - }, searchInput: { flex: 1, paddingVertical: 4, diff --git a/src/lib/hooks/__tests__/use-selected-theme.test.ts b/src/lib/hooks/__tests__/use-selected-theme.test.ts index 93cf77a..c54e92d 100644 --- a/src/lib/hooks/__tests__/use-selected-theme.test.ts +++ b/src/lib/hooks/__tests__/use-selected-theme.test.ts @@ -53,8 +53,8 @@ describe('loadSelectedTheme', () => { loadSelectedTheme(); expect(mockedStorage.getString).toHaveBeenCalledWith('SELECTED_THEME'); - expect(mockedColorScheme.set).not.toHaveBeenCalled(); - expect(console.log).toHaveBeenCalledWith('No custom theme found, using system default'); + expect(mockedColorScheme.set).toHaveBeenCalledWith('dark'); + expect(console.log).toHaveBeenCalledWith('No custom theme found, defaulting to dark mode'); }); it('should handle storage errors gracefully', () => { diff --git a/src/lib/hooks/use-selected-theme.tsx b/src/lib/hooks/use-selected-theme.tsx index edea6bd..dd6bfb1 100644 --- a/src/lib/hooks/use-selected-theme.tsx +++ b/src/lib/hooks/use-selected-theme.tsx @@ -26,7 +26,7 @@ export const useSelectedTheme = () => { [setColorScheme, _setTheme] ); - const selectedTheme = (theme ?? 'system') as ColorSchemeType; + const selectedTheme = (theme ?? 'dark') as ColorSchemeType; return { selectedTheme, setSelectedTheme } as const; }; // to be used in the root file to load the selected theme from MMKV @@ -34,7 +34,7 @@ export const loadSelectedTheme = () => { try { // On web, skip theme loading as it causes issues with NativeWind if (Platform.OS === 'web') { - console.log('Skipping theme loading on web platform - using system default'); + console.log('Skipping theme loading on web platform - defaulting to dark mode'); return; } @@ -43,7 +43,8 @@ export const loadSelectedTheme = () => { console.log('Loading selected theme:', theme); colorScheme.set(theme as ColorSchemeType); } else { - console.log('No custom theme found, using system default'); + console.log('No custom theme found, defaulting to dark mode'); + colorScheme.set('dark'); } } catch (error) { console.error('Failed to load selected theme:', error); diff --git a/src/lib/hooks/use-selected-theme.web.tsx b/src/lib/hooks/use-selected-theme.web.tsx index eef4c22..c2da997 100644 --- a/src/lib/hooks/use-selected-theme.web.tsx +++ b/src/lib/hooks/use-selected-theme.web.tsx @@ -24,13 +24,20 @@ export const useSelectedTheme = () => { [setColorScheme] ); - const selectedTheme = (theme ?? 'system') as ColorSchemeType; + const selectedTheme = (theme ?? 'dark') as ColorSchemeType; return { selectedTheme, setSelectedTheme } as const; }; export const loadSelectedTheme = () => { try { - console.log('Skipping theme loading on web platform - using system default'); + const storedTheme = localStorage.getItem(SELECTED_THEME); + if (storedTheme) { + console.log('Loading selected theme:', storedTheme); + colorScheme.set(storedTheme as ColorSchemeType); + } else { + console.log('No custom theme found, defaulting to dark mode'); + colorScheme.set('dark'); + } return; } catch (error) { console.error('Failed to load selected theme:', error); From d2695385857801de6bcbabcd811b0b5ddc82a392 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Wed, 21 Jan 2026 10:19:03 -0800 Subject: [PATCH 02/14] RD-T39 New note action sheet, home teaks for collapsing panels. --- src/app/(app)/home.tsx | 44 ++- src/app/(app)/home.web.tsx | 44 ++- .../__tests__/add-note-bottom-sheet.test.tsx | 227 ++++++++++++ .../dispatch-console/active-calls-panel.tsx | 2 +- .../dispatch-console/activity-log-panel.tsx | 2 +- .../add-note-bottom-sheet.tsx | 333 ++++++++++++++++++ src/components/dispatch-console/index.ts | 1 + .../dispatch-console/map-widget.tsx | 2 +- .../dispatch-console/notes-panel.tsx | 2 +- .../dispatch-console/personnel-panel.tsx | 2 +- .../dispatch-console/stats-header.tsx | 8 +- .../dispatch-console/units-panel.tsx | 2 +- src/translations/ar.json | 11 +- src/translations/en.json | 11 +- src/translations/es.json | 11 +- 15 files changed, 679 insertions(+), 23 deletions(-) create mode 100644 src/components/dispatch-console/__tests__/add-note-bottom-sheet.test.tsx create mode 100644 src/components/dispatch-console/add-note-bottom-sheet.tsx diff --git a/src/app/(app)/home.tsx b/src/app/(app)/home.tsx index a97f679..1400fb9 100644 --- a/src/app/(app)/home.tsx +++ b/src/app/(app)/home.tsx @@ -9,7 +9,7 @@ import { getCallNotes, saveCallNote } from '@/api/calls/callNotes'; import { getCallExtraData } from '@/api/calls/calls'; import { getMapDataAndMarkers } from '@/api/mapping/mapping'; import { AudioStreamBottomSheet } from '@/components/audio-stream/audio-stream-bottom-sheet'; -import { ActiveCallFilterBanner, ActiveCallsPanel, ActivityLogPanel, MapWidget, NotesPanel, PersonnelPanel, PTTInterface, StatsHeader, UnitsPanel } from '@/components/dispatch-console'; +import { ActiveCallFilterBanner, ActiveCallsPanel, ActivityLogPanel, AddNoteBottomSheet, MapWidget, NotesPanel, PersonnelPanel, PTTInterface, StatsHeader, UnitsPanel } from '@/components/dispatch-console'; import { Box } from '@/components/ui/box'; import { FocusAwareStatusBar } from '@/components/ui/focus-aware-status-bar'; import { HStack } from '@/components/ui/hstack'; @@ -76,6 +76,7 @@ export default function DispatchConsole() { // Local state const [currentTime, setCurrentTime] = useState(new Date().toLocaleTimeString('en-US', { hour12: false })); const [isAddingNote, setIsAddingNote] = useState(false); + const [isAddNoteSheetOpen, setIsAddNoteSheetOpen] = useState(false); const [selectedPersonnelData, setSelectedPersonnelData] = useState(null); const [selectedUnitData, setSelectedUnitData] = useState(null); @@ -188,8 +189,16 @@ export default function DispatchConsole() { const activeCalls = calls.filter((c) => isCallActive(c.State)).length; const pendingCalls = calls.filter((c) => isCallPending(c.State)).length; const scheduledCalls = calls.filter((c) => isCallScheduled(c.State)).length; - const availableUnits = units.filter((u) => !u.CurrentStatusId || u.CurrentStatusId === 'available').length; - const onSceneUnits = units.filter((u) => u.CurrentStatusId === 'on_scene').length; + // Check CurrentStatus (human-readable name) for availability - case insensitive + const availableUnits = units.filter((u) => { + const status = (u.CurrentStatus || '').toLowerCase(); + return !u.CurrentStatus || status === 'available' || status === 'standing by'; + }).length; + // Personnel with Available or Standing By status + const availablePersonnel = personnel.filter((p) => { + const status = (p.Status || '').toLowerCase(); + return status === 'available' || status === 'standing by'; + }).length; const onDutyPersonnel = personnel.filter((p) => p.Staffing && p.Staffing.toLowerCase() !== 'off duty').length; return { @@ -197,7 +206,7 @@ export default function DispatchConsole() { pendingCalls, scheduledCalls, unitsAvailable: availableUnits, - unitsOnScene: onSceneUnits, + personnelAvailable: availablePersonnel, personnelOnDuty: onDutyPersonnel, }; }, [calls, units, personnel]); @@ -274,6 +283,21 @@ export default function DispatchConsole() { } }; + // Handle opening add note sheet + const handleOpenAddNoteSheet = () => { + setIsAddNoteSheetOpen(true); + }; + + // Handle note added from bottom sheet + const handleNoteAdded = () => { + fetchNotes(); + addActivityLogEntry({ + type: 'system', + action: t('dispatch.note_created'), + description: t('dispatch.note_added'), + }); + }; + // Handle setting unit status for a call const handleSetUnitStatusForCall = (unitId: string, unitName: string) => { // This could open a status selection modal or navigate to unit status screen @@ -432,6 +456,7 @@ export default function DispatchConsole() { callNotes={selectedCallNotes} onAddCallNote={handleAddCallNote} isAddingNote={isAddingNote} + onNewNote={handleOpenAddNoteSheet} /> @@ -481,6 +506,7 @@ export default function DispatchConsole() { callNotes={selectedCallNotes} onAddCallNote={handleAddCallNote} isAddingNote={isAddingNote} + onNewNote={handleOpenAddNoteSheet} /> + + {/* Add Note Bottom Sheet */} + setIsAddNoteSheetOpen(false)} + onNoteAdded={handleNoteAdded} + /> ); } diff --git a/src/app/(app)/home.web.tsx b/src/app/(app)/home.web.tsx index 0fb8eba..bd58962 100644 --- a/src/app/(app)/home.web.tsx +++ b/src/app/(app)/home.web.tsx @@ -9,7 +9,7 @@ import { getCallNotes, saveCallNote } from '@/api/calls/callNotes'; import { getCallExtraData } from '@/api/calls/calls'; import { getMapDataAndMarkers } from '@/api/mapping/mapping'; import { AudioStreamBottomSheet } from '@/components/audio-stream/audio-stream-bottom-sheet'; -import { ActiveCallFilterBanner, ActiveCallsPanel, ActivityLogPanel, MapWidget, NotesPanel, PersonnelPanel, PTTInterface, StatsHeader, UnitsPanel } from '@/components/dispatch-console'; +import { ActiveCallFilterBanner, ActiveCallsPanel, ActivityLogPanel, AddNoteBottomSheet, MapWidget, NotesPanel, PersonnelPanel, PTTInterface, StatsHeader, UnitsPanel } from '@/components/dispatch-console'; import { Box } from '@/components/ui/box'; import { FocusAwareStatusBar } from '@/components/ui/focus-aware-status-bar'; import { HStack } from '@/components/ui/hstack'; @@ -78,6 +78,7 @@ export default function DispatchConsoleWeb() { // Local state const [currentTime, setCurrentTime] = useState(new Date().toLocaleTimeString('en-US', { hour12: false })); const [isAddingNote, setIsAddingNote] = useState(false); + const [isAddNoteSheetOpen, setIsAddNoteSheetOpen] = useState(false); const [selectedPersonnelData, setSelectedPersonnelData] = useState(null); const [selectedUnitData, setSelectedUnitData] = useState(null); @@ -281,8 +282,16 @@ export default function DispatchConsoleWeb() { const activeCalls = calls.filter((c) => isCallActive(c.State)).length; const pendingCalls = calls.filter((c) => isCallPending(c.State)).length; const scheduledCalls = calls.filter((c) => isCallScheduled(c.State)).length; - const availableUnits = units.filter((u) => !u.CurrentStatusId || u.CurrentStatusId === 'available').length; - const onSceneUnits = units.filter((u) => u.CurrentStatusId === 'on_scene').length; + // Check CurrentStatus (human-readable name) for availability - case insensitive + const availableUnits = units.filter((u) => { + const status = (u.CurrentStatus || '').toLowerCase(); + return !u.CurrentStatus || status === 'available' || status === 'standing by'; + }).length; + // Personnel with Available or Standing By status + const availablePersonnel = personnel.filter((p) => { + const status = (p.Status || '').toLowerCase(); + return status === 'available' || status === 'standing by'; + }).length; const onDutyPersonnel = personnel.filter((p) => p.Staffing && p.Staffing.toLowerCase() !== 'off duty').length; return { @@ -290,7 +299,7 @@ export default function DispatchConsoleWeb() { pendingCalls, scheduledCalls, unitsAvailable: availableUnits, - unitsOnScene: onSceneUnits, + personnelAvailable: availablePersonnel, personnelOnDuty: onDutyPersonnel, }; }, [calls, units, personnel]); @@ -427,6 +436,21 @@ export default function DispatchConsoleWeb() { } }; + // Handle opening add note sheet + const handleOpenAddNoteSheet = () => { + setIsAddNoteSheetOpen(true); + }; + + // Handle note added from bottom sheet + const handleNoteAdded = () => { + fetchNotes(); + addActivityLogEntry({ + type: 'system', + action: t('dispatch.note_created'), + description: t('dispatch.note_added'), + }); + }; + // Handle setting unit status for call const handleSetUnitStatusForCall = (unitId: string) => { const unit = units.find((u) => u.UnitId === unitId); @@ -519,6 +543,7 @@ export default function DispatchConsoleWeb() { callNotes={selectedCallNotes} onAddCallNote={handleAddCallNote} isAddingNote={isAddingNote} + onNewNote={handleOpenAddNoteSheet} /> @@ -568,6 +593,7 @@ export default function DispatchConsoleWeb() { callNotes={selectedCallNotes} onAddCallNote={handleAddCallNote} isAddingNote={isAddingNote} + onNewNote={handleOpenAddNoteSheet} /> + + {/* Add Note Bottom Sheet */} + setIsAddNoteSheetOpen(false)} + onNoteAdded={handleNoteAdded} + /> ); } diff --git a/src/components/dispatch-console/__tests__/add-note-bottom-sheet.test.tsx b/src/components/dispatch-console/__tests__/add-note-bottom-sheet.test.tsx new file mode 100644 index 0000000..efc6e55 --- /dev/null +++ b/src/components/dispatch-console/__tests__/add-note-bottom-sheet.test.tsx @@ -0,0 +1,227 @@ +import React from 'react'; + +import { render, waitFor, cleanup, act } from '@testing-library/react-native'; + +import { AddNoteBottomSheet } from '../add-note-bottom-sheet'; + +// Prevent console noise during tests +const originalConsoleError = console.error; +beforeAll(() => { + console.error = jest.fn(); +}); + +afterAll(() => { + console.error = originalConsoleError; +}); + +afterEach(() => { + cleanup(); +}); + +// Mock nativewind +jest.mock('nativewind', () => ({ + useColorScheme: () => ({ colorScheme: 'light' }), + cssInterop: jest.fn(), +})); + +// Mock cssInterop globally +(global as any).cssInterop = jest.fn(); + +// Mock UI components with simplified implementations +jest.mock('@/components/ui/actionsheet', () => { + const { View } = require('react-native'); + return { + Actionsheet: ({ children, isOpen }: any) => (isOpen ? {children} : null), + ActionsheetBackdrop: () => null, + ActionsheetContent: ({ children }: any) => {children}, + ActionsheetDragIndicator: () => null, + ActionsheetDragIndicatorWrapper: ({ children }: any) => children, + }; +}); + +jest.mock('@/components/ui/button', () => ({ + Button: ({ children, onPress, testID, disabled, ...props }: any) => { + const { TouchableOpacity } = require('react-native'); + return {children}; + }, + ButtonText: ({ children, ...props }: any) => { + const { Text } = require('react-native'); + return {children}; + }, +})); + +jest.mock('@/components/ui/text', () => ({ + Text: ({ children, ...props }: any) => { + const { Text: RNText } = require('react-native'); + return {children}; + }, +})); + +jest.mock('@/components/ui/heading', () => ({ + Heading: ({ children, ...props }: any) => { + const { Text } = require('react-native'); + return {children}; + }, +})); + +jest.mock('@/components/ui/vstack', () => ({ + VStack: ({ children, ...props }: any) => { + const { View } = require('react-native'); + return {children}; + }, +})); + +jest.mock('@/components/ui/hstack', () => ({ + HStack: ({ children, ...props }: any) => { + const { View } = require('react-native'); + return {children}; + }, +})); + +jest.mock('@/components/ui/form-control', () => ({ + FormControl: ({ children, ...props }: any) => { + const { View } = require('react-native'); + return {children}; + }, + FormControlLabel: ({ children, ...props }: any) => { + const { View } = require('react-native'); + return {children}; + }, + FormControlLabelText: ({ children, ...props }: any) => { + const { Text } = require('react-native'); + return {children}; + }, + FormControlError: ({ children, ...props }: any) => { + const { View } = require('react-native'); + return {children}; + }, + FormControlErrorText: ({ children, ...props }: any) => { + const { Text } = require('react-native'); + return {children}; + }, +})); + +jest.mock('@/components/ui/input', () => ({ + Input: ({ children, ...props }: any) => { + const { View } = require('react-native'); + return {children}; + }, + InputField: (props: any) => { + const { TextInput } = require('react-native'); + return ; + }, +})); + +jest.mock('@/components/ui/textarea', () => ({ + Textarea: ({ children, ...props }: any) => { + const { View } = require('react-native'); + return {children}; + }, + TextareaInput: (props: any) => { + const { TextInput } = require('react-native'); + return ; + }, +})); + +jest.mock('@/components/ui/select', () => ({ + Select: ({ children, testID, ...props }: any) => { + const { View } = require('react-native'); + return {children}; + }, + SelectTrigger: ({ children, ...props }: any) => { + const { TouchableOpacity } = require('react-native'); + return {children}; + }, + SelectInput: ({ placeholder, ...props }: any) => { + const { Text } = require('react-native'); + return {placeholder}; + }, + SelectIcon: () => null, + SelectPortal: ({ children }: any) => children, + SelectBackdrop: () => null, + SelectContent: ({ children }: any) => { + const { View } = require('react-native'); + return {children}; + }, + SelectDragIndicatorWrapper: ({ children }: any) => children, + SelectDragIndicator: () => null, + SelectItem: ({ label, value, ...props }: any) => { + const { Text } = require('react-native'); + return {label}; + }, +})); + +// Mock dependencies +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => { + const translations: Record = { + 'dispatch.add_note_title': 'Add New Note', + 'dispatch.note_title_label': 'Title', + 'dispatch.note_title_placeholder': 'Enter note title...', + 'dispatch.note_category_label': 'Category', + 'dispatch.note_category_placeholder': 'Select a category', + 'dispatch.note_no_category': 'No Category', + 'dispatch.note_body_label': 'Note Content', + 'dispatch.note_body_placeholder': 'Enter note content...', + 'common.cancel': 'Cancel', + 'common.save': 'Save', + 'form.required': 'This field is required', + }; + return translations[key] || key; + }, + }), +})); + +jest.mock('@/api/notes/notes', () => ({ + getNoteCategories: jest.fn().mockResolvedValue({ + Data: [ + { NoteCategoryId: '1', Category: 'General' }, + { NoteCategoryId: '2', Category: 'Important' }, + ], + }), + saveNote: jest.fn().mockResolvedValue({ Data: { NoteId: '123' } }), +})); + +jest.mock('@/lib/logging', () => ({ + logger: { + info: jest.fn(), + error: jest.fn(), + }, +})); + +describe('AddNoteBottomSheet', () => { + const defaultProps = { + isOpen: true, + onClose: jest.fn(), + onNoteAdded: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders when open', async () => { + const { getByTestId } = render(); + + await waitFor(() => { + expect(getByTestId('actionsheet')).toBeTruthy(); + }); + }); + + it('does not render when closed', () => { + const { queryByTestId } = render(); + + expect(queryByTestId('actionsheet')).toBeNull(); + }); + + it('fetches categories when opened', async () => { + const { getNoteCategories } = require('@/api/notes/notes'); + + render(); + + await waitFor(() => { + expect(getNoteCategories).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/components/dispatch-console/active-calls-panel.tsx b/src/components/dispatch-console/active-calls-panel.tsx index 02bab1b..f3b9dc2 100644 --- a/src/components/dispatch-console/active-calls-panel.tsx +++ b/src/components/dispatch-console/active-calls-panel.tsx @@ -206,7 +206,7 @@ export const ActiveCallsPanel: React.FC = ({ selectedCall }, [fetchCalls, fetchCallPriorities]); return ( - + = ({ }; return ( - + void; + onNoteAdded: () => void; +} + +export function AddNoteBottomSheet({ isOpen, onClose, onNoteAdded }: AddNoteBottomSheetProps) { + const { t } = useTranslation(); + const { colorScheme } = useColorScheme(); + const [isLoading, setIsLoading] = useState(false); + const [categories, setCategories] = useState([]); + const [isCategoriesLoading, setIsCategoriesLoading] = useState(false); + + const { + control, + handleSubmit, + reset, + formState: { errors }, + } = useForm({ + defaultValues: { + title: '', + body: '', + category: '', + }, + }); + + // Fetch categories when sheet opens + useEffect(() => { + if (isOpen) { + fetchCategories(); + } + }, [isOpen]); + + // Reset form when sheet closes + useEffect(() => { + if (!isOpen) { + reset(); + } + }, [isOpen, reset]); + + const fetchCategories = async () => { + setIsCategoriesLoading(true); + try { + const response = await getNoteCategories(); + if (response?.Data) { + setCategories(response.Data); + } + } catch (error) { + logger.error({ + message: 'Failed to fetch note categories', + context: { error }, + }); + } finally { + setIsCategoriesLoading(false); + } + }; + + const onFormSubmit = async (data: AddNoteForm) => { + setIsLoading(true); + try { + const noteInput = new SaveNoteInput(); + noteInput.Title = data.title; + noteInput.Body = data.body; + noteInput.Category = data.category; + noteInput.IsAdminOnly = false; + noteInput.ExpiresOn = ''; + + await saveNote(noteInput); + + logger.info({ + message: 'Note created successfully', + context: { title: data.title }, + }); + + onNoteAdded(); + onClose(); + } catch (error) { + logger.error({ + message: 'Failed to create note', + context: { error }, + }); + } finally { + setIsLoading(false); + } + }; + + const handleClose = () => { + if (!isLoading) { + onClose(); + } + }; + + return ( + + + + + + + + + + {/* Header */} + + {t('dispatch.add_note_title')} + + + {/* Title Field */} + + + + {t('dispatch.note_title_label')} + + + ( + + + + )} + /> + {errors.title ? ( + + {errors.title.message} + + ) : null} + + + {/* Category Field */} + + + + {t('dispatch.note_category_label')} + + + ( + + )} + /> + + + {/* Body Field */} + + + + {t('dispatch.note_body_label')} + + + ( + + )} + /> + {errors.body ? ( + + {errors.body.message} + + ) : null} + + + {/* Action Buttons */} + + + + + + + + + ); +} + +const styles = StyleSheet.create({ + scrollContent: { + flexGrow: 1, + paddingBottom: 20, + }, + textareaInput: { + minHeight: 120, + textAlignVertical: 'top', + }, +}); diff --git a/src/components/dispatch-console/index.ts b/src/components/dispatch-console/index.ts index 4fdd31b..7407627 100644 --- a/src/components/dispatch-console/index.ts +++ b/src/components/dispatch-console/index.ts @@ -1,6 +1,7 @@ // Dispatch Console Components export { ActiveCallFilterBanner } from './active-call-filter-banner'; export { ActiveCallsPanel } from './active-calls-panel'; +export { AddNoteBottomSheet } from './add-note-bottom-sheet'; export { type ActivityLogEntry, ActivityLogPanel } from './activity-log-panel'; export { AnimatedRefreshIcon } from './animated-refresh-icon'; export { MapWidget } from './map-widget'; diff --git a/src/components/dispatch-console/map-widget.tsx b/src/components/dispatch-console/map-widget.tsx index 66847d5..b9747d7 100644 --- a/src/components/dispatch-console/map-widget.tsx +++ b/src/components/dispatch-console/map-widget.tsx @@ -94,7 +94,7 @@ export const MapWidget: React.FC = ({ pins, onExpandMap, onRefre return ( <> - + = ({ notes, isLoading, onRefr const notesCount = displayNotes ? filteredCallNotes?.length || 0 : filteredNotes.length; return ( - + = ({ const onDutyCount = displayedPersonnel.filter((p) => p.Staffing && p.Staffing.toLowerCase() !== 'off duty').length; return ( - + = ({ activeCalls, pendingCalls, scheduledCalls, unitsAvailable, unitsOnScene, personnelOnDuty, currentTime, weatherLatitude, weatherLongitude }) => { +export const StatsHeader: React.FC = ({ activeCalls, pendingCalls, scheduledCalls, unitsAvailable, personnelAvailable, personnelOnDuty, currentTime, weatherLatitude, weatherLongitude }) => { const { t } = useTranslation(); const { colorScheme } = useColorScheme(); const isDark = colorScheme === 'dark'; @@ -73,8 +73,8 @@ export const StatsHeader: React.FC = ({ activeCalls, pendingCa {/* Units Available */} - {/* Units On Scene */} - + {/* Personnel Available */} + {/* Personnel On Duty */} diff --git a/src/components/dispatch-console/units-panel.tsx b/src/components/dispatch-console/units-panel.tsx index bbd6901..2788efd 100644 --- a/src/components/dispatch-console/units-panel.tsx +++ b/src/components/dispatch-console/units-panel.tsx @@ -162,7 +162,7 @@ export const UnitsPanel: React.FC = ({ units, isLoading, onRefr const availableUnits = displayedUnits.filter((u) => !u.CurrentStatusId || u.CurrentStatusId === 'available').length; return ( - + Date: Wed, 21 Jan 2026 10:37:40 -0800 Subject: [PATCH 03/14] RD-T39 Working on PTT --- src/components/dispatch-console/index.ts | 1 + .../dispatch-console/ptt-channel-selector.tsx | 128 +++++ .../dispatch-console/ptt-interface.tsx | 277 +++++++++- src/hooks/__tests__/use-ptt.test.ts | 490 +++++++++++++++++ src/hooks/use-ptt.ts | 517 ++++++++++++++++++ src/stores/app/livekit-store.ts | 72 +-- src/translations/en.json | 6 + 7 files changed, 1440 insertions(+), 51 deletions(-) create mode 100644 src/components/dispatch-console/ptt-channel-selector.tsx create mode 100644 src/hooks/__tests__/use-ptt.test.ts create mode 100644 src/hooks/use-ptt.ts diff --git a/src/components/dispatch-console/index.ts b/src/components/dispatch-console/index.ts index 7407627..03131b5 100644 --- a/src/components/dispatch-console/index.ts +++ b/src/components/dispatch-console/index.ts @@ -9,6 +9,7 @@ export { NotesPanel } from './notes-panel'; export { PanelHeader } from './panel-header'; export { PersonnelActionsPanel } from './personnel-actions-panel'; export { PersonnelPanel } from './personnel-panel'; +export { PTTChannelSelector } from './ptt-channel-selector'; export { PTTInterface } from './ptt-interface'; export { StatsHeader } from './stats-header'; export { UnitActionsPanel } from './unit-actions-panel'; diff --git a/src/components/dispatch-console/ptt-channel-selector.tsx b/src/components/dispatch-console/ptt-channel-selector.tsx new file mode 100644 index 0000000..5206525 --- /dev/null +++ b/src/components/dispatch-console/ptt-channel-selector.tsx @@ -0,0 +1,128 @@ +import { Check, Radio } from 'lucide-react-native'; +import { useColorScheme } from 'nativewind'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { FlatList, Pressable, StyleSheet, View } from 'react-native'; + +import { type DepartmentVoiceChannelResultData } from '@/models/v4/voice/departmentVoiceResultData'; + +import { Actionsheet, ActionsheetBackdrop, ActionsheetContent, ActionsheetDragIndicator, ActionsheetDragIndicatorWrapper } from '../ui/actionsheet'; +import { Badge, BadgeText } from '../ui/badge'; +import { Button, ButtonText } from '../ui/button'; +import { Heading } from '../ui/heading'; +import { HStack } from '../ui/hstack'; +import { Icon } from '../ui/icon'; +import { Text } from '../ui/text'; +import { VStack } from '../ui/vstack'; + +interface PTTChannelSelectorProps { + isOpen: boolean; + onClose: () => void; + channels: DepartmentVoiceChannelResultData[]; + selectedChannelId?: string; + onSelectChannel: (channelId: string) => void; + isConnected: boolean; +} + +export const PTTChannelSelector: React.FC = ({ isOpen, onClose, channels, selectedChannelId, onSelectChannel, isConnected }) => { + const { t } = useTranslation(); + const { colorScheme } = useColorScheme(); + const isDark = colorScheme === 'dark'; + + const renderChannelItem = ({ item }: { item: DepartmentVoiceChannelResultData }) => { + const isSelected = item.Id === selectedChannelId; + + return ( + onSelectChannel(item.Id)} style={StyleSheet.flatten([styles.channelItem, { backgroundColor: isDark ? '#1f2937' : '#f9fafb' }, isSelected && styles.channelItemSelected])}> + + + + + + + {item.Name} + {item.IsDefault ? ( + + {t('dispatch.default_channel')} + + ) : null} + + + + {isSelected ? ( + + + + ) : null} + + + ); + }; + + return ( + + + + + + + + + {/* Header */} + + {t('dispatch.select_channel')} + {isConnected ? t('dispatch.change_channel_warning') : t('dispatch.select_channel_description')} + + + {/* Channel List */} + {channels.length > 0 ? ( + item.Id} style={styles.channelList} contentContainerStyle={styles.channelListContent} showsVerticalScrollIndicator={false} /> + ) : ( + + + {t('dispatch.no_channels_available')} + + )} + + {/* Close Button */} + + + + + ); +}; + +const styles = StyleSheet.create({ + channelList: { + maxHeight: 300, + }, + channelListContent: { + paddingVertical: 4, + }, + channelItem: { + padding: 12, + borderRadius: 8, + marginBottom: 8, + }, + channelItemSelected: { + borderWidth: 2, + borderColor: '#3b82f6', + }, + channelIcon: { + width: 40, + height: 40, + borderRadius: 20, + alignItems: 'center', + justifyContent: 'center', + }, + checkIcon: { + width: 28, + height: 28, + borderRadius: 14, + backgroundColor: 'rgba(34, 197, 94, 0.1)', + alignItems: 'center', + justifyContent: 'center', + }, +}); diff --git a/src/components/dispatch-console/ptt-interface.tsx b/src/components/dispatch-console/ptt-interface.tsx index bd91fbf..42b7cf3 100644 --- a/src/components/dispatch-console/ptt-interface.tsx +++ b/src/components/dispatch-console/ptt-interface.tsx @@ -1,6 +1,6 @@ -import { Headphones, Mic, MicOff, Radio, Volume2, VolumeX } from 'lucide-react-native'; +import { Headphones, Mic, MicOff, PhoneOff, Radio, Volume2, VolumeX, Wifi, WifiOff } from 'lucide-react-native'; import { useColorScheme } from 'nativewind'; -import React, { useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Platform, Pressable, StyleSheet, View } from 'react-native'; @@ -9,28 +9,179 @@ import { HStack } from '@/components/ui/hstack'; import { Icon } from '@/components/ui/icon'; import { Text } from '@/components/ui/text'; import { VStack } from '@/components/ui/vstack'; +import { usePTT } from '@/hooks/use-ptt'; import { useAudioStreamStore } from '@/stores/app/audio-stream-store'; +import { PTTChannelSelector } from './ptt-channel-selector'; + interface PTTInterfaceProps { + /** Optional callback when PTT press starts (for activity logging) */ onPTTPress?: () => void; + /** Optional callback when PTT press ends (for activity logging) */ onPTTRelease?: () => void; + /** Optional callback to open audio streams panel */ onOpenAudioStreams?: () => void; + /** External transmitting state (for activity logging only, actual state managed by hook) */ isTransmitting?: boolean; + /** Display name for current channel (fallback only, actual channel from hook) */ currentChannel?: string; } -export const PTTInterface: React.FC = ({ onPTTPress, onPTTRelease, onOpenAudioStreams, isTransmitting = false, currentChannel = 'Main Channel' }) => { +export const PTTInterface: React.FC = ({ + onPTTPress, + onPTTRelease, + onOpenAudioStreams, + isTransmitting: externalTransmitting = false, + currentChannel: externalChannel = 'Main Channel', +}) => { const { t } = useTranslation(); const { colorScheme } = useColorScheme(); - const [isMuted, setIsMuted] = useState(false); const { isPlaying, currentStream, setIsBottomSheetVisible } = useAudioStreamStore(); - const handleOpenAudioStreams = () => { + // PTT hook for LiveKit integration + const { + isConnected, + isConnecting, + isTransmitting: pttTransmitting, + isMuted, + currentChannel: pttChannel, + availableChannels, + isVoiceEnabled, + error, + connect, + disconnect, + startTransmitting, + stopTransmitting, + toggleMute, + selectChannel, + refreshVoiceSettings, + } = usePTT({ + onTransmittingChange: (transmitting) => { + if (transmitting) { + onPTTPress?.(); + } else { + onPTTRelease?.(); + } + }, + }); + + // Local state for channel selector + const [isChannelSelectorOpen, setIsChannelSelectorOpen] = useState(false); + + // Use actual PTT state or fallback to external props + const isTransmitting = isConnected ? pttTransmitting : externalTransmitting; + const displayChannel = pttChannel?.Name || externalChannel; + + // Fetch voice settings on mount + useEffect(() => { + refreshVoiceSettings(); + }, [refreshVoiceSettings]); + + const handleOpenAudioStreams = useCallback(() => { if (onOpenAudioStreams) { onOpenAudioStreams(); } else { setIsBottomSheetVisible(true); } + }, [onOpenAudioStreams, setIsBottomSheetVisible]); + + const handlePTTPress = useCallback(async () => { + if (!isConnected) { + // If not connected, try to connect first + if (availableChannels.length > 0 && !pttChannel) { + // Auto-select default channel or first available + const defaultChannel = availableChannels.find((c) => c.IsDefault) || availableChannels[0]; + selectChannel(defaultChannel); + await connect(defaultChannel); + } else if (pttChannel) { + await connect(); + } + return; + } + + await startTransmitting(); + }, [isConnected, availableChannels, pttChannel, selectChannel, connect, startTransmitting]); + + const handlePTTRelease = useCallback(async () => { + if (isConnected && pttTransmitting) { + await stopTransmitting(); + } + }, [isConnected, pttTransmitting, stopTransmitting]); + + const handleMuteToggle = useCallback(async () => { + await toggleMute(); + }, [toggleMute]); + + const handleChannelPress = useCallback(() => { + if (isVoiceEnabled && availableChannels.length > 0) { + setIsChannelSelectorOpen(true); + } + }, [isVoiceEnabled, availableChannels]); + + const handleChannelSelect = useCallback( + async (channelId: string) => { + const channel = availableChannels.find((c) => c.Id === channelId); + if (channel) { + // Disconnect from current channel if connected + if (isConnected) { + await disconnect(); + } + selectChannel(channel); + setIsChannelSelectorOpen(false); + } + }, + [availableChannels, isConnected, disconnect, selectChannel] + ); + + const handleDisconnect = useCallback(async () => { + await disconnect(); + }, [disconnect]); + + // Get connection status indicator + const getConnectionIndicator = () => { + if (isConnecting) { + return ( + + ... + + ); + } + if (isConnected) { + if (isTransmitting) { + return ( + + TX + + ); + } + return ( + + RX + + ); + } + return ( + + OFF + + ); + }; + + // Determine PTT button state + const getPTTButtonStyle = () => { + if (!isVoiceEnabled) { + return [styles.pttButtonCompact, styles.pttButtonDisabled]; + } + if (isConnecting) { + return [styles.pttButtonCompact, styles.pttButtonConnecting]; + } + if (isTransmitting) { + return [styles.pttButtonCompact, styles.pttButtonActive]; + } + if (isConnected) { + return [styles.pttButtonCompact, styles.pttButtonConnected]; + } + return [styles.pttButtonCompact]; }; return ( @@ -38,19 +189,29 @@ export const PTTInterface: React.FC = ({ onPTTPress, onPTTRel {/* Channel & Stream Info */} - {/* TX/RX Indicator */} - - {isTransmitting ? 'TX' : 'RX'} - + {/* Connection Status Indicator */} + {getConnectionIndicator()} {/* Channel Info */} - - {currentChannel} - + + + + + {isVoiceEnabled ? displayChannel : t('dispatch.voice_disabled')} + + + - + {currentStream ? currentStream.Name : t('dispatch.no_stream')} @@ -62,21 +223,68 @@ export const PTTInterface: React.FC = ({ onPTTPress, onPTTRel {/* Compact Controls */} {/* Audio Streams Button */} - + + {/* Disconnect Button (only shown when connected) */} + {isConnected ? ( + + + + ) : null} + {/* Mute Button */} - setIsMuted(!isMuted)} style={StyleSheet.flatten([styles.compactControlButton, { backgroundColor: colorScheme === 'dark' ? '#374151' : '#e5e7eb' }, isMuted && styles.mutedButton])}> - + + {/* PTT Button */} - + + + {/* Error display */} + {error ? ( + + {error} + + ) : null} + + {/* Channel Selector Bottom Sheet */} + setIsChannelSelectorOpen(false)} + channels={availableChannels} + selectedChannelId={pttChannel?.Id} + onSelectChannel={handleChannelSelect} + isConnected={isConnected} + /> ); }; @@ -92,6 +300,9 @@ const styles = StyleSheet.create({ mutedButton: { backgroundColor: 'rgba(239, 68, 68, 0.1)', }, + disconnectButton: { + backgroundColor: '#ef4444', + }, pttButtonCompact: { width: 44, height: 44, @@ -123,6 +334,15 @@ const styles = StyleSheet.create({ }, }), } as any, + pttButtonConnected: { + backgroundColor: '#3b82f6', + }, + pttButtonConnecting: { + backgroundColor: '#f59e0b', + }, + pttButtonDisabled: { + backgroundColor: '#9ca3af', + }, transmittingIndicator: { backgroundColor: '#ef4444', paddingHorizontal: 6, @@ -131,7 +351,7 @@ const styles = StyleSheet.create({ minWidth: 28, alignItems: 'center', }, - readyIndicator: { + connectedIndicator: { backgroundColor: '#22c55e', paddingHorizontal: 6, paddingVertical: 2, @@ -139,4 +359,25 @@ const styles = StyleSheet.create({ minWidth: 28, alignItems: 'center', }, + connectingIndicator: { + backgroundColor: '#f59e0b', + paddingHorizontal: 6, + paddingVertical: 2, + borderRadius: 4, + minWidth: 28, + alignItems: 'center', + }, + disconnectedIndicator: { + backgroundColor: '#6b7280', + paddingHorizontal: 6, + paddingVertical: 2, + borderRadius: 4, + minWidth: 28, + alignItems: 'center', + }, + errorBanner: { + backgroundColor: '#ef4444', + paddingHorizontal: 8, + paddingVertical: 4, + }, }); diff --git a/src/hooks/__tests__/use-ptt.test.ts b/src/hooks/__tests__/use-ptt.test.ts new file mode 100644 index 0000000..d5ca6b2 --- /dev/null +++ b/src/hooks/__tests__/use-ptt.test.ts @@ -0,0 +1,490 @@ +import { act, renderHook, waitFor } from '@testing-library/react-native'; + +// Mock modules before importing the hook +jest.mock('@/stores/app/livekit-store', () => ({ + useLiveKitStore: jest.fn(() => ({ + isConnected: false, + isConnecting: false, + currentRoom: null, + currentRoomInfo: null, + isVoiceEnabled: true, + voipServerWebsocketSslAddress: 'wss://test.example.com', + availableRooms: [ + { Id: 'room-1', Name: 'Channel 1', Token: 'token-1', IsDefault: true, ConferenceNumber: 1 }, + { Id: 'room-2', Name: 'Channel 2', Token: 'token-2', IsDefault: false, ConferenceNumber: 2 }, + ], + fetchVoiceSettings: jest.fn().mockResolvedValue(undefined), + connectToRoom: jest.fn().mockResolvedValue(undefined), + disconnectFromRoom: jest.fn().mockResolvedValue(undefined), + })), +})); + +jest.mock('expo-av', () => ({ + Audio: { + setAudioModeAsync: jest.fn().mockResolvedValue(undefined), + }, +})); + +jest.mock('@/lib/logging', () => ({ + logger: { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, +})); + +// Mock CallKeep service (iOS only) +jest.mock( + '@/services/callkeep.service.ios', + () => ({ + callKeepService: { + setup: jest.fn().mockResolvedValue(undefined), + startCall: jest.fn().mockResolvedValue(undefined), + endCall: jest.fn().mockResolvedValue(undefined), + cleanup: jest.fn(), + setMuteStateCallback: jest.fn(), + }, + }), + { virtual: true } +); + +// Import Platform after mocks are set up +import { Platform } from 'react-native'; + +import { usePTT } from '../use-ptt'; +import { useLiveKitStore } from '@/stores/app/livekit-store'; + +// Create a typed mock reference +const mockUseLiveKitStore = useLiveKitStore as jest.MockedFunction; + +describe('usePTT hook', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('Initial State', () => { + it('should return initial state correctly', () => { + const { result } = renderHook(() => usePTT()); + + expect(result.current.isConnected).toBe(false); + expect(result.current.isConnecting).toBe(false); + expect(result.current.isTransmitting).toBe(false); + expect(result.current.isMuted).toBe(false); + expect(result.current.error).toBeNull(); + expect(result.current.isVoiceEnabled).toBe(true); + expect(result.current.availableChannels).toHaveLength(2); + }); + + it('should fetch voice settings on mount', async () => { + const mockFetchVoiceSettings = jest.fn().mockResolvedValue(undefined); + mockUseLiveKitStore.mockReturnValue({ + isConnected: false, + isConnecting: false, + currentRoom: null, + currentRoomInfo: null, + isVoiceEnabled: true, + voipServerWebsocketSslAddress: 'wss://test.example.com', + availableRooms: [], + fetchVoiceSettings: mockFetchVoiceSettings, + connectToRoom: jest.fn(), + disconnectFromRoom: jest.fn(), + }); + + renderHook(() => usePTT()); + + await waitFor(() => { + expect(mockFetchVoiceSettings).toHaveBeenCalled(); + }); + }); + }); + + describe('Channel Selection', () => { + it('should select a channel', () => { + const { result } = renderHook(() => usePTT()); + + act(() => { + result.current.selectChannel({ + Id: 'room-1', + Name: 'Channel 1', + Token: 'token-1', + IsDefault: true, + ConferenceNumber: 1, + }); + }); + + expect(result.current.currentChannel).toEqual({ + Id: 'room-1', + Name: 'Channel 1', + Token: 'token-1', + IsDefault: true, + ConferenceNumber: 1, + }); + }); + }); + + describe('Connection', () => { + it('should connect to a channel', async () => { + const mockConnectToRoom = jest.fn().mockResolvedValue(undefined); + mockUseLiveKitStore.mockReturnValue({ + isConnected: false, + isConnecting: false, + currentRoom: null, + currentRoomInfo: null, + isVoiceEnabled: true, + voipServerWebsocketSslAddress: 'wss://test.example.com', + availableRooms: [], + fetchVoiceSettings: jest.fn().mockResolvedValue(undefined), + connectToRoom: mockConnectToRoom, + disconnectFromRoom: jest.fn(), + }); + + const { result } = renderHook(() => usePTT()); + + const channel = { + Id: 'room-1', + Name: 'Channel 1', + Token: 'token-1', + IsDefault: true, + ConferenceNumber: 1, + }; + + await act(async () => { + await result.current.connect(channel); + }); + + expect(mockConnectToRoom).toHaveBeenCalledWith(channel, channel.Token); + }); + + it('should set error when voice is disabled', async () => { + mockUseLiveKitStore.mockReturnValue({ + isConnected: false, + isConnecting: false, + currentRoom: null, + currentRoomInfo: null, + isVoiceEnabled: false, + voipServerWebsocketSslAddress: 'wss://test.example.com', + availableRooms: [], + fetchVoiceSettings: jest.fn().mockResolvedValue(undefined), + connectToRoom: jest.fn(), + disconnectFromRoom: jest.fn(), + }); + + const onError = jest.fn(); + const { result } = renderHook(() => usePTT({ onError })); + + await act(async () => { + await result.current.connect({ + Id: 'room-1', + Name: 'Channel 1', + Token: 'token-1', + IsDefault: true, + ConferenceNumber: 1, + }); + }); + + expect(result.current.error).toBe('Voice is not enabled for this department'); + expect(onError).toHaveBeenCalledWith('Voice is not enabled for this department'); + }); + + it('should disconnect from a channel', async () => { + const mockDisconnectFromRoom = jest.fn().mockResolvedValue(undefined); + const mockLocalParticipant = { + setMicrophoneEnabled: jest.fn().mockResolvedValue(undefined), + }; + const mockRoom = { + localParticipant: mockLocalParticipant, + disconnect: jest.fn().mockResolvedValue(undefined), + }; + + mockUseLiveKitStore.mockReturnValue({ + isConnected: true, + isConnecting: false, + currentRoom: mockRoom, + currentRoomInfo: { Id: 'room-1', Name: 'Channel 1' }, + isVoiceEnabled: true, + voipServerWebsocketSslAddress: 'wss://test.example.com', + availableRooms: [], + fetchVoiceSettings: jest.fn().mockResolvedValue(undefined), + connectToRoom: jest.fn(), + disconnectFromRoom: mockDisconnectFromRoom, + }); + + const { result } = renderHook(() => usePTT()); + + await act(async () => { + await result.current.disconnect(); + }); + + expect(mockDisconnectFromRoom).toHaveBeenCalled(); + }); + }); + + describe('Transmitting', () => { + it('should start transmitting when connected', async () => { + const mockSetMicrophoneEnabled = jest.fn().mockResolvedValue(undefined); + const mockRoom = { + localParticipant: { + setMicrophoneEnabled: mockSetMicrophoneEnabled, + }, + }; + + mockUseLiveKitStore.mockReturnValue({ + isConnected: true, + isConnecting: false, + currentRoom: mockRoom, + currentRoomInfo: { Id: 'room-1', Name: 'Channel 1' }, + isVoiceEnabled: true, + voipServerWebsocketSslAddress: 'wss://test.example.com', + availableRooms: [], + fetchVoiceSettings: jest.fn().mockResolvedValue(undefined), + connectToRoom: jest.fn(), + disconnectFromRoom: jest.fn(), + }); + + const onTransmittingChange = jest.fn(); + const { result } = renderHook(() => usePTT({ onTransmittingChange })); + + await act(async () => { + await result.current.startTransmitting(); + }); + + expect(mockSetMicrophoneEnabled).toHaveBeenCalledWith(true); + expect(result.current.isTransmitting).toBe(true); + }); + + it('should stop transmitting', async () => { + const mockSetMicrophoneEnabled = jest.fn().mockResolvedValue(undefined); + const mockRoom = { + localParticipant: { + setMicrophoneEnabled: mockSetMicrophoneEnabled, + }, + }; + + mockUseLiveKitStore.mockReturnValue({ + isConnected: true, + isConnecting: false, + currentRoom: mockRoom, + currentRoomInfo: { Id: 'room-1', Name: 'Channel 1' }, + isVoiceEnabled: true, + voipServerWebsocketSslAddress: 'wss://test.example.com', + availableRooms: [], + fetchVoiceSettings: jest.fn().mockResolvedValue(undefined), + connectToRoom: jest.fn(), + disconnectFromRoom: jest.fn(), + }); + + const { result } = renderHook(() => usePTT()); + + // Start transmitting first + await act(async () => { + await result.current.startTransmitting(); + }); + + expect(result.current.isTransmitting).toBe(true); + + // Stop transmitting + await act(async () => { + await result.current.stopTransmitting(); + }); + + expect(mockSetMicrophoneEnabled).toHaveBeenLastCalledWith(false); + expect(result.current.isTransmitting).toBe(false); + }); + + it('should not transmit when not connected', async () => { + mockUseLiveKitStore.mockReturnValue({ + isConnected: false, + isConnecting: false, + currentRoom: null, + currentRoomInfo: null, + isVoiceEnabled: true, + voipServerWebsocketSslAddress: 'wss://test.example.com', + availableRooms: [], + fetchVoiceSettings: jest.fn().mockResolvedValue(undefined), + connectToRoom: jest.fn(), + disconnectFromRoom: jest.fn(), + }); + + const { result } = renderHook(() => usePTT()); + + await act(async () => { + await result.current.startTransmitting(); + }); + + expect(result.current.isTransmitting).toBe(false); + }); + }); + + describe('Mute Toggle', () => { + it('should toggle mute state', async () => { + const mockSetMicrophoneEnabled = jest.fn().mockResolvedValue(undefined); + const mockRoom = { + localParticipant: { + setMicrophoneEnabled: mockSetMicrophoneEnabled, + }, + }; + + mockUseLiveKitStore.mockReturnValue({ + isConnected: true, + isConnecting: false, + currentRoom: mockRoom, + currentRoomInfo: { Id: 'room-1', Name: 'Channel 1' }, + isVoiceEnabled: true, + voipServerWebsocketSslAddress: 'wss://test.example.com', + availableRooms: [], + fetchVoiceSettings: jest.fn().mockResolvedValue(undefined), + connectToRoom: jest.fn(), + disconnectFromRoom: jest.fn(), + }); + + const { result } = renderHook(() => usePTT()); + + expect(result.current.isMuted).toBe(false); + + await act(async () => { + await result.current.toggleMute(); + }); + + expect(result.current.isMuted).toBe(true); + expect(mockSetMicrophoneEnabled).toHaveBeenCalledWith(false); + + await act(async () => { + await result.current.toggleMute(); + }); + + expect(result.current.isMuted).toBe(false); + expect(mockSetMicrophoneEnabled).toHaveBeenCalledWith(true); + }); + + it('should set mute state directly', async () => { + const mockSetMicrophoneEnabled = jest.fn().mockResolvedValue(undefined); + const mockRoom = { + localParticipant: { + setMicrophoneEnabled: mockSetMicrophoneEnabled, + }, + }; + + mockUseLiveKitStore.mockReturnValue({ + isConnected: true, + isConnecting: false, + currentRoom: mockRoom, + currentRoomInfo: { Id: 'room-1', Name: 'Channel 1' }, + isVoiceEnabled: true, + voipServerWebsocketSslAddress: 'wss://test.example.com', + availableRooms: [], + fetchVoiceSettings: jest.fn().mockResolvedValue(undefined), + connectToRoom: jest.fn(), + disconnectFromRoom: jest.fn(), + }); + + const { result } = renderHook(() => usePTT()); + + await act(async () => { + await result.current.setMuted(true); + }); + + expect(result.current.isMuted).toBe(true); + expect(mockSetMicrophoneEnabled).toHaveBeenCalledWith(false); + }); + }); + + describe('Callbacks', () => { + it('should call onConnectionChange when connection state changes', async () => { + const onConnectionChange = jest.fn(); + + // Start disconnected + mockUseLiveKitStore.mockReturnValue({ + isConnected: false, + isConnecting: false, + currentRoom: null, + currentRoomInfo: null, + isVoiceEnabled: true, + voipServerWebsocketSslAddress: 'wss://test.example.com', + availableRooms: [], + fetchVoiceSettings: jest.fn().mockResolvedValue(undefined), + connectToRoom: jest.fn(), + disconnectFromRoom: jest.fn(), + }); + + const { rerender } = renderHook(() => usePTT({ onConnectionChange })); + + expect(onConnectionChange).toHaveBeenCalledWith(false); + + // Simulate connection + mockUseLiveKitStore.mockReturnValue({ + isConnected: true, + isConnecting: false, + currentRoom: { localParticipant: {} }, + currentRoomInfo: { Id: 'room-1', Name: 'Channel 1' }, + isVoiceEnabled: true, + voipServerWebsocketSslAddress: 'wss://test.example.com', + availableRooms: [], + fetchVoiceSettings: jest.fn().mockResolvedValue(undefined), + connectToRoom: jest.fn(), + disconnectFromRoom: jest.fn(), + }); + + rerender({}); + + expect(onConnectionChange).toHaveBeenCalledWith(true); + }); + + it('should call onTransmittingChange when transmitting state changes', async () => { + const mockSetMicrophoneEnabled = jest.fn().mockResolvedValue(undefined); + const mockRoom = { + localParticipant: { + setMicrophoneEnabled: mockSetMicrophoneEnabled, + }, + }; + + mockUseLiveKitStore.mockReturnValue({ + isConnected: true, + isConnecting: false, + currentRoom: mockRoom, + currentRoomInfo: { Id: 'room-1', Name: 'Channel 1' }, + isVoiceEnabled: true, + voipServerWebsocketSslAddress: 'wss://test.example.com', + availableRooms: [], + fetchVoiceSettings: jest.fn().mockResolvedValue(undefined), + connectToRoom: jest.fn(), + disconnectFromRoom: jest.fn(), + }); + + const onTransmittingChange = jest.fn(); + const { result } = renderHook(() => usePTT({ onTransmittingChange })); + + await act(async () => { + await result.current.startTransmitting(); + }); + + expect(onTransmittingChange).toHaveBeenCalledWith(true); + }); + }); + + describe('App State Handling', () => { + it('should set up app state listener on mount', () => { + const mockRemove = jest.fn(); + const addEventListenerSpy = jest.spyOn(require('react-native').AppState, 'addEventListener').mockReturnValue({ remove: mockRemove }); + + renderHook(() => usePTT()); + + expect(addEventListenerSpy).toHaveBeenCalledWith('change', expect.any(Function)); + + addEventListenerSpy.mockRestore(); + }); + + it('should clean up app state listener on unmount', () => { + const mockRemove = jest.fn(); + const addEventListenerSpy = jest.spyOn(require('react-native').AppState, 'addEventListener').mockReturnValue({ remove: mockRemove }); + + const { unmount } = renderHook(() => usePTT()); + + unmount(); + + expect(mockRemove).toHaveBeenCalled(); + + addEventListenerSpy.mockRestore(); + }); + }); +}); diff --git a/src/hooks/use-ptt.ts b/src/hooks/use-ptt.ts new file mode 100644 index 0000000..63dedc5 --- /dev/null +++ b/src/hooks/use-ptt.ts @@ -0,0 +1,517 @@ +import { Audio } from 'expo-av'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { AppState, type AppStateStatus, Platform } from 'react-native'; + +import { logger } from '@/lib/logging'; +import { type DepartmentVoiceChannelResultData } from '@/models/v4/voice/departmentVoiceResultData'; +import { useLiveKitStore } from '@/stores/app/livekit-store'; + +// Platform-specific imports for iOS CallKeep +let callKeepService: any = null; +if (Platform.OS === 'ios') { + try { + callKeepService = require('@/services/callkeep.service.ios').callKeepService; + } catch { + logger.warn({ message: 'CallKeep service not available on iOS' }); + } +} + +// Platform-specific imports for Android notifications +let notifee: any = null; +if (Platform.OS === 'android') { + try { + notifee = require('@notifee/react-native').default; + } catch { + logger.warn({ message: 'Notifee not available on Android' }); + } +} + +export interface UsePTTOptions { + onConnectionChange?: (connected: boolean) => void; + onError?: (error: string) => void; + onTransmittingChange?: (transmitting: boolean) => void; +} + +export interface UsePTTReturn { + // State + isConnected: boolean; + isConnecting: boolean; + isTransmitting: boolean; + isMuted: boolean; + currentChannel: DepartmentVoiceChannelResultData | null; + availableChannels: DepartmentVoiceChannelResultData[]; + isVoiceEnabled: boolean; + error: string | null; + + // Actions + connect: (channel?: DepartmentVoiceChannelResultData) => Promise; + disconnect: () => Promise; + startTransmitting: () => Promise; + stopTransmitting: () => Promise; + toggleMute: () => Promise; + setMuted: (muted: boolean) => Promise; + selectChannel: (channel: DepartmentVoiceChannelResultData) => void; + refreshVoiceSettings: () => Promise; +} + +/** + * Custom hook for Push-To-Talk functionality using LiveKit + * Handles voice room connections, microphone control, and platform-specific audio routing + */ +export function usePTT(options: UsePTTOptions = {}): UsePTTReturn { + const { onConnectionChange, onError, onTransmittingChange } = options; + + // LiveKit store state + const { + isConnected: storeConnected, + isConnecting: storeConnecting, + currentRoom, + currentRoomInfo, + isVoiceEnabled, + voipServerWebsocketSslAddress, + availableRooms, + fetchVoiceSettings, + connectToRoom, + disconnectFromRoom, + } = useLiveKitStore(); + + // Local state + const [isTransmitting, setIsTransmitting] = useState(false); + const [isMuted, setIsMuted] = useState(false); + const [error, setError] = useState(null); + const [selectedChannel, setSelectedChannel] = useState(null); + + // Refs for tracking app state and channel for reconnection + const appState = useRef(AppState.currentState); + const wasConnectedBeforeBackground = useRef(false); + const selectedChannelRef = useRef(null); + + // Keep ref in sync with state + useEffect(() => { + selectedChannelRef.current = selectedChannel; + }, [selectedChannel]); + + // Handle connection state changes + useEffect(() => { + onConnectionChange?.(storeConnected); + }, [storeConnected, onConnectionChange]); + + // Handle transmitting state changes + useEffect(() => { + onTransmittingChange?.(isTransmitting); + }, [isTransmitting, onTransmittingChange]); + + // Handle app state changes for iOS background audio + useEffect(() => { + const handleAppStateChange = async (nextAppState: AppStateStatus) => { + if (appState.current.match(/inactive|background/) && nextAppState === 'active') { + // App has come to foreground + logger.debug({ message: 'PTT: App came to foreground' }); + + // Note: Reconnection would need to be handled manually by the user + // as automatic reconnection may cause issues with CallKeep/foreground service + if (wasConnectedBeforeBackground.current && !storeConnected) { + logger.info({ message: 'PTT: Was connected before background, may need reconnection' }); + } + } else if (nextAppState.match(/inactive|background/)) { + // App is going to background + logger.debug({ message: 'PTT: App going to background' }); + wasConnectedBeforeBackground.current = storeConnected; + + // On iOS, keep connection alive via CallKeep + // On Android, keep connection alive via foreground service (handled in livekit-store) + // On Web, connections persist naturally + } + + appState.current = nextAppState; + }; + + const subscription = AppState.addEventListener('change', handleAppStateChange); + return () => subscription.remove(); + }, [storeConnected]); + + // Initialize voice settings on mount + useEffect(() => { + refreshVoiceSettings(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + /** + * Configure audio mode for PTT usage + */ + const configureAudioMode = useCallback(async () => { + if (Platform.OS === 'web') { + // Web doesn't need audio mode configuration + return; + } + + try { + await Audio.setAudioModeAsync({ + allowsRecordingIOS: true, + staysActiveInBackground: true, + playsInSilentModeIOS: true, + shouldDuckAndroid: true, + playThroughEarpieceAndroid: false, + }); + + logger.debug({ message: 'PTT: Audio mode configured successfully' }); + } catch (err) { + logger.error({ + message: 'PTT: Failed to configure audio mode', + context: { error: err }, + }); + } + }, []); + + /** + * Start iOS CallKeep session for background audio + */ + const startCallKeepSession = useCallback(async (channelName: string) => { + if (Platform.OS !== 'ios' || !callKeepService) return; + + try { + const callUUID = await callKeepService.startCall(channelName); + logger.info({ + message: 'PTT: CallKeep session started', + context: { callUUID, channelName }, + }); + + // Set up mute callback - directly set state instead of using setMutedInternal + callKeepService.setMuteStateCallback(async (muted: boolean) => { + setIsMuted(muted); + // Note: Microphone state will be synced when the user interacts + }); + } catch (err) { + logger.warn({ + message: 'PTT: Failed to start CallKeep session', + context: { error: err }, + }); + } + }, []); + + /** + * End iOS CallKeep session + */ + const endCallKeepSession = useCallback(async () => { + if (Platform.OS !== 'ios' || !callKeepService) return; + + try { + await callKeepService.endCall(); + callKeepService.setMuteStateCallback(null); + logger.info({ message: 'PTT: CallKeep session ended' }); + } catch (err) { + logger.warn({ + message: 'PTT: Failed to end CallKeep session', + context: { error: err }, + }); + } + }, []); + + /** + * Start Android foreground service for background audio + */ + const startAndroidForegroundService = useCallback(async (channelName: string) => { + if (Platform.OS !== 'android' || !notifee) return; + + try { + notifee.registerForegroundService(async () => { + return new Promise(() => { + logger.debug({ message: 'PTT: Android foreground service registered' }); + }); + }); + + await notifee.displayNotification({ + title: 'PTT Active', + body: `Connected to ${channelName}`, + android: { + channelId: 'ptt-channel', + asForegroundService: true, + smallIcon: 'ic_launcher', + ongoing: true, + }, + }); + + logger.info({ message: 'PTT: Android foreground service started' }); + } catch (err) { + logger.warn({ + message: 'PTT: Failed to start Android foreground service', + context: { error: err }, + }); + } + }, []); + + /** + * Stop Android foreground service + */ + const stopAndroidForegroundService = useCallback(async () => { + if (Platform.OS !== 'android' || !notifee) return; + + try { + await notifee.stopForegroundService(); + logger.info({ message: 'PTT: Android foreground service stopped' }); + } catch (err) { + logger.warn({ + message: 'PTT: Failed to stop Android foreground service', + context: { error: err }, + }); + } + }, []); + + /** + * Internal mute state setter that also controls LiveKit microphone + */ + const setMutedInternal = useCallback( + async (muted: boolean) => { + setIsMuted(muted); + + if (currentRoom?.localParticipant) { + try { + await currentRoom.localParticipant.setMicrophoneEnabled(!muted); + logger.debug({ + message: 'PTT: Microphone state changed', + context: { muted }, + }); + } catch (err) { + logger.error({ + message: 'PTT: Failed to set microphone state', + context: { error: err }, + }); + } + } + }, + [currentRoom] + ); + + /** + * Refresh voice settings from the server + */ + const refreshVoiceSettings = useCallback(async () => { + try { + await fetchVoiceSettings(); + logger.info({ message: 'PTT: Voice settings refreshed' }); + } catch (err) { + const errorMsg = err instanceof Error ? err.message : 'Failed to fetch voice settings'; + setError(errorMsg); + onError?.(errorMsg); + logger.error({ + message: 'PTT: Failed to refresh voice settings', + context: { error: err }, + }); + } + }, [fetchVoiceSettings, onError]); + + /** + * Connect to a voice channel + */ + const connect = useCallback( + async (channel?: DepartmentVoiceChannelResultData) => { + const targetChannel = channel || selectedChannel; + + if (!targetChannel) { + const errorMsg = 'No channel selected'; + setError(errorMsg); + onError?.(errorMsg); + return; + } + + if (!isVoiceEnabled) { + const errorMsg = 'Voice is not enabled for this department'; + setError(errorMsg); + onError?.(errorMsg); + return; + } + + if (storeConnecting || storeConnected) { + logger.warn({ message: 'PTT: Already connecting or connected' }); + return; + } + + setError(null); + + try { + // Configure audio mode for the platform + await configureAudioMode(); + + // Get connection token + logger.info({ + message: 'PTT: Connecting to channel', + context: { channelId: targetChannel.Id, channelName: targetChannel.Name }, + }); + + // Connect using the channel's token + await connectToRoom(targetChannel, targetChannel.Token); + + // Start platform-specific background audio support + if (Platform.OS === 'ios') { + await startCallKeepSession(targetChannel.Name); + } else if (Platform.OS === 'android') { + await startAndroidForegroundService(targetChannel.Name); + } + + setSelectedChannel(targetChannel); + + logger.info({ + message: 'PTT: Connected to channel', + context: { channelName: targetChannel.Name }, + }); + } catch (err) { + const errorMsg = err instanceof Error ? err.message : 'Failed to connect'; + setError(errorMsg); + onError?.(errorMsg); + logger.error({ + message: 'PTT: Connection failed', + context: { error: err, channelId: targetChannel.Id }, + }); + } + }, + [selectedChannel, isVoiceEnabled, storeConnecting, storeConnected, configureAudioMode, connectToRoom, startCallKeepSession, startAndroidForegroundService, onError] + ); + + /** + * Disconnect from the current voice channel + */ + const disconnect = useCallback(async () => { + try { + logger.info({ message: 'PTT: Disconnecting from channel' }); + + // Stop transmitting if we are - inline implementation to avoid circular dependency + if (currentRoom?.localParticipant) { + try { + await currentRoom.localParticipant.setMicrophoneEnabled(false); + } catch (err) { + logger.warn({ + message: 'PTT: Failed to disable microphone during disconnect', + context: { error: err }, + }); + } + } + + // Disconnect from LiveKit room + await disconnectFromRoom(); + + // End platform-specific background audio support + if (Platform.OS === 'ios') { + await endCallKeepSession(); + } else if (Platform.OS === 'android') { + await stopAndroidForegroundService(); + } + + setIsTransmitting(false); + setIsMuted(false); + setError(null); + + logger.info({ message: 'PTT: Disconnected from channel' }); + } catch (err) { + const errorMsg = err instanceof Error ? err.message : 'Failed to disconnect'; + setError(errorMsg); + onError?.(errorMsg); + logger.error({ + message: 'PTT: Disconnect failed', + context: { error: err }, + }); + } + }, [currentRoom, disconnectFromRoom, endCallKeepSession, stopAndroidForegroundService, onError]); + + /** + * Start transmitting (unmute and enable PTT) + */ + const startTransmitting = useCallback(async () => { + if (!storeConnected || !currentRoom?.localParticipant) { + logger.warn({ message: 'PTT: Cannot transmit - not connected' }); + return; + } + + try { + // Enable microphone + await currentRoom.localParticipant.setMicrophoneEnabled(true); + setIsTransmitting(true); + setIsMuted(false); + + logger.debug({ message: 'PTT: Started transmitting' }); + } catch (err) { + const errorMsg = err instanceof Error ? err.message : 'Failed to start transmitting'; + setError(errorMsg); + onError?.(errorMsg); + logger.error({ + message: 'PTT: Failed to start transmitting', + context: { error: err }, + }); + } + }, [storeConnected, currentRoom, onError]); + + /** + * Stop transmitting (mute and disable PTT) + */ + const stopTransmitting = useCallback(async () => { + if (!currentRoom?.localParticipant) { + setIsTransmitting(false); + return; + } + + try { + // Disable microphone + await currentRoom.localParticipant.setMicrophoneEnabled(false); + setIsTransmitting(false); + + logger.debug({ message: 'PTT: Stopped transmitting' }); + } catch (err) { + const errorMsg = err instanceof Error ? err.message : 'Failed to stop transmitting'; + setError(errorMsg); + onError?.(errorMsg); + logger.error({ + message: 'PTT: Failed to stop transmitting', + context: { error: err }, + }); + } + }, [currentRoom, onError]); + + /** + * Toggle mute state + */ + const toggleMute = useCallback(async () => { + await setMutedInternal(!isMuted); + }, [isMuted, setMutedInternal]); + + /** + * Set mute state + */ + const setMuted = useCallback( + async (muted: boolean) => { + await setMutedInternal(muted); + }, + [setMutedInternal] + ); + + /** + * Select a channel (does not connect automatically) + */ + const selectChannel = useCallback((channel: DepartmentVoiceChannelResultData) => { + setSelectedChannel(channel); + logger.debug({ + message: 'PTT: Channel selected', + context: { channelId: channel.Id, channelName: channel.Name }, + }); + }, []); + + return { + // State + isConnected: storeConnected, + isConnecting: storeConnecting, + isTransmitting, + isMuted, + currentChannel: currentRoomInfo || selectedChannel, + availableChannels: availableRooms, + isVoiceEnabled, + error, + + // Actions + connect, + disconnect, + startTransmitting, + stopTransmitting, + toggleMute, + setMuted, + selectChannel, + refreshVoiceSettings, + }; +} diff --git a/src/stores/app/livekit-store.ts b/src/stores/app/livekit-store.ts index 0ba5222..8900bcb 100644 --- a/src/stores/app/livekit-store.ts +++ b/src/stores/app/livekit-store.ts @@ -86,7 +86,7 @@ interface LiveKitState { // Room operations connectToRoom: (roomInfo: DepartmentVoiceChannelResultData, token: string) => Promise; - disconnectFromRoom: () => void; + disconnectFromRoom: () => Promise; fetchVoiceSettings: () => Promise; fetchCanConnectToVoice: () => Promise; requestPermissions: () => Promise; @@ -206,35 +206,38 @@ export const useLiveKitStore = create((set, get) => ({ await audioService.playConnectToAudioRoomSound(); - try { - const startForegroundService = async () => { - notifee.registerForegroundService(async () => { - // Minimal function with no interval or tasks to reduce strain on the main thread - return new Promise(() => { - logger.debug({ - message: 'Foreground service registered', + // Start foreground service only on Android + if (Platform.OS === 'android') { + try { + const startForegroundService = async () => { + notifee.registerForegroundService(async () => { + // Minimal function with no interval or tasks to reduce strain on the main thread + return new Promise(() => { + logger.debug({ + message: 'Foreground service registered', + }); }); }); - }); - // Step 3: Display the notification as a foreground service - await notifee.displayNotification({ - title: 'Active PTT Call', - body: 'There is an active PTT call in progress.', - android: { - channelId: 'notif', - asForegroundService: true, - smallIcon: 'ic_launcher', // Ensure this icon exists in res/drawable - }, - }); - }; + // Step 3: Display the notification as a foreground service + await notifee.displayNotification({ + title: 'Active PTT Call', + body: 'There is an active PTT call in progress.', + android: { + channelId: 'notif', + asForegroundService: true, + smallIcon: 'ic_launcher', // Ensure this icon exists in res/drawable + }, + }); + }; - await startForegroundService(); - } catch (error) { - logger.error({ - message: 'Failed to register foreground service', - context: { error }, - }); + await startForegroundService(); + } catch (error) { + logger.error({ + message: 'Failed to register foreground service', + context: { error }, + }); + } } set({ currentRoom: room, @@ -257,13 +260,16 @@ export const useLiveKitStore = create((set, get) => ({ await currentRoom.disconnect(); await audioService.playDisconnectedFromAudioRoomSound(); - try { - await notifee.stopForegroundService(); - } catch (error) { - logger.error({ - message: 'Failed to stop foreground service', - context: { error }, - }); + // Stop foreground service only on Android + if (Platform.OS === 'android') { + try { + await notifee.stopForegroundService(); + } catch (error) { + logger.error({ + message: 'Failed to stop foreground service', + context: { error }, + }); + } } set({ currentRoom: null, diff --git a/src/translations/en.json b/src/translations/en.json index bed3d71..38c4b9a 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -689,6 +689,12 @@ "ptt_end": "PTT End", "transmitting_on": "Transmitting on {{channel}}", "transmission_ended": "Transmission ended", + "voice_disabled": "Voice disabled", + "select_channel": "Select Channel", + "select_channel_description": "Choose a voice channel to connect to", + "change_channel_warning": "Selecting a new channel will disconnect from the current one", + "default_channel": "Default", + "no_channels_available": "No voice channels available", "system_update": "System Update", "data_refreshed": "Data refreshed from server", "call_selected": "Call Selected", From e9cac80a438bb4465d958c835a362a1e566f90e4 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Wed, 21 Jan 2026 22:32:15 -0800 Subject: [PATCH 04/14] RD-T39 Working on the side menu --- env.js | 2 - package.json | 2 +- src/app/(app)/_layout.tsx | 180 +++++++++++------- src/app/(app)/home.tsx | 6 +- src/app/(app)/home.web.tsx | 6 +- .../add-note-bottom-sheet.tsx | 119 ++---------- src/components/dispatch-console/index.ts | 2 +- .../dispatch-console/ptt-interface.tsx | 80 ++++---- .../sidebar/__tests__/side-menu.test.tsx | 88 ++++++++- src/components/sidebar/side-menu.tsx | 32 +++- src/components/sidebar/side-menu.web.tsx | 13 +- src/components/sidebar/web-sidebar.tsx | 19 ++ src/hooks/use-ptt.ts | 54 ++++-- src/lib/env.js | 11 +- 14 files changed, 345 insertions(+), 269 deletions(-) create mode 100644 src/components/sidebar/web-sidebar.tsx diff --git a/env.js b/env.js index 37c8b41..bc944d3 100644 --- a/env.js +++ b/env.js @@ -88,7 +88,6 @@ const client = z.object({ LOGGING_KEY: z.string(), APP_KEY: z.string(), MAPBOX_PUBKEY: z.string(), - MAPBOX_DLKEY: z.string(), IS_MOBILE_APP: z.boolean(), SENTRY_DSN: z.string(), COUNTLY_APP_KEY: z.string(), @@ -125,7 +124,6 @@ const _clientEnv = { APP_KEY: process.env.DISPATCH_APP_KEY || '', IS_MOBILE_APP: true, // or whatever default you want MAPBOX_PUBKEY: process.env.DISPATCH_MAPBOX_PUBKEY || '', - MAPBOX_DLKEY: process.env.DISPATCH_MAPBOX_DLKEY || '', SENTRY_DSN: process.env.DISPATCH_SENTRY_DSN || '', COUNTLY_APP_KEY: process.env.DISPATCH_COUNTLY_APP_KEY || '', COUNTLY_SERVER_URL: process.env.DISPATCH_COUNTLY_SERVER_URL || '', diff --git a/package.json b/package.json index 400f882..3aa4056 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "prebuild": "cross-env EXPO_NO_DOTENV=1 yarn expo prebuild", "android": "cross-env EXPO_NO_DOTENV=1 expo run:android", "ios": "cross-env EXPO_NO_DOTENV=1 expo run:ios --device", - "web": "cross-env EXPO_NO_DOTENV=1 expo start --web", + "web": "cross-env expo start --web --clear", "xcode": "xed -b ios", "doctor": "npx expo-doctor@latest", "start:staging": "cross-env APP_ENV=staging yarn run start", diff --git a/src/app/(app)/_layout.tsx b/src/app/(app)/_layout.tsx index 79527e9..7c3f05b 100644 --- a/src/app/(app)/_layout.tsx +++ b/src/app/(app)/_layout.tsx @@ -7,7 +7,14 @@ import { Redirect, Slot } from 'expo-router'; import { Menu } from 'lucide-react-native'; import React, { useCallback, useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; -import { ActivityIndicator, Platform, StyleSheet } from 'react-native'; +import { + ActivityIndicator, + Platform, + StyleSheet, + Text as RNText, + TouchableOpacity, + View as RNView, +} from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { NotificationButton } from '@/components/notifications/NotificationButton'; @@ -348,75 +355,47 @@ export default function TabLayout() { }, }); - const content = ( - + const content = Platform.OS === 'web' ? ( + {/* Top Navigation Bar */} - + - - {t('app.title', 'Resgrid Responder')} + + {t('app.title', 'Resgrid Responder')} + + + + + {/* Sidebar - simple show/hide */} + {isOpen ? ( + + + + setIsOpen(false)} style={layoutStyles.closeButton}> + Close + + + + ) : null} + + {/* Main content area */} + + + + + + ) : ( + + {/* Top Navigation Bar */} + + + + {t('app.title', 'Resgrid Responder')} + - - - {/* Drawer menu - always rendered as modal, closed by default */} - {Platform.OS === 'web' ? ( - // Web-specific drawer implementation with fixed positioning - isOpen && ( - - {/* Backdrop */} - setIsOpen(false)} - // @ts-ignore - web specific styles - style={{ - position: 'absolute', - top: 0, - left: 0, - right: 0, - bottom: 0, - backgroundColor: 'rgba(0, 0, 0, 0.5)', - }} - /> - {/* Drawer Content */} - - - - - - - - - - ) - ) : ( - // Native drawer implementation + + {/* Native drawer implementation */} setIsOpen(false)}> setIsOpen(false)} /> @@ -430,15 +409,14 @@ export default function TabLayout() { - )} - {/* Main content area */} - - + {/* Main content area */} + + + - - ); + ); // On web, skip Novu integration as it may cause rendering issues if (Platform.OS === 'web') { @@ -468,6 +446,19 @@ interface CreateDrawerMenuButtonProps { } const CreateDrawerMenuButton = ({ setIsOpen }: CreateDrawerMenuButtonProps) => { + // Use React Native primitives on web to avoid infinite render loops from gluestack-ui/lucide + if (Platform.OS === 'web') { + return ( + setIsOpen(true)} + testID="drawer-menu-button" + style={layoutStyles.menuButton} + > + + + ); + } + return ( {/* Add Note Bottom Sheet */} - setIsAddNoteSheetOpen(false)} - onNoteAdded={handleNoteAdded} - /> + setIsAddNoteSheetOpen(false)} onNoteAdded={handleNoteAdded} /> ); } diff --git a/src/app/(app)/home.web.tsx b/src/app/(app)/home.web.tsx index bd58962..fbbbb95 100644 --- a/src/app/(app)/home.web.tsx +++ b/src/app/(app)/home.web.tsx @@ -717,11 +717,7 @@ export default function DispatchConsoleWeb() { {/* Add Note Bottom Sheet */} - setIsAddNoteSheetOpen(false)} - onNoteAdded={handleNoteAdded} - /> + setIsAddNoteSheetOpen(false)} onNoteAdded={handleNoteAdded} /> ); } diff --git a/src/components/dispatch-console/add-note-bottom-sheet.tsx b/src/components/dispatch-console/add-note-bottom-sheet.tsx index 7d787fa..d97cd04 100644 --- a/src/components/dispatch-console/add-note-bottom-sheet.tsx +++ b/src/components/dispatch-console/add-note-bottom-sheet.tsx @@ -9,36 +9,14 @@ import { logger } from '@/lib/logging'; import { type NoteCategoryResultData } from '@/models/v4/notes/noteCategoryResultData'; import { SaveNoteInput } from '@/models/v4/notes/saveNoteInput'; -import { - Actionsheet, - ActionsheetBackdrop, - ActionsheetContent, - ActionsheetDragIndicator, - ActionsheetDragIndicatorWrapper, -} from '../ui/actionsheet'; +import { Actionsheet, ActionsheetBackdrop, ActionsheetContent, ActionsheetDragIndicator, ActionsheetDragIndicatorWrapper } from '../ui/actionsheet'; import { Button, ButtonSpinner, ButtonText } from '../ui/button'; -import { - FormControl, - FormControlError, - FormControlErrorText, - FormControlLabel, - FormControlLabelText, -} from '../ui/form-control'; +import { FormControl, FormControlError, FormControlErrorText, FormControlLabel, FormControlLabelText } from '../ui/form-control'; import { HStack } from '../ui/hstack'; import { Input, InputField } from '../ui/input'; -import { - Select, - SelectBackdrop, - SelectContent, - SelectDragIndicator, - SelectDragIndicatorWrapper, - SelectInput, - SelectItem, - SelectPortal, - SelectTrigger, -} from '../ui/select'; -import { Textarea, TextareaInput } from '../ui/textarea'; +import { Select, SelectBackdrop, SelectContent, SelectDragIndicator, SelectDragIndicatorWrapper, SelectInput, SelectItem, SelectPortal, SelectTrigger } from '../ui/select'; import { Text } from '../ui/text'; +import { Textarea, TextareaInput } from '../ui/textarea'; import { VStack } from '../ui/vstack'; interface AddNoteForm { @@ -142,35 +120,20 @@ export function AddNoteBottomSheet({ isOpen, onClose, onNoteAdded }: AddNoteBott return ( - + - + {/* Header */} - - {t('dispatch.add_note_title')} - + {t('dispatch.add_note_title')} {/* Title Field */} - - {t('dispatch.note_title_label')} - + {t('dispatch.note_title_label')} ( - - + + )} /> @@ -208,29 +161,15 @@ export function AddNoteBottomSheet({ isOpen, onClose, onNoteAdded }: AddNoteBott {/* Category Field */} - - {t('dispatch.note_category_label')} - + {t('dispatch.note_category_label')} ( - + + @@ -240,11 +179,7 @@ export function AddNoteBottomSheet({ isOpen, onClose, onNoteAdded }: AddNoteBott {categories.map((category) => ( - + ))} @@ -256,11 +191,7 @@ export function AddNoteBottomSheet({ isOpen, onClose, onNoteAdded }: AddNoteBott {/* Body Field */} - - {t('dispatch.note_body_label')} - + {t('dispatch.note_body_label')} ( -