Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 62 additions & 5 deletions src/app/(app)/__tests__/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,14 @@ jest.mock('nativewind', () => ({
__esModule: true,
}));

import { render, waitFor } from '@testing-library/react-native';
import { render, screen, waitFor } from '@testing-library/react-native';
import { useColorScheme } from 'nativewind';
import React from 'react';
import { SafeAreaProvider } from 'react-native-safe-area-context';

import Map from '../index';
import { useAppLifecycle } from '@/hooks/use-app-lifecycle';
import { FALLBACK_DAY_MAP_STYLE, FALLBACK_NIGHT_MAP_STYLE } from '@/lib/map-style';
import { useLocationStore } from '@/stores/app/location-store';
import { locationService } from '@/services/location';

Expand Down Expand Up @@ -140,6 +141,7 @@ jest.mock('@/stores/app/core-store', () => {
activePriority: null,
activeUnit: null,
activeUnitStatus: null,
config: null,
};
const mockFn = jest.fn((selector) => (typeof selector === 'function' ? selector(storeState) : storeState)) as jest.Mock & { getState: () => typeof storeState };
mockFn.getState = () => storeState;
Expand Down Expand Up @@ -373,8 +375,8 @@ describe('Map Component - App Lifecycle', () => {
expect(mockLocationService.startLocationUpdates).toHaveBeenCalled();
});

// The map should use the light style
// Since we can't directly test the MapView props, we test that the component renders without errors
// No config loaded yet: the department-style fallback for day.
expect(screen.getByTestId('map-view').props.styleURL).toBe(FALLBACK_DAY_MAP_STYLE);
unmount();
});

Expand All @@ -391,8 +393,8 @@ describe('Map Component - App Lifecycle', () => {
expect(mockLocationService.startLocationUpdates).toHaveBeenCalled();
});

// The map should use the dark style
// Since we can't directly test the MapView props, we test that the component renders without errors
// No config loaded yet: the department-style fallback for night.
expect(screen.getByTestId('map-view').props.styleURL).toBe(FALLBACK_NIGHT_MAP_STYLE);
unmount();
});

Expand Down Expand Up @@ -450,6 +452,61 @@ describe('Map Component - App Lifecycle', () => {
unmount();
});
});
describe('Map Component - department map style', () => {
const SATELLITE = 'mapbox://styles/mapbox/satellite-v9';
const NAVIGATION_NIGHT = 'mapbox://styles/mapbox/navigation-night-v1';
const coreStoreState = () => jest.requireMock('@/stores/app/core-store').useCoreStore.getState() as Record<string, unknown>;
const setTheme = (colorScheme: 'light' | 'dark') => mockUseColorScheme.mockReturnValue({ colorScheme, setColorScheme: jest.fn(), toggleColorScheme: jest.fn() });

beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();

mockUseAppLifecycle.mockReturnValue(defaultAppLifecycleState);
mockLocationService.startLocationUpdates = jest.fn().mockResolvedValue(undefined);
mockLocationService.stopLocationUpdates = jest.fn().mockResolvedValue(undefined);
currentLocationState = { ...defaultLocationState, speed: 0, accuracy: 10 };
setLocationState({});

coreStoreState().config = { MapDayStyleUrl: SATELLITE, MapNightStyleUrl: NAVIGATION_NIGHT };
});

afterEach(() => {
coreStoreState().config = null;
jest.runOnlyPendingTimers();
jest.useRealTimers();
});

it('renders the configured day style in light mode', () => {
setTheme('light');

const { unmount } = render(<Map />, { wrapper: TestWrapper });

expect(screen.getByTestId('map-view').props.styleURL).toBe(SATELLITE);
unmount();
});

it('renders the configured night style in dark mode', () => {
setTheme('dark');

const { unmount } = render(<Map />, { wrapper: TestWrapper });

expect(screen.getByTestId('map-view').props.styleURL).toBe(NAVIGATION_NIGHT);
unmount();
});

it('switches to the night style when the theme flips', () => {
setTheme('light');
const { rerender, unmount } = render(<Map />, { wrapper: TestWrapper });
expect(screen.getByTestId('map-view').props.styleURL).toBe(SATELLITE);

setTheme('dark');
rerender(<Map />);

expect(screen.getByTestId('map-view').props.styleURL).toBe(NAVIGATION_NIGHT);
unmount();
});
});
describe('Map Component - follow camera', () => {
beforeEach(() => {
jest.clearAllMocks();
Expand Down
12 changes: 6 additions & 6 deletions src/app/(app)/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,12 @@ import { useAppLifecycle } from '@/hooks/use-app-lifecycle';
import { applyLiveLocationsSince, useMapLiveLocations } from '@/hooks/use-map-live-locations';
import { useMapSignalRUpdates } from '@/hooks/use-map-signalr-updates';
import { useWeatherAlertBanner } from '@/hooks/use-weather-alert-banner';
import { Env } from '@/lib/env';
import { logger } from '@/lib/logging';
import { applyPitchHysteresis, applyZoomHysteresis, createCirclePolygon, normalizeHeading, normalizeSpeed, smoothSpeed, zoomForSpeed } from '@/lib/map-camera';
import { getDepartmentMapCenter } from '@/lib/map-center';
import { getPinEntityId } from '@/lib/map-pin-ids';
import { useDepartmentMapStyle } from '@/lib/map-style';
import { getMapboxAccessToken } from '@/lib/mapbox-token';
import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData';
import { locationService } from '@/services/location';
import { useCoreStore } from '@/stores/app/core-store';
Expand All @@ -34,7 +35,7 @@ import { useRoutesStore } from '@/stores/routes/store';
import { useToastStore } from '@/stores/toast/store';
import { useWeatherAlertsStore } from '@/stores/weather-alerts/store';

Mapbox.setAccessToken(Env.UNIT_MAPBOX_PUBKEY);
Mapbox.setAccessToken(getMapboxAccessToken());

// Minimum interval between programmatic camera-follow updates. GPS fixes
// arrive every ~15s; without a throttle each fix drives a native camera
Expand Down Expand Up @@ -116,10 +117,9 @@ function MapContent() {
const fetchActiveLayers = useMapsStore((state) => state.fetchActiveLayers);
const fetchLayerGeoJSON = useMapsStore((state) => state.fetchLayerGeoJSON);

// Get map style based on current theme
const getMapStyle = useCallback(() => {
return colorScheme === 'dark' ? Mapbox.StyleURL.Dark : Mapbox.StyleURL.Street;
}, [colorScheme]);
// The department's day/night base map for the current theme
const departmentMapStyle = useDepartmentMapStyle();
const getMapStyle = useCallback(() => departmentMapStyle, [departmentMapStyle]);

const [styleURL, setStyleURL] = useState({ styleURL: getMapStyle() });

Expand Down
8 changes: 8 additions & 0 deletions src/app/call/new/__tests__/address-search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,10 @@ describe('Address Search Logic', () => {
MapCenterLatitude: 0,
MapCenterLongitude: 0,
MapCenterZoomLevel: 9,
MapDayStyleUrl: '',
MapNightStyleUrl: '',
AppMapboxAccessToken: '',
IsDepartmentMapOverride: false,
};

beforeEach(() => {
Expand Down Expand Up @@ -172,6 +176,10 @@ describe('Address Search Logic', () => {
MapCenterLatitude: 0,
MapCenterLongitude: 0,
MapCenterZoomLevel: 9,
MapDayStyleUrl: '',
MapNightStyleUrl: '',
AppMapboxAccessToken: '',
IsDepartmentMapOverride: false,
};

const result = await performAddressSearch('123 Main St', configWithoutKey);
Expand Down
8 changes: 8 additions & 0 deletions src/app/call/new/__tests__/coordinates-search.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,10 @@ describe('Coordinates Search Logic', () => {
MapCenterLatitude: 0,
MapCenterLongitude: 0,
MapCenterZoomLevel: 9,
MapDayStyleUrl: '',
MapNightStyleUrl: '',
AppMapboxAccessToken: '',
IsDepartmentMapOverride: false,
};

beforeEach(() => {
Expand Down Expand Up @@ -278,6 +282,10 @@ describe('Coordinates Search Logic', () => {
MapCenterLatitude: 0,
MapCenterLongitude: 0,
MapCenterZoomLevel: 9,
MapDayStyleUrl: '',
MapNightStyleUrl: '',
AppMapboxAccessToken: '',
IsDepartmentMapOverride: false,
};

const result = await performCoordinatesSearch('40.7128, -74.0060', configWithoutKey);
Expand Down
8 changes: 8 additions & 0 deletions src/app/call/new/__tests__/plus-code-search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,10 @@ describe('Plus Code Search Logic', () => {
MapCenterLatitude: 0,
MapCenterLongitude: 0,
MapCenterZoomLevel: 9,
MapDayStyleUrl: '',
MapNightStyleUrl: '',
AppMapboxAccessToken: '',
IsDepartmentMapOverride: false,
};

beforeEach(() => {
Expand Down Expand Up @@ -145,6 +149,10 @@ describe('Plus Code Search Logic', () => {
MapCenterLatitude: 0,
MapCenterLongitude: 0,
MapCenterZoomLevel: 9,
MapDayStyleUrl: '',
MapNightStyleUrl: '',
AppMapboxAccessToken: '',
IsDepartmentMapOverride: false,
};

const result = await performPlusCodeSearch('849VCWC8+R9', configWithoutKey);
Expand Down
4 changes: 4 additions & 0 deletions src/app/call/new/__tests__/what3words.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ const mockConfig: GetConfigResultData = {
MapCenterLatitude: 0,
MapCenterLongitude: 0,
MapCenterZoomLevel: 9,
MapDayStyleUrl: '',
MapNightStyleUrl: '',
AppMapboxAccessToken: '',
IsDepartmentMapOverride: false,
};

// Mock the core store
Expand Down
4 changes: 3 additions & 1 deletion src/app/maps/custom/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { HStack } from '@/components/ui/hstack';
import { Icon } from '@/components/ui/icon';
import { Text } from '@/components/ui/text';
import { VStack } from '@/components/ui/vstack';
import { useDepartmentMapStyle } from '@/lib/map-style';
import { type CustomMapLayerResultData } from '@/models/v4/mapping/customMapResultData';
import { useMapsStore } from '@/stores/maps/store';

Expand All @@ -25,6 +26,7 @@ export default function CustomMapViewer() {
const error = useMapsStore((state) => state.error);
const fetchCustomMap = useMapsStore((state) => state.fetchCustomMap);
const clearCurrentMap = useMapsStore((state) => state.clearCurrentMap);
const mapStyle = useDepartmentMapStyle();

const [layerVisibility, setLayerVisibility] = useState<Record<string, boolean>>({});
const [showLayerSheet, setShowLayerSheet] = useState(false);
Expand Down Expand Up @@ -171,7 +173,7 @@ export default function CustomMapViewer() {
<View className="flex-1 bg-gray-50 dark:bg-gray-900">
{/* Map */}
<View className="flex-1">
<Mapbox.MapView style={{ flex: 1 }} styleURL={Mapbox.StyleURL.Street} logoEnabled={false} attributionEnabled={false}>
<Mapbox.MapView style={{ flex: 1 }} styleURL={mapStyle} logoEnabled={false} attributionEnabled={false}>
<Mapbox.Camera zoomLevel={currentCustomMap.ZoomLevel || 14} centerCoordinate={[currentCustomMap.CenterLongitude, currentCustomMap.CenterLatitude]} animationMode="flyTo" animationDuration={1000} />

{visibleLayers.map(renderLayer)}
Expand Down
4 changes: 3 additions & 1 deletion src/app/maps/indoor/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { Icon } from '@/components/ui/icon';
import { Input, InputField, InputIcon, InputSlot } from '@/components/ui/input';
import { Text } from '@/components/ui/text';
import { VStack } from '@/components/ui/vstack';
import { useDepartmentMapStyle } from '@/lib/map-style';
import { type IndoorMapFloorResultData } from '@/models/v4/mapping/indoorMapResultData';
import { useMapsStore } from '@/stores/maps/store';

Expand All @@ -33,6 +34,7 @@ export default function IndoorMapViewer() {
const fetchIndoorMap = useMapsStore((state) => state.fetchIndoorMap);
const setCurrentFloor = useMapsStore((state) => state.setCurrentFloor);
const clearCurrentMap = useMapsStore((state) => state.clearCurrentMap);
const mapStyle = useDepartmentMapStyle();

const [zoneSearchQuery, setZoneSearchQuery] = useState('');
const [selectedZone, setSelectedZone] = useState<Record<string, unknown> | null>(null);
Expand Down Expand Up @@ -156,7 +158,7 @@ export default function IndoorMapViewer() {
{/* The map stays mounted across floor switches — unmounting it tore down
and rebuilt the whole GL context on every tab tap. The loader is
overlaid instead. */}
<Mapbox.MapView style={{ flex: 1 }} styleURL={Mapbox.StyleURL.Street} logoEnabled={false} attributionEnabled={false}>
<Mapbox.MapView style={{ flex: 1 }} styleURL={mapStyle} logoEnabled={false} attributionEnabled={false}>
<Mapbox.Camera zoomLevel={currentIndoorMap.BoundsNELatitude ? 18 : 16} centerCoordinate={[currentIndoorMap.CenterLongitude, currentIndoorMap.CenterLatitude]} animationMode="flyTo" animationDuration={800} />

{/* Floor plan image overlay */}
Expand Down
12 changes: 9 additions & 3 deletions src/app/routes/active.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,14 @@ import { HStack } from '@/components/ui/hstack';
import { Icon } from '@/components/ui/icon';
import { Text } from '@/components/ui/text';
import { VStack } from '@/components/ui/vstack';
import { Env } from '@/lib/env';
import { useDepartmentMapStyle } from '@/lib/map-style';
import { getMapboxAccessToken } from '@/lib/mapbox-token';
import { RouteStopStatus } from '@/models/v4/routes/routeInstanceStopResultData';
import { useCoreStore } from '@/stores/app/core-store';
import { useLocationStore } from '@/stores/app/location-store';
import { useRoutesStore } from '@/stores/routes/store';

Mapbox.setAccessToken(Env.UNIT_MAPBOX_PUBKEY);
Mapbox.setAccessToken(getMapboxAccessToken());

const POLL_INTERVAL_MS = 30_000;
const GEOFENCE_CIRCLE_STEPS = 64;
Expand Down Expand Up @@ -94,6 +95,7 @@ export default function ActiveRouteScreen() {
const checkOut = useRoutesStore((s) => s.checkOut);
const skip = useRoutesStore((s) => s.skip);
const ackDeviation = useRoutesStore((s) => s.ackDeviation);
const mapStyle = useDepartmentMapStyle();

// instanceId may be absent when navigating from start.tsx — fall back to store.
// Guard against the literal string "undefined" that can appear in URL params.
Expand Down Expand Up @@ -238,11 +240,15 @@ export default function ActiveRouteScreen() {
// Could navigate to a deviations detail screen in the future
}, []);

const handleMapLoaded = useCallback(() => {
setIsMapReady(true);
}, []);

return (
<Box className="flex-1 bg-white dark:bg-gray-900">
{/* Map area - 60% height */}
<Box className="h-[60%]">
<Mapbox.MapView style={styles.map} styleURL={Mapbox.StyleURL.Street} onDidFinishLoadingMap={() => setIsMapReady(true)}>
<Mapbox.MapView style={styles.map} styleURL={mapStyle} onDidFinishLoadingMap={handleMapLoaded}>
<Mapbox.Camera ref={cameraRef} />

{/* Route polyline */}
Expand Down
14 changes: 10 additions & 4 deletions src/app/routes/directions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,14 @@ import { useTranslation } from 'react-i18next';
import { ActivityIndicator, Linking, Platform, ScrollView, StyleSheet, Text as RNText, View } from 'react-native';

import { Loading } from '@/components/common/loading';
import { Camera, LineLayer, MapView, PointAnnotation, ShapeSource, StyleURL, UserLocation } from '@/components/maps/mapbox';
import { Camera, LineLayer, MapView, PointAnnotation, ShapeSource, UserLocation } from '@/components/maps/mapbox';
import { Box } from '@/components/ui/box';
import { Button, ButtonIcon, ButtonText } from '@/components/ui/button';
import { HStack } from '@/components/ui/hstack';
import { Text } from '@/components/ui/text';
import { VStack } from '@/components/ui/vstack';
import { Env } from '@/lib/env';
import { useDepartmentMapStyle } from '@/lib/map-style';
import { getMapboxAccessToken } from '@/lib/mapbox-token';
import { RouteStopStatus } from '@/models/v4/routes/routeInstanceStopResultData';
import { useRoutesStore } from '@/stores/routes/store';

Expand Down Expand Up @@ -146,7 +147,7 @@ export const deriveDrivingCondition = (congestion: CongestionSegment[]): { label
export async function fetchMapboxDirections(waypoints: [number, number][], language: string = 'en'): Promise<RouteDirectionsInfo | null> {
if (waypoints.length < 2) return null;

const token = Env.UNIT_MAPBOX_PUBKEY;
const token = getMapboxAccessToken();
if (!token) return null;

// Build the coordinate string for Mapbox Directions API
Expand Down Expand Up @@ -381,6 +382,7 @@ export default function RouteDirectionsScreen() {
const { t, i18n } = useTranslation();
const { instanceId } = useLocalSearchParams<{ instanceId: string }>();
const { colorScheme } = useColorScheme();
const mapStyle = useDepartmentMapStyle();
const cameraRef = useRef<any>(null);
const [isMapReady, setIsMapReady] = useState(false);
const [isFetchingDirections, setIsFetchingDirections] = useState(false);
Expand Down Expand Up @@ -609,6 +611,10 @@ export default function RouteDirectionsScreen() {
}
}, [destination, t]);

const handleMapLoaded = useCallback(() => {
setIsMapReady(true);
}, []);

// --- Loading states ---
if (isLoadingDirections && sortedStops.length === 0) {
return (
Expand All @@ -631,7 +637,7 @@ export default function RouteDirectionsScreen() {
<View style={styles.container}>
{/* Map */}
<View style={styles.mapContainer}>
<MapView style={styles.map} styleURL={colorScheme === 'dark' ? StyleURL.Dark : StyleURL.Street} onDidFinishLoadingMap={() => setIsMapReady(true)}>
<MapView style={styles.map} styleURL={mapStyle} onDidFinishLoadingMap={handleMapLoaded}>
<UserLocation visible={true} />
{/* Bare Camera — positioned imperatively via fitBounds once isMapReady && validStops */}
<Camera ref={cameraRef} />
Expand Down
4 changes: 3 additions & 1 deletion src/app/routes/history/instance/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { HStack } from '@/components/ui/hstack';
import { Icon } from '@/components/ui/icon';
import { Text } from '@/components/ui/text';
import { VStack } from '@/components/ui/vstack';
import { useDepartmentMapStyle } from '@/lib/map-style';
import { type RouteDeviationResultData, RouteDeviationType } from '@/models/v4/routes/routeDeviationResultData';
import { type RouteInstanceResultData, RouteInstanceStatus } from '@/models/v4/routes/routeInstanceResultData';
import { type RouteInstanceStopResultData, RouteStopStatus } from '@/models/v4/routes/routeInstanceStopResultData';
Expand Down Expand Up @@ -116,6 +117,7 @@ function getStopIcon(status: number) {
export default function RouteInstanceDetail() {
const { t } = useTranslation();
const { id: instanceId } = useLocalSearchParams<{ id: string }>();
const mapStyle = useDepartmentMapStyle();
// Local history state — never touches the global live-route slices
const [activeInstance, setActiveInstance] = useState<RouteInstanceResultData | null>(null);
const [instanceStops, setInstanceStops] = useState<RouteInstanceStopResultData[]>([]);
Expand Down Expand Up @@ -250,7 +252,7 @@ export default function RouteInstanceDetail() {
<ScrollView className="flex-1" contentContainerStyle={{ paddingBottom: 40 }}>
{/* Map */}
<Box className="h-72 overflow-hidden">
<Mapbox.MapView style={{ flex: 1 }} styleURL={Mapbox.StyleURL.Street} attributionEnabled={false} logoEnabled={false}>
<Mapbox.MapView style={{ flex: 1 }} styleURL={mapStyle} attributionEnabled={false} logoEnabled={false}>
{/* Camera fitted to bounds */}
{mapBounds ? (
<Mapbox.Camera bounds={mapBounds} padding={{ paddingTop: 40, paddingBottom: 40, paddingLeft: 40, paddingRight: 40 }} animationDuration={0} />
Expand Down
Loading
Loading