From b646d91bb39572c9cfb11feffdd352b5707ec0bb Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Fri, 23 Jan 2026 09:35:04 -0800 Subject: [PATCH 1/4] RU-T46 Fixing issue with call creation map, perf fix for action. Livekit fixes. --- __mocks__/@livekit/react-native-webrtc.ts | 12 + expo-env.d.ts | 2 +- src/api/calls/calls.ts | 6 +- .../calls/__tests__/call-files-modal.test.tsx | 83 +++---- .../__tests__/call-notes-modal-new.test.tsx | 111 +++++++++- .../close-call-bottom-sheet.test.tsx | 110 +++++---- .../__tests__/livekit-bottom-sheet.test.tsx | 20 ++ .../livekit/livekit-bottom-sheet.tsx | 8 +- .../maps/full-screen-location-picker.tsx | 209 ++++++++++-------- src/components/maps/location-picker.tsx | 134 +++++++---- src/components/maps/static-map.tsx | 4 + .../settings/unit-selection-bottom-sheet.tsx | 16 +- .../sidebar/__tests__/unit-sidebar.test.tsx | 11 + .../livekit-call/store/useLiveKitCallStore.ts | 2 +- .../__tests__/app-reset.service.test.ts | 8 +- .../__tests__/bluetooth-audio.service.test.ts | 20 ++ src/services/bluetooth-audio.service.ts | 22 +- src/stores/app/livekit-store.ts | 37 ++++ src/stores/status/__tests__/store.test.ts | 8 + src/stores/status/store.ts | 37 +++- src/translations/ar.json | 1 + src/translations/en.json | 1 + src/translations/es.json | 1 + 23 files changed, 589 insertions(+), 274 deletions(-) create mode 100644 __mocks__/@livekit/react-native-webrtc.ts diff --git a/__mocks__/@livekit/react-native-webrtc.ts b/__mocks__/@livekit/react-native-webrtc.ts new file mode 100644 index 00000000..4e3d21c6 --- /dev/null +++ b/__mocks__/@livekit/react-native-webrtc.ts @@ -0,0 +1,12 @@ +// Mock for @livekit/react-native-webrtc +export const RTCAudioSession = { + configure: jest.fn().mockResolvedValue(undefined), + setCategory: jest.fn().mockResolvedValue(undefined), + setMode: jest.fn().mockResolvedValue(undefined), + getActiveAudioSession: jest.fn().mockReturnValue(null), + setActive: jest.fn().mockResolvedValue(undefined), +}; + +export default { + RTCAudioSession, +}; diff --git a/expo-env.d.ts b/expo-env.d.ts index 5411fdde..bf3c1693 100644 --- a/expo-env.d.ts +++ b/expo-env.d.ts @@ -1,3 +1,3 @@ /// -// NOTE: This file should not be edited and should be in your git ignore \ No newline at end of file +// NOTE: This file should not be edited and should be in your git ignore diff --git a/src/api/calls/calls.ts b/src/api/calls/calls.ts index dfae5528..4324714c 100644 --- a/src/api/calls/calls.ts +++ b/src/api/calls/calls.ts @@ -3,9 +3,13 @@ import { type CallExtraDataResult } from '@/models/v4/calls/callExtraDataResult' import { type CallResult } from '@/models/v4/calls/callResult'; import { type SaveCallResult } from '@/models/v4/calls/saveCallResult'; +import { createCachedApiEndpoint } from '../common/cached-client'; import { createApiEndpoint } from '../common/client'; -const callsApi = createApiEndpoint('/Calls/GetActiveCalls'); +const callsApi = createCachedApiEndpoint('/Calls/GetActiveCalls', { + ttl: 30 * 1000, // Cache for 30 seconds - calls can change frequently + enabled: true, +}); const getCallApi = createApiEndpoint('/Calls/GetCall'); const getCallExtraDataApi = createApiEndpoint('/Calls/GetCallExtraData'); const createCallApi = createApiEndpoint('/Calls/SaveCall'); diff --git a/src/components/calls/__tests__/call-files-modal.test.tsx b/src/components/calls/__tests__/call-files-modal.test.tsx index ed137d19..0e729df7 100644 --- a/src/components/calls/__tests__/call-files-modal.test.tsx +++ b/src/components/calls/__tests__/call-files-modal.test.tsx @@ -37,14 +37,29 @@ const defaultMockFiles = [ }, ]; -let mockStoreState: any = { - callFiles: defaultMockFiles, +// Create a single object that will be mutated - never reassign! +const mockStoreState = { + callFiles: defaultMockFiles as any, isLoadingFiles: false, - errorFiles: null, + errorFiles: null as string | null, fetchCallFiles: mockFetchCallFiles, clearFiles: mockClearFiles, }; +// Helper function to update mock state without replacing the object +const setMockStoreState = (updates: Partial) => { + Object.assign(mockStoreState, updates); +}; + +// Reset mock state to defaults +const resetMockStoreState = () => { + mockStoreState.callFiles = defaultMockFiles; + mockStoreState.isLoadingFiles = false; + mockStoreState.errorFiles = null; + mockStoreState.fetchCallFiles = mockFetchCallFiles; + mockStoreState.clearFiles = mockClearFiles; +}; + jest.mock('@/stores/calls/detail-store', () => ({ useCallDetailStore: () => mockStoreState, })); @@ -301,13 +316,7 @@ describe('CallFilesModal', () => { beforeEach(() => { jest.clearAllMocks(); // Reset to default state - mockStoreState = { - callFiles: defaultMockFiles, - isLoadingFiles: false, - errorFiles: null, - fetchCallFiles: mockFetchCallFiles, - clearFiles: mockClearFiles, - }; + resetMockStoreState(); }); it('renders correctly when closed', () => { @@ -421,13 +430,11 @@ describe('CallFilesModal', () => { describe('Loading States', () => { beforeEach(() => { // Mock loading state - mockStoreState = { + setMockStoreState({ callFiles: null, isLoadingFiles: true, errorFiles: null, - fetchCallFiles: mockFetchCallFiles, - clearFiles: mockClearFiles, - }; + }); }); it('displays loading spinner when fetching files', () => { @@ -443,13 +450,11 @@ describe('CallFilesModal', () => { describe('Error States', () => { beforeEach(() => { // Mock error state - mockStoreState = { + setMockStoreState({ callFiles: [], isLoadingFiles: false, errorFiles: 'Network error occurred', - fetchCallFiles: mockFetchCallFiles, - clearFiles: mockClearFiles, - }; + }); }); it('displays error message when file fetch fails', () => { @@ -476,13 +481,11 @@ describe('CallFilesModal', () => { describe('Empty States', () => { beforeEach(() => { // Mock empty state - mockStoreState = { + setMockStoreState({ callFiles: [], isLoadingFiles: false, errorFiles: null, - fetchCallFiles: mockFetchCallFiles, - clearFiles: mockClearFiles, - }; + }); }); it('displays empty state when no files available', () => { @@ -503,12 +506,11 @@ describe('CallFilesModal', () => { beforeEach(() => { // Reset to default state with files - mockStoreState = { + setMockStoreState({ callFiles: defaultMockFiles, isLoadingFiles: false, errorFiles: null, - fetchCallFiles: mockFetchCallFiles, - }; + }); }); it('downloads and shares file when clicked', async () => { @@ -591,13 +593,7 @@ describe('CallFilesModal', () => { describe('File Format Utilities', () => { beforeEach(() => { // Reset to default state - mockStoreState = { - callFiles: defaultMockFiles, - isLoadingFiles: false, - errorFiles: null, - fetchCallFiles: mockFetchCallFiles, - clearFiles: mockClearFiles, - }; + resetMockStoreState(); }); it('formats file sizes correctly', () => { @@ -625,13 +621,7 @@ describe('CallFilesModal', () => { beforeEach(() => { jest.clearAllMocks(); // Reset to default state - mockStoreState = { - callFiles: defaultMockFiles, - isLoadingFiles: false, - errorFiles: null, - fetchCallFiles: mockFetchCallFiles, - clearFiles: mockClearFiles, - }; + resetMockStoreState(); }); it('should track analytics event when modal is opened', () => { @@ -653,10 +643,9 @@ describe('CallFilesModal', () => { }); it('should track analytics event with loading state', () => { - mockStoreState = { - ...mockStoreState, + setMockStoreState({ isLoadingFiles: true, - }; + }); render(); @@ -670,10 +659,9 @@ describe('CallFilesModal', () => { }); it('should track analytics event with error state', () => { - mockStoreState = { - ...mockStoreState, + setMockStoreState({ errorFiles: 'Failed to load files', - }; + }); render(); @@ -687,10 +675,9 @@ describe('CallFilesModal', () => { }); it('should track analytics event with no files', () => { - mockStoreState = { - ...mockStoreState, + setMockStoreState({ callFiles: [], - }; + }); render(); diff --git a/src/components/calls/__tests__/call-notes-modal-new.test.tsx b/src/components/calls/__tests__/call-notes-modal-new.test.tsx index 6ae11779..3e1bc4f6 100644 --- a/src/components/calls/__tests__/call-notes-modal-new.test.tsx +++ b/src/components/calls/__tests__/call-notes-modal-new.test.tsx @@ -9,6 +9,11 @@ import { useCallDetailStore } from '@/stores/calls/detail-store'; jest.mock('react-i18next'); jest.mock('@/lib/auth'); jest.mock('@/stores/calls/detail-store'); +jest.mock('@/hooks/use-analytics', () => ({ + useAnalytics: () => ({ + trackEvent: jest.fn(), + }), +})); // Mock navigation jest.mock('@react-navigation/native', () => ({ @@ -25,10 +30,13 @@ jest.mock('nativewind', () => ({ })); // Mock lucide-react-native icons -jest.mock('lucide-react-native', () => ({ - SearchIcon: 'SearchIcon', - X: 'X', -})); +jest.mock('lucide-react-native', () => { + const { View } = require('react-native'); + return { + SearchIcon: (props: any) => , + X: (props: any) => , + }; +}); // Mock Loading component jest.mock('../../common/loading', () => ({ @@ -58,6 +66,10 @@ jest.mock('react-native-keyboard-controller', () => ({ const { View } = require('react-native'); return {children}; }, + KeyboardStickyView: ({ children }: any) => { + const { View } = require('react-native'); + return {children}; + }, })); // Mock react-native-gesture-handler @@ -94,10 +106,86 @@ jest.mock('@gorhom/bottom-sheet', () => { }; }); -// Mock lucide-react-native icons -jest.mock('lucide-react-native', () => ({ - SearchIcon: 'SearchIcon', - X: 'X', +// Mock UI components +jest.mock('../../ui/box', () => ({ + Box: ({ children, ...props }: any) => { + const { View } = require('react-native'); + return {children}; + }, +})); + +jest.mock('../../ui/button', () => ({ + Button: ({ children, onPress, disabled, isDisabled, ...props }: any) => { + const { TouchableOpacity } = require('react-native'); + const isButtonDisabled = disabled || isDisabled; + return ( + + {children} + + ); + }, + ButtonText: ({ children, ...props }: any) => { + const { Text } = require('react-native'); + return {children}; + }, +})); + +jest.mock('../../ui/heading', () => ({ + Heading: ({ children, ...props }: any) => { + const { Text } = require('react-native'); + return {children}; + }, +})); + +jest.mock('../../ui/hstack', () => ({ + HStack: ({ children, ...props }: any) => { + const { View } = require('react-native'); + return {children}; + }, +})); + +jest.mock('../../ui/vstack', () => ({ + VStack: ({ children, ...props }: any) => { + const { View } = require('react-native'); + return {children}; + }, +})); + +jest.mock('../../ui/text', () => ({ + Text: ({ children, ...props }: any) => { + const { Text: RNText } = require('react-native'); + return {children}; + }, +})); + +jest.mock('../../ui/input', () => ({ + Input: ({ children, ...props }: any) => { + const { View } = require('react-native'); + return {children}; + }, + InputField: ({ placeholder, value, onChangeText, ...props }: any) => { + const { TextInput } = require('react-native'); + return ; + }, + InputSlot: ({ children, ...props }: any) => { + const { View } = require('react-native'); + return {children}; + }, +})); + +jest.mock('../../ui/textarea', () => ({ + Textarea: ({ children, ...props }: any) => { + const { View } = require('react-native'); + return {children}; + }, + TextareaInput: ({ placeholder, value, onChangeText, ...props }: any) => { + const { TextInput } = require('react-native'); + return ; + }, })); const mockUseTranslation = useTranslation as jest.MockedFunction; @@ -146,6 +234,9 @@ describe('CallNotesModal', () => { 'callNotes.searchPlaceholder': 'Search notes...', 'callNotes.addNotePlaceholder': 'Add a note...', 'callNotes.addNote': 'Add Note', + 'callNotes.noNotesFound': 'No notes found', + 'callNotes.addNoteLabel': 'Add a note', + 'common.cancel': 'Cancel', }; return translations[key] || key; }, @@ -185,8 +276,8 @@ describe('CallNotesModal', () => { it('renders correctly when closed', () => { const { queryByText } = render(); - // Bottom sheet should still render but with index -1 (closed) - expect(queryByText('Call Notes')).toBeTruthy(); + // Component returns null when closed + expect(queryByText('Call Notes')).toBeFalsy(); }); it('handles search input correctly', () => { diff --git a/src/components/calls/__tests__/close-call-bottom-sheet.test.tsx b/src/components/calls/__tests__/close-call-bottom-sheet.test.tsx index e344f53a..75a70eca 100644 --- a/src/components/calls/__tests__/close-call-bottom-sheet.test.tsx +++ b/src/components/calls/__tests__/close-call-bottom-sheet.test.tsx @@ -121,41 +121,61 @@ jest.mock('@/components/ui/form-control', () => ({ }, })); -jest.mock('@/components/ui/select', () => ({ - Select: ({ children, testID, selectedValue, onValueChange, ...props }: any) => { - const { View, TouchableOpacity, Text } = require('react-native'); - return ( - - {children} - onValueChange && onValueChange('1')}> - Select Option - - - ); - }, - SelectTrigger: ({ children, ...props }: any) => { - const { View } = require('react-native'); - return {children}; - }, - SelectInput: ({ placeholder, ...props }: any) => { - const { Text } = require('react-native'); - return {placeholder}; - }, - SelectIcon: () => null, - SelectPortal: ({ children, ...props }: any) => { - const { View } = require('react-native'); - return {children}; - }, - SelectBackdrop: () => null, - SelectContent: ({ children, ...props }: any) => { - const { View } = require('react-native'); - return {children}; - }, - SelectItem: ({ label, value, ...props }: any) => { - const { View, Text } = require('react-native'); - return {label}; - }, -})); +jest.mock('@/components/ui/select', () => { + // Store the callback for each select + const selectCallbacks: Record void> = {}; + + return { + Select: ({ children, testID, selectedValue, onValueChange, ...props }: any) => { + const React = require('react'); + const { View, TouchableOpacity, Text } = require('react-native'); + + // Store the callback for external access + React.useEffect(() => { + if (testID && onValueChange) { + selectCallbacks[testID] = onValueChange; + } + return () => { + if (testID) { + delete selectCallbacks[testID]; + } + }; + }, [testID, onValueChange]); + + return ( + + {children} + + ); + }, + SelectTrigger: ({ children, ...props }: any) => { + const { View } = require('react-native'); + return {children}; + }, + SelectInput: ({ placeholder, ...props }: any) => { + const { Text } = require('react-native'); + return {placeholder}; + }, + SelectIcon: () => null, + SelectPortal: ({ children, ...props }: any) => { + const { View } = require('react-native'); + return {children}; + }, + SelectBackdrop: () => null, + SelectContent: ({ children, ...props }: any) => { + const { View } = require('react-native'); + return {children}; + }, + SelectItem: ({ label, value, ...props }: any) => { + const { View, Text } = require('react-native'); + return {label}; + }, + }; +}); jest.mock('@/components/ui/textarea', () => ({ Textarea: ({ children, ...props }: any) => { @@ -170,6 +190,7 @@ jest.mock('@/components/ui/textarea', () => ({ const mockRouter = { back: jest.fn(), + replace: jest.fn(), }; const mockUseTranslation = { @@ -272,7 +293,7 @@ describe('CloseCallBottomSheet', () => { }); expect(mockShowToast).toHaveBeenCalledWith('success', 'call_detail.close_call_success'); expect(mockFetchCalls).toHaveBeenCalled(); - expect(mockRouter.back).toHaveBeenCalled(); + expect(mockRouter.replace).toHaveBeenCalled(); expect(mockOnClose).toHaveBeenCalled(); }); }); @@ -300,7 +321,7 @@ describe('CloseCallBottomSheet', () => { }); expect(mockShowToast).toHaveBeenCalledWith('success', 'call_detail.close_call_success'); expect(mockFetchCalls).toHaveBeenCalled(); - expect(mockRouter.back).toHaveBeenCalled(); + expect(mockRouter.replace).toHaveBeenCalled(); expect(mockOnClose).toHaveBeenCalled(); }); }); @@ -324,7 +345,7 @@ describe('CloseCallBottomSheet', () => { }); expect(mockFetchCalls).not.toHaveBeenCalled(); - expect(mockRouter.back).not.toHaveBeenCalled(); + expect(mockRouter.replace).not.toHaveBeenCalled(); }); it.each([ @@ -429,19 +450,20 @@ describe('CloseCallBottomSheet', () => { // Wait for the entire flow to complete await waitFor(() => { expect(mockCloseCall).toHaveBeenCalled(); - expect(mockFetchCalls).toHaveBeenCalled(); }); - // Wait for all toast messages and error handling to complete + // Wait for success toast and close since closeCall succeeded await waitFor(() => { expect(mockShowToast).toHaveBeenCalledWith('success', 'call_detail.close_call_success'); expect(mockOnClose).toHaveBeenCalled(); - expect(console.error).toHaveBeenCalledWith('Error closing call:', expect.any(Error)); - expect(mockShowToast).toHaveBeenCalledWith('error', 'call_detail.close_call_error'); }); - // Since closeCall succeeded, the modal should be closed but router.back() should not be called due to fetchCalls failure - expect(mockRouter.back).not.toHaveBeenCalled(); + // router.replace is called BEFORE fetchCalls, so it should have been called + // even though fetchCalls failed + await waitFor(() => { + expect(mockRouter.replace).toHaveBeenCalled(); + expect(mockFetchCalls).toHaveBeenCalled(); + }); }); it('should not render when isOpen is false', () => { diff --git a/src/components/livekit/__tests__/livekit-bottom-sheet.test.tsx b/src/components/livekit/__tests__/livekit-bottom-sheet.test.tsx index ad621f4a..65695a26 100644 --- a/src/components/livekit/__tests__/livekit-bottom-sheet.test.tsx +++ b/src/components/livekit/__tests__/livekit-bottom-sheet.test.tsx @@ -2,6 +2,26 @@ import { render, act } from '@testing-library/react-native'; import React from 'react'; import { beforeEach, describe, expect, it, jest } from '@jest/globals'; +// Mock @livekit/react-native-webrtc before any imports that use it +jest.mock('@livekit/react-native-webrtc', () => ({ + RTCAudioSession: { + audioSessionDidActivate: jest.fn(), + audioSessionDidDeactivate: jest.fn(), + }, +})); + +// Mock CallKeep service +jest.mock('@/services/callkeep.service.ios', () => ({ + callKeepService: { + setup: (jest.fn() as jest.Mock<() => Promise>).mockResolvedValue(undefined), + startCall: (jest.fn() as jest.Mock<() => Promise>).mockResolvedValue('test-uuid'), + endCall: (jest.fn() as jest.Mock<() => Promise>).mockResolvedValue(undefined), + isCallActiveNow: (jest.fn() as jest.Mock<() => boolean>).mockReturnValue(false), + getCurrentCallUUID: (jest.fn() as jest.Mock<() => string | null>).mockReturnValue(null), + setMuteStateCallback: jest.fn(), + }, +})); + // Mock React Native and NativeWind jest.mock('nativewind', () => ({ useColorScheme: () => ({ colorScheme: 'light' }), diff --git a/src/components/livekit/livekit-bottom-sheet.tsx b/src/components/livekit/livekit-bottom-sheet.tsx index 9db7f4ab..3f832ebd 100644 --- a/src/components/livekit/livekit-bottom-sheet.tsx +++ b/src/components/livekit/livekit-bottom-sheet.tsx @@ -57,8 +57,8 @@ export const LiveKitBottomSheet = () => { currentRoomName: currentRoomInfo?.Name || 'none', isMuted: isMuted, isTalking: isTalking, - hasBluetoothMicrophone: selectedAudioDevices.microphone?.type === 'bluetooth', - hasBluetoothSpeaker: selectedAudioDevices.speaker?.type === 'bluetooth', + hasBluetoothMicrophone: selectedAudioDevices?.microphone?.type === 'bluetooth', + hasBluetoothSpeaker: selectedAudioDevices?.speaker?.type === 'bluetooth', permissionsRequested: permissionsRequested, }); } @@ -72,8 +72,8 @@ export const LiveKitBottomSheet = () => { currentRoomInfo, isMuted, isTalking, - selectedAudioDevices.microphone?.type, - selectedAudioDevices.speaker?.type, + selectedAudioDevices?.microphone?.type, + selectedAudioDevices?.speaker?.type, permissionsRequested, ]); diff --git a/src/components/maps/full-screen-location-picker.tsx b/src/components/maps/full-screen-location-picker.tsx index 1415a20a..e3ee46e1 100644 --- a/src/components/maps/full-screen-location-picker.tsx +++ b/src/components/maps/full-screen-location-picker.tsx @@ -1,14 +1,27 @@ import Mapbox from '@rnmapbox/maps'; import * as Location from 'expo-location'; -import { MapPinIcon, XIcon } from 'lucide-react-native'; +import { LocateIcon, MapPinIcon, XIcon } from 'lucide-react-native'; import React, { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Dimensions, StyleSheet, TouchableOpacity } from 'react-native'; +import { ActivityIndicator, Dimensions, StyleSheet, TouchableOpacity } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Box } from '@/components/ui/box'; import { Button, ButtonText } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; +import { Env } from '@/lib/env'; + +// Ensure Mapbox access token is set before using any Mapbox components +Mapbox.setAccessToken(Env.UNIT_MAPBOX_PUBKEY); + +// Default location (center of USA) used when user location is unavailable +const DEFAULT_LOCATION = { + latitude: 39.8283, + longitude: -98.5795, +}; + +// Timeout for location fetching (in milliseconds) +const LOCATION_TIMEOUT = 10000; interface FullScreenLocationPickerProps { initialLocation?: { @@ -24,77 +37,88 @@ const FullScreenLocationPicker: React.FC = ({ ini const insets = useSafeAreaInsets(); const mapRef = useRef(null); const cameraRef = useRef(null); + // Always start with a location - either initial, or default const [currentLocation, setCurrentLocation] = useState<{ latitude: number; longitude: number; - } | null>(initialLocation || null); - const [isLoading, setIsLoading] = useState(false); + }>(initialLocation || DEFAULT_LOCATION); + const [isLocating, setIsLocating] = useState(false); const [isReverseGeocoding, setIsReverseGeocoding] = useState(false); const [address, setAddress] = useState(undefined); - const [isMounted, setIsMounted] = useState(true); + const [hasUserLocation, setHasUserLocation] = useState(!!initialLocation); + const isMountedRef = useRef(true); - const reverseGeocode = React.useCallback( - async (latitude: number, longitude: number) => { - if (!isMounted) return; + const reverseGeocode = React.useCallback(async (latitude: number, longitude: number) => { + if (!isMountedRef.current) return; - setIsReverseGeocoding(true); - try { - const result = await Location.reverseGeocodeAsync({ - latitude, - longitude, - }); + setIsReverseGeocoding(true); + try { + const result = await Location.reverseGeocodeAsync({ + latitude, + longitude, + }); + + if (!isMountedRef.current) return; + + if (result && result.length > 0) { + const { street, name, city, region, country, postalCode } = result[0]; + let addressParts = []; + + if (street) addressParts.push(street); + if (name && name !== street) addressParts.push(name); + if (city) addressParts.push(city); + if (region) addressParts.push(region); + if (postalCode) addressParts.push(postalCode); + if (country) addressParts.push(country); - if (!isMounted) return; - - if (result && result.length > 0) { - const { street, name, city, region, country, postalCode } = result[0]; - let addressParts = []; - - if (street) addressParts.push(street); - if (name && name !== street) addressParts.push(name); - if (city) addressParts.push(city); - if (region) addressParts.push(region); - if (postalCode) addressParts.push(postalCode); - if (country) addressParts.push(country); - - setAddress(addressParts.join(', ')); - } else { - setAddress(undefined); - } - } catch (error) { - console.error('Error reverse geocoding:', error); - if (isMounted) setAddress(undefined); - } finally { - if (isMounted) setIsReverseGeocoding(false); + setAddress(addressParts.join(', ')); + } else { + setAddress(undefined); } - }, - [isMounted] - ); + } catch (error) { + console.error('Error reverse geocoding:', error); + if (isMountedRef.current) setAddress(undefined); + } finally { + if (isMountedRef.current) setIsReverseGeocoding(false); + } + }, []); const getUserLocation = React.useCallback(async () => { - if (!isMounted) return; + if (!isMountedRef.current) return; - setIsLoading(true); + setIsLocating(true); try { const { status } = await Location.requestForegroundPermissionsAsync(); if (status !== 'granted') { console.error('Location permission not granted'); - if (isMounted) setIsLoading(false); return; } - const location = await Location.getCurrentPositionAsync({}); - if (!isMounted) return; + // Create a timeout promise + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error('Location timeout')), LOCATION_TIMEOUT); + }); + + // Race between getting location and timeout + const location = await Promise.race([ + Location.getCurrentPositionAsync({ + accuracy: Location.Accuracy.Balanced, + }), + timeoutPromise, + ]); + + if (!isMountedRef.current) return; const newLocation = { latitude: location.coords.latitude, longitude: location.coords.longitude, }; setCurrentLocation(newLocation); + setHasUserLocation(true); reverseGeocode(newLocation.latitude, newLocation.longitude); // Move camera to user location - if (cameraRef.current && isMounted) { + if (cameraRef.current && isMountedRef.current) { cameraRef.current.setCamera({ centerCoordinate: [location.coords.longitude, location.coords.latitude], zoomLevel: 15, @@ -103,23 +127,26 @@ const FullScreenLocationPicker: React.FC = ({ ini } } catch (error) { console.error('Error getting location:', error); + // Don't update location - keep using whatever we have (initial or default) } finally { - if (isMounted) setIsLoading(false); + if (isMountedRef.current) setIsLocating(false); } - }, [isMounted, reverseGeocode]); + }, [reverseGeocode]); useEffect(() => { - setIsMounted(true); + isMountedRef.current = true; if (initialLocation) { setCurrentLocation(initialLocation); + setHasUserLocation(true); reverseGeocode(initialLocation.latitude, initialLocation.longitude); } else { + // Try to get user location, but don't block the map from showing getUserLocation(); } return () => { - setIsMounted(false); + isMountedRef.current = false; }; }, [initialLocation, getUserLocation, reverseGeocode]); @@ -130,70 +157,56 @@ const FullScreenLocationPicker: React.FC = ({ ini longitude: coordinates[0], }; setCurrentLocation(newLocation); + setHasUserLocation(true); reverseGeocode(newLocation.latitude, newLocation.longitude); }; const handleConfirmLocation = () => { - if (currentLocation) { - onLocationSelected({ - ...currentLocation, - address, - }); - onClose(); - } + onLocationSelected({ + ...currentLocation, + address, + }); + onClose(); }; - if (isLoading) { - return ( - - {t('common.loading')} - - ); - } - return ( - {currentLocation ? ( - - - {/* Marker for the selected location */} - - - - - - - ) : ( - - {t('common.no_location')} - - {t('common.get_my_location')} - - - )} + + + {/* Marker for the selected location */} + + + + + + {/* Close button */} + {/* My Location button */} + + {isLocating ? : } + + {/* Location info and confirm button */} + {!hasUserLocation && {t('common.tap_map_to_select')}} {isReverseGeocoding ? ( {t('common.loading_address')} ) : address ? ( {address} - ) : ( + ) : hasUserLocation ? ( {t('common.no_address_found')} - )} + ) : null} - {currentLocation && ( - - {currentLocation.latitude.toFixed(6)}, {currentLocation.longitude.toFixed(6)} - - )} + + {currentLocation.latitude.toFixed(6)}, {currentLocation.longitude.toFixed(6)} + - @@ -227,6 +240,22 @@ const styles = StyleSheet.create({ elevation: 4, zIndex: 10, }, + myLocationButton: { + position: 'absolute', + right: 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, diff --git a/src/components/maps/location-picker.tsx b/src/components/maps/location-picker.tsx index 730436d1..6b13419a 100644 --- a/src/components/maps/location-picker.tsx +++ b/src/components/maps/location-picker.tsx @@ -1,12 +1,26 @@ import Mapbox from '@rnmapbox/maps'; import * as Location from 'expo-location'; +import { LocateIcon, MapPinIcon } from 'lucide-react-native'; import React, { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { StyleSheet, TouchableOpacity } from 'react-native'; +import { ActivityIndicator, StyleSheet, TouchableOpacity } from 'react-native'; import { Box } from '@/components/ui/box'; import { Button, ButtonText } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; +import { Env } from '@/lib/env'; + +// Ensure Mapbox access token is set before using any Mapbox components +Mapbox.setAccessToken(Env.UNIT_MAPBOX_PUBKEY); + +// Default location (center of USA) used when user location is unavailable +const DEFAULT_LOCATION = { + latitude: 39.8283, + longitude: -98.5795, +}; + +// Timeout for location fetching (in milliseconds) +const LOCATION_TIMEOUT = 10000; interface LocationPickerProps { initialLocation?: { @@ -21,30 +35,50 @@ const LocationPicker: React.FC = ({ initialLocation, onLoca const { t } = useTranslation(); const mapRef = useRef(null); const cameraRef = useRef(null); + const isMountedRef = useRef(true); + // Always start with a location - either initial, or default const [currentLocation, setCurrentLocation] = useState<{ latitude: number; longitude: number; - } | null>(initialLocation || null); - const [isLoading, setIsLoading] = useState(false); + }>(initialLocation || DEFAULT_LOCATION); + const [isLocating, setIsLocating] = useState(false); + const [hasUserLocation, setHasUserLocation] = useState(!!initialLocation); const getUserLocation = React.useCallback(async () => { - setIsLoading(true); + if (!isMountedRef.current) return; + + setIsLocating(true); try { const { status } = await Location.requestForegroundPermissionsAsync(); if (status !== 'granted') { console.error('Location permission not granted'); - setIsLoading(false); return; } - const location = await Location.getCurrentPositionAsync({}); - setCurrentLocation({ + // Create a timeout promise + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error('Location timeout')), LOCATION_TIMEOUT); + }); + + // Race between getting location and timeout + const location = await Promise.race([ + Location.getCurrentPositionAsync({ + accuracy: Location.Accuracy.Balanced, + }), + timeoutPromise, + ]); + + if (!isMountedRef.current) return; + + const newLocation = { latitude: location.coords.latitude, longitude: location.coords.longitude, - }); + }; + setCurrentLocation(newLocation); + setHasUserLocation(true); // Move camera to user location - if (cameraRef.current) { + if (cameraRef.current && isMountedRef.current) { cameraRef.current.setCamera({ centerCoordinate: [location.coords.longitude, location.coords.latitude], zoomLevel: 15, @@ -53,14 +87,18 @@ const LocationPicker: React.FC = ({ initialLocation, onLoca } } catch (error) { console.error('Error getting location:', error); + // Don't update location - keep using whatever we have (initial or default) } finally { - setIsLoading(false); + if (isMountedRef.current) setIsLocating(false); } }, []); useEffect(() => { + isMountedRef.current = true; + if (initialLocation) { setCurrentLocation(initialLocation); + setHasUserLocation(true); // Move camera to the new location if (cameraRef.current) { cameraRef.current.setCamera({ @@ -70,55 +108,48 @@ const LocationPicker: React.FC = ({ initialLocation, onLoca }); } } else { - getUserLocation().catch((error) => { - console.error('Failed to get user location:', error); - }); + // Try to get user location, but don't block the map from showing + getUserLocation(); } + + return () => { + isMountedRef.current = false; + }; }, [initialLocation, getUserLocation]); const handleMapPress = (event: any) => { const { coordinates } = event.geometry; - setCurrentLocation({ + const newLocation = { latitude: coordinates[1], longitude: coordinates[0], - }); + }; + setCurrentLocation(newLocation); + setHasUserLocation(true); }; const handleConfirmLocation = () => { - if (currentLocation) { - onLocationSelected(currentLocation); - } + onLocationSelected(currentLocation); }; - if (isLoading) { - return ( - - {t('common.loading')} - - ); - } - return ( - {currentLocation ? ( - - - {/* Marker for the selected location */} - - - - - ) : ( - - {t('common.no_location')} - - {t('common.get_my_location')} - - - )} + + + {/* Marker for the selected location */} + + + + + + + + {/* My Location button */} + + {isLocating ? : } + - @@ -136,6 +167,23 @@ const styles = StyleSheet.create({ map: { flex: 1, }, + myLocationButton: { + position: 'absolute', + top: 8, + right: 8, + width: 32, + height: 32, + borderRadius: 16, + backgroundColor: 'white', + justifyContent: 'center', + alignItems: 'center', + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.2, + shadowRadius: 4, + elevation: 4, + zIndex: 10, + }, }); export default LocationPicker; diff --git a/src/components/maps/static-map.tsx b/src/components/maps/static-map.tsx index a2abe9db..e3b19bd9 100644 --- a/src/components/maps/static-map.tsx +++ b/src/components/maps/static-map.tsx @@ -6,6 +6,10 @@ import { StyleSheet } from 'react-native'; import { Box } from '@/components/ui/box'; import { Text } from '@/components/ui/text'; +import { Env } from '@/lib/env'; + +// Ensure Mapbox access token is set before using any Mapbox components +Mapbox.setAccessToken(Env.UNIT_MAPBOX_PUBKEY); interface StaticMapProps { latitude: number; diff --git a/src/components/settings/unit-selection-bottom-sheet.tsx b/src/components/settings/unit-selection-bottom-sheet.tsx index 7b9cd0f6..78c08ab4 100644 --- a/src/components/settings/unit-selection-bottom-sheet.tsx +++ b/src/components/settings/unit-selection-bottom-sheet.tsx @@ -33,13 +33,7 @@ const UnitItem = React.memo(({ unit, isSelected, isLoading, onSel }, [onSelect, unit]); return ( - + {unit.Name} @@ -190,13 +184,7 @@ export const UnitSelectionBottomSheet = React.memo 0 ? ( {units.map((unit) => ( - + ))} ) : ( diff --git a/src/components/sidebar/__tests__/unit-sidebar.test.tsx b/src/components/sidebar/__tests__/unit-sidebar.test.tsx index 57f8b126..64b8a064 100644 --- a/src/components/sidebar/__tests__/unit-sidebar.test.tsx +++ b/src/components/sidebar/__tests__/unit-sidebar.test.tsx @@ -1,6 +1,17 @@ import { render, screen, fireEvent } from '@testing-library/react-native'; import React from 'react'; +// Mock @livekit/react-native-webrtc before any imports that use it +jest.mock('@livekit/react-native-webrtc', () => ({ + RTCAudioSession: { + configure: jest.fn().mockResolvedValue(undefined), + setCategory: jest.fn().mockResolvedValue(undefined), + setMode: jest.fn().mockResolvedValue(undefined), + getActiveAudioSession: jest.fn().mockReturnValue(null), + setActive: jest.fn().mockResolvedValue(undefined), + }, +})); + import { useCoreStore } from '@/stores/app/core-store'; import { useLocationStore } from '@/stores/app/location-store'; import { useLiveKitStore } from '@/stores/app/livekit-store'; diff --git a/src/features/livekit-call/store/useLiveKitCallStore.ts b/src/features/livekit-call/store/useLiveKitCallStore.ts index df709077..a02cf478 100644 --- a/src/features/livekit-call/store/useLiveKitCallStore.ts +++ b/src/features/livekit-call/store/useLiveKitCallStore.ts @@ -1,6 +1,6 @@ import { ConnectionState, type LocalParticipant, type Participant, type RemoteParticipant, Room, type RoomConnectOptions, RoomEvent, type RoomOptions } from 'livekit-client'; // livekit-react-native re-exports these import { Platform } from 'react-native'; -import create from 'zustand'; +import { create } from 'zustand'; import { logger } from '../../../lib/logging'; import { callKeepService } from '../../../services/callkeep.service.ios'; diff --git a/src/services/__tests__/app-reset.service.test.ts b/src/services/__tests__/app-reset.service.test.ts index 853cc747..02adf71c 100644 --- a/src/services/__tests__/app-reset.service.test.ts +++ b/src/services/__tests__/app-reset.service.test.ts @@ -422,7 +422,7 @@ describe('app-reset.service', () => { await resetAllStores(); expect(mockLiveKitDisconnect).toHaveBeenCalled(); - expect(useLiveKitStore.setState).toHaveBeenCalledWith(INITIAL_LIVEKIT_STATE); + expect(useLiveKitStore.setState).toHaveBeenCalledWith(INITIAL_LIVEKIT_STATE, true); }); it('should not disconnect from LiveKit room if not connected', async () => { @@ -437,7 +437,7 @@ describe('app-reset.service', () => { await resetAllStores(); expect(localMockDisconnect).not.toHaveBeenCalled(); - expect(useLiveKitStore.setState).toHaveBeenCalledWith(INITIAL_LIVEKIT_STATE); + expect(useLiveKitStore.setState).toHaveBeenCalledWith(INITIAL_LIVEKIT_STATE, true); }); }); @@ -463,7 +463,9 @@ describe('app-reset.service', () => { cleanup: jest.fn().mockRejectedValue(error), }); - await expect(clearAllAppData()).rejects.toThrow('Cleanup failed'); + // The cleanup error is caught and logged within resetAllStores, + // so clearAllAppData should complete without throwing + await expect(clearAllAppData()).resolves.toBeUndefined(); }); }); }); diff --git a/src/services/__tests__/bluetooth-audio.service.test.ts b/src/services/__tests__/bluetooth-audio.service.test.ts index 406699d3..05637a93 100644 --- a/src/services/__tests__/bluetooth-audio.service.test.ts +++ b/src/services/__tests__/bluetooth-audio.service.test.ts @@ -1,6 +1,26 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import 'react-native'; +// Mock @livekit/react-native-webrtc before any imports that use it +jest.mock('@livekit/react-native-webrtc', () => ({ + RTCAudioSession: { + audioSessionDidActivate: jest.fn(), + audioSessionDidDeactivate: jest.fn(), + }, +})); + +// Mock CallKeep service before importing modules that use it +jest.mock('@/services/callkeep.service.ios', () => ({ + callKeepService: { + setup: jest.fn().mockResolvedValue(undefined), + startCall: jest.fn().mockResolvedValue('test-uuid'), + endCall: jest.fn().mockResolvedValue(undefined), + isCallActiveNow: jest.fn().mockReturnValue(false), + getCurrentCallUUID: jest.fn().mockReturnValue(null), + setMuteStateCallback: jest.fn(), + }, +})); + // Mock dependencies first before importing the service jest.mock('react-native-ble-manager', () => ({ __esModule: true, diff --git a/src/services/bluetooth-audio.service.ts b/src/services/bluetooth-audio.service.ts index 78bd5819..f403c48f 100644 --- a/src/services/bluetooth-audio.service.ts +++ b/src/services/bluetooth-audio.service.ts @@ -1456,17 +1456,18 @@ class BluetoothAudioService { private async setMicrophoneEnabled(enabled: boolean): Promise { const liveKitStore = useLiveKitStore.getState(); if (liveKitStore.currentRoom) { - const currentMuteState = !liveKitStore.currentRoom.localParticipant.isMicrophoneEnabled; + const currentMicEnabled = liveKitStore.currentRoom.localParticipant.isMicrophoneEnabled; try { - if (enabled && !currentMuteState) return; // already enabled - if (!enabled && currentMuteState) return; // already disabled + // Skip if already in the desired state + if (enabled && currentMicEnabled) return; // already enabled + if (!enabled && !currentMicEnabled) return; // already disabled - await liveKitStore.currentRoom.localParticipant.setMicrophoneEnabled(currentMuteState); + await liveKitStore.currentRoom.localParticipant.setMicrophoneEnabled(enabled); logger.info({ - message: 'Microphone toggled via Bluetooth button', - context: { enabled: currentMuteState }, + message: 'Microphone set via Bluetooth PTT button', + context: { enabled }, }); useBluetoothAudioStore.getState().setLastButtonAction({ @@ -1481,10 +1482,15 @@ class BluetoothAudioService { } } catch (error) { logger.error({ - message: 'Failed to toggle microphone via Bluetooth button', - context: { error }, + message: 'Failed to set microphone via Bluetooth PTT button', + context: { error, enabled }, }); } + } else { + logger.warn({ + message: 'Cannot set microphone - no active LiveKit room', + context: { enabled }, + }); } } diff --git a/src/stores/app/livekit-store.ts b/src/stores/app/livekit-store.ts index 0ba5222f..8dfacf94 100644 --- a/src/stores/app/livekit-store.ts +++ b/src/stores/app/livekit-store.ts @@ -8,6 +8,7 @@ import { getCanConnectToVoiceSession, getDepartmentVoiceSettings } from '../../a import { logger } from '../../lib/logging'; import { type DepartmentVoiceChannelResultData } from '../../models/v4/voice/departmentVoiceResultData'; import { audioService } from '../../services/audio.service'; +import { callKeepService } from '../../services/callkeep.service.ios'; import { useBluetoothAudioStore } from './bluetooth-audio-store'; // Helper function to setup audio routing based on selected devices @@ -235,7 +236,27 @@ export const useLiveKitStore = create((set, get) => ({ message: 'Failed to register foreground service', context: { error }, }); + // Don't fail the connection if foreground service fails on Android + // The call will still work but may be killed in background } + + // Start CallKeep call for iOS background audio support + if (Platform.OS === 'ios') { + try { + const callUUID = await callKeepService.startCall(roomInfo.Name || 'Voice Channel'); + logger.info({ + message: 'CallKeep call started for iOS background support', + context: { callUUID, roomName: roomInfo.Name }, + }); + } catch (callKeepError) { + logger.warn({ + message: 'Failed to start CallKeep call - background audio may not work', + context: { error: callKeepError }, + }); + // Don't fail the connection if CallKeep fails + } + } + set({ currentRoom: room, currentRoomInfo: roomInfo, @@ -257,6 +278,21 @@ export const useLiveKitStore = create((set, get) => ({ await currentRoom.disconnect(); await audioService.playDisconnectedFromAudioRoomSound(); + // End CallKeep call on iOS + if (Platform.OS === 'ios') { + try { + await callKeepService.endCall(); + logger.debug({ + message: 'CallKeep call ended', + }); + } catch (callKeepError) { + logger.warn({ + message: 'Failed to end CallKeep call', + context: { error: callKeepError }, + }); + } + } + try { await notifee.stopForegroundService(); } catch (error) { @@ -269,6 +305,7 @@ export const useLiveKitStore = create((set, get) => ({ currentRoom: null, currentRoomInfo: null, isConnected: false, + isTalking: false, }); } }, diff --git a/src/stores/status/__tests__/store.test.ts b/src/stores/status/__tests__/store.test.ts index 6adb7044..bb357499 100644 --- a/src/stores/status/__tests__/store.test.ts +++ b/src/stores/status/__tests__/store.test.ts @@ -56,6 +56,14 @@ jest.mock('@/stores/roles/store', () => ({ })), }, })); +jest.mock('@/stores/calls/store', () => ({ + useCallsStore: { + getState: jest.fn(() => ({ + calls: [], + })), + setState: jest.fn(), + }, +})); jest.mock('@/services/offline-event-manager.service', () => ({ offlineEventManager: { queueUnitStatusEvent: jest.fn(), diff --git a/src/stores/status/store.ts b/src/stores/status/store.ts index 5ff58d54..596ce3a5 100644 --- a/src/stores/status/store.ts +++ b/src/stores/status/store.ts @@ -13,6 +13,7 @@ import { offlineEventManager } from '@/services/offline-event-manager.service'; import { useCoreStore } from '../app/core-store'; import { useLocationStore } from '../app/location-store'; +import { useCallsStore } from '../calls/store'; import { useRolesStore } from '../roles/store'; type StatusStep = 'select-status' | 'select-destination' | 'add-note'; @@ -76,14 +77,36 @@ export const useStatusBottomSheetStore = create((set, ge fetchDestinationData: async (unitId: string) => { set({ isLoading: true, error: null }); try { - // Fetch calls and groups (stations) in parallel - const [callsResponse, groupsResponse] = await Promise.all([getCalls(), getAllGroups()]); + // Check if we already have calls in the calls store to avoid redundant API calls + const callsStore = useCallsStore.getState(); + const existingCalls = callsStore.calls; - set({ - availableCalls: callsResponse.Data || [], - availableStations: groupsResponse.Data || [], - isLoading: false, - }); + // Only fetch calls if we don't have any in the store + // Groups are cached (2 day TTL) so getAllGroups is already fast + const needsCallsFetch = existingCalls.length === 0; + + if (needsCallsFetch) { + // Fetch calls and groups in parallel + const [callsResponse, groupsResponse] = await Promise.all([getCalls(), getAllGroups()]); + + // Also update the calls store so other parts of the app benefit + useCallsStore.setState({ calls: callsResponse.Data || [] }); + + set({ + availableCalls: callsResponse.Data || [], + availableStations: groupsResponse.Data || [], + isLoading: false, + }); + } else { + // Use existing calls, only fetch groups (which is cached) + const groupsResponse = await getAllGroups(); + + set({ + availableCalls: existingCalls, + availableStations: groupsResponse.Data || [], + isLoading: false, + }); + } } catch (error) { set({ error: 'Failed to fetch destination data', diff --git a/src/translations/ar.json b/src/translations/ar.json index 28fb07f0..a3130c7e 100644 --- a/src/translations/ar.json +++ b/src/translations/ar.json @@ -340,6 +340,7 @@ "step": "خطوة", "submit": "إرسال", "submitting": "جاري الإرسال...", + "tap_map_to_select": "انقر على الخريطة لتحديد الموقع", "tryAgainLater": "يرجى المحاولة مرة أخرى لاحقًا", "unknown": "غير معروف", "upload": "رفع", diff --git a/src/translations/en.json b/src/translations/en.json index 17d2f126..45d029ab 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -340,6 +340,7 @@ "step": "Step", "submit": "Submit", "submitting": "Submitting...", + "tap_map_to_select": "Tap on the map to select a location", "tryAgainLater": "Please try again later", "unknown": "Unknown", "upload": "Upload", diff --git a/src/translations/es.json b/src/translations/es.json index 8175eabb..1a59f4e1 100644 --- a/src/translations/es.json +++ b/src/translations/es.json @@ -340,6 +340,7 @@ "step": "Paso", "submit": "Enviar", "submitting": "Enviando...", + "tap_map_to_select": "Toca el mapa para seleccionar una ubicación", "tryAgainLater": "Por favor, inténtelo de nuevo más tarde", "unknown": "Desconocido", "upload": "Subir", From 15f8bc5fe869b5ea8c52ba853b0ba803e394c404 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Fri, 23 Jan 2026 11:58:00 -0800 Subject: [PATCH 2/4] RU-T46 PR#198 fixes --- src/api/calls/calls.ts | 28 +++++++ .../maps/full-screen-location-picker.tsx | 6 +- src/components/maps/location-picker.tsx | 8 +- src/services/callkeep.service.android.ts | 77 +++++++++++++++++++ src/stores/app/livekit-store.ts | 2 +- src/stores/calls/store.ts | 5 +- src/stores/status/store.ts | 16 ++-- 7 files changed, 130 insertions(+), 12 deletions(-) create mode 100644 src/services/callkeep.service.android.ts diff --git a/src/api/calls/calls.ts b/src/api/calls/calls.ts index 4324714c..1f3f5e88 100644 --- a/src/api/calls/calls.ts +++ b/src/api/calls/calls.ts @@ -1,3 +1,4 @@ +import { cacheManager } from '@/lib/cache/cache-manager'; import { type ActiveCallsResult } from '@/models/v4/calls/activeCallsResult'; import { type CallExtraDataResult } from '@/models/v4/calls/callExtraDataResult'; import { type CallResult } from '@/models/v4/calls/callResult'; @@ -126,6 +127,15 @@ export const createCall = async (callData: CreateCallRequest) => { }; const response = await createCallApi.post(data); + + // Invalidate cache after successful mutation + try { + cacheManager.remove('/Calls/GetActiveCalls'); + } catch (error) { + // Silently handle cache removal errors + console.warn('Failed to invalidate calls cache:', error); + } + return response.data; }; @@ -174,6 +184,15 @@ export const updateCall = async (callData: UpdateCallRequest) => { }; const response = await updateCallApi.post(data); + + // Invalidate cache after successful mutation + try { + cacheManager.remove('/Calls/GetActiveCalls'); + } catch (error) { + // Silently handle cache removal errors + console.warn('Failed to invalidate calls cache:', error); + } + return response.data; }; @@ -185,5 +204,14 @@ export const closeCall = async (callData: CloseCallRequest) => { }; const response = await closeCallApi.put(data); + + // Invalidate cache after successful mutation + try { + cacheManager.remove('/Calls/GetActiveCalls'); + } catch (error) { + // Silently handle cache removal errors + console.warn('Failed to invalidate calls cache:', error); + } + return response.data; }; diff --git a/src/components/maps/full-screen-location-picker.tsx b/src/components/maps/full-screen-location-picker.tsx index e3ee46e1..2bbebf69 100644 --- a/src/components/maps/full-screen-location-picker.tsx +++ b/src/components/maps/full-screen-location-picker.tsx @@ -62,7 +62,7 @@ const FullScreenLocationPicker: React.FC = ({ ini if (result && result.length > 0) { const { street, name, city, region, country, postalCode } = result[0]; - let addressParts = []; + const addressParts: string[] = []; if (street) addressParts.push(street); if (name && name !== street) addressParts.push(name); @@ -150,7 +150,7 @@ const FullScreenLocationPicker: React.FC = ({ ini }; }, [initialLocation, getUserLocation, reverseGeocode]); - const handleMapPress = (event: any) => { + const handleMapPress = (event: GeoJSON.Feature) => { const { coordinates } = event.geometry; const newLocation = { latitude: coordinates[1], @@ -193,7 +193,7 @@ const FullScreenLocationPicker: React.FC = ({ ini {/* Location info and confirm button */} - {!hasUserLocation && {t('common.tap_map_to_select')}} + {!hasUserLocation ? {t('common.tap_map_to_select')} : null} {isReverseGeocoding ? ( {t('common.loading_address')} ) : address ? ( diff --git a/src/components/maps/location-picker.tsx b/src/components/maps/location-picker.tsx index 6b13419a..7fd76794 100644 --- a/src/components/maps/location-picker.tsx +++ b/src/components/maps/location-picker.tsx @@ -55,9 +55,10 @@ const LocationPicker: React.FC = ({ initialLocation, onLoca return; } - // Create a timeout promise + // Create a timeout promise with cleanup + let timeoutId: NodeJS.Timeout; const timeoutPromise = new Promise((_, reject) => { - setTimeout(() => reject(new Error('Location timeout')), LOCATION_TIMEOUT); + timeoutId = setTimeout(() => reject(new Error('Location timeout')), LOCATION_TIMEOUT); }); // Race between getting location and timeout @@ -68,6 +69,9 @@ const LocationPicker: React.FC = ({ initialLocation, onLoca timeoutPromise, ]); + // Clear timeout if location resolved first + clearTimeout(timeoutId); + if (!isMountedRef.current) return; const newLocation = { diff --git a/src/services/callkeep.service.android.ts b/src/services/callkeep.service.android.ts new file mode 100644 index 00000000..58d8dbea --- /dev/null +++ b/src/services/callkeep.service.android.ts @@ -0,0 +1,77 @@ +import { logger } from '../lib/logging'; + +export interface CallKeepConfig { + appName: string; + maximumCallGroups: number; + maximumCallsPerCallGroup: number; + includesCallsInRecents: boolean; + supportsVideo: boolean; + ringtoneSound?: string; +} + +/** + * No-op implementation of CallKeepService for Android + * CallKeep functionality is only supported on iOS + */ +export class CallKeepService { + private static instance: CallKeepService | null = null; + + private constructor() {} + + static getInstance(): CallKeepService { + if (!CallKeepService.instance) { + CallKeepService.instance = new CallKeepService(); + } + return CallKeepService.instance; + } + + async setup(_config: CallKeepConfig): Promise { + logger.debug({ + message: 'CallKeep setup skipped - not supported on Android', + }); + } + + async startCall(_displayName: string): Promise { + logger.debug({ + message: 'CallKeep startCall skipped - not supported on Android', + }); + return ''; + } + + async endCall(): Promise { + logger.debug({ + message: 'CallKeep endCall skipped - not supported on Android', + }); + } + + async setMuted(_muted: boolean): Promise { + logger.debug({ + message: 'CallKeep setMuted skipped - not supported on Android', + }); + } + + setMuteStateCallback(_callback: (muted: boolean) => void | null): void { + logger.debug({ + message: 'CallKeep setMuteStateCallback skipped - not supported on Android', + }); + } + + async updateCallDisplay(_displayName: string): Promise { + logger.debug({ + message: 'CallKeep updateCallDisplay skipped - not supported on Android', + }); + } + + isCallActive(): boolean { + return false; + } + + async cleanup(): Promise { + logger.debug({ + message: 'CallKeep cleanup skipped - not supported on Android', + }); + } +} + +// Export singleton instance +export const callKeepService = CallKeepService.getInstance(); diff --git a/src/stores/app/livekit-store.ts b/src/stores/app/livekit-store.ts index 8dfacf94..0ac901b0 100644 --- a/src/stores/app/livekit-store.ts +++ b/src/stores/app/livekit-store.ts @@ -8,7 +8,7 @@ import { getCanConnectToVoiceSession, getDepartmentVoiceSettings } from '../../a import { logger } from '../../lib/logging'; import { type DepartmentVoiceChannelResultData } from '../../models/v4/voice/departmentVoiceResultData'; import { audioService } from '../../services/audio.service'; -import { callKeepService } from '../../services/callkeep.service.ios'; +import { callKeepService } from '../../services/callkeep.service'; import { useBluetoothAudioStore } from './bluetooth-audio-store'; // Helper function to setup audio routing based on selected devices diff --git a/src/stores/calls/store.ts b/src/stores/calls/store.ts index 21f8c97a..faeb9a14 100644 --- a/src/stores/calls/store.ts +++ b/src/stores/calls/store.ts @@ -13,6 +13,7 @@ interface CallsState { callTypes: CallTypeResultData[]; isLoading: boolean; error: string | null; + lastFetchedAt: number; fetchCalls: () => Promise; fetchCallPriorities: () => Promise; fetchCallTypes: () => Promise; @@ -25,6 +26,7 @@ export const useCallsStore = create((set, get) => ({ callTypes: [], isLoading: false, error: null, + lastFetchedAt: 0, init: async () => { set({ isLoading: true, error: null }); const callsResponse = await getCalls(); @@ -35,13 +37,14 @@ export const useCallsStore = create((set, get) => ({ callPriorities: callPrioritiesResponse.Data, callTypes: callTypesResponse.Data, isLoading: false, + lastFetchedAt: Date.now(), }); }, fetchCalls: async () => { set({ isLoading: true, error: null }); try { const response = await getCalls(); - set({ calls: response.Data, isLoading: false }); + set({ calls: response.Data, isLoading: false, lastFetchedAt: Date.now() }); } catch (error) { set({ error: 'Failed to fetch calls', isLoading: false }); } diff --git a/src/stores/status/store.ts b/src/stores/status/store.ts index 596ce3a5..cdf268d6 100644 --- a/src/stores/status/store.ts +++ b/src/stores/status/store.ts @@ -22,6 +22,9 @@ type DestinationType = 'none' | 'call' | 'station'; // Status type that can accept both custom statuses and regular statuses type StatusType = CustomStatusResultData | StatusesResultData; +// Store TTL: 5 minutes in milliseconds +const STORE_TTL_MS = 5 * 60 * 1000; + interface StatusBottomSheetStore { isOpen: boolean; currentStep: StatusStep; @@ -77,21 +80,24 @@ export const useStatusBottomSheetStore = create((set, ge fetchDestinationData: async (unitId: string) => { set({ isLoading: true, error: null }); try { - // Check if we already have calls in the calls store to avoid redundant API calls + // Check if we already have calls in the calls store and if they're still fresh const callsStore = useCallsStore.getState(); const existingCalls = callsStore.calls; + const lastFetchedAt = callsStore.lastFetchedAt || 0; + const isStale = !lastFetchedAt || Date.now() - lastFetchedAt > STORE_TTL_MS; - // Only fetch calls if we don't have any in the store + // Fetch calls if we don't have any or if they're stale // Groups are cached (2 day TTL) so getAllGroups is already fast - const needsCallsFetch = existingCalls.length === 0; + const needsCallsFetch = existingCalls.length === 0 || isStale; if (needsCallsFetch) { // Fetch calls and groups in parallel const [callsResponse, groupsResponse] = await Promise.all([getCalls(), getAllGroups()]); - // Also update the calls store so other parts of the app benefit - useCallsStore.setState({ calls: callsResponse.Data || [] }); + // Update the calls store with fresh data and timestamp + useCallsStore.setState({ calls: callsResponse.Data || [], lastFetchedAt: Date.now() }); + // Set availableCalls from the fresh response set({ availableCalls: callsResponse.Data || [], availableStations: groupsResponse.Data || [], From 8864c1504ee838a6579c7265925003c373b962f9 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Fri, 23 Jan 2026 12:53:35 -0800 Subject: [PATCH 3/4] RU-T46 PR#198 fixes --- src/api/calls/calls.ts | 92 +++++++------------ .../maps/full-screen-location-picker.tsx | 19 ++-- src/components/maps/location-picker.tsx | 23 +++-- src/services/callkeep.service.android.ts | 20 ++-- src/services/callkeep.service.ts | 11 +++ src/stores/calls/store.ts | 12 +-- src/translations/ar.json | 2 + src/translations/en.json | 2 + src/translations/es.json | 2 + 9 files changed, 88 insertions(+), 95 deletions(-) create mode 100644 src/services/callkeep.service.ts diff --git a/src/api/calls/calls.ts b/src/api/calls/calls.ts index 1f3f5e88..ed4809b1 100644 --- a/src/api/calls/calls.ts +++ b/src/api/calls/calls.ts @@ -83,33 +83,34 @@ export interface CloseCallRequest { note?: string; } -export const createCall = async (callData: CreateCallRequest) => { - let dispatchList = ''; - - if (callData.dispatchEveryone) { - dispatchList = '0'; - } else { - const dispatchEntries: string[] = []; - - if (callData.dispatchUsers) { - //dispatchEntries.push(...callData.dispatchUsers.map((user) => `U:${user}`)); - dispatchEntries.push(...callData.dispatchUsers); - } - if (callData.dispatchGroups) { - //dispatchEntries.push(...callData.dispatchGroups.map((group) => `G:${group}`)); - dispatchEntries.push(...callData.dispatchGroups); - } - if (callData.dispatchRoles) { - //dispatchEntries.push(...callData.dispatchRoles.map((role) => `R:${role}`)); - dispatchEntries.push(...callData.dispatchRoles); - } - if (callData.dispatchUnits) { - //dispatchEntries.push(...callData.dispatchUnits.map((unit) => `U:${unit}`)); - dispatchEntries.push(...callData.dispatchUnits); - } - - dispatchList = dispatchEntries.join('|'); +/** + * Helper function to build the dispatch list string from dispatch data + */ +const buildDispatchList = (data: { dispatchEveryone?: boolean; dispatchUsers?: string[]; dispatchGroups?: string[]; dispatchRoles?: string[]; dispatchUnits?: string[] }): string => { + if (data.dispatchEveryone) { + return '0'; + } + + const dispatchEntries: string[] = []; + + if (data.dispatchUsers) { + dispatchEntries.push(...data.dispatchUsers); + } + if (data.dispatchGroups) { + dispatchEntries.push(...data.dispatchGroups); } + if (data.dispatchRoles) { + dispatchEntries.push(...data.dispatchRoles); + } + if (data.dispatchUnits) { + dispatchEntries.push(...data.dispatchUnits); + } + + return dispatchEntries.join('|'); +}; + +export const createCall = async (callData: CreateCallRequest) => { + const dispatchList = buildDispatchList(callData); const data = { Name: callData.name, @@ -127,7 +128,7 @@ export const createCall = async (callData: CreateCallRequest) => { }; const response = await createCallApi.post(data); - + // Invalidate cache after successful mutation try { cacheManager.remove('/Calls/GetActiveCalls'); @@ -135,37 +136,12 @@ export const createCall = async (callData: CreateCallRequest) => { // Silently handle cache removal errors console.warn('Failed to invalidate calls cache:', error); } - + return response.data; }; export const updateCall = async (callData: UpdateCallRequest) => { - let dispatchList = ''; - - if (callData.dispatchEveryone) { - dispatchList = '0'; - } else { - const dispatchEntries: string[] = []; - - if (callData.dispatchUsers) { - //dispatchEntries.push(...callData.dispatchUsers.map((user) => `U:${user}`)); - dispatchEntries.push(...callData.dispatchUsers); - } - if (callData.dispatchGroups) { - //dispatchEntries.push(...callData.dispatchGroups.map((group) => `G:${group}`)); - dispatchEntries.push(...callData.dispatchGroups); - } - if (callData.dispatchRoles) { - //dispatchEntries.push(...callData.dispatchRoles.map((role) => `R:${role}`)); - dispatchEntries.push(...callData.dispatchRoles); - } - if (callData.dispatchUnits) { - //dispatchEntries.push(...callData.dispatchUnits.map((unit) => `U:${unit}`)); - dispatchEntries.push(...callData.dispatchUnits); - } - - dispatchList = dispatchEntries.join('|'); - } + const dispatchList = buildDispatchList(callData); const data = { CallId: callData.callId, @@ -184,7 +160,7 @@ export const updateCall = async (callData: UpdateCallRequest) => { }; const response = await updateCallApi.post(data); - + // Invalidate cache after successful mutation try { cacheManager.remove('/Calls/GetActiveCalls'); @@ -192,7 +168,7 @@ export const updateCall = async (callData: UpdateCallRequest) => { // Silently handle cache removal errors console.warn('Failed to invalidate calls cache:', error); } - + return response.data; }; @@ -204,7 +180,7 @@ export const closeCall = async (callData: CloseCallRequest) => { }; const response = await closeCallApi.put(data); - + // Invalidate cache after successful mutation try { cacheManager.remove('/Calls/GetActiveCalls'); @@ -212,6 +188,6 @@ export const closeCall = async (callData: CloseCallRequest) => { // Silently handle cache removal errors console.warn('Failed to invalidate calls cache:', error); } - + return response.data; }; diff --git a/src/components/maps/full-screen-location-picker.tsx b/src/components/maps/full-screen-location-picker.tsx index 2bbebf69..1ba69fc9 100644 --- a/src/components/maps/full-screen-location-picker.tsx +++ b/src/components/maps/full-screen-location-picker.tsx @@ -151,14 +151,17 @@ const FullScreenLocationPicker: React.FC = ({ ini }, [initialLocation, getUserLocation, reverseGeocode]); const handleMapPress = (event: GeoJSON.Feature) => { - const { coordinates } = event.geometry; - const newLocation = { - latitude: coordinates[1], - longitude: coordinates[0], - }; - setCurrentLocation(newLocation); - setHasUserLocation(true); - reverseGeocode(newLocation.latitude, newLocation.longitude); + if (event.geometry.type !== 'GeometryCollection' && 'coordinates' in event.geometry) { + const coords = event.geometry.coordinates as number[]; + const [longitude, latitude] = coords; + const newLocation = { + latitude, + longitude, + }; + setCurrentLocation(newLocation); + setHasUserLocation(true); + reverseGeocode(newLocation.latitude, newLocation.longitude); + } }; const handleConfirmLocation = () => { diff --git a/src/components/maps/location-picker.tsx b/src/components/maps/location-picker.tsx index 7fd76794..ac9cd8ba 100644 --- a/src/components/maps/location-picker.tsx +++ b/src/components/maps/location-picker.tsx @@ -11,7 +11,11 @@ import { Text } from '@/components/ui/text'; import { Env } from '@/lib/env'; // Ensure Mapbox access token is set before using any Mapbox components -Mapbox.setAccessToken(Env.UNIT_MAPBOX_PUBKEY); +if (!Env.UNIT_MAPBOX_PUBKEY) { + console.error('Mapbox access token is not configured. Please set UNIT_MAPBOX_PUBKEY in your environment.'); +} else { + Mapbox.setAccessToken(Env.UNIT_MAPBOX_PUBKEY); +} // Default location (center of USA) used when user location is unavailable const DEFAULT_LOCATION = { @@ -56,7 +60,7 @@ const LocationPicker: React.FC = ({ initialLocation, onLoca } // Create a timeout promise with cleanup - let timeoutId: NodeJS.Timeout; + let timeoutId: ReturnType | undefined; const timeoutPromise = new Promise((_, reject) => { timeoutId = setTimeout(() => reject(new Error('Location timeout')), LOCATION_TIMEOUT); }); @@ -70,7 +74,7 @@ const LocationPicker: React.FC = ({ initialLocation, onLoca ]); // Clear timeout if location resolved first - clearTimeout(timeoutId); + if (timeoutId !== undefined) clearTimeout(timeoutId); if (!isMountedRef.current) return; @@ -121,11 +125,12 @@ const LocationPicker: React.FC = ({ initialLocation, onLoca }; }, [initialLocation, getUserLocation]); - const handleMapPress = (event: any) => { - const { coordinates } = event.geometry; + const handleMapPress = (event: GeoJSON.Feature) => { + const geometry = event.geometry as GeoJSON.Point; + const [longitude, latitude] = geometry.coordinates; const newLocation = { - latitude: coordinates[1], - longitude: coordinates[0], + latitude, + longitude, }; setCurrentLocation(newLocation); setHasUserLocation(true); @@ -140,7 +145,7 @@ const LocationPicker: React.FC = ({ initialLocation, onLoca {/* Marker for the selected location */} - + @@ -148,7 +153,7 @@ const LocationPicker: React.FC = ({ initialLocation, onLoca {/* My Location button */} - + {isLocating ? : } diff --git a/src/services/callkeep.service.android.ts b/src/services/callkeep.service.android.ts index 58d8dbea..f10ab868 100644 --- a/src/services/callkeep.service.android.ts +++ b/src/services/callkeep.service.android.ts @@ -31,7 +31,7 @@ export class CallKeepService { }); } - async startCall(_displayName: string): Promise { + async startCall(_roomName: string, _handle?: string): Promise { logger.debug({ message: 'CallKeep startCall skipped - not supported on Android', }); @@ -44,26 +44,18 @@ export class CallKeepService { }); } - async setMuted(_muted: boolean): Promise { - logger.debug({ - message: 'CallKeep setMuted skipped - not supported on Android', - }); - } - - setMuteStateCallback(_callback: (muted: boolean) => void | null): void { + setMuteStateCallback(_callback: ((muted: boolean) => void) | null): void { logger.debug({ message: 'CallKeep setMuteStateCallback skipped - not supported on Android', }); } - async updateCallDisplay(_displayName: string): Promise { - logger.debug({ - message: 'CallKeep updateCallDisplay skipped - not supported on Android', - }); + isCallActiveNow(): boolean { + return false; } - isCallActive(): boolean { - return false; + getCurrentCallUUID(): string | null { + return null; } async cleanup(): Promise { diff --git a/src/services/callkeep.service.ts b/src/services/callkeep.service.ts new file mode 100644 index 00000000..8d0952a7 --- /dev/null +++ b/src/services/callkeep.service.ts @@ -0,0 +1,11 @@ +import { Platform } from 'react-native'; + +import { callKeepService as androidCallKeepService } from './callkeep.service.android'; +import { callKeepService as iosCallKeepService } from './callkeep.service.ios'; + +// Export the appropriate platform-specific implementation +export const callKeepService = Platform.OS === 'ios' ? iosCallKeepService : androidCallKeepService; + +// Re-export types from iOS (they're the same in both) +export type { CallKeepConfig } from './callkeep.service.ios'; +export { CallKeepService } from './callkeep.service.ios'; diff --git a/src/stores/calls/store.ts b/src/stores/calls/store.ts index faeb9a14..16270a51 100644 --- a/src/stores/calls/store.ts +++ b/src/stores/calls/store.ts @@ -33,9 +33,9 @@ export const useCallsStore = create((set, get) => ({ const callPrioritiesResponse = await getCallPriorities(); const callTypesResponse = await getCallTypes(); set({ - calls: callsResponse.Data, - callPriorities: callPrioritiesResponse.Data, - callTypes: callTypesResponse.Data, + calls: Array.isArray(callsResponse.Data) ? callsResponse.Data : [], + callPriorities: Array.isArray(callPrioritiesResponse.Data) ? callPrioritiesResponse.Data : [], + callTypes: Array.isArray(callTypesResponse.Data) ? callTypesResponse.Data : [], isLoading: false, lastFetchedAt: Date.now(), }); @@ -44,7 +44,7 @@ export const useCallsStore = create((set, get) => ({ set({ isLoading: true, error: null }); try { const response = await getCalls(); - set({ calls: response.Data, isLoading: false, lastFetchedAt: Date.now() }); + set({ calls: Array.isArray(response.Data) ? response.Data : [], isLoading: false, lastFetchedAt: Date.now() }); } catch (error) { set({ error: 'Failed to fetch calls', isLoading: false }); } @@ -53,7 +53,7 @@ export const useCallsStore = create((set, get) => ({ set({ isLoading: true, error: null }); try { const response = await getCallPriorities(); - set({ callPriorities: response.Data, isLoading: false }); + set({ callPriorities: Array.isArray(response.Data) ? response.Data : [], isLoading: false }); } catch (error) { set({ error: 'Failed to fetch call priorities', isLoading: false }); } @@ -68,7 +68,7 @@ export const useCallsStore = create((set, get) => ({ set({ isLoading: true, error: null }); try { const response = await getCallTypes(); - set({ callTypes: response.Data, isLoading: false }); + set({ callTypes: Array.isArray(response.Data) ? response.Data : [], isLoading: false }); } catch (error) { set({ error: 'Failed to fetch call types', isLoading: false }); } diff --git a/src/translations/ar.json b/src/translations/ar.json index a3130c7e..3e22b0ac 100644 --- a/src/translations/ar.json +++ b/src/translations/ar.json @@ -316,6 +316,7 @@ "go_back": "رجوع", "loading": "جاري التحميل...", "loading_address": "جاري تحميل العنوان...", + "my_location": "موقعي", "next": "التالي", "noActiveUnit": "لم يتم تعيين وحدة نشطة", "noActiveUnitDescription": "يرجى تعيين وحدة نشطة من صفحة الإعدادات للوصول إلى عناصر التحكم في الحالة", @@ -335,6 +336,7 @@ "route": "مسار", "save": "حفظ", "search": "بحث...", + "selected_location": "الموقع المحدد", "set_location": "تعيين الموقع", "share": "مشاركة", "step": "خطوة", diff --git a/src/translations/en.json b/src/translations/en.json index 45d029ab..8f5c9bbe 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -316,6 +316,7 @@ "go_back": "Go Back", "loading": "Loading...", "loading_address": "Loading address...", + "my_location": "My Location", "next": "Next", "noActiveUnit": "No Active Unit Set", "noActiveUnitDescription": "Please set an active unit from the settings page to access status controls", @@ -335,6 +336,7 @@ "route": "Route", "save": "Save", "search": "Search...", + "selected_location": "Selected Location", "set_location": "Set Location", "share": "Share", "step": "Step", diff --git a/src/translations/es.json b/src/translations/es.json index 1a59f4e1..aa650563 100644 --- a/src/translations/es.json +++ b/src/translations/es.json @@ -316,6 +316,7 @@ "go_back": "Volver", "loading": "Cargando...", "loading_address": "Cargando dirección...", + "my_location": "Mi ubicación", "next": "Siguiente", "noActiveUnit": "No hay unidad activa establecida", "noActiveUnitDescription": "Por favor establezca una unidad activa desde la página de configuración para acceder a los controles de estado", @@ -335,6 +336,7 @@ "route": "Ruta", "save": "Guardar", "search": "Buscar...", + "selected_location": "Ubicación seleccionada", "set_location": "Establecer ubicación", "share": "Compartir", "step": "Paso", From 8da921110b77b9eff06737cc1c3d436d954718a0 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Fri, 23 Jan 2026 13:04:10 -0800 Subject: [PATCH 4/4] RU-T46 PR#198 fixes --- src/components/maps/full-screen-location-picker.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/components/maps/full-screen-location-picker.tsx b/src/components/maps/full-screen-location-picker.tsx index 1ba69fc9..f0e5604b 100644 --- a/src/components/maps/full-screen-location-picker.tsx +++ b/src/components/maps/full-screen-location-picker.tsx @@ -94,9 +94,10 @@ const FullScreenLocationPicker: React.FC = ({ ini return; } - // Create a timeout promise + // Create a timeout promise with cleanup + let timeoutId: ReturnType | undefined; const timeoutPromise = new Promise((_, reject) => { - setTimeout(() => reject(new Error('Location timeout')), LOCATION_TIMEOUT); + timeoutId = setTimeout(() => reject(new Error('Location timeout')), LOCATION_TIMEOUT); }); // Race between getting location and timeout @@ -107,6 +108,9 @@ const FullScreenLocationPicker: React.FC = ({ ini timeoutPromise, ]); + // Clear timeout if location resolved first + if (timeoutId !== undefined) clearTimeout(timeoutId); + if (!isMountedRef.current) return; const newLocation = {