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..baaba52 100644 --- a/src/app/(app)/_layout.tsx +++ b/src/app/(app)/_layout.tsx @@ -7,7 +7,7 @@ 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'; @@ -41,6 +41,7 @@ export default function TabLayout() { const [isFirstTime, _setIsFirstTime] = useIsFirstTime(); const [isOpen, setIsOpen] = React.useState(false); const [isNotificationsOpen, setIsNotificationsOpen] = React.useState(false); + const [webColorScheme, setWebColorScheme] = React.useState<'light' | 'dark'>('light'); // Get store states first (hooks must be at top level) const config = useCoreStore((state) => state.config); @@ -59,6 +60,16 @@ export default function TabLayout() { const { isActive, appState } = useAppLifecycle(); const insets = useSafeAreaInsets(); + // Web dark mode detection (safe - only runs on web) + React.useEffect(() => { + if (Platform.OS !== 'web') return; + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + setWebColorScheme(mediaQuery.matches ? 'dark' : 'light'); + const handler = (e: MediaQueryListEvent) => setWebColorScheme(e.matches ? 'dark' : 'light'); + mediaQuery.addEventListener('change', handler); + return () => mediaQuery.removeEventListener('change', handler); + }, []); + // Refs to track initialization state const hasInitialized = useRef(false); const isInitializing = useRef(false); @@ -348,75 +359,66 @@ export default function TabLayout() { }, }); - const content = ( - - {/* Top Navigation Bar */} - - - - {t('app.title', 'Resgrid Responder')} + // Web theme with dark mode support (matching panel header and button colors) + const webIsDark = webColorScheme === 'dark'; + const webTheme = { + navBar: { backgroundColor: webIsDark ? '#1f2937' : '#f9fafb' }, // gray-800 / gray-50 (panel header colors) + navBarText: { color: webIsDark ? '#f9fafb' : '#030712' }, // gray-50 / gray-950 + sidebar: { + backgroundColor: webIsDark ? '#030712' : '#f3f4f6', // gray-950 / gray-100 + borderRightColor: webIsDark ? '#1f2937' : '#e5e7eb', // gray-800 / gray-200 + }, + sidebarFooter: { + borderTopColor: webIsDark ? '#1f2937' : '#e5e7eb', + backgroundColor: webIsDark ? '#111827' : '#ffffff', // gray-900 / white + }, + closeButton: { backgroundColor: '#2563eb' }, // blue-600 (panel button color) + closeButtonText: { color: '#ffffff' }, + mainContent: { backgroundColor: webIsDark ? '#030712' : '#f3f4f6' }, // gray-950 / gray-100 + }; + + const content = + Platform.OS === 'web' ? ( + + {/* Top Navigation Bar */} + + + + {t('app.title', 'Resgrid Responder')} + + + + + {/* Sidebar - simple show/hide */} + {isOpen ? ( + + + + setIsOpen(false)} style={[layoutStyles.closeButton, webTheme.closeButton]}> + {t('menu.close', 'Close Menu')} + + + + ) : 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 +432,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') { @@ -465,9 +466,20 @@ export default function TabLayout() { interface CreateDrawerMenuButtonProps { setIsOpen: (isOpen: boolean) => void; + colorScheme?: 'light' | 'dark'; } -const CreateDrawerMenuButton = ({ setIsOpen }: CreateDrawerMenuButtonProps) => { +const CreateDrawerMenuButton = ({ setIsOpen, colorScheme }: CreateDrawerMenuButtonProps) => { + // Use React Native primitives on web to avoid infinite render loops from gluestack-ui/lucide + if (Platform.OS === 'web') { + const isDark = colorScheme === 'dark'; + return ( + setIsOpen(true)} testID="drawer-menu-button" style={layoutStyles.menuButton}> + + + ); + } + return ( (null); const [selectedUnitData, setSelectedUnitData] = useState(null); @@ -188,16 +189,27 @@ 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; - const onDutyPersonnel = personnel.filter((p) => p.Staffing && p.Staffing.toLowerCase() !== 'off duty').length; + // Only count units/personnel with explicit 'available' or 'standing by' status (case-insensitive) + // Missing or null statuses are NOT considered available for safety in dispatch scenarios + const availableUnits = units.filter((u) => { + const status = (u.CurrentStatus || '').toLowerCase(); + return status === 'available' || status === 'standing by'; + }).length; + const availablePersonnel = personnel.filter((p) => { + const status = (p.Status || '').toLowerCase(); + return status === 'available' || status === 'standing by'; + }).length; + const onDutyPersonnel = personnel.filter((p) => { + const staffing = (p.Staffing || '').toLowerCase(); + return staffing && staffing !== 'off duty' && staffing !== 'unavailable'; + }).length; return { activeCalls, pendingCalls, scheduledCalls, unitsAvailable: availableUnits, - unitsOnScene: onSceneUnits, + personnelAvailable: availablePersonnel, personnelOnDuty: onDutyPersonnel, }; }, [calls, units, personnel]); @@ -274,6 +286,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_to_console'), + }); + }; + // 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 +459,7 @@ export default function DispatchConsole() { callNotes={selectedCallNotes} onAddCallNote={handleAddCallNote} isAddingNote={isAddingNote} + onNewNote={handleOpenAddNoteSheet} /> @@ -481,6 +509,7 @@ export default function DispatchConsole() { callNotes={selectedCallNotes} onAddCallNote={handleAddCallNote} isAddingNote={isAddingNote} + onNewNote={handleOpenAddNoteSheet} /> (colorScheme === 'dark' ? [styles.container, styles.containerDark] : [styles.container, styles.containerLight]), [colorScheme]); + return ( - + {/* Stats Header */} @@ -584,7 +616,7 @@ export default function DispatchConsole() { pendingCalls={stats.pendingCalls} scheduledCalls={stats.scheduledCalls} unitsAvailable={stats.unitsAvailable} - unitsOnScene={stats.unitsOnScene} + personnelAvailable={stats.personnelAvailable} personnelOnDuty={stats.personnelOnDuty} currentTime={currentTime} weatherLatitude={mapCenterLatitude} @@ -599,6 +631,9 @@ export default function DispatchConsole() { {/* Audio Stream Bottom Sheet */} + + {/* Add Note Bottom Sheet */} + setIsAddNoteSheetOpen(false)} onNoteAdded={handleNoteAdded} /> ); } @@ -606,8 +641,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..9c7f654 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'; @@ -8,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'; @@ -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; @@ -76,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); @@ -279,16 +282,27 @@ 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; - const onDutyPersonnel = personnel.filter((p) => p.Staffing && p.Staffing.toLowerCase() !== 'off duty').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) => { + const staffing = (p.Staffing || '').toLowerCase(); + return staffing && staffing !== 'off duty' && staffing !== 'unavailable'; + }).length; return { activeCalls, pendingCalls, scheduledCalls, unitsAvailable: availableUnits, - unitsOnScene: onSceneUnits, + personnelAvailable: availablePersonnel, personnelOnDuty: onDutyPersonnel, }; }, [calls, units, personnel]); @@ -425,6 +439,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); @@ -517,6 +546,7 @@ export default function DispatchConsoleWeb() { callNotes={selectedCallNotes} onAddCallNote={handleAddCallNote} isAddingNote={isAddingNote} + onNewNote={handleOpenAddNoteSheet} /> @@ -566,6 +596,7 @@ export default function DispatchConsoleWeb() { callNotes={selectedCallNotes} onAddCallNote={handleAddCallNote} isAddingNote={isAddingNote} + onNewNote={handleOpenAddNoteSheet} /> + {/* Stats Header */} @@ -669,7 +703,7 @@ export default function DispatchConsoleWeb() { pendingCalls={stats.pendingCalls} scheduledCalls={stats.scheduledCalls} unitsAvailable={stats.unitsAvailable} - unitsOnScene={stats.unitsOnScene} + personnelAvailable={stats.personnelAvailable} personnelOnDuty={stats.personnelOnDuty} currentTime={currentTime} weatherLatitude={mapCenterLatitude} @@ -684,6 +718,9 @@ export default function DispatchConsoleWeb() { {/* Audio Stream Bottom Sheet */} + + {/* Add Note Bottom Sheet */} + setIsAddNoteSheetOpen(false)} onNoteAdded={handleNoteAdded} /> ); } @@ -691,8 +728,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/call/new/index.web.tsx b/src/app/call/new/index.web.tsx index dd60f24..180d72c 100644 --- a/src/app/call/new/index.web.tsx +++ b/src/app/call/new/index.web.tsx @@ -345,7 +345,7 @@ export default function NewCallWeb() { setIsSubmitting(false); } }, - [selectedLocation, callPriorities, callTypes, toast, t, router] + [selectedLocation, callPriorities, callTypes, toast, t] ); // Keyboard shortcuts diff --git a/src/app/onboarding.tsx b/src/app/onboarding.tsx index 3a849bb..308ab21 100644 --- a/src/app/onboarding.tsx +++ b/src/app/onboarding.tsx @@ -2,6 +2,7 @@ import { type Href, useRouter } from 'expo-router'; import { Bell, ChevronRight, MapPin, Users } from 'lucide-react-native'; import { useColorScheme } from 'nativewind'; import React, { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { Dimensions, Image } from 'react-native'; import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated'; @@ -21,20 +22,20 @@ type OnboardingItemProps = { icon: React.ReactNode; }; -const onboardingData: OnboardingItemProps[] = [ +const getOnboardingData = (t: (key: string) => string): OnboardingItemProps[] => [ { - title: 'Command Your Operations', - description: 'Create, dispatch, and manage emergency calls with a powerful mobile command center at your fingertips', + title: t('onboarding.screen1.title'), + description: t('onboarding.screen1.description'), icon: , }, { - title: 'Real-Time Situational Awareness', - description: 'Track all units, personnel, and resources on an interactive map with live status updates and AVL', + title: t('onboarding.screen2.title'), + description: t('onboarding.screen2.description'), icon: , }, { - title: 'Seamless Coordination', - description: 'Communicate instantly with field units, update call statuses, and coordinate response efforts from anywhere', + title: t('onboarding.screen3.title'), + description: t('onboarding.screen3.description'), icon: , }, ]; @@ -62,6 +63,7 @@ const Pagination: React.FC<{ currentIndex: number; length: number }> = ({ curren }; export default function Onboarding() { + const { t } = useTranslation(); const [_, setIsFirstTime] = useIsFirstTime(); const { status, setIsOnboarding } = useAuthStore(); const router = useRouter(); @@ -69,6 +71,7 @@ export default function Onboarding() { const flatListRef = useRef>(null); const buttonOpacity = useSharedValue(0); const { colorScheme } = useColorScheme(); + const onboardingData = getOnboardingData(t); useEffect(() => { setIsOnboarding(); @@ -137,11 +140,11 @@ export default function Onboarding() { router.replace('/login' as Href); }} > - Skip + {t('onboarding.skip')} @@ -157,7 +160,7 @@ export default function Onboarding() { router.replace('/login' as Href); }} > - Let's Get Started + {t('onboarding.getStarted')} )} 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 219a008..a4a69ab 100644 --- a/src/components/dispatch-console/active-calls-panel.tsx +++ b/src/components/dispatch-console/active-calls-panel.tsx @@ -148,24 +148,10 @@ export const ActiveCallsPanel: React.FC = ({ selectedCall fetchCallPriorities(); }, [fetchCalls, fetchCallPriorities]); - // Log calls state for debugging - useEffect(() => { - console.log('[ActiveCallsPanel] Calls updated:', { - totalCalls: calls.length, - callStates: calls.map((c) => ({ id: c.CallId, name: c.Name, state: c.State })), - }); - }, [calls]); - const activeCalls = useMemo(() => { // Filter for active or open calls using utility function let filtered = calls.filter((c) => isCallActive(c.State)); - console.log('[ActiveCallsPanel] Active calls filtered:', { - total: calls.length, - active: filtered.length, - filteredCalls: filtered.map((c) => ({ id: c.CallId, name: c.Name, state: c.State })), - }); - // Apply search filter if (searchQuery.trim()) { const query = searchQuery.toLowerCase().trim(); @@ -205,7 +191,7 @@ export const ActiveCallsPanel: React.FC = ({ selectedCall }, [fetchCalls, fetchCallPriorities]); return ( - + = ({ selectedCall {!isCollapsed ? ( {/* Search Input */} - + = ({ selectedCall ) : null} - + {error ? ( @@ -288,15 +274,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..c27dd1e 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} ); @@ -497,7 +498,7 @@ export const ActivityLogPanel: React.FC = ({ }; 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 [saveError, setSaveError] = useState(null); + + 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(); + setSaveError(null); + } + }, [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); + setSaveError(null); + 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 }, + }); + + setSaveError(null); + onNoteAdded(); + onClose(); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; + const userFriendlyMessage = t('dispatch.note_save_error', { error: errorMessage }); + setSaveError(userFriendlyMessage); + logger.error({ + message: 'Failed to create note', + context: { error }, + }); + } finally { + setIsLoading(false); + } + }; + + const handleClose = () => { + if (!isLoading) { + onClose(); + } + }; + + return ( + + + + + + + + + + {/* Header */} + {t('dispatch.add_note_title')} + + {/* Error Display */} + {saveError ? ( + + {saveError} + + ) : null} + + {/* 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..c215fe7 100644 --- a/src/components/dispatch-console/index.ts +++ b/src/components/dispatch-console/index.ts @@ -2,12 +2,14 @@ export { ActiveCallFilterBanner } from './active-call-filter-banner'; export { ActiveCallsPanel } from './active-calls-panel'; export { type ActivityLogEntry, ActivityLogPanel } from './activity-log-panel'; +export { AddNoteBottomSheet } from './add-note-bottom-sheet'; export { AnimatedRefreshIcon } from './animated-refresh-icon'; export { MapWidget } from './map-widget'; 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/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 ( - + = ({ 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..b6711ae 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'; @@ -173,7 +174,7 @@ export const PersonnelPanel: React.FC = ({ const onDutyCount = displayedPersonnel.filter((p) => p.Staffing && p.Staffing.toLowerCase() !== 'off duty').length; return ( - + = ({ {!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-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 3d8eda5..478a48a 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, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Platform, Pressable, StyleSheet, View } from 'react-native'; @@ -9,28 +9,184 @@ 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 = () => { + // Use refs to store callback functions to avoid re-creating onTransmittingChange + const onPTTPressRef = useRef(onPTTPress); + const onPTTReleaseRef = useRef(onPTTRelease); + + // Keep refs in sync with props + useEffect(() => { + onPTTPressRef.current = onPTTPress; + }, [onPTTPress]); + + useEffect(() => { + onPTTReleaseRef.current = onPTTRelease; + }, [onPTTRelease]); + + // Memoize the callback to prevent infinite loops - use refs to access latest props + const handleTransmittingChange = useCallback((transmitting: boolean) => { + if (transmitting) { + onPTTPressRef.current?.(); + } else { + onPTTReleaseRef.current?.(); + } + }, []); + + // 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: handleTransmittingChange, + }); + + // 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; + + 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 +194,22 @@ 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 +221,49 @@ 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, 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} + /> ); }; @@ -86,13 +273,15 @@ const styles = StyleSheet.create({ width: 36, height: 36, borderRadius: 18, - backgroundColor: '#e5e7eb', alignItems: 'center', justifyContent: 'center', }, mutedButton: { backgroundColor: 'rgba(239, 68, 68, 0.1)', }, + disconnectButton: { + backgroundColor: '#ef4444', + }, pttButtonCompact: { width: 44, height: 44, @@ -124,6 +313,15 @@ const styles = StyleSheet.create({ }, }), } as any, + pttButtonConnected: { + backgroundColor: '#3b82f6', + }, + pttButtonConnecting: { + backgroundColor: '#f59e0b', + }, + pttButtonDisabled: { + backgroundColor: '#9ca3af', + }, transmittingIndicator: { backgroundColor: '#ef4444', paddingHorizontal: 6, @@ -132,7 +330,7 @@ const styles = StyleSheet.create({ minWidth: 28, alignItems: 'center', }, - readyIndicator: { + connectedIndicator: { backgroundColor: '#22c55e', paddingHorizontal: 6, paddingVertical: 2, @@ -140,4 +338,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/components/dispatch-console/stats-header.tsx b/src/components/dispatch-console/stats-header.tsx index f1b3890..a8983ed 100644 --- a/src/components/dispatch-console/stats-header.tsx +++ b/src/components/dispatch-console/stats-header.tsx @@ -1,4 +1,4 @@ -import { AlertTriangle, CalendarClock, Clock, Phone, Radio, Truck, Users } from 'lucide-react-native'; +import { AlertTriangle, CalendarClock, Clock, Phone, Truck, User, Users } from 'lucide-react-native'; import { useColorScheme } from 'nativewind'; import React from 'react'; import { useTranslation } from 'react-i18next'; @@ -46,15 +46,17 @@ interface StatsHeaderProps { pendingCalls: number; scheduledCalls: number; unitsAvailable: number; - unitsOnScene: number; + personnelAvailable: number; personnelOnDuty: number; currentTime: string; weatherLatitude?: number | null; weatherLongitude?: number | null; } -export const StatsHeader: React.FC = ({ 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'; return ( @@ -71,8 +73,8 @@ export const StatsHeader: React.FC = ({ activeCalls, pendingCa {/* Units Available */} - {/* Units On Scene */} - + {/* Personnel Available */} + {/* Personnel On Duty */} @@ -83,7 +85,7 @@ export const StatsHeader: React.FC = ({ activeCalls, pendingCa {currentTime} - + @@ -102,6 +104,5 @@ const styles = StyleSheet.create({ divider: { width: 1, height: 24, - backgroundColor: '#d1d5db', }, }); diff --git a/src/components/dispatch-console/units-panel.tsx b/src/components/dispatch-console/units-panel.tsx index c089ed1..3bf230b 100644 --- a/src/components/dispatch-console/units-panel.tsx +++ b/src/components/dispatch-console/units-panel.tsx @@ -161,7 +161,7 @@ export const UnitsPanel: React.FC = ({ units, isLoading, onRefr const availableUnits = displayedUnits.filter((u) => !u.CurrentStatusId || u.CurrentStatusId === 'available').length; return ( - + = ({ units, isLoading, onRefr {!isCollapsed ? ( {/* Search Input */} - + = ({ units, isLoading, onRefr ) : null} - + {displayedUnits.length === 0 ? ( @@ -240,15 +240,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/maps/full-screen-location-picker.tsx b/src/components/maps/full-screen-location-picker.tsx index 1415a20..e190218 100644 --- a/src/components/maps/full-screen-location-picker.tsx +++ b/src/components/maps/full-screen-location-picker.tsx @@ -173,12 +173,12 @@ const FullScreenLocationPicker: React.FC = ({ ini )} {/* Close button */} - + {/* Location info and confirm button */} - + {isReverseGeocoding ? ( {t('common.loading_address')} ) : address ? ( diff --git a/src/components/maps/full-screen-location-picker.web.tsx b/src/components/maps/full-screen-location-picker.web.tsx new file mode 100644 index 0000000..fa9ebe7 --- /dev/null +++ b/src/components/maps/full-screen-location-picker.web.tsx @@ -0,0 +1,353 @@ +import { MapPinIcon, XIcon } from 'lucide-react-native'; +import mapboxgl from 'mapbox-gl'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Pressable, StyleSheet, View } from 'react-native'; +import { Text } from 'react-native'; + +import { Button, ButtonText } from '@/components/ui/button'; +import { Env } from '@/lib/env'; + +// Mapbox GL CSS needs to be injected for web +const MAPBOX_GL_CSS_URL = 'https://api.mapbox.com/mapbox-gl-js/v3.15.0/mapbox-gl.css'; + +interface FullScreenLocationPickerProps { + initialLocation?: { + latitude: number; + longitude: number; + }; + onLocationSelected: (location: { latitude: number; longitude: number; address?: string }) => void; + onClose: () => void; +} + +const FullScreenLocationPicker: React.FC = ({ initialLocation, onLocationSelected, onClose }) => { + const { t } = useTranslation(); + const mapContainer = useRef(null); + const map = useRef(null); + const marker = useRef(null); + const [currentLocation, setCurrentLocation] = useState<{ + latitude: number; + longitude: number; + } | null>(initialLocation || null); + const [isLoading, setIsLoading] = useState(false); + const [isReverseGeocoding, setIsReverseGeocoding] = useState(false); + const [address, setAddress] = useState(undefined); + + // Inject Mapbox GL CSS + useEffect(() => { + if (!document.getElementById('mapbox-gl-css')) { + const link = document.createElement('link'); + link.id = 'mapbox-gl-css'; + link.href = MAPBOX_GL_CSS_URL; + link.rel = 'stylesheet'; + document.head.appendChild(link); + } + }, []); + + const reverseGeocode = useCallback(async (latitude: number, longitude: number) => { + setIsReverseGeocoding(true); + try { + const response = await fetch(`https://api.mapbox.com/geocoding/v5/mapbox.places/${longitude},${latitude}.json?access_token=${Env.MAPBOX_PUBKEY}`); + + if (!response.ok) { + const body = await response.text(); + console.error('Reverse geocoding failed:', { status: response.status, body }); + setAddress(undefined); + return; + } + + const data = await response.json(); + + if (data.features && data.features.length > 0) { + setAddress(data.features[0].place_name); + } else { + setAddress(undefined); + } + } catch (error) { + console.error('Error reverse geocoding:', error); + setAddress(undefined); + } finally { + setIsReverseGeocoding(false); + } + }, []); + + const getUserLocation = useCallback(async () => { + setIsLoading(true); + try { + if ('geolocation' in navigator) { + navigator.geolocation.getCurrentPosition( + (position) => { + const newLocation = { + latitude: position.coords.latitude, + longitude: position.coords.longitude, + }; + setCurrentLocation(newLocation); + reverseGeocode(newLocation.latitude, newLocation.longitude); + + // Move map to user location + if (map.current) { + map.current.flyTo({ + center: [newLocation.longitude, newLocation.latitude], + zoom: 15, + }); + } + + // Update marker + if (marker.current) { + marker.current.setLngLat([newLocation.longitude, newLocation.latitude]); + } else if (map.current) { + marker.current = new mapboxgl.Marker({ color: '#FF0000', draggable: true }) + .setLngLat([newLocation.longitude, newLocation.latitude]) + .addTo(map.current); + marker.current.on('dragend', () => { + const lngLat = marker.current?.getLngLat(); + if (lngLat) { + const draggedLocation = { + latitude: lngLat.lat, + longitude: lngLat.lng, + }; + setCurrentLocation(draggedLocation); + reverseGeocode(draggedLocation.latitude, draggedLocation.longitude); + } + }); + } + + setIsLoading(false); + }, + (error) => { + console.error('Error getting location:', error); + setIsLoading(false); + } + ); + } else { + console.error('Geolocation not supported'); + setIsLoading(false); + } + } catch (error) { + console.error('Error getting location:', error); + setIsLoading(false); + } + }, [reverseGeocode]); + + // Initialize map + useEffect(() => { + if (map.current) return; + if (!mapContainer.current) return; + + mapboxgl.accessToken = Env.MAPBOX_PUBKEY; + + const initialCenter: [number, number] = currentLocation ? [currentLocation.longitude, currentLocation.latitude] : [-98.5795, 39.8283]; + + map.current = new mapboxgl.Map({ + container: mapContainer.current, + style: 'mapbox://styles/mapbox/streets-v12', + center: initialCenter, + zoom: currentLocation ? 15 : 3, + }); + + map.current.addControl(new mapboxgl.NavigationControl(), 'top-right'); + + // Add geolocate control + const geolocateControl = new mapboxgl.GeolocateControl({ + positionOptions: { + enableHighAccuracy: true, + }, + trackUserLocation: false, + showUserHeading: true, + }); + map.current.addControl(geolocateControl); + + // Create initial marker if we have a location + if (currentLocation) { + marker.current = new mapboxgl.Marker({ color: '#FF0000', draggable: true }).setLngLat([currentLocation.longitude, currentLocation.latitude]).addTo(map.current); + + // Handle marker drag + marker.current.on('dragend', () => { + const lngLat = marker.current?.getLngLat(); + if (lngLat) { + const newLocation = { + latitude: lngLat.lat, + longitude: lngLat.lng, + }; + setCurrentLocation(newLocation); + reverseGeocode(newLocation.latitude, newLocation.longitude); + } + }); + + reverseGeocode(currentLocation.latitude, currentLocation.longitude); + } + + // Handle map click + map.current.on('click', (e) => { + const newLocation = { + latitude: e.lngLat.lat, + longitude: e.lngLat.lng, + }; + setCurrentLocation(newLocation); + reverseGeocode(newLocation.latitude, newLocation.longitude); + + // Update or create marker + if (marker.current) { + marker.current.setLngLat([e.lngLat.lng, e.lngLat.lat]); + } else if (map.current) { + marker.current = new mapboxgl.Marker({ color: '#FF0000', draggable: true }).setLngLat([e.lngLat.lng, e.lngLat.lat]).addTo(map.current); + + marker.current.on('dragend', () => { + const lngLat = marker.current?.getLngLat(); + if (lngLat) { + const draggedLocation = { + latitude: lngLat.lat, + longitude: lngLat.lng, + }; + setCurrentLocation(draggedLocation); + reverseGeocode(draggedLocation.latitude, draggedLocation.longitude); + } + }); + } + }); + + // If no initial location, get user location + if (!currentLocation) { + getUserLocation(); + } + + return () => { + marker.current?.remove(); + marker.current = null; + map.current?.remove(); + map.current = null; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleConfirmLocation = () => { + if (currentLocation) { + onLocationSelected({ + ...currentLocation, + address, + }); + onClose(); + } + }; + + return ( + + {/* Map - Always rendered to keep Mapbox instance mounted */} +
+ + {/* Loading overlay */} + {isLoading ? ( + + + {t('common.loading')} + + + ) : null} + + {/* Close button */} + + + + + {/* Location info and confirm button */} + + {isReverseGeocoding ? ( + {t('common.loading_address')} + ) : address ? ( + {address} + ) : ( + {t('common.no_address_found')} + )} + + {currentLocation ? ( + + {currentLocation.latitude.toFixed(6)}, {currentLocation.longitude.toFixed(6)} + + ) : null} + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + width: '100%', + height: '100%', + position: 'relative', + }, + loadingOverlay: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + zIndex: 1000, + }, + loadingContainer: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: '#e5e7eb', + }, + loadingText: { + color: '#6b7280', + }, + closeButton: { + position: 'absolute', + top: 16, + left: 16, + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: 'white', + justifyContent: 'center', + alignItems: 'center', + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.2, + shadowRadius: 4, + elevation: 4, + zIndex: 10, + }, + bottomPanel: { + position: 'absolute', + bottom: 0, + left: 0, + right: 0, + backgroundColor: 'white', + padding: 16, + paddingBottom: 32, + borderTopLeftRadius: 16, + borderTopRightRadius: 16, + shadowColor: '#000', + shadowOffset: { width: 0, height: -2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 4, + }, + addressText: { + fontSize: 14, + color: '#374151', + marginBottom: 8, + }, + noAddressText: { + fontSize: 14, + color: '#6b7280', + marginBottom: 8, + }, + coordinatesText: { + fontSize: 12, + color: '#6b7280', + marginBottom: 16, + }, + confirmButton: { + width: '100%', + }, +}); + +export default FullScreenLocationPicker; diff --git a/src/components/maps/location-picker.tsx b/src/components/maps/location-picker.tsx index e497b03..730f783 100644 --- a/src/components/maps/location-picker.tsx +++ b/src/components/maps/location-picker.tsx @@ -84,14 +84,14 @@ const LocationPicker: React.FC = ({ initialLocation, onLoca if (isLoading) { return ( - + {t('common.loading')} ); } return ( - + {currentLocation ? ( diff --git a/src/components/maps/location-picker.web.tsx b/src/components/maps/location-picker.web.tsx new file mode 100644 index 0000000..a5a01cf --- /dev/null +++ b/src/components/maps/location-picker.web.tsx @@ -0,0 +1,218 @@ +import mapboxgl from 'mapbox-gl'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Pressable, StyleSheet, View } from 'react-native'; +import { Text } from 'react-native'; + +import { Button, ButtonText } from '@/components/ui/button'; +import { Env } from '@/lib/env'; + +// Mapbox GL CSS needs to be injected for web +const MAPBOX_GL_CSS_URL = 'https://api.mapbox.com/mapbox-gl-js/v3.15.0/mapbox-gl.css'; + +interface LocationPickerProps { + initialLocation?: { + latitude: number; + longitude: number; + }; + onLocationSelected: (location: { latitude: number; longitude: number; address?: string }) => void; + height?: number; +} + +const LocationPicker: React.FC = ({ initialLocation, onLocationSelected, height = 200 }) => { + const { t } = useTranslation(); + const mapContainer = useRef(null); + const map = useRef(null); + const marker = useRef(null); + const [currentLocation, setCurrentLocation] = useState<{ + latitude: number; + longitude: number; + } | null>(initialLocation || null); + const [isLoading, setIsLoading] = useState(false); + + // Inject Mapbox GL CSS + useEffect(() => { + if (!document.getElementById('mapbox-gl-css')) { + const link = document.createElement('link'); + link.id = 'mapbox-gl-css'; + link.href = MAPBOX_GL_CSS_URL; + link.rel = 'stylesheet'; + document.head.appendChild(link); + } + }, []); + + const getUserLocation = useCallback(async () => { + setIsLoading(true); + try { + if ('geolocation' in navigator) { + navigator.geolocation.getCurrentPosition( + (position) => { + const newLocation = { + latitude: position.coords.latitude, + longitude: position.coords.longitude, + }; + setCurrentLocation(newLocation); + + // Move map to user location + if (map.current) { + map.current.flyTo({ + center: [newLocation.longitude, newLocation.latitude], + zoom: 15, + }); + } + + // Update marker + if (marker.current) { + marker.current.setLngLat([newLocation.longitude, newLocation.latitude]); + } else if (map.current) { + marker.current = new mapboxgl.Marker({ color: '#FF0000' }).setLngLat([newLocation.longitude, newLocation.latitude]).addTo(map.current); + } + + setIsLoading(false); + }, + (error) => { + console.error('Error getting location:', error); + setIsLoading(false); + } + ); + } else { + console.error('Geolocation not supported'); + setIsLoading(false); + } + } catch (error) { + console.error('Error getting location:', error); + setIsLoading(false); + } + }, []); + + // Initialize map + useEffect(() => { + if (map.current) return; + if (!mapContainer.current) return; + + mapboxgl.accessToken = Env.MAPBOX_PUBKEY; + + const initialCenter: [number, number] = currentLocation ? [currentLocation.longitude, currentLocation.latitude] : [-98.5795, 39.8283]; + + map.current = new mapboxgl.Map({ + container: mapContainer.current, + style: 'mapbox://styles/mapbox/streets-v12', + center: initialCenter, + zoom: currentLocation ? 15 : 3, + }); + + map.current.addControl(new mapboxgl.NavigationControl(), 'top-right'); + + // Create initial marker if we have a location + if (currentLocation) { + marker.current = new mapboxgl.Marker({ color: '#FF0000' }).setLngLat([currentLocation.longitude, currentLocation.latitude]).addTo(map.current); + } + + // Handle map click + map.current.on('click', (e) => { + const newLocation = { + latitude: e.lngLat.lat, + longitude: e.lngLat.lng, + }; + setCurrentLocation(newLocation); + + // Update or create marker + if (marker.current) { + marker.current.setLngLat([e.lngLat.lng, e.lngLat.lat]); + } else if (map.current) { + marker.current = new mapboxgl.Marker({ color: '#FF0000' }).setLngLat([e.lngLat.lng, e.lngLat.lat]).addTo(map.current); + } + }); + + // If no initial location, get user location + if (!currentLocation) { + getUserLocation(); + } + + return () => { + marker.current?.remove(); + marker.current = null; + map.current?.remove(); + map.current = null; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleConfirmLocation = () => { + if (currentLocation) { + onLocationSelected(currentLocation); + } + }; + + if (isLoading) { + return ( + + + {t('common.loading')} + + + ); + } + + return ( + +
+ {!currentLocation ? ( + + {t('common.no_location')} + + {t('common.get_my_location')} + + + ) : null} + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + width: '100%', + position: 'relative', + overflow: 'hidden', + borderRadius: 8, + }, + loadingContainer: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: '#e5e7eb', + }, + loadingText: { + color: '#6b7280', + }, + noLocationContainer: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'rgba(229, 231, 235, 0.9)', + }, + noLocationText: { + color: '#6b7280', + }, + getLocationText: { + color: '#3b82f6', + marginTop: 8, + }, + buttonContainer: { + position: 'absolute', + left: 16, + right: 16, + bottom: 16, + }, +}); + +export default LocationPicker; diff --git a/src/components/maps/pin-marker.tsx b/src/components/maps/pin-marker.tsx index 06fed46..67bba6c 100644 --- a/src/components/maps/pin-marker.tsx +++ b/src/components/maps/pin-marker.tsx @@ -24,8 +24,8 @@ const PinMarker: React.FC = ({ imagePath, title, size = 32, onPr return ( - - + + {title} diff --git a/src/components/maps/static-map.tsx b/src/components/maps/static-map.tsx index a2abe9d..19d01fe 100644 --- a/src/components/maps/static-map.tsx +++ b/src/components/maps/static-map.tsx @@ -25,14 +25,14 @@ const StaticMap: React.FC = ({ latitude, longitude, address, zoo if (!latitude || !longitude) { return ( - + {t('call_detail.no_location')} ); } return ( - + {/* Marker for the location */} diff --git a/src/components/maps/static-map.web.tsx b/src/components/maps/static-map.web.tsx index b6bcf8c..d1863f8 100644 --- a/src/components/maps/static-map.web.tsx +++ b/src/components/maps/static-map.web.tsx @@ -27,7 +27,7 @@ const StaticMap: React.FC = ({ latitude, longitude, address, zoo if (!document.getElementById('mapbox-gl-css')) { const link = document.createElement('link'); link.id = 'mapbox-gl-css'; - link.href = 'https://api.mapbox.com/mapbox-gl-js/v3.1.2/mapbox-gl.css'; + link.href = 'https://api.mapbox.com/mapbox-gl-js/v3.15.0/mapbox-gl.css'; link.rel = 'stylesheet'; document.head.appendChild(link); } diff --git a/src/components/maps/unified-map-view.tsx b/src/components/maps/unified-map-view.tsx index b48214d..e771852 100644 --- a/src/components/maps/unified-map-view.tsx +++ b/src/components/maps/unified-map-view.tsx @@ -218,7 +218,7 @@ export const UnifiedMapView: React.FC = ({ const initialCenter: [number, number] = location.longitude && location.latitude ? [location.longitude, location.latitude] : [-98.5795, 39.8283]; return ( - + = ({ }, [visibleLayers, isMapReady]); return ( - +
); diff --git a/src/components/sidebar/__tests__/side-menu.test.tsx b/src/components/sidebar/__tests__/side-menu.test.tsx index fede04a..4986891 100644 --- a/src/components/sidebar/__tests__/side-menu.test.tsx +++ b/src/components/sidebar/__tests__/side-menu.test.tsx @@ -1,20 +1,164 @@ -import { render, screen } from '@testing-library/react-native'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react-native'; import React from 'react'; import SideMenu from '../side-menu'; +// Mock react-i18next +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => { + const translations: Record = { + 'menu.menu': 'Menu', + 'menu.home': 'Home', + 'menu.calls': 'Calls', + 'menu.calls_list': 'Calls List', + 'menu.new_call': 'New Call', + 'menu.map': 'Map', + 'menu.personnel': 'Personnel', + 'menu.units': 'Units', + 'menu.messages': 'Messages', + 'menu.protocols': 'Protocols', + 'menu.contacts': 'Contacts', + 'menu.settings': 'Settings', + }; + return translations[key] || key; + }, + }), +})); + +// Mock expo-router +const mockPush = jest.fn(); +jest.mock('expo-router', () => ({ + useRouter: () => ({ + push: mockPush, + }), +})); + describe('SideMenu', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + it('should render without crashing', () => { render(); - // The current SideMenu component is a stub that just renders "Side Menu" - expect(screen.getByText('Side Menu')).toBeTruthy(); + expect(screen.getByTestId('side-menu-scroll-view')).toBeTruthy(); + }); + + it('should render header', () => { + render(); + + expect(screen.getByText('Menu')).toBeTruthy(); + }); + + it('should render all menu items', () => { + render(); + + expect(screen.getByText('Home')).toBeTruthy(); + expect(screen.getByText('Calls')).toBeTruthy(); + expect(screen.getByText('Map')).toBeTruthy(); + expect(screen.getByText('Personnel')).toBeTruthy(); + expect(screen.getByText('Units')).toBeTruthy(); + expect(screen.getByText('Protocols')).toBeTruthy(); + expect(screen.getByText('Contacts')).toBeTruthy(); + expect(screen.getByText('Settings')).toBeTruthy(); + }); + + it('should call onNavigate when a menu item is pressed', () => { + const mockOnNavigate = jest.fn(); + render(); + + const homeItem = screen.getByText('Home'); + fireEvent.press(homeItem); + + expect(mockOnNavigate).toHaveBeenCalledTimes(1); + }); + + it('should navigate to correct route when menu item is pressed', async () => { + const mockOnNavigate = jest.fn(); + render(); + + // Test direct navigation with Settings (not a parent item) + const settingsItem = screen.getByText('Settings'); + fireEvent.press(settingsItem); + + await waitFor(() => { + expect(mockPush).toHaveBeenCalledWith('/settings'); + }); + }); + + it('should expand parent menu items to show children', async () => { + render(); + + // Calls is a parent item - pressing it should expand to show children + const callsItem = screen.getByText('Calls'); + fireEvent.press(callsItem); + + // After expanding, child items should be visible + await waitFor(() => { + expect(screen.getByText('Calls List')).toBeTruthy(); + expect(screen.getByText('New Call')).toBeTruthy(); + }); }); - it('should accept onNavigate prop', () => { + it('should navigate when child menu item is pressed', async () => { const mockOnNavigate = jest.fn(); render(); - expect(screen.getByText('Side Menu')).toBeTruthy(); + // First expand the Calls parent + const callsItem = screen.getByText('Calls'); + fireEvent.press(callsItem); + + // Wait for children to be visible and press Calls List + await waitFor(() => { + expect(screen.getByText('Calls List')).toBeTruthy(); + }); + + const callsListItem = screen.getByText('Calls List'); + fireEvent.press(callsListItem); + + await waitFor(() => { + expect(mockPush).toHaveBeenCalledWith('/calls'); + expect(mockOnNavigate).toHaveBeenCalled(); + }); + }); + + it('should work without onNavigate prop', async () => { + render(); + + const settingsItem = screen.getByText('Settings'); + fireEvent.press(settingsItem); + + await waitFor(() => { + expect(mockPush).toHaveBeenCalledWith('/settings'); + }); + }); + + it('should display menu items with lucide icons', () => { + render(); + + // Verify all menu item labels are rendered (icons are now lucide components) + expect(screen.getByText('Home')).toBeTruthy(); + expect(screen.getByText('Calls')).toBeTruthy(); + expect(screen.getByText('Map')).toBeTruthy(); + expect(screen.getByText('Personnel')).toBeTruthy(); + expect(screen.getByText('Units')).toBeTruthy(); + expect(screen.getByText('Protocols')).toBeTruthy(); + expect(screen.getByText('Contacts')).toBeTruthy(); + expect(screen.getByText('Settings')).toBeTruthy(); + }); + + it('should apply light theme styles by default', () => { + render(); + + const container = screen.getByTestId('side-menu-scroll-view'); + expect(container).toBeTruthy(); + }); + + it('should accept colorScheme prop for dark mode', () => { + render(); + + const container = screen.getByTestId('side-menu-scroll-view'); + expect(container).toBeTruthy(); }); -}); +}); \ No newline at end of file diff --git a/src/components/sidebar/side-menu.tsx b/src/components/sidebar/side-menu.tsx index e8d77ca..862a1fa 100644 --- a/src/components/sidebar/side-menu.tsx +++ b/src/components/sidebar/side-menu.tsx @@ -1,17 +1,193 @@ -import React from 'react'; - -import { Box } from '@/components/ui/box'; -import { Text } from '@/components/ui/text'; +import { type Href, useRouter } from 'expo-router'; +import { Contact, FileText, Home, List, type LucideIcon, Map as MapIcon, MessageCircle, Phone, Plus, Settings, Truck, Users } from 'lucide-react-native'; +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; interface SideMenuProps { onNavigate?: () => void; + colorScheme?: 'light' | 'dark'; +} + +interface MenuItem { + id: string; + label: string; + icon: LucideIcon; + route?: string; + children?: MenuItem[]; } -const SideMenu: React.FC = ({ onNavigate }) => { +const getMenuItems = (t: (key: string) => string): MenuItem[] => [ + { id: 'home', label: t('menu.home'), icon: Home, route: '/' }, + { + id: 'calls', + label: t('menu.calls'), + icon: Phone, + children: [ + { id: 'calls-list', label: t('menu.calls_list'), icon: List, route: '/calls' }, + { id: 'new-call', label: t('menu.new_call'), icon: Plus, route: '/call/new' }, + ], + }, + { id: 'map', label: t('menu.map'), icon: MapIcon, route: '/map' }, + { id: 'personnel', label: t('menu.personnel'), icon: Users, route: '/personnel' }, + { id: 'units', label: t('menu.units'), icon: Truck, route: '/units' }, + { id: 'protocols', label: t('menu.protocols'), icon: FileText, route: '/protocols' }, + { id: 'contacts', label: t('menu.contacts'), icon: Contact, route: '/contacts' }, + { id: 'settings', label: t('menu.settings'), icon: Settings, route: '/settings' }, +]; + +// Color palette matching home page panels +const colors = { + light: { + background: '#f3f4f6', // gray-100 + headerBg: '#f9fafb', // gray-50 (matching panel headers) + headerBorder: '#e5e7eb', // gray-200 + headerText: '#111827', // gray-900 + menuItemText: '#374151', // gray-700 + menuItemIcon: '#4b5563', // gray-600 + menuItemPressed: '#e5e7eb', // gray-200 + divider: '#e5e7eb', // gray-200 + chevron: '#6b7280', // gray-500 + childBg: '#ffffff', // white for child items + }, + dark: { + background: '#030712', // gray-950 + headerBg: '#1f2937', // gray-800 (matching panel headers) + headerBorder: '#374151', // gray-700 + headerText: '#f9fafb', // gray-50 + menuItemText: '#d1d5db', // gray-300 + menuItemIcon: '#9ca3af', // gray-400 + menuItemPressed: '#374151', // gray-700 + divider: '#374151', // gray-700 + chevron: '#9ca3af', // gray-400 + childBg: '#111827', // gray-900 for child items + }, +}; + +function SideMenu({ onNavigate, colorScheme: propColorScheme }: SideMenuProps): React.JSX.Element { + const router = useRouter(); + const { t } = useTranslation(); + const [expandedItems, setExpandedItems] = useState>(new Set()); + const menuItems = getMenuItems(t); + + // Use prop if provided, otherwise default to light on web + const isDark = propColorScheme === 'dark'; + const theme = isDark ? colors.dark : colors.light; + + const handleNavigation = (route: string) => { + onNavigate?.(); + router.push(route as Href); + }; + + const toggleExpanded = (itemId: string) => { + setExpandedItems((prev) => { + const newSet = new Set(prev); + if (newSet.has(itemId)) { + newSet.delete(itemId); + } else { + newSet.add(itemId); + } + return newSet; + }); + }; + + const renderMenuItem = (item: MenuItem, isChild = false) => { + const hasChildren = item.children && item.children.length > 0; + const isExpanded = expandedItems.has(item.id); + const IconComponent = item.icon; + + return ( + + { + if (hasChildren) { + toggleExpanded(item.id); + } else if (item.route) { + handleNavigation(item.route); + } + }} + style={({ pressed }) => [styles.menuItem, isChild ? [styles.childMenuItem, { backgroundColor: theme.childBg }] : null, pressed ? { backgroundColor: theme.menuItemPressed } : null]} + > + + + + {item.label} + {hasChildren ? {isExpanded ? '▼' : '▶'} : null} + + {hasChildren && isExpanded ? {item.children?.map((child) => renderMenuItem(child, true))} : null} + + ); + }; + return ( - - Side Menu - + + + {t('menu.menu')} + + + {menuItems.map((item, index) => ( + + {renderMenuItem(item)} + {index < menuItems.length - 1 ? : null} + + ))} + + ); -}; +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + header: { + paddingHorizontal: 20, + paddingVertical: 16, + borderBottomWidth: 1, + }, + headerText: { + fontSize: 20, + fontWeight: '700', + }, + scrollView: { + flex: 1, + }, + menuItem: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 20, + paddingVertical: 14, + }, + childMenuItem: { + paddingLeft: 48, + paddingVertical: 12, + }, + childMenuItemText: { + fontSize: 14, + }, + menuItemIcon: { + width: 24, + marginRight: 16, + alignItems: 'center', + justifyContent: 'center', + }, + menuItemText: { + fontSize: 16, + fontWeight: '500', + flex: 1, + }, + chevron: { + fontSize: 12, + marginLeft: 8, + }, + childrenContainer: { + borderLeftWidth: 2, + marginLeft: 28, + }, + divider: { + height: 1, + marginHorizontal: 20, + }, +}); + export default SideMenu; diff --git a/src/components/sidebar/side-menu.web.tsx b/src/components/sidebar/side-menu.web.tsx deleted file mode 100644 index fe2d6ae..0000000 --- a/src/components/sidebar/side-menu.web.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import React from 'react'; - -import { Box } from '@/components/ui/box'; - -import SideMenu from './side-menu'; - -const WebSidebar = () => { - return {/* common sidebar contents for web and mobile */}; -}; -export default WebSidebar; diff --git a/src/components/sidebar/web-sidebar.tsx b/src/components/sidebar/web-sidebar.tsx new file mode 100644 index 0000000..54837eb --- /dev/null +++ b/src/components/sidebar/web-sidebar.tsx @@ -0,0 +1,19 @@ +import React from 'react'; + +import { Box } from '@/components/ui/box'; + +import SideMenu from './side-menu'; + +interface WebSidebarProps { + onNavigate?: () => void; +} + +const WebSidebar: React.FC = ({ onNavigate }) => { + return ( + + + + ); +}; + +export default WebSidebar; diff --git a/src/features/livekit-call/components/LiveKitCallModal.tsx b/src/features/livekit-call/components/LiveKitCallModal.tsx index 4ab492a..19e6556 100644 --- a/src/features/livekit-call/components/LiveKitCallModal.tsx +++ b/src/features/livekit-call/components/LiveKitCallModal.tsx @@ -45,8 +45,8 @@ const LiveKitCallModal: React.FC = ({ } }; - const handleLeaveRoom = () => { - actions.disconnectFromRoom(); + const handleLeaveRoom = async () => { + await actions.disconnectFromRoom(); onClose(); // Close modal on leaving }; diff --git a/src/hooks/__tests__/use-ptt.test.ts b/src/hooks/__tests__/use-ptt.test.ts new file mode 100644 index 0000000..c0622c8 --- /dev/null +++ b/src/hooks/__tests__/use-ptt.test.ts @@ -0,0 +1,498 @@ +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 () => { + const mockFetchVoiceSettings = jest.fn().mockResolvedValue(undefined); + mockUseLiveKitStore.mockReturnValue({ + isConnected: false, + isConnecting: false, + currentRoom: null, + currentRoomInfo: null, + isVoiceEnabled: false, + voipServerWebsocketSslAddress: 'wss://test.example.com', + availableRooms: [], + fetchVoiceSettings: mockFetchVoiceSettings, + connectToRoom: jest.fn(), + disconnectFromRoom: jest.fn(), + }); + + const onError = jest.fn(); + const { result } = renderHook(() => usePTT({ onError })); + + // Wait for the initial fetchVoiceSettings to complete on mount + await waitFor(() => { + expect(mockFetchVoiceSettings).toHaveBeenCalled(); + }); + + await act(async () => { + await result.current.connect({ + Id: 'room-1', + Name: 'Channel 1', + Token: 'token-1', + IsDefault: true, + ConferenceNumber: 1, + }); + }); + + await waitFor(() => { + 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..e435bcd --- /dev/null +++ b/src/hooks/use-ptt.ts @@ -0,0 +1,474 @@ +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' }); + } +} + +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); + + // Refs for callbacks to prevent effect re-runs when callback references change + const onConnectionChangeRef = useRef(onConnectionChange); + const onErrorRef = useRef(onError); + const onTransmittingChangeRef = useRef(onTransmittingChange); + + // Keep ref in sync with state + useEffect(() => { + selectedChannelRef.current = selectedChannel; + }, [selectedChannel]); + + // Keep callback refs in sync with latest options + useEffect(() => { + onConnectionChangeRef.current = onConnectionChange; + }, [onConnectionChange]); + + useEffect(() => { + onErrorRef.current = onError; + }, [onError]); + + useEffect(() => { + onTransmittingChangeRef.current = onTransmittingChange; + }, [onTransmittingChange]); + + // Handle connection state changes - use ref to avoid infinite loops + useEffect(() => { + onConnectionChangeRef.current?.(storeConnected); + }, [storeConnected]); + + // Handle transmitting state changes - use ref to avoid infinite loops + useEffect(() => { + onTransmittingChangeRef.current?.(isTransmitting); + }, [isTransmitting]); + + // 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 -- refreshVoiceSettings depends on fetchVoiceSettings from Zustand store which is stable + }, []); + + /** + * 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 }, + }); + } + }, []); + + /** + * 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] + ); + + /** + * 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 - sync both UI state and LiveKit microphone + callKeepService.setMuteStateCallback(async (muted: boolean) => { + await setMutedInternal(muted); + }); + } catch (err) { + logger.warn({ + message: 'PTT: Failed to start CallKeep session', + context: { error: err }, + }); + } + }, + [setMutedInternal] + ); + + /** + * 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 }, + }); + } + }, []); + + /** + * Refresh voice settings from the server + */ + const refreshVoiceSettings = useCallback(async () => { + try { + await fetchVoiceSettings(); + setError(null); + logger.info({ message: 'PTT: Voice settings refreshed' }); + } catch (err) { + const errorMsg = err instanceof Error ? err.message : 'Failed to fetch voice settings'; + setError(errorMsg); + onErrorRef.current?.(errorMsg); + logger.error({ + message: 'PTT: Failed to refresh voice settings', + context: { error: err }, + }); + } + }, [fetchVoiceSettings]); + + /** + * Connect to a voice channel + */ + const connect = useCallback( + async (channel?: DepartmentVoiceChannelResultData) => { + const targetChannel = channel || selectedChannel; + + if (!targetChannel) { + const errorMsg = 'No channel selected'; + setError(errorMsg); + onErrorRef.current?.(errorMsg); + return; + } + + if (!isVoiceEnabled) { + const errorMsg = 'Voice is not enabled for this department'; + setError(errorMsg); + onErrorRef.current?.(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); + } + + 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); + onErrorRef.current?.(errorMsg); + logger.error({ + message: 'PTT: Connection failed', + context: { error: err, channelId: targetChannel.Id }, + }); + } + }, + [selectedChannel, isVoiceEnabled, storeConnecting, storeConnected, configureAudioMode, connectToRoom, startCallKeepSession] + ); + + /** + * 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(); + } + + 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); + onErrorRef.current?.(errorMsg); + logger.error({ + message: 'PTT: Disconnect failed', + context: { error: err }, + }); + } + }, [currentRoom, disconnectFromRoom, endCallKeepSession]); + + /** + * 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); + onErrorRef.current?.(errorMsg); + logger.error({ + message: 'PTT: Failed to start transmitting', + context: { error: err }, + }); + } + }, [storeConnected, currentRoom]); + + /** + * 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); + onErrorRef.current?.(errorMsg); + logger.error({ + message: 'PTT: Failed to stop transmitting', + context: { error: err }, + }); + } + }, [currentRoom]); + + /** + * 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/lib/env.js b/src/lib/env.js index 1773b1b..dbbd076 100644 --- a/src/lib/env.js +++ b/src/lib/env.js @@ -34,12 +34,13 @@ const getExpoEnv = () => { /** * Merge environments with web runtime taking precedence * This allows Docker-injected values to override build-time values + * Priority order: window.__ENV__ > Expo Constants */ const mergeEnvironments = () => { const expoEnv = getExpoEnv(); const webRuntimeEnv = getWebRuntimeEnv(); - // If we're on web and have runtime env, merge with runtime taking precedence + // If we're on web and have runtime env (Docker), use it with highest priority if (webRuntimeEnv) { return { ...expoEnv, @@ -49,6 +50,14 @@ const mergeEnvironments = () => { }; } + // For web platform, ensure IS_MOBILE_APP is false + if (Platform.OS === 'web') { + return { + ...expoEnv, + IS_MOBILE_APP: false, + }; + } + return expoEnv; }; 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..e8f3d19 100644 --- a/src/lib/hooks/use-selected-theme.tsx +++ b/src/lib/hooks/use-selected-theme.tsx @@ -1,6 +1,5 @@ import { colorScheme, useColorScheme } from 'nativewind'; import React from 'react'; -import { Platform } from 'react-native'; import { useMMKVString } from 'react-native-mmkv'; import { storage } from '../storage'; @@ -26,24 +25,19 @@ 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 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'); - return; - } - const theme = storage.getString(SELECTED_THEME); if (theme !== undefined) { 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); diff --git a/src/stores/app/livekit-store.ts b/src/stores/app/livekit-store.ts index 0ba5222..95f5e10 100644 --- a/src/stores/app/livekit-store.ts +++ b/src/stores/app/livekit-store.ts @@ -86,10 +86,14 @@ interface LiveKitState { // Room operations connectToRoom: (roomInfo: DepartmentVoiceChannelResultData, token: string) => Promise; - disconnectFromRoom: () => void; + disconnectFromRoom: () => Promise; fetchVoiceSettings: () => Promise; fetchCanConnectToVoice: () => Promise; requestPermissions: () => Promise; + + // Android foreground service + startAndroidForegroundService: () => Promise; + stopAndroidForegroundService: () => Promise; } export const useLiveKitStore = create((set, get) => ({ @@ -206,36 +210,11 @@ 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', - }); - }); - }); - - // 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 }, - }); + // Start foreground service only on Android + if (Platform.OS === 'android') { + await get().startAndroidForegroundService(); } + set({ currentRoom: room, currentRoomInfo: roomInfo, @@ -257,14 +236,11 @@ 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') { + await get().stopAndroidForegroundService(); } + set({ currentRoom: null, currentRoomInfo: null, @@ -326,4 +302,72 @@ export const useLiveKitStore = create((set, get) => ({ }); } }, + + startAndroidForegroundService: async () => { + if (Platform.OS !== 'android') return; + + try { + logger.debug({ + message: 'Starting Android foreground service', + }); + + 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', + }); + }); + }); + + // Create the notification channel before displaying the notification (required for Android 8+) + await notifee.createChannel({ + id: 'ptt-channel', + name: 'PTT Calls', + description: 'Notifications for active Push-to-Talk calls', + importance: AndroidImportance.HIGH, + sound: 'default', + }); + + await notifee.displayNotification({ + title: 'Active PTT Call', + body: 'There is an active PTT call in progress.', + android: { + channelId: 'ptt-channel', + asForegroundService: true, + smallIcon: 'ic_launcher', + }, + }); + + logger.debug({ + message: 'Android foreground service started successfully', + }); + } catch (error) { + logger.error({ + message: 'Failed to start Android foreground service', + context: { error }, + }); + } + }, + + stopAndroidForegroundService: async () => { + if (Platform.OS !== 'android') return; + + try { + logger.debug({ + message: 'Stopping Android foreground service', + }); + + await notifee.stopForegroundService(); + + logger.debug({ + message: 'Android foreground service stopped successfully', + }); + } catch (error) { + logger.error({ + message: 'Failed to stop Android foreground service', + context: { error }, + }); + } + }, })); diff --git a/src/translations/ar.json b/src/translations/ar.json index 59feda9..76a06d0 100644 --- a/src/translations/ar.json +++ b/src/translations/ar.json @@ -162,6 +162,7 @@ "no_timeline": "لا توجد أحداث زمنية متاحة", "not_available": "غير متوفر", "not_found": "لم يتم العثور على المكالمة", + "missing_call_id": "معرّف المكالمة مفقود", "note": "ملاحظة", "notes": "ملاحظات", "priority": "الأولوية", @@ -287,12 +288,21 @@ "what3words_invalid_format": "تنسيق what3words غير صحيح. استخدم التنسيق: كلمة.كلمة.كلمة", "what3words_not_found": "لم يتم العثور على عنوان what3words، جرب عنوان آخر", "what3words_placeholder": "أدخل عنوان what3words (مثال: filled.count.soap)", - "what3words_required": "يرجى إدخال عنوان what3words للبحث" + "what3words_required": "يرجى إدخال عنوان what3words للبحث", + "expand_map": "توسيع الخريطة", + "contact_information": "معلومات الاتصال", + "new_call_web_hint": "أكمل تفاصيل المكالمة أدناه. اضغط Ctrl+Enter للإنشاء.", + "edit_call_web_hint": "حدّث تفاصيل المكالمة أدناه. اضغط Ctrl+S للحفظ.", + "keyboard_shortcuts": "نصيحة: اضغط Ctrl+Enter للإنشاء، Escape للإلغاء", + "edit_keyboard_shortcuts": "نصيحة: اضغط Ctrl+S للحفظ، Escape للإلغاء" }, "common": { "add": "إضافة", "back": "رجوع", "cancel": "إلغاء", + "creating": "جاري الإنشاء...", + "saving": "جاري الحفظ...", + "unsaved_changes": "تغييرات غير محفوظة", "close": "إغلاق", "confirm": "تأكيد", "confirm_location": "تأكيد الموقع", @@ -487,6 +497,20 @@ "why_down_message": "نحن نقوم بصيانة مجدولة لتحسين تجربتك. نعتذر عن أي إزعاج.", "why_down_title": "لماذا الموقع معطل؟" }, + "menu": { + "calls": "المكالمات", + "calls_list": "قائمة المكالمات", + "contacts": "جهات الاتصال", + "home": "الرئيسية", + "map": "الخريطة", + "menu": "القائمة", + "messages": "الرسائل", + "new_call": "مكالمة جديدة", + "personnel": "الموظفون", + "protocols": "البروتوكولات", + "settings": "الإعدادات", + "units": "الوحدات" + }, "map": { "call_set_as_current": "تم تعيين المكالمة كمكالمة حالية", "failed_to_open_maps": "فشل في فتح تطبيق الخرائط", @@ -518,7 +542,21 @@ "title": "الملاحظات" }, "onboarding": { - "message": "مرحبًا بك في تطبيق obytes" + "screen1": { + "title": "Resgrid Dispatch", + "description": "إنشاء وإرسال وإدارة مكالمات الطوارئ مع مركز قيادة متنقل قوي في متناول يدك" + }, + "screen2": { + "title": "الوعي الظرفي في الوقت الفعلي", + "description": "تتبع جميع الوحدات والأفراد والموارد على خريطة تفاعلية مع تحديثات الحالة المباشرة وAVL" + }, + "screen3": { + "title": "التنسيق السلس", + "description": "تواصل فورًا مع الوحدات الميدانية، وقم بتحديث حالات المكالمات، وتنسيق جهود الاستجابة من أي مكان" + }, + "skip": "تخطي", + "next": "التالي", + "getStarted": "لنبدأ" }, "protocols": { "details": { @@ -677,7 +715,7 @@ "pending_calls": "معلقة", "scheduled_calls": "مجدولة", "units_available": "متاحة", - "units_on_scene": "في الموقع", + "personnel_available": "متاحين", "personnel_on_duty": "في الخدمة", "units": "الوحدات", "personnel": "الموظفين", @@ -698,6 +736,12 @@ "ptt_end": "انتهاء الإرسال", "transmitting_on": "جاري الإرسال على {{channel}}", "transmission_ended": "انتهى الإرسال", + "voice_disabled": "تم تعطيل الصوت", + "select_channel": "اختر القناة", + "select_channel_description": "اختر قناة صوتية للاتصال بها", + "change_channel_warning": "اختيار قناة جديدة سيؤدي إلى قطع الاتصال من القناة الحالية", + "default_channel": "افتراضي", + "no_channels_available": "لا توجد قنوات صوتية متاحة", "system_update": "تحديث النظام", "data_refreshed": "تم تحديث البيانات من الخادم", "call_selected": "تم تحديد المكالمة", @@ -716,6 +760,17 @@ "no_call_notes": "لا توجد ملاحظات للمكالمة", "add_call_note_placeholder": "أضف ملاحظة...", "note_added": "تمت إضافة الملاحظة", + "note_added_to_console": "تمت إضافة ملاحظة جديدة إلى وحدة التحكم", + "add_note_title": "إضافة ملاحظة جديدة", + "note_title_label": "العنوان", + "note_title_placeholder": "أدخل عنوان الملاحظة...", + "note_category_label": "الفئة", + "note_category_placeholder": "اختر فئة", + "note_no_category": "بدون فئة", + "note_body_label": "محتوى الملاحظة", + "note_body_placeholder": "أدخل محتوى الملاحظة...", + "note_save_error": "فشل حفظ الملاحظة: {{error}}", + "note_created": "تم إنشاء الملاحظة", "units_on_call": "الوحدات في المكالمة", "no_units_on_call": "لا توجد وحدات في المكالمة", "personnel_on_call": "الموظفين في المكالمة", diff --git a/src/translations/en.json b/src/translations/en.json index 3ade310..cb1200d 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -162,6 +162,7 @@ "no_timeline": "No timeline events available", "not_available": "N/A", "not_found": "Call not found", + "missing_call_id": "Call ID is missing", "note": "Note", "notes": "Notes", "priority": "Priority", @@ -287,12 +288,21 @@ "what3words_invalid_format": "Invalid what3words format. Please use format: word.word.word", "what3words_not_found": "what3words address not found, please try a different address", "what3words_placeholder": "Enter what3words address (e.g., filled.count.soap)", - "what3words_required": "Please enter a what3words address to search" + "what3words_required": "Please enter a what3words address to search", + "expand_map": "Expand Map", + "contact_information": "Contact Information", + "new_call_web_hint": "Fill in the call details below. Press Ctrl+Enter to create.", + "edit_call_web_hint": "Update the call details below. Press Ctrl+S to save.", + "keyboard_shortcuts": "Tip: Press Ctrl+Enter to create, Escape to cancel", + "edit_keyboard_shortcuts": "Tip: Press Ctrl+S to save, Escape to cancel" }, "common": { "add": "Add", "back": "Back", "cancel": "Cancel", + "creating": "Creating...", + "saving": "Saving...", + "unsaved_changes": "Unsaved changes", "close": "Close", "confirm": "Confirm", "confirm_location": "Confirm Location", @@ -487,6 +497,20 @@ "why_down_message": "We are performing scheduled maintenance to improve your experience. We apologize for any inconvenience.", "why_down_title": "Why is the Site Down?" }, + "menu": { + "calls": "Calls", + "calls_list": "Calls List", + "contacts": "Contacts", + "home": "Home", + "map": "Map", + "menu": "Menu", + "messages": "Messages", + "new_call": "New Call", + "personnel": "Personnel", + "protocols": "Protocols", + "settings": "Settings", + "units": "Units" + }, "map": { "call_set_as_current": "Call set as current call", "failed_to_open_maps": "Failed to open maps application", @@ -521,7 +545,21 @@ "title": "Notes" }, "onboarding": { - "message": "Welcome to obytes app site" + "screen1": { + "title": "Resgrid Dispatch", + "description": "Create, dispatch, and manage emergency calls with a powerful mobile command center at your fingertips" + }, + "screen2": { + "title": "Real-Time Situational Awareness", + "description": "Track all units, personnel, and resources on an interactive map with live status updates and AVL" + }, + "screen3": { + "title": "Seamless Coordination", + "description": "Communicate instantly with field units, update call statuses, and coordinate response efforts from anywhere" + }, + "skip": "Skip", + "next": "Next", + "getStarted": "Let's Get Started" }, "protocols": { "details": { @@ -668,7 +706,7 @@ "pending_calls": "Pending", "scheduled_calls": "Scheduled", "units_available": "Available", - "units_on_scene": "On Scene", + "personnel_available": "Available", "personnel_on_duty": "On Duty", "units": "Units", "personnel": "Personnel", @@ -689,6 +727,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", @@ -707,6 +751,17 @@ "no_call_notes": "No call notes", "add_call_note_placeholder": "Add a note...", "note_added": "Note Added", + "note_added_to_console": "A new note has been added to the console", + "add_note_title": "Add New Note", + "note_title_label": "Title", + "note_title_placeholder": "Enter note title...", + "note_category_label": "Category", + "note_category_placeholder": "Select a category", + "note_no_category": "No Category", + "note_body_label": "Note Content", + "note_body_placeholder": "Enter note content...", + "note_save_error": "Failed to save note: {{error}}", + "note_created": "Note Created", "units_on_call": "Units on Call", "no_units_on_call": "No units on call", "personnel_on_call": "Personnel on Call", diff --git a/src/translations/es.json b/src/translations/es.json index c70f2f2..843361b 100644 --- a/src/translations/es.json +++ b/src/translations/es.json @@ -162,6 +162,7 @@ "no_timeline": "No hay eventos de línea de tiempo disponibles", "not_available": "N/D", "not_found": "Llamada no encontrada", + "missing_call_id": "Falta el ID de la llamada", "note": "Nota", "notes": "Notas", "priority": "Prioridad", @@ -287,12 +288,21 @@ "what3words_invalid_format": "Formato what3words inválido. Use el formato: palabra.palabra.palabra", "what3words_not_found": "Dirección what3words no encontrada, intente con otra dirección", "what3words_placeholder": "Introduce dirección what3words (ej: filled.count.soap)", - "what3words_required": "Por favor introduce una dirección what3words para buscar" + "what3words_required": "Por favor introduce una dirección what3words para buscar", + "expand_map": "Expandir Mapa", + "contact_information": "Información de Contacto", + "new_call_web_hint": "Complete los detalles de la llamada a continuación. Presione Ctrl+Enter para crear.", + "edit_call_web_hint": "Actualice los detalles de la llamada a continuación. Presione Ctrl+S para guardar.", + "keyboard_shortcuts": "Consejo: Presione Ctrl+Enter para crear, Escape para cancelar", + "edit_keyboard_shortcuts": "Consejo: Presione Ctrl+S para guardar, Escape para cancelar" }, "common": { "add": "Añadir", "back": "Atrás", "cancel": "Cancelar", + "creating": "Creando...", + "saving": "Guardando...", + "unsaved_changes": "Cambios sin guardar", "close": "Cerrar", "confirm": "Confirmar", "confirm_location": "Confirmar ubicación", @@ -495,8 +505,36 @@ "search": "Buscar notas...", "title": "Notas" }, + "menu": { + "calls": "Llamadas", + "calls_list": "Lista de Llamadas", + "contacts": "Contactos", + "home": "Inicio", + "map": "Mapa", + "menu": "Menú", + "messages": "Mensajes", + "new_call": "Nueva Llamada", + "personnel": "Personal", + "protocols": "Protocolos", + "settings": "Configuración", + "units": "Unidades" + }, "onboarding": { - "message": "Bienvenido al sitio de la aplicación obytes" + "screen1": { + "title": "Resgrid Dispatch", + "description": "Cree, despache y gestione llamadas de emergencia con un potente centro de comando móvil al alcance de su mano" + }, + "screen2": { + "title": "Conciencia Situacional en Tiempo Real", + "description": "Rastree todas las unidades, personal y recursos en un mapa interactivo con actualizaciones de estado en vivo y AVL" + }, + "screen3": { + "title": "Coordinación Fluida", + "description": "Comuníquese instantáneamente con las unidades de campo, actualice los estados de llamadas y coordine los esfuerzos de respuesta desde cualquier lugar" + }, + "skip": "Omitir", + "next": "Siguiente", + "getStarted": "Empecemos" }, "protocols": { "details": { @@ -655,7 +693,7 @@ "pending_calls": "Pendientes", "scheduled_calls": "Programadas", "units_available": "Disponibles", - "units_on_scene": "En Escena", + "personnel_available": "Disponibles", "personnel_on_duty": "De Servicio", "units": "Unidades", "personnel": "Personal", @@ -676,6 +714,12 @@ "ptt_end": "Fin PTT", "transmitting_on": "Transmitiendo en {{channel}}", "transmission_ended": "Transmisión finalizada", + "voice_disabled": "Voz deshabilitada", + "select_channel": "Seleccionar Canal", + "select_channel_description": "Elija un canal de voz para conectarse", + "change_channel_warning": "Seleccionar un nuevo canal desconectará del actual", + "default_channel": "Predeterminado", + "no_channels_available": "No hay canales de voz disponibles", "system_update": "Actualización del Sistema", "data_refreshed": "Datos actualizados desde el servidor", "call_selected": "Llamada Seleccionada", @@ -694,6 +738,17 @@ "no_call_notes": "Sin notas de llamada", "add_call_note_placeholder": "Añadir una nota...", "note_added": "Nota Añadida", + "note_added_to_console": "Se ha añadido una nueva nota a la consola", + "add_note_title": "Añadir Nueva Nota", + "note_title_label": "Título", + "note_title_placeholder": "Ingrese el título de la nota...", + "note_category_label": "Categoría", + "note_category_placeholder": "Seleccione una categoría", + "note_no_category": "Sin Categoría", + "note_body_label": "Contenido de la Nota", + "note_body_placeholder": "Ingrese el contenido de la nota...", + "note_save_error": "Error al guardar la nota: {{error}}", + "note_created": "Nota Creada", "units_on_call": "Unidades en Llamada", "no_units_on_call": "Sin unidades en llamada", "personnel_on_call": "Personal en Llamada",