diff --git a/.DS_Store b/.DS_Store index 2ae6c9f..c1b9d5f 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..23ee116 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,57 @@ +You are an expert in TypeScript, React Native, Expo, and Mobile App Development. + +Code Style and Structure: + +- Write concise, type-safe TypeScript code. +- Use functional components and hooks over class components. +- Ensure components are modular, reusable, and maintainable. +- Organize files by feature, grouping related components, hooks, and styles. + +Naming Conventions: + +- Use camelCase for variable and function names (e.g., `isFetchingData`, `handleUserInput`). +- Use PascalCase for component names (e.g., `UserProfile`, `ChatScreen`). +- Directory and File names should be lowercase and hyphenated (e.g., `user-profile`, `chat-screen`). + +TypeScript Usage: + +- Use TypeScript for all components, favoring interfaces for props and state. +- Enable strict typing in `tsconfig.json`. +- Avoid using `any`; strive for precise types. +- Utilize `React.FC` for defining functional components with props. + +Performance Optimization: + +- Minimize `useEffect`, `useState`, and heavy computations inside render methods. +- Use `React.memo()` for components with static props to prevent unnecessary re-renders. +- Optimize FlatLists with props like `removeClippedSubviews`, `maxToRenderPerBatch`, and `windowSize`. +- Use `getItemLayout` for FlatLists when items have a consistent size to improve performance. +- Avoid anonymous functions in `renderItem` or event handlers to prevent re-renders. + +UI and Styling: + +- Use consistent styling leveraging `gluestack-ui`. If there isn't a Gluestack component in the `components/ui` directory for the component you are trying to use consistently style it either through `StyleSheet.create()` or Styled Components. +- Ensure responsive design by considering different screen sizes and orientations. +- Optimize image handling using libraries designed for React Native, like `react-native-fast-image`. + +Best Practices: + +- Follow React Native's threading model to ensure smooth UI performance. +- Use React Navigation for handling navigation and deep linking with best practices. +- Create and use Jest to test to validate all generated components +- Generate tests for all components, services and logic generated. Ensure tests run without errors and fix any issues. + +Additional Rules: + +- Use `yarn` as the package manager. +- Use Expo's secure store for sensitive data +- Implement proper offline support +- Use `zustand` for state management +- Use `react-hook-form` for form handling +- Use `react-query` for data fetching +- Use `react-i18next` for internationalization +- Use `react-native-mmkv` for local storage +- Use `axios` for API requests +- Use `@rnmapbox/maps` for maps, mapping or vehicle navigation +- Use `lucide-react-native` for icons and use those components directly in the markup and don't use the gluestack-ui icon component +- Use ? : for conditional rendering and not && diff --git a/src/api/units/unitLocation.ts b/src/api/units/unitLocation.ts new file mode 100644 index 0000000..c9fbae5 --- /dev/null +++ b/src/api/units/unitLocation.ts @@ -0,0 +1,21 @@ +import { createApiEndpoint } from '@/api/common/client'; +import { type SaveUnitLocationInput } from '@/models/v4/unitLocation/saveUnitLocationInput'; +import { type SaveUnitLocationResult } from '@/models/v4/unitLocation/saveUnitLocationResult'; +import { type UnitLocationResult } from '@/models/v4/unitLocation/unitLocationResult'; + +const setUnitLocationApi = createApiEndpoint('/UnitLocation/SetUnitLocation'); +const getUnitLocationApi = createApiEndpoint('/UnitLocation/GetLatestUnitLocation'); + +export const setUnitLocation = async (data: SaveUnitLocationInput) => { + const response = await setUnitLocationApi.post({ + ...data, + }); + return response.data; +}; + +export const getUnitLocation = async (unitId: string) => { + const response = await getUnitLocationApi.get({ + unitId: unitId, + }); + return response.data; +}; diff --git a/src/app/(app)/index.tsx b/src/app/(app)/index.tsx index 06ac62e..80c6f3e 100644 --- a/src/app/(app)/index.tsx +++ b/src/app/(app)/index.tsx @@ -32,6 +32,7 @@ export default function Map() { latitude: state.latitude, longitude: state.longitude, heading: state.heading, + isMapLocked: state.isMapLocked, })); const _mapOptions = Object.keys(Mapbox.StyleURL) @@ -82,18 +83,28 @@ export default function Map() { latitude: location.latitude, longitude: location.longitude, heading: location.heading, + isMapLocked: location.isMapLocked, }, }); - if (!hasUserMovedMap) { + // When map is locked, always follow the location + // When map is unlocked, only follow if user hasn't moved the map + if (location.isMapLocked || !hasUserMovedMap) { cameraRef.current?.setCamera({ centerCoordinate: [location.longitude, location.latitude], zoomLevel: 12, - animationDuration: 1000, + animationDuration: location.isMapLocked ? 500 : 1000, }); } } - }, [location.latitude, location.longitude, location.heading, hasUserMovedMap]); + }, [location.latitude, location.longitude, location.heading, location.isMapLocked, hasUserMovedMap]); + + // Reset hasUserMovedMap when map gets locked + useEffect(() => { + if (location.isMapLocked) { + setHasUserMovedMap(false); + } + }, [location.isMapLocked]); useEffect(() => { const fetchMapDataAndMarkers = async () => { @@ -125,7 +136,8 @@ export default function Map() { }, [pulseAnim]); const onCameraChanged = (event: any) => { - if (event.properties.isUserInteraction) { + // Only register user interaction if map is not locked + if (event.properties.isUserInteraction && !location.isMapLocked) { setHasUserMovedMap(true); } }; @@ -177,6 +189,9 @@ export default function Map() { setSelectedPin(null); }; + // Show recenter button only when map is not locked and user has moved the map + const showRecenterButton = !location.isMapLocked && hasUserMovedMap && location.latitude && location.longitude; + return ( <> - + {location.latitude && location.longitude && ( @@ -220,8 +235,8 @@ export default function Map() { - {/* Recenter Button */} - {hasUserMovedMap && location.latitude && location.longitude && ( + {/* Recenter Button - only show when map is not locked and user has moved the map */} + {showRecenterButton && ( diff --git a/src/components/sidebar/__tests__/call-sidebar.test.tsx b/src/components/sidebar/__tests__/call-sidebar.test.tsx index b5fd4a3..2d064ed 100644 --- a/src/components/sidebar/__tests__/call-sidebar.test.tsx +++ b/src/components/sidebar/__tests__/call-sidebar.test.tsx @@ -11,6 +11,7 @@ import { useCoreStore } from '@/stores/app/core-store'; import { useCallsStore } from '@/stores/calls/store'; import { type CallResultData } from '@/models/v4/calls/callResultData'; import { type CallPriorityResultData } from '@/models/v4/callPriorities/callPriorityResultData'; +import { openMapsWithDirections, openMapsWithAddress } from '@/lib/navigation'; // Mock dependencies jest.mock('react-i18next'); @@ -20,6 +21,7 @@ jest.mock('expo-router'); jest.mock('react-native/Libraries/Alert/Alert'); jest.mock('@/stores/app/core-store'); jest.mock('@/stores/calls/store'); +jest.mock('@/lib/navigation'); // Mock UI components jest.mock('@/components/ui/bottom-sheet', () => ({ @@ -184,6 +186,8 @@ const mockRouter = router as jest.Mocked; const mockAlert = Alert as jest.Mocked; const mockUseCoreStore = useCoreStore as jest.MockedFunction; const mockUseCallsStore = useCallsStore as jest.MockedFunction; +const mockOpenMapsWithDirections = openMapsWithDirections as jest.MockedFunction; +const mockOpenMapsWithAddress = openMapsWithAddress as jest.MockedFunction; describe('SidebarCallCard', () => { const mockSetActiveCall = jest.fn(); @@ -224,6 +228,8 @@ describe('SidebarCallCard', () => { mockAlert.alert = jest.fn(); mockRouter.push = jest.fn(); + mockOpenMapsWithDirections.mockResolvedValue(true); + mockOpenMapsWithAddress.mockResolvedValue(true); }); describe('Basic Rendering', () => { @@ -251,7 +257,7 @@ describe('SidebarCallCard', () => { expect(screen.getByText('Test Emergency Call')).toBeTruthy(); }); - it('should show action buttons when active call exists', () => { + it('should show action buttons when active call exists with coordinates', () => { mockUseCoreStore.mockReturnValue({ activeCall: mockCall, activePriority: mockPriority, @@ -264,6 +270,69 @@ describe('SidebarCallCard', () => { expect(screen.getByTestId('map-pin-icon')).toBeTruthy(); expect(screen.getByTestId('circle-x-icon')).toBeTruthy(); }); + + it('should show map button when active call has address only', () => { + const callWithAddressOnly = { + ...mockCall, + Latitude: '', + Longitude: '', + Address: '123 Test Street', + }; + + mockUseCoreStore.mockReturnValue({ + activeCall: callWithAddressOnly, + activePriority: mockPriority, + setActiveCall: mockSetActiveCall, + }); + + render(); + + expect(screen.getByTestId('eye-icon')).toBeTruthy(); + expect(screen.getByTestId('map-pin-icon')).toBeTruthy(); + expect(screen.getByTestId('circle-x-icon')).toBeTruthy(); + }); + + it('should not show map button when active call has no location data', () => { + const callWithoutLocation = { + ...mockCall, + Latitude: '', + Longitude: '', + Address: '', + }; + + mockUseCoreStore.mockReturnValue({ + activeCall: callWithoutLocation, + activePriority: mockPriority, + setActiveCall: mockSetActiveCall, + }); + + render(); + + expect(screen.getByTestId('eye-icon')).toBeTruthy(); + expect(() => screen.getByTestId('map-pin-icon')).toThrow(); + expect(screen.getByTestId('circle-x-icon')).toBeTruthy(); + }); + + it('should not show map button when active call has empty address', () => { + const callWithEmptyAddress = { + ...mockCall, + Latitude: '', + Longitude: '', + Address: ' ', + }; + + mockUseCoreStore.mockReturnValue({ + activeCall: callWithEmptyAddress, + activePriority: mockPriority, + setActiveCall: mockSetActiveCall, + }); + + render(); + + expect(screen.getByTestId('eye-icon')).toBeTruthy(); + expect(() => screen.getByTestId('map-pin-icon')).toThrow(); + expect(screen.getByTestId('circle-x-icon')).toBeTruthy(); + }); }); describe('Bottom Sheet Behavior', () => { @@ -356,6 +425,88 @@ describe('SidebarCallCard', () => { { cancelable: true } ); }); + + it('should open maps with coordinates when map pin button is pressed with valid coordinates', async () => { + render(); + + fireEvent.press(screen.getByTestId('map-pin-icon')); + + expect(mockOpenMapsWithDirections).toHaveBeenCalledWith( + mockCall.Latitude, + mockCall.Longitude, + mockCall.Address + ); + expect(mockOpenMapsWithAddress).not.toHaveBeenCalled(); + }); + + it('should open maps with address when map pin button is pressed with address only', async () => { + const callWithAddressOnly = { + ...mockCall, + Latitude: '', + Longitude: '', + Address: '123 Test Street', + }; + + mockUseCoreStore.mockReturnValue({ + activeCall: callWithAddressOnly, + activePriority: mockPriority, + setActiveCall: mockSetActiveCall, + }); + + render(); + + fireEvent.press(screen.getByTestId('map-pin-icon')); + + expect(mockOpenMapsWithAddress).toHaveBeenCalledWith('123 Test Street'); + expect(mockOpenMapsWithDirections).not.toHaveBeenCalled(); + }); + + it('should show error alert when openMapsWithDirections fails', async () => { + mockOpenMapsWithDirections.mockRejectedValue(new Error('Navigation failed')); + + render(); + + fireEvent.press(screen.getByTestId('map-pin-icon')); + + // Wait for the async operation to complete + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(mockAlert.alert).toHaveBeenCalledWith( + 'calls.no_location_title', + 'calls.no_location_message', + [{ text: 'common.ok' }] + ); + }); + + it('should show error alert when openMapsWithAddress fails', async () => { + const callWithAddressOnly = { + ...mockCall, + Latitude: '', + Longitude: '', + Address: '123 Test Street', + }; + + mockUseCoreStore.mockReturnValue({ + activeCall: callWithAddressOnly, + activePriority: mockPriority, + setActiveCall: mockSetActiveCall, + }); + + mockOpenMapsWithAddress.mockRejectedValue(new Error('Address navigation failed')); + + render(); + + fireEvent.press(screen.getByTestId('map-pin-icon')); + + // Wait for the async operation to complete + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(mockAlert.alert).toHaveBeenCalledWith( + 'calls.no_location_title', + 'calls.no_location_message', + [{ text: 'common.ok' }] + ); + }); }); describe('Accessibility', () => { diff --git a/src/components/sidebar/__tests__/unit-sidebar.test.tsx b/src/components/sidebar/__tests__/unit-sidebar.test.tsx new file mode 100644 index 0000000..0456285 --- /dev/null +++ b/src/components/sidebar/__tests__/unit-sidebar.test.tsx @@ -0,0 +1,240 @@ +import { render, screen, fireEvent } from '@testing-library/react-native'; +import React from 'react'; + +import { useCoreStore } from '@/stores/app/core-store'; +import { useLocationStore } from '@/stores/app/location-store'; +import { useLiveKitStore } from '@/stores/app/livekit-store'; + +import { SidebarUnitCard } from '../unit-sidebar'; + +// Mock the stores +jest.mock('@/stores/app/core-store'); +jest.mock('@/stores/app/location-store'); +jest.mock('@/stores/app/livekit-store'); + +const mockUseCoreStore = useCoreStore as jest.MockedFunction; +const mockUseLocationStore = useLocationStore as jest.MockedFunction; +const mockUseLiveKitStore = useLiveKitStore as jest.MockedFunction; + +describe('SidebarUnitCard', () => { + const mockSetMapLocked = jest.fn(); + const mockSetIsBottomSheetVisible = jest.fn(); + + const defaultProps = { + unitName: 'Test Unit', + unitType: 'Ambulance', + unitGroup: 'Test Group', + bgColor: 'bg-blue-500', + }; + + beforeEach(() => { + jest.clearAllMocks(); + + mockUseCoreStore.mockReturnValue({ + activeUnit: null, + }); + + mockUseLocationStore.mockReturnValue({ + isMapLocked: false, + setMapLocked: mockSetMapLocked, + }); + + mockUseLiveKitStore.mockReturnValue({ + setIsBottomSheetVisible: mockSetIsBottomSheetVisible, + currentRoomInfo: null, + isConnected: false, + isTalking: false, + }); + }); + + it('renders unit information correctly', () => { + render(); + + expect(screen.getByText('Test Unit')).toBeTruthy(); + expect(screen.getByText('Ambulance')).toBeTruthy(); + expect(screen.getByText('Test Group')).toBeTruthy(); + }); + + it('renders with default props when no active unit', () => { + render(); + + // Should render the default props since no active unit is set + expect(screen.getByText('Test Unit')).toBeTruthy(); + expect(screen.getByText('Ambulance')).toBeTruthy(); + expect(screen.getByText('Test Group')).toBeTruthy(); + }); + + describe('Map Lock Button', () => { + it('renders map lock button with unlock icon when map is not locked', () => { + render(); + + const mapLockButton = screen.getByTestId('map-lock-button'); + expect(mapLockButton).toBeTruthy(); + + // Check that the button has the correct styling for unlocked state + expect(mapLockButton).toHaveStyle({ + backgroundColor: 'transparent', + borderColor: '#007AFF', + }); + }); + + it('renders map lock button with lock icon when map is locked', () => { + mockUseLocationStore.mockReturnValue({ + isMapLocked: true, + setMapLocked: mockSetMapLocked, + }); + + render(); + + const mapLockButton = screen.getByTestId('map-lock-button'); + expect(mapLockButton).toBeTruthy(); + + // Check that the button has the correct styling for locked state + expect(mapLockButton).toHaveStyle({ + backgroundColor: '#007AFF', + borderColor: '#007AFF', + }); + }); + + it('toggles map lock state when pressed', () => { + render(); + + const mapLockButton = screen.getByTestId('map-lock-button'); + fireEvent.press(mapLockButton); + + expect(mockSetMapLocked).toHaveBeenCalledWith(true); + }); + + it('toggles map lock state from locked to unlocked when pressed', () => { + mockUseLocationStore.mockReturnValue({ + isMapLocked: true, + setMapLocked: mockSetMapLocked, + }); + + render(); + + const mapLockButton = screen.getByTestId('map-lock-button'); + fireEvent.press(mapLockButton); + + expect(mockSetMapLocked).toHaveBeenCalledWith(false); + }); + }); + + describe('Call Button', () => { + it('renders call button with correct styling when not connected', () => { + render(); + + const callButton = screen.getByTestId('call-button'); + expect(callButton).toBeTruthy(); + + // Check that the button has the correct styling for disconnected state + expect(callButton).toHaveStyle({ + backgroundColor: 'transparent', + borderColor: '#007AFF', + }); + }); + + it('renders call button with active styling when connected', () => { + mockUseLiveKitStore.mockReturnValue({ + setIsBottomSheetVisible: mockSetIsBottomSheetVisible, + currentRoomInfo: null, + isConnected: true, + isTalking: false, + }); + + render(); + + const callButton = screen.getByTestId('call-button'); + expect(callButton).toBeTruthy(); + + // Check that the button has the correct styling for connected state + expect(callButton).toHaveStyle({ + backgroundColor: '#007AFF', + borderColor: '#007AFF', + }); + }); + + it('opens LiveKit when call button is pressed', () => { + render(); + + const callButton = screen.getByTestId('call-button'); + fireEvent.press(callButton); + + expect(mockSetIsBottomSheetVisible).toHaveBeenCalledWith(true); + }); + }); + + describe('Room Status Display', () => { + it('shows room status when connected and has room info', () => { + const mockRoomInfo = { + Name: 'Test Room', + Id: '123', + }; + + mockUseLiveKitStore.mockReturnValue({ + setIsBottomSheetVisible: mockSetIsBottomSheetVisible, + currentRoomInfo: mockRoomInfo, + isConnected: true, + isTalking: false, + }); + + render(); + + expect(screen.getByText('Test Room')).toBeTruthy(); + }); + + it('shows microphone icon when talking', () => { + const mockRoomInfo = { + Name: 'Test Room', + Id: '123', + }; + + mockUseLiveKitStore.mockReturnValue({ + setIsBottomSheetVisible: mockSetIsBottomSheetVisible, + currentRoomInfo: mockRoomInfo, + isConnected: true, + isTalking: true, + }); + + render(); + + expect(screen.getByText('Test Room')).toBeTruthy(); + // Note: Testing for specific icon presence would require additional setup + // for icon mocking, which is beyond the scope of this basic test + }); + + it('does not show room status when not connected', () => { + const mockRoomInfo = { + Name: 'Test Room', + Id: '123', + }; + + mockUseLiveKitStore.mockReturnValue({ + setIsBottomSheetVisible: mockSetIsBottomSheetVisible, + currentRoomInfo: mockRoomInfo, + isConnected: false, + isTalking: false, + }); + + render(); + + expect(screen.queryByText('Test Room')).toBeNull(); + }); + }); + + describe('Button Container Layout', () => { + it('renders both buttons in the correct order', () => { + render(); + + const mapLockButton = screen.getByTestId('map-lock-button'); + const callButton = screen.getByTestId('call-button'); + + expect(mapLockButton).toBeTruthy(); + expect(callButton).toBeTruthy(); + + // Both buttons should be present + expect(mapLockButton).toBeTruthy(); + expect(callButton).toBeTruthy(); + }); + }); +}); \ No newline at end of file diff --git a/src/components/sidebar/call-sidebar.tsx b/src/components/sidebar/call-sidebar.tsx index ca0dafc..82e7027 100644 --- a/src/components/sidebar/call-sidebar.tsx +++ b/src/components/sidebar/call-sidebar.tsx @@ -9,6 +9,7 @@ import { Alert, Pressable, ScrollView } from 'react-native'; import { CustomBottomSheet } from '@/components/ui/bottom-sheet'; import { Text } from '@/components/ui/text'; import { VStack } from '@/components/ui/vstack'; +import { openMapsWithAddress, openMapsWithDirections } from '@/lib/navigation'; import { useCoreStore } from '@/stores/app/core-store'; import { useCallsStore } from '@/stores/calls/store'; @@ -59,6 +60,41 @@ export const SidebarCallCard = () => { ); }; + // Check if location data exists (either coordinates or address) + const hasLocationData = (call: typeof activeCall) => { + if (!call) return false; + const hasCoordinates = call.Latitude && call.Longitude; + const hasAddress = call.Address && call.Address.trim() !== ''; + return hasCoordinates || hasAddress; + }; + + const handleDirections = async () => { + if (!activeCall) return; + + const latitude = activeCall.Latitude; + const longitude = activeCall.Longitude; + const address = activeCall.Address; + + // Check if we have coordinates + if (latitude && longitude) { + try { + await openMapsWithDirections(latitude, longitude, address); + } catch (error) { + Alert.alert(t('calls.no_location_title'), t('calls.no_location_message'), [{ text: t('common.ok') }]); + } + } else if (address && address.trim() !== '') { + // Fall back to address if no coordinates + try { + await openMapsWithAddress(address); + } catch (error) { + Alert.alert(t('calls.no_location_title'), t('calls.no_location_message'), [{ text: t('common.ok') }]); + } + } else { + // No location data available + Alert.alert(t('calls.no_location_title'), t('calls.no_location_message'), [{ text: t('common.ok') }]); + } + }; + return ( <> setIsBottomSheetOpen(true)} className="w-full" testID="call-selection-trigger"> @@ -86,16 +122,8 @@ export const SidebarCallCard = () => { - {activeCall?.Address && ( - )} diff --git a/src/components/sidebar/unit-sidebar.tsx b/src/components/sidebar/unit-sidebar.tsx index 3417fa5..6179754 100644 --- a/src/components/sidebar/unit-sidebar.tsx +++ b/src/components/sidebar/unit-sidebar.tsx @@ -1,10 +1,11 @@ -import { Mic, Phone } from 'lucide-react-native'; +import { Lock, Mic, Phone, Unlock } from 'lucide-react-native'; import * as React from 'react'; import { StyleSheet, TouchableOpacity, View } from 'react-native'; import { Text } from '@/components/ui/text'; import { useCoreStore } from '@/stores/app/core-store'; import { useLiveKitStore } from '@/stores/app/livekit-store'; +import { useLocationStore } from '@/stores/app/location-store'; import { Card } from '../ui/card'; @@ -18,6 +19,7 @@ type ItemProps = { export const SidebarUnitCard = ({ unitName: defaultUnitName, unitType: defaultUnitType, unitGroup: defaultUnitGroup, bgColor }: ItemProps) => { const activeUnit = useCoreStore((state) => state.activeUnit); const { setIsBottomSheetVisible, currentRoomInfo, isConnected, isTalking } = useLiveKitStore(); + const { isMapLocked, setMapLocked } = useLocationStore(); // Derive the display values from activeUnit when available, otherwise use defaults const displayName = activeUnit?.Name ?? defaultUnitName; @@ -28,6 +30,10 @@ export const SidebarUnitCard = ({ unitName: defaultUnitName, unitType: defaultUn setIsBottomSheetVisible(true); }; + const handleToggleMapLock = () => { + setMapLocked(!isMapLocked); + }; + return ( {displayType} @@ -45,9 +51,15 @@ export const SidebarUnitCard = ({ unitName: defaultUnitName, unitType: defaultUn ) : null} - - - + + + {isMapLocked ? : } + + + + + + ); @@ -60,6 +72,11 @@ const styles = StyleSheet.create({ alignItems: 'center', justifyContent: 'space-between', }, + buttonContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, callButton: { backgroundColor: 'transparent', borderWidth: 1, @@ -74,6 +91,20 @@ const styles = StyleSheet.create({ backgroundColor: '#007AFF', borderColor: '#007AFF', }, + mapLockButton: { + backgroundColor: 'transparent', + borderWidth: 1, + borderColor: '#007AFF', + borderRadius: 20, + width: 36, + height: 36, + alignItems: 'center', + justifyContent: 'center', + }, + mapLockButtonActive: { + backgroundColor: '#007AFF', + borderColor: '#007AFF', + }, roomStatus: { flex: 1, flexDirection: 'row', diff --git a/src/lib/__tests__/navigation.test.ts b/src/lib/__tests__/navigation.test.ts new file mode 100644 index 0000000..3395035 --- /dev/null +++ b/src/lib/__tests__/navigation.test.ts @@ -0,0 +1,384 @@ +import { Platform, Linking } from 'react-native'; +import { describe, expect, it, beforeEach, afterEach } from '@jest/globals'; +import { openMapsWithDirections, openMapsWithAddress } from '../navigation'; + +// Mock React Native modules +jest.mock('react-native', () => ({ + Platform: { + OS: 'ios', + }, + Linking: { + canOpenURL: jest.fn(), + openURL: jest.fn(), + }, +})); + +// Mock the logger +jest.mock('../logging', () => ({ + logger: { + error: jest.fn(), + }, +})); + +// Get the mocked Linking module +const MockedLinking = Linking as jest.Mocked; + +describe('Navigation Functions', () => { + beforeEach(() => { + jest.clearAllMocks(); + MockedLinking.canOpenURL.mockResolvedValue(true); + MockedLinking.openURL.mockResolvedValue(true); + }); + + afterEach(() => { + // Reset Platform.OS to default + (Platform as any).OS = 'ios'; + }); + + describe('openMapsWithDirections', () => { + describe('iOS Platform', () => { + beforeEach(() => { + (Platform as any).OS = 'ios'; + }); + + it('should open Apple Maps with current location as origin', async () => { + const result = await openMapsWithDirections(40.7128, -74.006, 'New York'); + + expect(MockedLinking.canOpenURL).toHaveBeenCalledWith('maps://maps.apple.com/?daddr=40.7128,-74.006&dirflg=d'); + expect(MockedLinking.openURL).toHaveBeenCalledWith('maps://maps.apple.com/?daddr=40.7128,-74.006&dirflg=d'); + expect(result).toBe(true); + }); + + it('should open Apple Maps with specific origin', async () => { + const result = await openMapsWithDirections(40.7128, -74.006, 'New York', 40.7589, -73.9851); + + expect(MockedLinking.canOpenURL).toHaveBeenCalledWith('maps://maps.apple.com/?saddr=40.7589,-73.9851&daddr=40.7128,-74.006&dirflg=d'); + expect(MockedLinking.openURL).toHaveBeenCalledWith('maps://maps.apple.com/?saddr=40.7589,-73.9851&daddr=40.7128,-74.006&dirflg=d'); + expect(result).toBe(true); + }); + + it('should handle string coordinates', async () => { + const result = await openMapsWithDirections('40.7128', '-74.006', 'New York'); + + expect(MockedLinking.canOpenURL).toHaveBeenCalledWith('maps://maps.apple.com/?daddr=40.7128,-74.006&dirflg=d'); + expect(MockedLinking.openURL).toHaveBeenCalledWith('maps://maps.apple.com/?daddr=40.7128,-74.006&dirflg=d'); + expect(result).toBe(true); + }); + }); + + describe('Android Platform', () => { + beforeEach(() => { + (Platform as any).OS = 'android'; + }); + + it('should open Google Maps with current location as origin', async () => { + const result = await openMapsWithDirections(40.7128, -74.006, 'New York'); + + expect(MockedLinking.canOpenURL).toHaveBeenCalledWith('google.navigation:q=40.7128,-74.006'); + expect(MockedLinking.openURL).toHaveBeenCalledWith('google.navigation:q=40.7128,-74.006'); + expect(result).toBe(true); + }); + + it('should open Google Maps with specific origin', async () => { + const result = await openMapsWithDirections(40.7128, -74.006, 'New York', 40.7589, -73.9851); + + expect(MockedLinking.canOpenURL).toHaveBeenCalledWith('google.navigation:q=40.7128,-74.006&origin=40.7589,-73.9851'); + expect(MockedLinking.openURL).toHaveBeenCalledWith('google.navigation:q=40.7128,-74.006&origin=40.7589,-73.9851'); + expect(result).toBe(true); + }); + }); + + describe('Web Platform', () => { + beforeEach(() => { + (Platform as any).OS = 'web'; + }); + + it('should open Google Maps web with current location as origin', async () => { + const result = await openMapsWithDirections(40.7128, -74.006, 'New York'); + + expect(MockedLinking.canOpenURL).toHaveBeenCalledWith('https://www.google.com/maps/dir/?api=1&destination=40.7128,-74.006&travelmode=driving'); + expect(MockedLinking.openURL).toHaveBeenCalledWith('https://www.google.com/maps/dir/?api=1&destination=40.7128,-74.006&travelmode=driving'); + expect(result).toBe(true); + }); + + it('should open Google Maps web with specific origin', async () => { + const result = await openMapsWithDirections(40.7128, -74.006, 'New York', 40.7589, -73.9851); + + expect(MockedLinking.canOpenURL).toHaveBeenCalledWith('https://www.google.com/maps/dir/?api=1&origin=40.7589,-73.9851&destination=40.7128,-74.006&travelmode=driving'); + expect(MockedLinking.openURL).toHaveBeenCalledWith('https://www.google.com/maps/dir/?api=1&origin=40.7589,-73.9851&destination=40.7128,-74.006&travelmode=driving'); + expect(result).toBe(true); + }); + }); + + describe('Windows Platform', () => { + beforeEach(() => { + (Platform as any).OS = 'windows'; + }); + + it('should open Google Maps web for Windows', async () => { + const result = await openMapsWithDirections(40.7128, -74.006, 'New York'); + + expect(MockedLinking.canOpenURL).toHaveBeenCalledWith('https://www.google.com/maps/dir/?api=1&destination=40.7128,-74.006&travelmode=driving'); + expect(MockedLinking.openURL).toHaveBeenCalledWith('https://www.google.com/maps/dir/?api=1&destination=40.7128,-74.006&travelmode=driving'); + expect(result).toBe(true); + }); + }); + + describe('macOS Platform', () => { + beforeEach(() => { + (Platform as any).OS = 'macos'; + }); + + it('should open Google Maps web for macOS', async () => { + const result = await openMapsWithDirections(40.7128, -74.006, 'New York'); + + expect(MockedLinking.canOpenURL).toHaveBeenCalledWith('https://www.google.com/maps/dir/?api=1&destination=40.7128,-74.006&travelmode=driving'); + expect(MockedLinking.openURL).toHaveBeenCalledWith('https://www.google.com/maps/dir/?api=1&destination=40.7128,-74.006&travelmode=driving'); + expect(result).toBe(true); + }); + }); + + describe('Fallback Behavior', () => { + beforeEach(() => { + (Platform as any).OS = 'ios'; + }); + + it('should use web fallback when canOpenURL returns false', async () => { + MockedLinking.canOpenURL.mockResolvedValue(false); + + const result = await openMapsWithDirections(40.7128, -74.006, 'New York'); + + expect(MockedLinking.canOpenURL).toHaveBeenCalledWith('maps://maps.apple.com/?daddr=40.7128,-74.006&dirflg=d'); + expect(MockedLinking.openURL).toHaveBeenCalledWith('https://www.google.com/maps/dir/?api=1&destination=40.7128,-74.006&travelmode=driving'); + expect(result).toBe(true); + }); + + it('should handle unknown platform', async () => { + (Platform as any).OS = 'unknown'; + + const result = await openMapsWithDirections(40.7128, -74.006, 'New York'); + + expect(MockedLinking.canOpenURL).toHaveBeenCalledWith('https://www.google.com/maps/dir/?api=1&destination=40.7128,-74.006&travelmode=driving'); + expect(MockedLinking.openURL).toHaveBeenCalledWith('https://www.google.com/maps/dir/?api=1&destination=40.7128,-74.006&travelmode=driving'); + expect(result).toBe(true); + }); + }); + + describe('Error Handling', () => { + beforeEach(() => { + (Platform as any).OS = 'ios'; + }); + + it('should handle Linking.openURL errors', async () => { + const mockError = new Error('Unable to open URL'); + MockedLinking.openURL.mockRejectedValue(mockError); + + const result = await openMapsWithDirections(40.7128, -74.006, 'New York'); + + expect(result).toBe(false); + }); + + it('should handle Linking.canOpenURL errors', async () => { + const mockError = new Error('Unable to check URL'); + MockedLinking.canOpenURL.mockRejectedValue(mockError); + + const result = await openMapsWithDirections(40.7128, -74.006, 'New York'); + + expect(result).toBe(false); + }); + }); + + describe('Parameter Validation', () => { + it('should handle undefined destination name', async () => { + const result = await openMapsWithDirections(40.7128, -74.006); + + expect(MockedLinking.canOpenURL).toHaveBeenCalledWith('maps://maps.apple.com/?daddr=40.7128,-74.006&dirflg=d'); + expect(result).toBe(true); + }); + + it('should handle zero coordinates', async () => { + const result = await openMapsWithDirections(0, 0, 'Equator'); + + expect(MockedLinking.canOpenURL).toHaveBeenCalledWith('maps://maps.apple.com/?daddr=0,0&dirflg=d'); + expect(result).toBe(true); + }); + + it('should handle negative coordinates', async () => { + const result = await openMapsWithDirections(-40.7128, -74.006, 'South America'); + + expect(MockedLinking.canOpenURL).toHaveBeenCalledWith('maps://maps.apple.com/?daddr=-40.7128,-74.006&dirflg=d'); + expect(result).toBe(true); + }); + }); + }); + + describe('openMapsWithAddress', () => { + describe('iOS Platform', () => { + beforeEach(() => { + (Platform as any).OS = 'ios'; + }); + + it('should open Apple Maps with address', async () => { + const result = await openMapsWithAddress('123 Main Street, New York, NY'); + + expect(MockedLinking.canOpenURL).toHaveBeenCalledWith('maps://maps.apple.com/?daddr=123%20Main%20Street%2C%20New%20York%2C%20NY&dirflg=d'); + expect(MockedLinking.openURL).toHaveBeenCalledWith('maps://maps.apple.com/?daddr=123%20Main%20Street%2C%20New%20York%2C%20NY&dirflg=d'); + expect(result).toBe(true); + }); + + it('should handle special characters in address', async () => { + const result = await openMapsWithAddress('123 Main St. & 1st Ave, New York, NY'); + + expect(MockedLinking.canOpenURL).toHaveBeenCalledWith('maps://maps.apple.com/?daddr=123%20Main%20St.%20%26%201st%20Ave%2C%20New%20York%2C%20NY&dirflg=d'); + expect(MockedLinking.openURL).toHaveBeenCalledWith('maps://maps.apple.com/?daddr=123%20Main%20St.%20%26%201st%20Ave%2C%20New%20York%2C%20NY&dirflg=d'); + expect(result).toBe(true); + }); + }); + + describe('Android Platform', () => { + beforeEach(() => { + (Platform as any).OS = 'android'; + }); + + it('should open Google Maps with address', async () => { + const result = await openMapsWithAddress('123 Main Street, New York, NY'); + + expect(MockedLinking.canOpenURL).toHaveBeenCalledWith('google.navigation:q=123%20Main%20Street%2C%20New%20York%2C%20NY'); + expect(MockedLinking.openURL).toHaveBeenCalledWith('google.navigation:q=123%20Main%20Street%2C%20New%20York%2C%20NY'); + expect(result).toBe(true); + }); + + it('should handle special characters in address', async () => { + const result = await openMapsWithAddress('123 Main St. & 1st Ave, New York, NY'); + + expect(MockedLinking.canOpenURL).toHaveBeenCalledWith('google.navigation:q=123%20Main%20St.%20%26%201st%20Ave%2C%20New%20York%2C%20NY'); + expect(MockedLinking.openURL).toHaveBeenCalledWith('google.navigation:q=123%20Main%20St.%20%26%201st%20Ave%2C%20New%20York%2C%20NY'); + expect(result).toBe(true); + }); + }); + + describe('Web Platform', () => { + beforeEach(() => { + (Platform as any).OS = 'web'; + }); + + it('should open Google Maps web with address', async () => { + const result = await openMapsWithAddress('123 Main Street, New York, NY'); + + expect(MockedLinking.canOpenURL).toHaveBeenCalledWith('https://www.google.com/maps/dir/?api=1&destination=123%20Main%20Street%2C%20New%20York%2C%20NY&travelmode=driving'); + expect(MockedLinking.openURL).toHaveBeenCalledWith('https://www.google.com/maps/dir/?api=1&destination=123%20Main%20Street%2C%20New%20York%2C%20NY&travelmode=driving'); + expect(result).toBe(true); + }); + + it('should handle special characters in address', async () => { + const result = await openMapsWithAddress('123 Main St. & 1st Ave, New York, NY'); + + expect(MockedLinking.canOpenURL).toHaveBeenCalledWith('https://www.google.com/maps/dir/?api=1&destination=123%20Main%20St.%20%26%201st%20Ave%2C%20New%20York%2C%20NY&travelmode=driving'); + expect(MockedLinking.openURL).toHaveBeenCalledWith('https://www.google.com/maps/dir/?api=1&destination=123%20Main%20St.%20%26%201st%20Ave%2C%20New%20York%2C%20NY&travelmode=driving'); + expect(result).toBe(true); + }); + }); + + describe('Windows Platform', () => { + beforeEach(() => { + (Platform as any).OS = 'windows'; + }); + + it('should open Google Maps web for Windows', async () => { + const result = await openMapsWithAddress('123 Main Street, New York, NY'); + + expect(MockedLinking.canOpenURL).toHaveBeenCalledWith('https://www.google.com/maps/dir/?api=1&destination=123%20Main%20Street%2C%20New%20York%2C%20NY&travelmode=driving'); + expect(MockedLinking.openURL).toHaveBeenCalledWith('https://www.google.com/maps/dir/?api=1&destination=123%20Main%20Street%2C%20New%20York%2C%20NY&travelmode=driving'); + expect(result).toBe(true); + }); + }); + + describe('macOS Platform', () => { + beforeEach(() => { + (Platform as any).OS = 'macos'; + }); + + it('should open Google Maps web for macOS', async () => { + const result = await openMapsWithAddress('123 Main Street, New York, NY'); + + expect(MockedLinking.canOpenURL).toHaveBeenCalledWith('https://www.google.com/maps/dir/?api=1&destination=123%20Main%20Street%2C%20New%20York%2C%20NY&travelmode=driving'); + expect(MockedLinking.openURL).toHaveBeenCalledWith('https://www.google.com/maps/dir/?api=1&destination=123%20Main%20Street%2C%20New%20York%2C%20NY&travelmode=driving'); + expect(result).toBe(true); + }); + }); + + describe('Fallback Behavior', () => { + beforeEach(() => { + (Platform as any).OS = 'ios'; + }); + + it('should use web fallback when canOpenURL returns false', async () => { + MockedLinking.canOpenURL.mockResolvedValue(false); + + const result = await openMapsWithAddress('123 Main Street, New York, NY'); + + expect(MockedLinking.canOpenURL).toHaveBeenCalledWith('maps://maps.apple.com/?daddr=123%20Main%20Street%2C%20New%20York%2C%20NY&dirflg=d'); + expect(MockedLinking.openURL).toHaveBeenCalledWith('https://www.google.com/maps/dir/?api=1&destination=123%20Main%20Street%2C%20New%20York%2C%20NY&travelmode=driving'); + expect(result).toBe(true); + }); + + it('should handle unknown platform', async () => { + (Platform as any).OS = 'unknown'; + + const result = await openMapsWithAddress('123 Main Street, New York, NY'); + + expect(MockedLinking.canOpenURL).toHaveBeenCalledWith('https://www.google.com/maps/dir/?api=1&destination=123%20Main%20Street%2C%20New%20York%2C%20NY&travelmode=driving'); + expect(MockedLinking.openURL).toHaveBeenCalledWith('https://www.google.com/maps/dir/?api=1&destination=123%20Main%20Street%2C%20New%20York%2C%20NY&travelmode=driving'); + expect(result).toBe(true); + }); + }); + + describe('Error Handling', () => { + beforeEach(() => { + (Platform as any).OS = 'ios'; + }); + + it('should handle Linking.openURL errors', async () => { + const mockError = new Error('Unable to open URL'); + MockedLinking.openURL.mockRejectedValue(mockError); + + const result = await openMapsWithAddress('123 Main Street, New York, NY'); + + expect(result).toBe(false); + }); + + it('should handle Linking.canOpenURL errors', async () => { + const mockError = new Error('Unable to check URL'); + MockedLinking.canOpenURL.mockRejectedValue(mockError); + + const result = await openMapsWithAddress('123 Main Street, New York, NY'); + + expect(result).toBe(false); + }); + }); + + describe('Parameter Validation', () => { + it('should handle empty address', async () => { + const result = await openMapsWithAddress(''); + + expect(MockedLinking.canOpenURL).toHaveBeenCalledWith('maps://maps.apple.com/?daddr=&dirflg=d'); + expect(result).toBe(true); + }); + + it('should handle address with only spaces', async () => { + const result = await openMapsWithAddress(' '); + + expect(MockedLinking.canOpenURL).toHaveBeenCalledWith('maps://maps.apple.com/?daddr=%20%20%20&dirflg=d'); + expect(result).toBe(true); + }); + + it('should handle very long address', async () => { + const longAddress = 'A'.repeat(1000); + const result = await openMapsWithAddress(longAddress); + + expect(MockedLinking.canOpenURL).toHaveBeenCalledWith(`maps://maps.apple.com/?daddr=${encodeURIComponent(longAddress)}&dirflg=d`); + expect(result).toBe(true); + }); + }); + }); +}); diff --git a/src/lib/navigation.ts b/src/lib/navigation.ts index 548c153..01bcc4c 100644 --- a/src/lib/navigation.ts +++ b/src/lib/navigation.ts @@ -2,6 +2,56 @@ import { Linking, Platform } from 'react-native'; import { logger } from './logging'; +/** + * Opens the device's native maps application with directions using an address. + * + * @param address - The destination address + * @returns Promise - True if the maps app was successfully opened + */ +export const openMapsWithAddress = async (address: string): Promise => { + const encodedAddress = encodeURIComponent(address); + let url = ''; + + // Platform-specific URL schemes + if (Platform.OS === 'ios') { + // Apple Maps (iOS) + url = `maps://maps.apple.com/?daddr=${encodedAddress}&dirflg=d`; + } else if (Platform.OS === 'android') { + // Google Maps (Android) + url = `google.navigation:q=${encodedAddress}`; + } else if (Platform.OS === 'web') { + // Google Maps (Web) + url = `https://www.google.com/maps/dir/?api=1&destination=${encodedAddress}&travelmode=driving`; + } else if (Platform.OS === 'windows' || Platform.OS === 'macos') { + // For desktop platforms, use web URL that will open in browser + url = `https://www.google.com/maps/dir/?api=1&destination=${encodedAddress}&travelmode=driving`; + } + + // Fallback to web URL if platform-specific URL is empty + if (!url) { + url = `https://www.google.com/maps/dir/?api=1&destination=${encodedAddress}&travelmode=driving`; + } + + try { + const canOpen = await Linking.canOpenURL(url); + if (canOpen) { + await Linking.openURL(url); + return true; + } else { + // If the specific map app can't be opened, try a web fallback + const webUrl = `https://www.google.com/maps/dir/?api=1&destination=${encodedAddress}&travelmode=driving`; + await Linking.openURL(webUrl); + return true; + } + } catch (error) { + logger.error({ + message: 'Failed to open maps application with address', + context: { error, url, address }, + }); + return false; + } +}; + /** * Opens the device's native maps application with directions from the user's current location * to the specified destination coordinates. diff --git a/src/services/__tests__/location.test.ts b/src/services/__tests__/location.test.ts index 82782c7..74c0f5d 100644 --- a/src/services/__tests__/location.test.ts +++ b/src/services/__tests__/location.test.ts @@ -1,157 +1,562 @@ -import { beforeEach, describe, expect, it, jest } from '@jest/globals'; -import { AppState } from 'react-native'; - -import { locationService } from '../location'; - -// Mock dependencies -jest.mock('expo-location', () => ({ - requestForegroundPermissionsAsync: jest.fn(), - requestBackgroundPermissionsAsync: jest.fn(), - watchPositionAsync: jest.fn(), - startLocationUpdatesAsync: jest.fn(), - stopLocationUpdatesAsync: jest.fn(), - Accuracy: { - Balanced: 'balanced', - }, +// Mock all dependencies first +jest.mock('@/api/units/unitLocation', () => ({ + setUnitLocation: jest.fn(), })); - -jest.mock('expo-task-manager', () => ({ - defineTask: jest.fn(), - isTaskRegisteredAsync: jest.fn(), +jest.mock('@/lib/hooks/use-background-geolocation', () => ({ + registerLocationServiceUpdater: jest.fn(), })); - -jest.mock('@/lib/storage/background-geolocation', () => ({ - loadBackgroundGeolocationState: jest.fn(), -})); - jest.mock('@/lib/logging', () => ({ logger: { info: jest.fn(), + warn: jest.fn(), error: jest.fn(), }, })); +jest.mock('@/lib/storage/background-geolocation', () => ({ + loadBackgroundGeolocationState: jest.fn(), +})); -jest.mock('react-native-mmkv', () => ({ - MMKV: jest.fn().mockImplementation(() => ({ - set: jest.fn(), - getString: jest.fn(), - delete: jest.fn(), - })), - useMMKVBoolean: jest.fn(() => [false, jest.fn()]), +// Create mock store states +const mockCoreStoreState = { + activeUnitId: 'unit-123' as string | null, +}; + +const mockLocationStoreState = { + setLocation: jest.fn(), + setBackgroundEnabled: jest.fn(), +}; + +// Mock stores with proper Zustand structure +jest.mock('@/stores/app/core-store', () => ({ + useCoreStore: { + getState: jest.fn(() => mockCoreStoreState), + }, })); jest.mock('@/stores/app/location-store', () => ({ useLocationStore: { - getState: jest.fn(() => ({ - setLocation: jest.fn(), - setBackgroundEnabled: jest.fn(), - })), + getState: jest.fn(() => mockLocationStoreState), }, })); -// Mock React Native AppState and Platform +jest.mock('expo-location', () => { + const mockRequestForegroundPermissions = jest.fn(); + const mockRequestBackgroundPermissions = jest.fn(); + const mockWatchPositionAsync = jest.fn(); + const mockStartLocationUpdatesAsync = jest.fn(); + const mockStopLocationUpdatesAsync = jest.fn(); + return { + requestForegroundPermissionsAsync: mockRequestForegroundPermissions, + requestBackgroundPermissionsAsync: mockRequestBackgroundPermissions, + watchPositionAsync: mockWatchPositionAsync, + startLocationUpdatesAsync: mockStartLocationUpdatesAsync, + stopLocationUpdatesAsync: mockStopLocationUpdatesAsync, + Accuracy: { + Balanced: 'balanced', + }, + }; +}); + +// TaskManager mocks are now handled in the jest.mock() call + +jest.mock('expo-task-manager', () => ({ + defineTask: jest.fn(), + isTaskRegisteredAsync: jest.fn(), +})); + jest.mock('react-native', () => ({ AppState: { - addEventListener: jest.fn().mockReturnValue({ + addEventListener: jest.fn(() => ({ remove: jest.fn(), - }), + })), currentState: 'active', }, - Platform: { - OS: 'ios', - }, })); +import * as Location from 'expo-location'; +import * as TaskManager from 'expo-task-manager'; +import { AppState } from 'react-native'; + +import { setUnitLocation } from '@/api/units/unitLocation'; +import { registerLocationServiceUpdater } from '@/lib/hooks/use-background-geolocation'; +import { logger } from '@/lib/logging'; +import { loadBackgroundGeolocationState } from '@/lib/storage/background-geolocation'; +import { SaveUnitLocationInput } from '@/models/v4/unitLocation/saveUnitLocationInput'; + +// Import the service after mocks are set up +let locationService: any; + +// Mock types +const mockSetUnitLocation = setUnitLocation as jest.MockedFunction; +const mockRegisterLocationServiceUpdater = registerLocationServiceUpdater as jest.MockedFunction; +const mockLogger = logger as jest.Mocked; +const mockLoadBackgroundGeolocationState = loadBackgroundGeolocationState as jest.MockedFunction; +const mockTaskManager = TaskManager as jest.Mocked; +const mockAppState = AppState as jest.Mocked; +const mockLocation = Location as jest.Mocked; + +// Mock location data +const mockLocationObject: Location.LocationObject = { + coords: { + latitude: 37.7749, + longitude: -122.4194, + altitude: 10.5, + accuracy: 5.0, + altitudeAccuracy: 2.0, + heading: 90.0, + speed: 15.5, + }, + timestamp: Date.now(), +}; + +// Mock API response +const mockApiResponse = { + Id: 'location-12345', + PageSize: 0, + Timestamp: '', + Version: '', + Node: '', + RequestId: '', + Status: '', + Environment: '', +}; + describe('LocationService', () => { + let mockLocationSubscription: jest.Mocked; + + beforeAll(() => { + // Import the service after all mocks are set up + const { locationService: service } = require('../location'); + locationService = service; + }); + beforeEach(() => { + // Clear all mock call history jest.clearAllMocks(); - // Set up mock return values - const mockExpoLocation = jest.requireMock('expo-location') as any; - mockExpoLocation.requestForegroundPermissionsAsync.mockResolvedValue({ status: 'granted' }); - mockExpoLocation.requestBackgroundPermissionsAsync.mockResolvedValue({ status: 'granted' }); - mockExpoLocation.watchPositionAsync.mockResolvedValue({ + // Reset mock functions in store states - recreate the mock functions + mockLocationStoreState.setLocation = jest.fn(); + mockLocationStoreState.setBackgroundEnabled = jest.fn(); + + // Clear the mock subscription - handled in the mock itself + + // Setup mock location subscription + mockLocationSubscription = { remove: jest.fn(), + } as jest.Mocked; + + // Setup Location API mocks + mockLocation.requestForegroundPermissionsAsync.mockResolvedValue({ + status: 'granted' as any, + expires: 'never', + granted: true, + canAskAgain: true, }); - const mockTaskManager = jest.requireMock('expo-task-manager') as any; + mockLocation.requestBackgroundPermissionsAsync.mockResolvedValue({ + status: 'granted' as any, + expires: 'never', + granted: true, + canAskAgain: true, + }); + + mockLocation.watchPositionAsync.mockResolvedValue(mockLocationSubscription); + mockLocation.startLocationUpdatesAsync.mockResolvedValue(); + mockLocation.stopLocationUpdatesAsync.mockResolvedValue(); + + // Setup TaskManager mocks mockTaskManager.isTaskRegisteredAsync.mockResolvedValue(false); - // Reset the AppState mock calls since the service was initialized during import - const mockAppState = AppState as any; - mockAppState.addEventListener.mockClear(); + // Setup storage mock + mockLoadBackgroundGeolocationState.mockResolvedValue(false); + + // Setup API mock + mockSetUnitLocation.mockResolvedValue(mockApiResponse); + + // Reset core store state + mockCoreStoreState.activeUnitId = 'unit-123'; + + // Reset internal state of the service + (locationService as any).locationSubscription = null; + (locationService as any).backgroundSubscription = null; + (locationService as any).isBackgroundGeolocationEnabled = false; }); - describe('Background Updates', () => { - it('should start background updates when app goes to background and setting is enabled', async () => { - // Mock the background geolocation setting as enabled - const mockBackgroundGeolocation = jest.requireMock('@/lib/storage/background-geolocation') as any; - const mockLoadBackgroundGeolocationState = mockBackgroundGeolocation.loadBackgroundGeolocationState as jest.MockedFunction<() => Promise>; - mockLoadBackgroundGeolocationState.mockResolvedValue(true); + describe('Singleton Pattern', () => { + it('should return the same instance when called multiple times', () => { + const LocationServiceClass = (locationService as any).constructor; + const instance1 = LocationServiceClass.getInstance(); + const instance2 = LocationServiceClass.getInstance(); + expect(instance1).toBe(instance2); + }); + }); + + describe('Permission Requests', () => { + it('should request both foreground and background permissions', async () => { + const result = await locationService.requestPermissions(); - // Start location updates to set the background enabled flag + expect(mockLocation.requestForegroundPermissionsAsync).toHaveBeenCalled(); + expect(mockLocation.requestBackgroundPermissionsAsync).toHaveBeenCalled(); + expect(result).toBe(true); + }); + + it('should return false if foreground permission is denied', async () => { + mockLocation.requestForegroundPermissionsAsync.mockResolvedValue({ + status: 'denied' as any, + expires: 'never', + granted: false, + canAskAgain: true, + }); + + const result = await locationService.requestPermissions(); + expect(result).toBe(false); + }); + + it('should return false if background permission is denied', async () => { + mockLocation.requestBackgroundPermissionsAsync.mockResolvedValue({ + status: 'denied' as any, + expires: 'never', + granted: false, + canAskAgain: true, + }); + + const result = await locationService.requestPermissions(); + expect(result).toBe(false); + }); + + it('should log permission status', async () => { + await locationService.requestPermissions(); + + expect(mockLogger.info).toHaveBeenCalledWith({ + message: 'Location permissions requested', + context: { + foregroundStatus: 'granted', + backgroundStatus: 'granted', + }, + }); + }); + }); + + describe('Location Updates', () => { + it('should start foreground location updates successfully', async () => { await locationService.startLocationUpdates(); - // Since the AppState listener was set up during service initialization, - // we need to call the updateBackgroundGeolocationSetting to enable it first - await locationService.updateBackgroundGeolocationSetting(true); + expect(mockLocation.watchPositionAsync).toHaveBeenCalledWith( + { + accuracy: Location.Accuracy.Balanced, + timeInterval: 15000, + distanceInterval: 10, + }, + expect.any(Function) + ); - // Verify that background updates would be handled correctly - expect(mockLoadBackgroundGeolocationState).toHaveBeenCalled(); + expect(mockLogger.info).toHaveBeenCalledWith({ + message: 'Foreground location updates started', + context: { backgroundEnabled: false }, + }); }); - it('should stop background updates when app becomes active', async () => { - // Mock the background geolocation setting as enabled - const mockBackgroundGeolocation = jest.requireMock('@/lib/storage/background-geolocation') as any; - const mockLoadBackgroundGeolocationState = mockBackgroundGeolocation.loadBackgroundGeolocationState as jest.MockedFunction<() => Promise>; + it('should throw error if permissions are not granted', async () => { + mockLocation.requestForegroundPermissionsAsync.mockResolvedValue({ + status: 'denied' as any, + expires: 'never', + granted: false, + canAskAgain: true, + }); + + await expect(locationService.startLocationUpdates()).rejects.toThrow('Location permissions not granted'); + }); + + it('should register background task if background geolocation is enabled', async () => { mockLoadBackgroundGeolocationState.mockResolvedValue(true); - // Start location updates and enable background geolocation await locationService.startLocationUpdates(); - await locationService.updateBackgroundGeolocationSetting(true); - // Test that the service can handle background geolocation updates - // Background updates should be managed by the service's internal logic - expect(mockLoadBackgroundGeolocationState).toHaveBeenCalled(); + expect(mockLocation.startLocationUpdatesAsync).toHaveBeenCalledWith('location-updates', { + accuracy: Location.Accuracy.Balanced, + timeInterval: 15000, + distanceInterval: 10, + foregroundService: { + notificationTitle: 'Location Tracking', + notificationBody: 'Tracking your location in the background', + }, + }); }); - it('should not start background updates when setting is disabled', async () => { - // Mock the background geolocation setting as disabled - const mockBackgroundGeolocation = jest.requireMock('@/lib/storage/background-geolocation') as any; - const mockLoadBackgroundGeolocationState = mockBackgroundGeolocation.loadBackgroundGeolocationState as jest.MockedFunction<() => Promise>; - mockLoadBackgroundGeolocationState.mockResolvedValue(false); + it('should not register background task if already registered', async () => { + mockLoadBackgroundGeolocationState.mockResolvedValue(true); + mockTaskManager.isTaskRegisteredAsync.mockResolvedValue(true); - // Start location updates await locationService.startLocationUpdates(); - // Verify that background geolocation setting was checked during startup - expect(mockLoadBackgroundGeolocationState).toHaveBeenCalled(); + expect(mockLocation.startLocationUpdatesAsync).not.toHaveBeenCalled(); + }); - // Explicitly disable background geolocation - await locationService.updateBackgroundGeolocationSetting(false); + it('should handle location updates and send to store and API', async () => { + await locationService.startLocationUpdates(); + + // Get the callback function passed to watchPositionAsync + const locationCallback = mockLocation.watchPositionAsync.mock.calls[0][1] as Function; + await locationCallback(mockLocationObject); + + expect(mockLocationStoreState.setLocation).toHaveBeenCalledWith(mockLocationObject); + expect(mockSetUnitLocation).toHaveBeenCalledWith(expect.any(SaveUnitLocationInput)); + expect(mockLogger.info).toHaveBeenCalledWith({ + message: 'Foreground location update received', + context: { + latitude: mockLocationObject.coords.latitude, + longitude: mockLocationObject.coords.longitude, + heading: mockLocationObject.coords.heading, + }, + }); }); + }); - it('should update background geolocation setting and handle immediate background state', async () => { - // Mock current app state as background - const mockAppState = AppState as any; - mockAppState.currentState = 'background'; + describe('Background Location Updates', () => { + beforeEach(() => { + // Set background geolocation enabled for these tests + (locationService as any).isBackgroundGeolocationEnabled = true; + }); - // Enable background geolocation - await locationService.updateBackgroundGeolocationSetting(true); + it('should start background updates when not already active', async () => { + await locationService.startBackgroundUpdates(); + + expect(mockLocation.watchPositionAsync).toHaveBeenCalledWith( + { + accuracy: Location.Accuracy.Balanced, + timeInterval: 60000, + distanceInterval: 20, + }, + expect.any(Function) + ); + + expect(mockLocationStoreState.setBackgroundEnabled).toHaveBeenCalledWith(true); + expect(mockLogger.info).toHaveBeenCalledWith({ + message: 'Starting background location updates', + }); + }); + + it('should not start background updates if already active', async () => { + (locationService as any).backgroundSubscription = mockLocationSubscription; + + await locationService.startBackgroundUpdates(); + + expect(mockLocation.watchPositionAsync).not.toHaveBeenCalled(); + }); + + it('should not start background updates if disabled', async () => { + (locationService as any).isBackgroundGeolocationEnabled = false; + + await locationService.startBackgroundUpdates(); + + expect(mockLocation.watchPositionAsync).not.toHaveBeenCalled(); + }); + + it('should stop background updates correctly', async () => { + (locationService as any).backgroundSubscription = mockLocationSubscription; + + await locationService.stopBackgroundUpdates(); + + expect(mockLocationSubscription.remove).toHaveBeenCalled(); + expect(mockLocationStoreState.setBackgroundEnabled).toHaveBeenCalledWith(false); + expect(mockLogger.info).toHaveBeenCalledWith({ + message: 'Stopping background location updates', + }); + }); - // Should start background updates since app is currently backgrounded - expect(AppState.currentState).toBe('background'); + it('should handle background location updates and send to API', async () => { + await locationService.startBackgroundUpdates(); + + // Get the callback function + const locationCallback = mockLocation.watchPositionAsync.mock.calls[0][1] as Function; + await locationCallback(mockLocationObject); + + expect(mockLocationStoreState.setLocation).toHaveBeenCalledWith(mockLocationObject); + expect(mockSetUnitLocation).toHaveBeenCalledWith(expect.any(SaveUnitLocationInput)); + }); + }); + + describe('API Integration', () => { + it('should send location data to API with correct format', async () => { + await locationService.startLocationUpdates(); + const locationCallback = mockLocation.watchPositionAsync.mock.calls[0][1] as Function; + await locationCallback(mockLocationObject); + + expect(mockSetUnitLocation).toHaveBeenCalledWith( + expect.objectContaining({ + UnitId: 'unit-123', + Latitude: mockLocationObject.coords.latitude.toString(), + Longitude: mockLocationObject.coords.longitude.toString(), + Accuracy: mockLocationObject.coords.accuracy?.toString(), + Altitude: mockLocationObject.coords.altitude?.toString(), + AltitudeAccuracy: mockLocationObject.coords.altitudeAccuracy?.toString(), + Speed: mockLocationObject.coords.speed?.toString(), + Heading: mockLocationObject.coords.heading?.toString(), + Timestamp: expect.any(String), + }) + ); }); - it('should disable background geolocation and stop updates', async () => { - // First enable background geolocation + it('should handle null values in location data', async () => { + const locationWithNulls: Location.LocationObject = { + coords: { + latitude: 37.7749, + longitude: -122.4194, + altitude: null, + accuracy: null, + altitudeAccuracy: null, + heading: null, + speed: null, + }, + timestamp: Date.now(), + }; + + await locationService.startLocationUpdates(); + const locationCallback = mockLocation.watchPositionAsync.mock.calls[0][1] as Function; + await locationCallback(locationWithNulls); + + expect(mockSetUnitLocation).toHaveBeenCalledWith( + expect.objectContaining({ + Accuracy: '0', + Altitude: '0', + AltitudeAccuracy: '0', + Speed: '0', + Heading: '0', + }) + ); + }); + + it('should skip API call if no active unit is selected', async () => { + // Change the core store state for this test + mockCoreStoreState.activeUnitId = null; + + await locationService.startLocationUpdates(); + const locationCallback = mockLocation.watchPositionAsync.mock.calls[0][1] as Function; + await locationCallback(mockLocationObject); + + expect(mockSetUnitLocation).not.toHaveBeenCalled(); + expect(mockLogger.warn).toHaveBeenCalledWith({ + message: 'No active unit selected, skipping location API call', + }); + + // Reset for other tests + mockCoreStoreState.activeUnitId = 'unit-123'; + }); + + it('should handle API errors gracefully', async () => { + const apiError = new Error('API Error'); + mockSetUnitLocation.mockRejectedValue(apiError); + + await locationService.startLocationUpdates(); + const locationCallback = mockLocation.watchPositionAsync.mock.calls[0][1] as Function; + await locationCallback(mockLocationObject); + + expect(mockLogger.error).toHaveBeenCalledWith({ + message: 'Failed to send location to API', + context: { + error: 'API Error', + latitude: mockLocationObject.coords.latitude, + longitude: mockLocationObject.coords.longitude, + }, + }); + }); + + it('should log successful API calls', async () => { + // Reset mock to resolved value + mockSetUnitLocation.mockResolvedValue(mockApiResponse); + + await locationService.startLocationUpdates(); + const locationCallback = mockLocation.watchPositionAsync.mock.calls[0][1] as Function; + await locationCallback(mockLocationObject); + + expect(mockLogger.info).toHaveBeenCalledWith({ + message: 'Location successfully sent to API', + context: { + unitId: 'unit-123', + resultId: mockApiResponse.Id, + latitude: mockLocationObject.coords.latitude, + longitude: mockLocationObject.coords.longitude, + }, + }); + }); + }); + + describe('Background Geolocation Setting Updates', () => { + it('should enable background tracking and register task', async () => { await locationService.updateBackgroundGeolocationSetting(true); - // Then disable it + expect(mockLocation.startLocationUpdatesAsync).toHaveBeenCalledWith( + 'location-updates', + expect.objectContaining({ + accuracy: Location.Accuracy.Balanced, + timeInterval: 15000, + distanceInterval: 10, + }) + ); + }); + + it('should disable background tracking and unregister task', async () => { + mockTaskManager.isTaskRegisteredAsync.mockResolvedValue(true); + await locationService.updateBackgroundGeolocationSetting(false); - // Background updates should be stopped - // This is verified by the service's internal state management + expect(mockLocation.stopLocationUpdatesAsync).toHaveBeenCalledWith('location-updates'); + }); + + it('should start background updates if app is backgrounded when enabled', async () => { + (AppState as any).currentState = 'background'; + const startBackgroundUpdatesSpy = jest.spyOn(locationService, 'startBackgroundUpdates'); + + await locationService.updateBackgroundGeolocationSetting(true); + + expect(startBackgroundUpdatesSpy).toHaveBeenCalled(); + }); + }); + + describe('Cleanup', () => { + it('should stop all location updates', async () => { + (locationService as any).locationSubscription = mockLocationSubscription; + (locationService as any).backgroundSubscription = mockLocationSubscription; + mockTaskManager.isTaskRegisteredAsync.mockResolvedValue(true); + + await locationService.stopLocationUpdates(); + + expect(mockLocationSubscription.remove).toHaveBeenCalledTimes(2); + expect(mockLocation.stopLocationUpdatesAsync).toHaveBeenCalledWith('location-updates'); + expect(mockLogger.info).toHaveBeenCalledWith({ + message: 'All location updates stopped', + }); + }); + + it('should cleanup app state subscription', () => { + locationService.cleanup(); + + // Note: The subscription's remove method is called, but we can't easily test it + // since the subscription is created dynamically inside the mock + expect(true).toBe(true); // This test passes if cleanup doesn't throw + }); + + it('should handle cleanup when no subscription exists', () => { + (locationService as any).appStateSubscription = null; + + expect(() => locationService.cleanup()).not.toThrow(); + }); + }); + + describe('Error Handling', () => { + it('should handle location subscription errors', async () => { + const error = new Error('Location subscription failed'); + mockLocation.watchPositionAsync.mockRejectedValue(error); + + await expect(locationService.startLocationUpdates()).rejects.toThrow('Location subscription failed'); + }); + + it('should handle background task registration errors', async () => { + const error = new Error('Task registration failed'); + mockLocation.startLocationUpdatesAsync.mockRejectedValue(error); + mockLoadBackgroundGeolocationState.mockResolvedValue(true); + + await expect(locationService.startLocationUpdates()).rejects.toThrow('Task registration failed'); }); }); }); diff --git a/src/services/location.ts b/src/services/location.ts index ec9dd18..418c13c 100644 --- a/src/services/location.ts +++ b/src/services/location.ts @@ -2,13 +2,62 @@ import * as Location from 'expo-location'; import * as TaskManager from 'expo-task-manager'; import { AppState, type AppStateStatus } from 'react-native'; +import { setUnitLocation } from '@/api/units/unitLocation'; import { registerLocationServiceUpdater } from '@/lib/hooks/use-background-geolocation'; import { logger } from '@/lib/logging'; import { loadBackgroundGeolocationState } from '@/lib/storage/background-geolocation'; +import { SaveUnitLocationInput } from '@/models/v4/unitLocation/saveUnitLocationInput'; +import { useCoreStore } from '@/stores/app/core-store'; import { useLocationStore } from '@/stores/app/location-store'; const LOCATION_TASK_NAME = 'location-updates'; +// Helper function to send location to API +const sendLocationToAPI = async (location: Location.LocationObject): Promise => { + try { + const { activeUnitId } = useCoreStore.getState(); + + if (!activeUnitId) { + logger.warn({ + message: 'No active unit selected, skipping location API call', + }); + return; + } + + const locationInput = new SaveUnitLocationInput(); + locationInput.UnitId = activeUnitId; + locationInput.Timestamp = new Date(location.timestamp).toISOString(); + locationInput.Latitude = location.coords.latitude.toString(); + locationInput.Longitude = location.coords.longitude.toString(); + locationInput.Accuracy = location.coords.accuracy?.toString() || '0'; + locationInput.Altitude = location.coords.altitude?.toString() || '0'; + locationInput.AltitudeAccuracy = location.coords.altitudeAccuracy?.toString() || '0'; + locationInput.Speed = location.coords.speed?.toString() || '0'; + locationInput.Heading = location.coords.heading?.toString() || '0'; + + const result = await setUnitLocation(locationInput); + + logger.info({ + message: 'Location successfully sent to API', + context: { + unitId: activeUnitId, + resultId: result.Id, + latitude: location.coords.latitude, + longitude: location.coords.longitude, + }, + }); + } catch (error) { + logger.error({ + message: 'Failed to send location to API', + context: { + error: error instanceof Error ? error.message : String(error), + latitude: location.coords.latitude, + longitude: location.coords.longitude, + }, + }); + } +}; + // Define the task TaskManager.defineTask(LOCATION_TASK_NAME, async ({ data, error }) => { if (error) { @@ -30,7 +79,12 @@ TaskManager.defineTask(LOCATION_TASK_NAME, async ({ data, error }) => { heading: location.coords.heading, }, }); + + // Update local store useLocationStore.getState().setLocation(location); + + // Send to API + await sendLocationToAPI(location); } } }); @@ -130,6 +184,7 @@ class LocationService { }, }); useLocationStore.getState().setLocation(location); + sendLocationToAPI(location); // Send to API for foreground updates } ); @@ -164,6 +219,7 @@ class LocationService { }, }); useLocationStore.getState().setLocation(location); + sendLocationToAPI(location); // Send to API for background updates } ); diff --git a/src/stores/app/__tests__/location-store.test.ts b/src/stores/app/__tests__/location-store.test.ts new file mode 100644 index 0000000..ae55cae --- /dev/null +++ b/src/stores/app/__tests__/location-store.test.ts @@ -0,0 +1,148 @@ +import { renderHook, act } from '@testing-library/react-native'; + +import { useLocationStore } from '../location-store'; + +describe('useLocationStore', () => { + beforeEach(() => { + // Reset the store before each test + useLocationStore.setState({ + latitude: null, + longitude: null, + heading: null, + accuracy: null, + speed: null, + altitude: null, + timestamp: null, + isBackgroundEnabled: false, + isMapLocked: false, + }); + }); + + it('should have initial state', () => { + const { result } = renderHook(() => useLocationStore()); + + expect(result.current.latitude).toBeNull(); + expect(result.current.longitude).toBeNull(); + expect(result.current.heading).toBeNull(); + expect(result.current.accuracy).toBeNull(); + expect(result.current.speed).toBeNull(); + expect(result.current.altitude).toBeNull(); + expect(result.current.timestamp).toBeNull(); + expect(result.current.isBackgroundEnabled).toBe(false); + expect(result.current.isMapLocked).toBe(false); + }); + + it('should update location data', () => { + const { result } = renderHook(() => useLocationStore()); + + const mockLocation = { + coords: { + latitude: 40.7128, + longitude: -74.006, + heading: 180, + accuracy: 5, + speed: 0, + altitude: 10, + altitudeAccuracy: 5, + }, + timestamp: 1640995200000, + }; + + act(() => { + result.current.setLocation(mockLocation); + }); + + expect(result.current.latitude).toBe(40.7128); + expect(result.current.longitude).toBe(-74.006); + expect(result.current.heading).toBe(180); + expect(result.current.accuracy).toBe(5); + expect(result.current.speed).toBe(0); + expect(result.current.altitude).toBe(10); + expect(result.current.timestamp).toBe(1640995200000); + }); + + it('should set background enabled', () => { + const { result } = renderHook(() => useLocationStore()); + + act(() => { + result.current.setBackgroundEnabled(true); + }); + + expect(result.current.isBackgroundEnabled).toBe(true); + + act(() => { + result.current.setBackgroundEnabled(false); + }); + + expect(result.current.isBackgroundEnabled).toBe(false); + }); + + describe('Map Lock Functionality', () => { + it('should set map locked state', () => { + const { result } = renderHook(() => useLocationStore()); + + // Initially unlocked + expect(result.current.isMapLocked).toBe(false); + + // Lock the map + act(() => { + result.current.setMapLocked(true); + }); + + expect(result.current.isMapLocked).toBe(true); + + // Unlock the map + act(() => { + result.current.setMapLocked(false); + }); + + expect(result.current.isMapLocked).toBe(false); + }); + + it('should toggle map lock state', () => { + const { result } = renderHook(() => useLocationStore()); + + // Start unlocked + expect(result.current.isMapLocked).toBe(false); + + // Toggle to locked + act(() => { + result.current.setMapLocked(!result.current.isMapLocked); + }); + + expect(result.current.isMapLocked).toBe(true); + + // Toggle back to unlocked + act(() => { + result.current.setMapLocked(!result.current.isMapLocked); + }); + + expect(result.current.isMapLocked).toBe(false); + }); + + it('should persist map lock state', () => { + const { result } = renderHook(() => useLocationStore()); + + // Set map locked + act(() => { + result.current.setMapLocked(true); + }); + + expect(result.current.isMapLocked).toBe(true); + + // Create a new hook instance (simulating app restart) + const { result: newResult } = renderHook(() => useLocationStore()); + + // Map lock state should be persisted + expect(newResult.current.isMapLocked).toBe(true); + }); + }); + + it('should have all required methods', () => { + const { result } = renderHook(() => useLocationStore()); + + expect(typeof result.current.setLocation).toBe('function'); + expect(typeof result.current.setBackgroundEnabled).toBe('function'); + expect(typeof result.current.setMapLocked).toBe('function'); + }); +}); diff --git a/src/stores/app/location-store.ts b/src/stores/app/location-store.ts index 000debd..1d09c18 100644 --- a/src/stores/app/location-store.ts +++ b/src/stores/app/location-store.ts @@ -13,8 +13,10 @@ export interface LocationState { altitude: number | null; timestamp: number | null; isBackgroundEnabled: boolean; + isMapLocked: boolean; setLocation: (location: Location.LocationObject) => void; setBackgroundEnabled: (enabled: boolean) => void; + setMapLocked: (locked: boolean) => void; } export const useLocationStore = create()( @@ -28,6 +30,7 @@ export const useLocationStore = create()( altitude: null, timestamp: null, isBackgroundEnabled: false, + isMapLocked: false, setLocation: (location) => set({ latitude: location.coords.latitude, @@ -39,6 +42,7 @@ export const useLocationStore = create()( timestamp: location.timestamp, }), setBackgroundEnabled: (enabled) => set({ isBackgroundEnabled: enabled }), + setMapLocked: (locked) => set({ isMapLocked: locked }), }), { name: 'location-storage', diff --git a/src/translations/ar.json b/src/translations/ar.json index 7ee3cc6..dc94358 100644 --- a/src/translations/ar.json +++ b/src/translations/ar.json @@ -180,6 +180,8 @@ "no_call_selected_info": "هذه الوحدة لا تستجيب حاليًا لأي مكالمات", "no_calls": "لا توجد مكالمات نشطة", "no_calls_description": "لم يتم العثور على مكالمات نشطة. اختر مكالمة نشطة لعرض التفاصيل.", + "no_location_message": "هذه المكالمة لا تحتوي على بيانات موقع متاحة للملاحة.", + "no_location_title": "الموقع غير متاح", "no_open_calls": "لا توجد مكالمات مفتوحة متاحة", "note": "ملاحظة", "note_placeholder": "أدخل ملاحظة المكالمة", @@ -243,6 +245,7 @@ "no_results_found": "لم يتم العثور على نتائج", "no_unit_selected": "لم يتم اختيار وحدة", "nothingToDisplay": "لا يوجد شيء للعرض في الوقت الحالي", + "ok": "موافق", "permission_denied": "تم رفض الإذن", "remove": "إزالة", "route": "مسار", diff --git a/src/translations/en.json b/src/translations/en.json index 8062953..8ce03a3 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -180,6 +180,8 @@ "no_call_selected_info": "This unit is not currently responding to any calls", "no_calls": "No active calls", "no_calls_description": "No active calls found. Select an active call to view details.", + "no_location_message": "This call does not have location data available for navigation.", + "no_location_title": "No Location Available", "no_open_calls": "No open calls available", "note": "Note", "note_placeholder": "Enter the note of the call", @@ -243,6 +245,7 @@ "no_results_found": "No results found", "no_unit_selected": "No Unit Selected", "nothingToDisplay": "There's nothing to display at the moment", + "ok": "Ok", "permission_denied": "Permission denied", "remove": "Remove", "route": "Route", diff --git a/src/translations/es.json b/src/translations/es.json index 1755b92..08dfb2f 100644 --- a/src/translations/es.json +++ b/src/translations/es.json @@ -180,6 +180,8 @@ "no_call_selected_info": "Esta unidad no está respondiendo a ninguna llamada actualmente", "no_calls": "No hay llamadas activas", "no_calls_description": "No se encontraron llamadas activas. Seleccione una llamada activa para ver los detalles.", + "no_location_message": "Esta llamada no tiene datos de ubicación disponibles para navegación.", + "no_location_title": "Ubicación No Disponible", "no_open_calls": "No hay llamadas abiertas disponibles", "note": "Nota", "note_placeholder": "Introduce la nota de la llamada", @@ -243,6 +245,7 @@ "no_results_found": "No se encontraron resultados", "no_unit_selected": "Ninguna unidad seleccionada", "nothingToDisplay": "No hay nada que mostrar en este momento", + "ok": "Ok", "permission_denied": "Permiso denegado", "remove": "Eliminar", "route": "Ruta",