From 672825d3522aeb1d51d7556b8eb2b6a1de813267 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Thu, 19 Mar 2026 21:02:32 -0700 Subject: [PATCH 1/3] RU-T49 Adding routes and more langs --- .gitignore | 1 + src/api/mapping/mapping.ts | 171 +++- src/api/routes/routes.ts | 211 +++++ src/app/(app)/_layout.tsx | 47 +- src/app/(app)/index.tsx | 166 ++++ src/app/(app)/routes.tsx | 185 ++++ src/app/_layout.tsx | 2 + src/app/login/login-form.tsx | 50 +- src/app/maps/_layout.tsx | 21 + src/app/maps/custom/[id].tsx | 289 +++++++ src/app/maps/index.tsx | 220 +++++ src/app/maps/indoor/[id].tsx | 292 +++++++ src/app/maps/search.tsx | 244 ++++++ src/app/routes/_layout.tsx | 25 + src/app/routes/active.tsx | 505 +++++++++++ src/app/routes/directions.tsx | 314 +++++++ src/app/routes/history/[planId].tsx | 202 +++++ src/app/routes/history/instance/[id].tsx | 524 ++++++++++++ src/app/routes/index.tsx | 174 ++++ src/app/routes/start.tsx | 430 ++++++++++ src/app/routes/stop/[id].tsx | 421 +++++++++ src/app/routes/stop/contact.tsx | 366 ++++++++ src/components/maps/map-view.web.tsx | 6 + src/components/maps/mapbox.native.ts | 6 + src/components/maps/mapbox.web.ts | 6 + src/components/routes/route-card.tsx | 122 +++ .../routes/route-deviation-banner.tsx | 53 ++ src/components/routes/stop-card.tsx | 102 +++ src/components/routes/stop-marker.tsx | 49 ++ src/components/settings/language-item.tsx | 9 +- src/components/ui/focus-aware-status-bar.tsx | 13 +- src/lib/i18n/index.tsx | 19 +- src/lib/i18n/resources.ts | 28 + src/lib/logging/index.tsx | 9 + src/models/v4/mapping/customMapResultData.ts | 40 + src/models/v4/mapping/indoorMapResultData.ts | 41 + src/models/v4/mapping/mappingResults.ts | 70 ++ src/models/v4/routes/directionsResultData.ts | 17 + .../v4/routes/routeDeviationResultData.ts | 20 + src/models/v4/routes/routeInputs.ts | 48 ++ .../v4/routes/routeInstanceResultData.ts | 39 + .../v4/routes/routeInstanceStopResultData.ts | 30 + src/models/v4/routes/routePlanResultData.ts | 37 + src/models/v4/routes/routeResults.ts | 54 ++ src/stores/auth/store.tsx | 8 + src/stores/maps/store.ts | 231 +++++ src/stores/routes/store.ts | 375 ++++++++ src/translations/ar.json | 127 ++- src/translations/de.json | 804 ++++++++++++++++++ src/translations/en.json | 123 +++ src/translations/es.json | 125 ++- src/translations/fr.json | 804 ++++++++++++++++++ src/translations/it.json | 804 ++++++++++++++++++ src/translations/pl.json | 804 ++++++++++++++++++ src/translations/sv.json | 804 ++++++++++++++++++ src/translations/uk.json | 804 ++++++++++++++++++ 56 files changed, 11480 insertions(+), 11 deletions(-) create mode 100644 src/api/routes/routes.ts create mode 100644 src/app/(app)/routes.tsx create mode 100644 src/app/maps/_layout.tsx create mode 100644 src/app/maps/custom/[id].tsx create mode 100644 src/app/maps/index.tsx create mode 100644 src/app/maps/indoor/[id].tsx create mode 100644 src/app/maps/search.tsx create mode 100644 src/app/routes/_layout.tsx create mode 100644 src/app/routes/active.tsx create mode 100644 src/app/routes/directions.tsx create mode 100644 src/app/routes/history/[planId].tsx create mode 100644 src/app/routes/history/instance/[id].tsx create mode 100644 src/app/routes/index.tsx create mode 100644 src/app/routes/start.tsx create mode 100644 src/app/routes/stop/[id].tsx create mode 100644 src/app/routes/stop/contact.tsx create mode 100644 src/components/routes/route-card.tsx create mode 100644 src/components/routes/route-deviation-banner.tsx create mode 100644 src/components/routes/stop-card.tsx create mode 100644 src/components/routes/stop-marker.tsx create mode 100644 src/models/v4/mapping/customMapResultData.ts create mode 100644 src/models/v4/mapping/indoorMapResultData.ts create mode 100644 src/models/v4/mapping/mappingResults.ts create mode 100644 src/models/v4/routes/directionsResultData.ts create mode 100644 src/models/v4/routes/routeDeviationResultData.ts create mode 100644 src/models/v4/routes/routeInputs.ts create mode 100644 src/models/v4/routes/routeInstanceResultData.ts create mode 100644 src/models/v4/routes/routeInstanceStopResultData.ts create mode 100644 src/models/v4/routes/routePlanResultData.ts create mode 100644 src/models/v4/routes/routeResults.ts create mode 100644 src/stores/maps/store.ts create mode 100644 src/stores/routes/store.ts create mode 100644 src/translations/de.json create mode 100644 src/translations/fr.json create mode 100644 src/translations/it.json create mode 100644 src/translations/pl.json create mode 100644 src/translations/sv.json create mode 100644 src/translations/uk.json diff --git a/.gitignore b/.gitignore index 9ee0cbe5..b288583b 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,4 @@ Gemfile expo-env.d.ts # @end expo-cli +.dual-graph/ diff --git a/src/api/mapping/mapping.ts b/src/api/mapping/mapping.ts index aa7d1da0..548418b7 100644 --- a/src/api/mapping/mapping.ts +++ b/src/api/mapping/mapping.ts @@ -1,12 +1,57 @@ +import { type FeatureCollection } from 'geojson'; + import { type GetMapDataAndMarkersResult } from '@/models/v4/mapping/getMapDataAndMarkersResult'; import { type GetMapLayersResult } from '@/models/v4/mapping/getMapLayersResult'; +import { + type GetAllActiveLayersResult, + type GetCustomMapResult, + type GetCustomMapsResult, + type GetGeoJSONResult, + type GetIndoorMapFloorResult, + type GetIndoorMapResult, + type GetIndoorMapsResult, + type SearchAllMapFeaturesResult, + type SearchCustomMapRegionsResult, + type SearchIndoorLocationsResult, +} from '@/models/v4/mapping/mappingResults'; +import { createCachedApiEndpoint } from '../common/cached-client'; import { createApiEndpoint } from '../common/client'; const getMayLayersApi = createApiEndpoint('/Mapping/GetMayLayers'); - const getMapDataAndMarkersApi = createApiEndpoint('/Mapping/GetMapDataAndMarkers'); +// Indoor map endpoints +const getIndoorMapsApi = createCachedApiEndpoint('/Mapping/GetIndoorMaps', { + ttl: 5 * 60 * 1000, + enabled: true, +}); +const getIndoorMapApi = createApiEndpoint('/Mapping/GetIndoorMap'); +const getIndoorMapFloorApi = createApiEndpoint('/Mapping/GetIndoorMapFloor'); +const getIndoorMapZonesGeoJSONApi = createApiEndpoint('/Mapping/GetIndoorMapZonesGeoJSON'); +const searchIndoorLocationsApi = createApiEndpoint('/Mapping/SearchIndoorLocations'); +const getNearbyIndoorMapsApi = createApiEndpoint('/Mapping/GetNearbyIndoorMaps'); + +// Custom map endpoints +const getCustomMapsApi = createCachedApiEndpoint('/Mapping/GetCustomMaps', { + ttl: 5 * 60 * 1000, + enabled: true, +}); +const getCustomMapApi = createApiEndpoint('/Mapping/GetCustomMap'); +const getCustomMapLayerApi = createApiEndpoint('/Mapping/GetCustomMapLayer'); +const getMapLayerGeoJSONApi = createApiEndpoint('/Mapping/GetMapLayerGeoJSON'); +const getCustomMapRegionsGeoJSONApi = createApiEndpoint('/Mapping/GetCustomMapRegionsGeoJSON'); +const searchCustomMapRegionsApi = createApiEndpoint('/Mapping/SearchCustomMapRegions'); + +// Discovery endpoints +const getAllActiveLayersApi = createCachedApiEndpoint('/Mapping/GetAllActiveLayers', { + ttl: 5 * 60 * 1000, + enabled: true, +}); +const searchAllMapFeaturesApi = createApiEndpoint('/Mapping/SearchAllMapFeatures'); + +// --- Existing Endpoints --- + export const getMapDataAndMarkers = async (signal?: AbortSignal) => { const response = await getMapDataAndMarkersApi.get(undefined, signal); return response.data; @@ -21,3 +66,127 @@ export const getMayLayers = async (type: number, signal?: AbortSignal) => { ); return response.data; }; + +// --- Indoor Maps --- + +export const getIndoorMaps = async () => { + const response = await getIndoorMapsApi.get(); + return response.data; +}; + +export const getIndoorMap = async (mapId: string) => { + const response = await getIndoorMapApi.get({ + id: encodeURIComponent(mapId), + }); + return response.data; +}; + +export const getIndoorMapFloor = async (floorId: string) => { + const response = await getIndoorMapFloorApi.get({ + floorId: encodeURIComponent(floorId), + }); + return response.data; +}; + +export const getIndoorMapZonesGeoJSON = async (floorId: string) => { + const response = await getIndoorMapZonesGeoJSONApi.get({ + floorId: encodeURIComponent(floorId), + }); + return response.data; +}; + +export const searchIndoorLocations = async (term: string, mapId?: string) => { + const params: Record = { term: encodeURIComponent(term) }; + if (mapId) { + params.mapId = encodeURIComponent(mapId); + } + const response = await searchIndoorLocationsApi.get(params); + return response.data; +}; + +export const getNearbyIndoorMaps = async (lat: number, lon: number, radiusMeters: number) => { + const response = await getNearbyIndoorMapsApi.get({ + lat, + lon, + radiusMeters, + }); + return response.data; +}; + +// --- Custom Maps --- + +export const getCustomMaps = async (type?: number) => { + const params: Record = {}; + if (type !== undefined) { + params.type = encodeURIComponent(type); + } + const response = await getCustomMapsApi.get(params); + return response.data; +}; + +export const getCustomMap = async (mapId: string) => { + const response = await getCustomMapApi.get({ + id: encodeURIComponent(mapId), + }); + return response.data; +}; + +export const getCustomMapLayer = async (layerId: string) => { + const response = await getCustomMapLayerApi.get({ + layerId: encodeURIComponent(layerId), + }); + return response.data; +}; + +export const getMapLayerGeoJSON = async (layerId: string) => { + const response = await getMapLayerGeoJSONApi.get({ + layerId: encodeURIComponent(layerId), + }); + return response.data; +}; + +export const getCustomMapRegionsGeoJSON = async (layerId: string) => { + const response = await getCustomMapRegionsGeoJSONApi.get({ + layerId: encodeURIComponent(layerId), + }); + return response.data; +}; + +export const searchCustomMapRegions = async (term: string, layerId?: string) => { + const params: Record = { term: encodeURIComponent(term) }; + if (layerId) { + params.layerId = encodeURIComponent(layerId); + } + const response = await searchCustomMapRegionsApi.get(params); + return response.data; +}; + +// --- Discovery & Search --- + +export const getAllActiveLayers = async () => { + const response = await getAllActiveLayersApi.get(); + return response.data; +}; + +export const searchAllMapFeatures = async (term: string, type?: 'all' | 'indoor' | 'custom') => { + const params: Record = { term: encodeURIComponent(term) }; + if (type) { + params.type = type; + } + const response = await searchAllMapFeaturesApi.get(params); + return response.data; +}; + +// --- URL Helpers (no fetch needed, constructs URLs for components) --- + +export const getFloorImageUrl = (floorId: string): string => { + return `/api/v4/Mapping/GetIndoorMapFloorImage/${encodeURIComponent(floorId)}`; +}; + +export const getCustomMapLayerImageUrl = (layerId: string): string => { + return `/api/v4/Mapping/GetCustomMapLayerImage/${encodeURIComponent(layerId)}`; +}; + +export const getCustomMapTileUrl = (layerId: string): string => { + return `/api/v4/Mapping/GetCustomMapTile/${encodeURIComponent(layerId)}/{z}/{x}/{y}`; +}; diff --git a/src/api/routes/routes.ts b/src/api/routes/routes.ts new file mode 100644 index 00000000..d1708e28 --- /dev/null +++ b/src/api/routes/routes.ts @@ -0,0 +1,211 @@ +import { cacheManager } from '@/lib/cache/cache-manager'; +import { type ContactResult } from '@/models/v4/contacts/contactResult'; +import { type ContactsResult } from '@/models/v4/contacts/contactsResult'; +import { + type CancelRouteInput, + type CheckInInput, + type CheckOutInput, + type EndRouteInput, + type GeofenceCheckInInput, + type PauseRouteInput, + type ResumeRouteInput, + type SkipStopInput, + type StartRouteInput, + type UpdateStopNotesInput, +} from '@/models/v4/routes/routeInputs'; +import { + type GetActiveRouteForUnitResult, + type GetActiveRouteInstancesResult, + type GetDirectionsResult, + type GetRouteDeviationsResult, + type GetRouteInstanceResult, + type GetRouteInstanceStopsResult, + type GetRouteInstancesResult, + type GetRoutePlanResult, + type GetRoutePlansResult, + type GetRouteProgressResult, + type GetScheduledRoutesResult, + type SaveRoutePlanResult, +} from '@/models/v4/routes/routeResults'; + +import { createCachedApiEndpoint } from '../common/cached-client'; +import { api, createApiEndpoint } from '../common/client'; + +// Route plan endpoints +const getRoutePlansApi = createCachedApiEndpoint('/Routes/GetRoutePlans', { + ttl: 60 * 1000, + enabled: true, +}); + +// Route lifecycle endpoints (no path params) +const startRouteApi = createApiEndpoint('/Routes/StartRoute'); +const endRouteApi = createApiEndpoint('/Routes/EndRoute'); +const pauseRouteApi = createApiEndpoint('/Routes/PauseRoute'); +const resumeRouteApi = createApiEndpoint('/Routes/ResumeRoute'); +const cancelRouteApi = createApiEndpoint('/Routes/CancelRoute'); + +// Instance tracking endpoints (no path params) +const getActiveRoutesApi = createApiEndpoint('/Routes/GetActiveRoutes'); + +// Stop interaction endpoints (no path params) +const checkInAtStopApi = createApiEndpoint('/Routes/CheckInAtStop'); +const checkOutFromStopApi = createApiEndpoint('/Routes/CheckOutFromStop'); +const skipStopApi = createApiEndpoint('/Routes/SkipStop'); +const geofenceCheckInApi = createApiEndpoint('/Routes/GeofenceCheckIn'); +const updateStopNotesApi = createApiEndpoint('/Routes/UpdateStopNotes'); + +// Deviation endpoints (no path params) +const getUnacknowledgedDeviationsApi = createApiEndpoint('/Routes/GetUnacknowledgedDeviations'); + +// Fleet monitoring endpoints (no path params) +const getActiveRouteInstancesApi = createApiEndpoint('/Routes/GetActiveRouteInstances'); +const getScheduledRoutesApi = createApiEndpoint('/Routes/GetScheduledRoutes'); + +// --- Route Plans --- + +export const getRoutePlans = async () => { + const response = await getRoutePlansApi.get(); + return response.data; +}; + +export const getRoutePlansForUnit = async (unitId: string) => { + const response = await api.get(`/Routes/GetRoutePlansForUnit/${encodeURIComponent(unitId)}`); + return response.data; +}; + +export const getRoutePlan = async (routePlanId: string) => { + const response = await api.get(`/Routes/GetRoutePlan/${encodeURIComponent(routePlanId)}`); + return response.data; +}; + +// --- Route Lifecycle --- + +export const startRoute = async (input: StartRouteInput) => { + const response = await startRouteApi.post(input as unknown as Record); + cacheManager.remove('/Routes/GetRoutePlans'); + return response.data; +}; + +export const endRoute = async (input: EndRouteInput) => { + const response = await endRouteApi.post(input as unknown as Record); + cacheManager.remove('/Routes/GetRoutePlans'); + return response.data; +}; + +export const pauseRoute = async (input: PauseRouteInput) => { + const response = await pauseRouteApi.post(input as unknown as Record); + return response.data; +}; + +export const resumeRoute = async (input: ResumeRouteInput) => { + const response = await resumeRouteApi.post(input as unknown as Record); + return response.data; +}; + +export const cancelRoute = async (input: CancelRouteInput) => { + const response = await cancelRouteApi.post(input as unknown as Record); + cacheManager.remove('/Routes/GetRoutePlans'); + return response.data; +}; + +// --- Instance Tracking --- + +export const getActiveRouteForUnit = async (unitId: string) => { + const response = await api.get(`/Routes/GetActiveRouteForUnit/${encodeURIComponent(unitId)}`); + return response.data; +}; + +export const getActiveRoutes = async () => { + const response = await getActiveRoutesApi.get(); + return response.data; +}; + +export const getRouteProgress = async (instanceId: string) => { + const response = await api.get(`/Routes/GetRouteProgress/${encodeURIComponent(instanceId)}`); + return response.data; +}; + +export const getStopsForInstance = async (instanceId: string) => { + const response = await api.get(`/Routes/GetStopsForInstance/${encodeURIComponent(instanceId)}`); + return response.data; +}; + +export const getInstanceDirections = async (instanceId: string) => { + const response = await api.get(`/Routes/GetInstanceDirections/${encodeURIComponent(instanceId)}`); + return response.data; +}; + +export const getDirections = async (routePlanId: string) => { + const response = await api.get(`/Routes/GetDirections/${encodeURIComponent(routePlanId)}`); + return response.data; +}; + +// --- Stop Interactions --- + +export const checkInAtStop = async (input: CheckInInput) => { + const response = await checkInAtStopApi.post(input as unknown as Record); + return response.data; +}; + +export const checkOutFromStop = async (input: CheckOutInput) => { + const response = await checkOutFromStopApi.post(input as unknown as Record); + return response.data; +}; + +export const skipStop = async (input: SkipStopInput) => { + const response = await skipStopApi.post(input as unknown as Record); + return response.data; +}; + +export const geofenceCheckIn = async (input: GeofenceCheckInInput) => { + const response = await geofenceCheckInApi.post(input as unknown as Record); + return response.data; +}; + +export const updateStopNotes = async (input: UpdateStopNotesInput) => { + const response = await updateStopNotesApi.post(input as unknown as Record); + return response.data; +}; + +// --- Contacts --- + +export const getStopContact = async (routeStopId: string) => { + const response = await api.get(`/Routes/GetStopContact/${encodeURIComponent(routeStopId)}`); + return response.data; +}; + +export const getRouteContacts = async (routePlanId: string) => { + const response = await api.get(`/Routes/GetRouteContacts/${encodeURIComponent(routePlanId)}`); + return response.data; +}; + +// --- Deviations --- + +export const getUnacknowledgedDeviations = async () => { + const response = await getUnacknowledgedDeviationsApi.get(); + return response.data; +}; + +export const acknowledgeDeviation = async (deviationId: string) => { + const response = await api.post(`/Routes/AcknowledgeDeviation/${encodeURIComponent(deviationId)}`, {}); + return response.data; +}; + +// --- History --- + +export const getRouteHistory = async (routePlanId: string) => { + const response = await api.get(`/Routes/GetRouteHistory/${encodeURIComponent(routePlanId)}`); + return response.data; +}; + +// --- Fleet Monitoring --- + +export const getActiveRouteInstances = async () => { + const response = await getActiveRouteInstancesApi.get(); + return response.data; +}; + +export const getScheduledRoutes = async () => { + const response = await getScheduledRoutesApi.get(); + return response.data; +}; diff --git a/src/app/(app)/_layout.tsx b/src/app/(app)/_layout.tsx index 8a179e49..71aa5433 100644 --- a/src/app/(app)/_layout.tsx +++ b/src/app/(app)/_layout.tsx @@ -1,8 +1,10 @@ /* eslint-disable react/no-unstable-nested-components */ import { NovuProvider } from '@novu/react-native'; +import Countly from 'countly-sdk-react-native-bridge'; +import * as NavigationBar from 'expo-navigation-bar'; import { Redirect, SplashScreen, Tabs } from 'expo-router'; -import { Contact, ListTree, Map, Megaphone, Menu, Notebook, Settings } from 'lucide-react-native'; +import { Contact, ListTree, Map, Megaphone, Menu, Navigation, Notebook, Settings } from 'lucide-react-native'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ActivityIndicator, Platform, StyleSheet, useWindowDimensions } from 'react-native'; @@ -36,6 +38,7 @@ import { useSignalRStore } from '@/stores/signalr/signalr-store'; export default function TabLayout() { const { t } = useTranslation(); const status = useAuthStore((state) => state.status); + const userId = useAuthStore((state) => state.userId); const [isFirstTime, _setIsFirstTime] = useIsFirstTime(); const [isOpen, setIsOpen] = React.useState(false); const [isNotificationsOpen, setIsNotificationsOpen] = React.useState(false); @@ -46,6 +49,34 @@ export default function TabLayout() { const { isActive, appState } = useAppLifecycle(); const { trackEvent } = useAnalytics(); + // Identify user in Countly when signed in + useEffect(() => { + if (status === 'signedIn' && userId) { + try { + Countly.userProfile.setUserProperties({ id: userId }); + Countly.userProfile.save(); + } catch { + // Countly may not be initialized (e.g., no app key configured) — ignore + } + } + }, [status, userId]); + + // Hide the Android system navigation bar so it doesn't cover the tab bar. + // The app runs edge-to-edge (react-native-edge-to-edge), so the nav bar overlays + // content by default. We hide it on mount and re-hide it via a listener whenever + // the map (or any other view) causes Android to show it again. + useEffect(() => { + if (Platform.OS !== 'android') return; + NavigationBar.setVisibilityAsync('hidden'); + NavigationBar.setBehaviorAsync('overlay-swipe'); + const subscription = NavigationBar.addVisibilityListener(({ visibility }) => { + if (visibility === 'visible') { + NavigationBar.setVisibilityAsync('hidden'); + } + }); + return () => subscription.remove(); + }, []); + // Refs to track initialization state const hasInitialized = useRef(false); const isInitializing = useRef(false); @@ -301,6 +332,7 @@ export default function TabLayout() { const callsIcon = useCallback(({ color }: { color: string }) => , []); const contactsIcon = useCallback(({ color }: { color: string }) => , []); const notesIcon = useCallback(({ color }: { color: string }) => , []); + const routesIcon = useCallback(({ color }: { color: string }) => , []); const protocolsIcon = useCallback(({ color }: { color: string }) => , []); const settingsIcon = useCallback(({ color }: { color: string }) => , []); @@ -345,6 +377,17 @@ export default function TabLayout() { [t, contactsIcon, headerRightNotification] ); + const routesOptions = useMemo( + () => ({ + title: t('tabs.routes'), + headerShown: true as const, + tabBarIcon: routesIcon, + tabBarButtonTestID: 'routes-tab' as const, + headerRight: headerRightNotification, + }), + [t, routesIcon, headerRightNotification] + ); + const notesOptions = useMemo( () => ({ title: t('tabs.notes'), @@ -423,6 +466,8 @@ export default function TabLayout() { + + diff --git a/src/app/(app)/index.tsx b/src/app/(app)/index.tsx index 0a266a6b..43727993 100644 --- a/src/app/(app)/index.tsx +++ b/src/app/(app)/index.tsx @@ -11,6 +11,7 @@ import { Loading } from '@/components/common/loading'; import MapPins from '@/components/maps/map-pins'; import Mapbox from '@/components/maps/mapbox'; import PinDetailModal from '@/components/maps/pin-detail-modal'; +import { StopMarker } from '@/components/routes/stop-marker'; import { FocusAwareStatusBar } from '@/components/ui/focus-aware-status-bar'; import { useAnalytics } from '@/hooks/use-analytics'; import { useAppLifecycle } from '@/hooks/use-app-lifecycle'; @@ -21,6 +22,8 @@ import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersD import { locationService } from '@/services/location'; import { useCoreStore } from '@/stores/app/core-store'; import { useLocationStore } from '@/stores/app/location-store'; +import { useMapsStore } from '@/stores/maps/store'; +import { useRoutesStore } from '@/stores/routes/store'; import { useToastStore } from '@/stores/toast/store'; Mapbox.setAccessToken(Env.UNIT_MAPBOX_PUBKEY); @@ -55,6 +58,21 @@ function MapContent() { const locationHeading = useLocationStore((state) => state.heading); const isMapLocked = useLocationStore((state) => state.isMapLocked); + // Route overlay state + const activeUnitId = useCoreStore((state) => state.activeUnitId); + const activeInstance = useRoutesStore((state) => state.activeInstance); + const instanceStops = useRoutesStore((state) => state.instanceStops); + const fetchActiveRoute = useRoutesStore((state) => state.fetchActiveRoute); + const fetchStopsForInstance = useRoutesStore((state) => state.fetchStopsForInstance); + const [showRouteOverlay, setShowRouteOverlay] = useState(true); + + // Map layers state + const activeLayers = useMapsStore((state) => state.activeLayers); + const layerToggles = useMapsStore((state) => state.layerToggles); + const cachedGeoJSON = useMapsStore((state) => state.cachedGeoJSON); + 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; @@ -65,6 +83,81 @@ function MapContent() { const pulseAnim = useRef(new Animated.Value(1)).current; useMapSignalRUpdates(setMapPins); + // Fetch active route overlay data + useEffect(() => { + if (activeUnitId) { + fetchActiveRoute(activeUnitId); + fetchActiveLayers(); + } + }, [activeUnitId, fetchActiveRoute, fetchActiveLayers]); + + // Fetch stops when active instance changes + useEffect(() => { + if (activeInstance?.RouteInstanceId) { + fetchStopsForInstance(activeInstance.RouteInstanceId); + } + }, [activeInstance?.RouteInstanceId, fetchStopsForInstance]); + + // Fetch GeoJSON for enabled layers + useEffect(() => { + activeLayers.forEach((layer) => { + if (layerToggles[layer.LayerId] && !cachedGeoJSON[layer.LayerId]) { + fetchLayerGeoJSON(layer.LayerId); + } + }); + }, [activeLayers, layerToggles, cachedGeoJSON, fetchLayerGeoJSON]); + + // Parse route geometry for overlay + const routeOverlayGeoJSON = useMemo(() => { + if (!showRouteOverlay || !activeInstance) return null; + const geometry = activeInstance.ActualRouteGeometry || ''; + if (!geometry) return null; + try { + const parsed = JSON.parse(geometry); + if (parsed.type === 'Feature' || parsed.type === 'FeatureCollection') return parsed; + if (parsed.type === 'LineString' || parsed.type === 'MultiLineString') { + return { type: 'Feature' as const, properties: {}, geometry: parsed }; + } + if (Array.isArray(parsed)) { + return { type: 'Feature' as const, properties: {}, geometry: { type: 'LineString', coordinates: parsed } }; + } + return null; + } catch { + return null; + } + }, [showRouteOverlay, activeInstance]); + + // Get remaining stops for route overlay + const remainingStops = useMemo(() => { + if (!showRouteOverlay || !activeInstance) return []; + return instanceStops.filter((s) => s.Status === 0 || s.Status === 1); + }, [showRouteOverlay, activeInstance, instanceStops]); + + // Next stop for geofence circle + const nextStop = useMemo(() => { + return remainingStops.find((s) => s.Status === 0 || s.Status === 1) || null; + }, [remainingStops]); + + // Geofence circle GeoJSON + const geofenceGeoJSON = useMemo((): GeoJSON.Feature | null => { + if (!nextStop || !nextStop.GeofenceRadiusMeters) return null; + const points = 64; + const coords: number[][] = []; + const radiusDeg = nextStop.GeofenceRadiusMeters / 111320; + for (let i = 0; i <= points; i++) { + const angle = (i / points) * 2 * Math.PI; + coords.push([ + nextStop.Longitude + radiusDeg * Math.cos(angle), + nextStop.Latitude + radiusDeg * Math.sin(angle), + ]); + } + return { + type: 'Feature', + properties: {}, + geometry: { type: 'Polygon', coordinates: [coords] }, + }; + }, [nextStop]); + // Update map style when theme changes useEffect(() => { const newStyle = getMapStyle(); @@ -415,6 +508,79 @@ function MapContent() { ) : null} + + {/* Active route polyline overlay */} + {routeOverlayGeoJSON ? ( + + + + ) : null} + + {/* Geofence circle around next stop */} + {geofenceGeoJSON ? ( + + + + + ) : null} + + {/* Route stop markers */} + {showRouteOverlay + ? remainingStops.map((stop) => + stop.Latitude && stop.Longitude ? ( + + + + ) : null + ) + : null} + + {/* Custom map layer overlays */} + {activeLayers.map((layer) => + layerToggles[layer.LayerId] && cachedGeoJSON[layer.LayerId] ? ( + + + + + ) : null + )} {/* Recenter Button - only show when map is not locked and user has moved the map */} diff --git a/src/app/(app)/routes.tsx b/src/app/(app)/routes.tsx new file mode 100644 index 00000000..553ba4ee --- /dev/null +++ b/src/app/(app)/routes.tsx @@ -0,0 +1,185 @@ +import { router } from 'expo-router'; +import { Navigation, PlusIcon, RefreshCcwDotIcon, Search, X } from 'lucide-react-native'; +import React, { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Pressable, RefreshControl, View } from 'react-native'; + +import { RouteCard } from '@/components/routes/route-card'; +import { Loading } from '@/components/common/loading'; +import ZeroState from '@/components/common/zero-state'; +import { Badge, BadgeText } from '@/components/ui/badge'; +import { Box } from '@/components/ui/box'; +import { Fab, FabIcon } from '@/components/ui/fab'; +import { FlatList } from '@/components/ui/flat-list'; +import { HStack } from '@/components/ui/hstack'; +import { Input, InputField, InputIcon, InputSlot } from '@/components/ui/input'; +import { Text } from '@/components/ui/text'; +import { type RoutePlanResultData } from '@/models/v4/routes/routePlanResultData'; +import { useCoreStore } from '@/stores/app/core-store'; +import { useRoutesStore } from '@/stores/routes/store'; +import { useUnitsStore } from '@/stores/units/store'; + +export default function Routes() { + const { t } = useTranslation(); + const routePlans = useRoutesStore((state) => state.routePlans); + const activeInstance = useRoutesStore((state) => state.activeInstance); + const isLoading = useRoutesStore((state) => state.isLoading); + const error = useRoutesStore((state) => state.error); + const fetchAllRoutePlans = useRoutesStore((state) => state.fetchAllRoutePlans); + const fetchActiveRoute = useRoutesStore((state) => state.fetchActiveRoute); + const activeUnitId = useCoreStore((state) => state.activeUnitId); + const activeUnit = useCoreStore((state) => state.activeUnit); + const units = useUnitsStore((state) => state.units); + const fetchUnits = useUnitsStore((state) => state.fetchUnits); + const [searchQuery, setSearchQuery] = useState(''); + + const unitMap = useMemo( + () => Object.fromEntries(units.map((u) => [u.UnitId, u.Name])), + [units] + ); + + useEffect(() => { + fetchAllRoutePlans(); + if (activeUnitId) { + fetchActiveRoute(activeUnitId); + } + if (units.length === 0) { + fetchUnits(); + } + }, [activeUnitId, fetchAllRoutePlans, fetchActiveRoute, fetchUnits, units.length]); + + const handleRefresh = () => { + fetchAllRoutePlans(); + if (activeUnitId) { + fetchActiveRoute(activeUnitId); + } + }; + + const handleRoutePress = (route: RoutePlanResultData) => { + if (activeInstance && activeInstance.RoutePlanId === route.RoutePlanId) { + const iid = activeInstance.RouteInstanceId; + const url = iid && iid !== 'undefined' + ? `/routes/active?planId=${route.RoutePlanId}&instanceId=${iid}` + : `/routes/active?planId=${route.RoutePlanId}`; + router.push(url as any); + } else { + router.push(`/routes/start?planId=${route.RoutePlanId}` as any); + } + }; + + const filteredRoutes = useMemo(() => { + const active = routePlans.filter((route) => route.RouteStatus === 1); + if (!searchQuery) return active; + const q = searchQuery.toLowerCase(); + return active.filter((route) => { + const isRouteMyUnit = route.UnitId != null && String(route.UnitId) === String(activeUnitId); + const unitName = route.UnitId != null ? (unitMap[route.UnitId] || (isRouteMyUnit ? (activeUnit?.Name ?? '') : '')) : ''; + return ( + route.Name.toLowerCase().includes(q) || + (route.Description?.toLowerCase() || '').includes(q) || + unitName.toLowerCase().includes(q) + ); + }); + }, [routePlans, searchQuery, unitMap, activeUnitId, activeUnit]); + + const renderContent = () => { + if (isLoading) { + return ; + } + + if (error) { + return ; + } + + return ( + + testID="routes-list" + data={filteredRoutes} + ListHeaderComponent={ + activeInstance ? ( + { + const iid = activeInstance.RouteInstanceId; + const url = iid && iid !== 'undefined' + ? `/routes/active?planId=${activeInstance.RoutePlanId}&instanceId=${iid}` + : `/routes/active?planId=${activeInstance.RoutePlanId}`; + router.push(url as any); + }} + > + + + + + + {activeInstance.RoutePlanName || t('routes.active_route')} + + + + {t('routes.active')} + + + + {t('routes.progress', { + percent: activeInstance.StopsTotal + ? Math.round(((activeInstance.StopsCompleted ?? 0) / activeInstance.StopsTotal) * 100) + : 0, + })} + + + + ) : null + } + renderItem={({ item }: { item: RoutePlanResultData }) => { + const isMyUnit = item.UnitId != null && String(item.UnitId) === String(activeUnitId); + const unitName = item.UnitId != null + ? (unitMap[item.UnitId] || (isMyUnit ? (activeUnit?.Name ?? '') : '')) + : ''; + return ( + handleRoutePress(item)}> + + + ); + }} + keyExtractor={(item: RoutePlanResultData) => item.RoutePlanId} + refreshControl={} + ListEmptyComponent={ + + } + contentContainerStyle={{ paddingBottom: 20 }} + /> + ); + }; + + return ( + + + + + + + + {searchQuery ? ( + setSearchQuery('')}> + + + ) : null} + + + {renderContent()} + + router.push('/routes/start' as any)} testID="new-route-fab"> + + + + + ); +} diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index 86e53694..4d76d024 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -172,6 +172,8 @@ function RootLayout() { + + ); diff --git a/src/app/login/login-form.tsx b/src/app/login/login-form.tsx index 6a08a9c2..0c2f09f2 100644 --- a/src/app/login/login-form.tsx +++ b/src/app/login/login-form.tsx @@ -1,5 +1,5 @@ import { zodResolver } from '@hookform/resolvers/zod'; -import { AlertTriangle, EyeIcon, EyeOffIcon, LogIn, ShieldCheck } from 'lucide-react-native'; +import { AlertTriangle, EyeIcon, EyeOffIcon, Globe, LogIn, ShieldCheck } from 'lucide-react-native'; import { useColorScheme } from 'nativewind'; import React, { useState } from 'react'; import type { SubmitHandler } from 'react-hook-form'; @@ -13,8 +13,12 @@ import { View } from '@/components/ui'; import { Button, ButtonSpinner, ButtonText } from '@/components/ui/button'; import { FormControl, FormControlError, FormControlErrorIcon, FormControlErrorText, FormControlLabel, FormControlLabelText } from '@/components/ui/form-control'; import { Input, InputField, InputIcon, InputSlot } from '@/components/ui/input'; +import { Select, SelectBackdrop, SelectContent, SelectDragIndicator, SelectDragIndicatorWrapper, SelectIcon, SelectInput, SelectItem, SelectPortal, SelectTrigger } from '@/components/ui/select'; import { Text } from '@/components/ui/text'; import colors from '@/constants/colors'; +import { translate, useSelectedLanguage } from '@/lib'; +import type { Language } from '@/lib/i18n/resources'; +import { ChevronDownIcon } from 'lucide-react-native'; // Function to create schema - makes it easier to mock for testing const createLoginFormSchema = () => @@ -46,7 +50,24 @@ export type LoginFormProps = { export const LoginForm = ({ onSubmit = () => { }, isLoading = false, error = undefined, onServerUrlPress, onSsoPress }: LoginFormProps) => { const { colorScheme } = useColorScheme(); - const { t } = useTranslation(); + const { t, i18n: i18nInstance } = useTranslation(); + const { language, setLanguage } = useSelectedLanguage(); + + const langs = React.useMemo( + () => [ + { label: translate('settings.english'), value: 'en' }, + { label: translate('settings.spanish'), value: 'es' }, + { label: translate('settings.swedish'), value: 'sv' }, + { label: translate('settings.german'), value: 'de' }, + { label: translate('settings.french'), value: 'fr' }, + { label: translate('settings.italian'), value: 'it' }, + { label: translate('settings.polish'), value: 'pl' }, + { label: translate('settings.ukrainian'), value: 'uk' }, + { label: translate('settings.arabic'), value: 'ar' }, + ], + // eslint-disable-next-line react-hooks/exhaustive-deps + [i18nInstance.language] + ); const { control, handleSubmit, @@ -190,6 +211,31 @@ export const LoginForm = ({ onSubmit = () => { }, isLoading = false, error = und ) : null} + + {/* Language selector */} + + + + ); diff --git a/src/app/maps/_layout.tsx b/src/app/maps/_layout.tsx new file mode 100644 index 00000000..55afee32 --- /dev/null +++ b/src/app/maps/_layout.tsx @@ -0,0 +1,21 @@ +import { Stack } from 'expo-router'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +export default function MapsLayout() { + const { t } = useTranslation(); + + return ( + + + + + + + ); +} diff --git a/src/app/maps/custom/[id].tsx b/src/app/maps/custom/[id].tsx new file mode 100644 index 00000000..4265feb4 --- /dev/null +++ b/src/app/maps/custom/[id].tsx @@ -0,0 +1,289 @@ +import { useLocalSearchParams } from 'expo-router'; +import { ChevronDown, Eye, EyeOff, Layers, X } from 'lucide-react-native'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Pressable, ScrollView, View } from 'react-native'; + +import { getCustomMapLayerImageUrl, getCustomMapTileUrl } from '@/api/mapping/mapping'; +import Mapbox from '@/components/maps/mapbox'; +import { Loading } from '@/components/common/loading'; +import ZeroState from '@/components/common/zero-state'; +import { Box } from '@/components/ui/box'; +import { Card } from '@/components/ui/card'; +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 { type CustomMapLayerResultData } from '@/models/v4/mapping/customMapResultData'; +import { useMapsStore } from '@/stores/maps/store'; + +export default function CustomMapViewer() { + const { t } = useTranslation(); + const { id } = useLocalSearchParams<{ id: string }>(); + const currentCustomMap = useMapsStore((state) => state.currentCustomMap); + const isLoading = useMapsStore((state) => state.isLoading); + const error = useMapsStore((state) => state.error); + const fetchCustomMap = useMapsStore((state) => state.fetchCustomMap); + const clearCurrentMap = useMapsStore((state) => state.clearCurrentMap); + + const [layerVisibility, setLayerVisibility] = useState>({}); + const [showLayerSheet, setShowLayerSheet] = useState(false); + const [selectedFeature, setSelectedFeature] = useState | null>(null); + + useEffect(() => { + if (id) { + fetchCustomMap(id); + } + return () => { + clearCurrentMap(); + }; + }, [id, fetchCustomMap, clearCurrentMap]); + + // Initialize layer visibility from defaults + useEffect(() => { + if (currentCustomMap?.Layers) { + const visibility: Record = {}; + currentCustomMap.Layers.forEach((layer) => { + visibility[layer.CustomMapLayerId] = layer.IsOnByDefault; + }); + setLayerVisibility(visibility); + } + }, [currentCustomMap]); + + const toggleLayerVisibility = useCallback((layerId: string) => { + setLayerVisibility((prev) => ({ ...prev, [layerId]: !prev[layerId] })); + }, []); + + const handleFeaturePress = useCallback((event: { features?: Array<{ properties?: Record }> }) => { + if (event.features && event.features.length > 0) { + const feature = event.features[0]; + setSelectedFeature(feature.properties ?? null); + } + }, []); + + const visibleLayers = useMemo(() => { + if (!currentCustomMap?.Layers) return []; + return currentCustomMap.Layers.filter((layer) => layerVisibility[layer.CustomMapLayerId]); + }, [currentCustomMap, layerVisibility]); + + const renderVectorLayer = (layer: CustomMapLayerResultData) => { + if (!layer.GeoJson) return null; + + let geoJSON; + try { + geoJSON = typeof layer.GeoJson === 'string' ? JSON.parse(layer.GeoJson) : layer.GeoJson; + } catch { + return null; + } + + return ( + + + + + + ); + }; + + const renderImageLayer = (layer: CustomMapLayerResultData) => { + if (!layer.HasImage) return null; + + const imageUrl = getCustomMapLayerImageUrl(layer.CustomMapLayerId); + + return ( + + + + ); + }; + + const renderTiledLayer = (layer: CustomMapLayerResultData) => { + if (!layer.HasTiles) return null; + + const tileUrl = getCustomMapTileUrl(layer.CustomMapLayerId); + + return ( + + + + ); + }; + + const renderLayer = (layer: CustomMapLayerResultData) => { + if (layer.HasTiles) { + return renderTiledLayer(layer); + } + if (layer.HasImage) { + return renderImageLayer(layer); + } + return renderVectorLayer(layer); + }; + + if (isLoading) { + return ( + + + + ); + } + + if (error || !currentCustomMap) { + return ( + + + + ); + } + + return ( + + {/* Map */} + + + + + {visibleLayers.map(renderLayer)} + + + {/* Feature detail popover */} + {selectedFeature ? ( + + + + + + {(selectedFeature.name as string) || (selectedFeature.Name as string) || 'Region'} + + {selectedFeature.description || selectedFeature.Description ? ( + + {(selectedFeature.description as string) || (selectedFeature.Description as string)} + + ) : null} + {selectedFeature.type || selectedFeature.Type ? ( + + Type: {(selectedFeature.type as string) || (selectedFeature.Type as string)} + + ) : null} + + setSelectedFeature(null)} className="p-1"> + + + + + + ) : null} + + {/* Layer toggle button */} + setShowLayerSheet(!showLayerSheet)} + className="absolute right-4 top-4 rounded-full bg-white p-3 shadow-md dark:bg-gray-800" + > + + + + + {/* Bottom sheet for layer toggles */} + {showLayerSheet ? ( + + + + {t('maps.layer_toggles')} + setShowLayerSheet(false)} className="p-1"> + + + + + {currentCustomMap.Layers.map((layer) => ( + toggleLayerVisibility(layer.CustomMapLayerId)} + > + + + + + + {layer.Name} + + + + + + + ))} + + + + ) : null} + + ); +} diff --git a/src/app/maps/index.tsx b/src/app/maps/index.tsx new file mode 100644 index 00000000..4eb62052 --- /dev/null +++ b/src/app/maps/index.tsx @@ -0,0 +1,220 @@ +import { useFocusEffect } from '@react-navigation/native'; +import { router } from 'expo-router'; +import { Building2, Layers, Map, Search, X } from 'lucide-react-native'; +import React, { useCallback, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Pressable, RefreshControl, SectionList, View } from 'react-native'; + +import { Loading } from '@/components/common/loading'; +import ZeroState from '@/components/common/zero-state'; +import { Badge, BadgeText } from '@/components/ui/badge'; +import { Box } from '@/components/ui/box'; +import { Card } from '@/components/ui/card'; +import { FocusAwareStatusBar } from '@/components/ui/focus-aware-status-bar'; +import { HStack } from '@/components/ui/hstack'; +import { Icon } from '@/components/ui/icon'; +import { Input, InputField, InputIcon, InputSlot } from '@/components/ui/input'; +import { Switch } from '@/components/ui/switch'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; +import { type CustomMapResultData, CustomMapType } from '@/models/v4/mapping/customMapResultData'; +import { type IndoorMapResultData } from '@/models/v4/mapping/indoorMapResultData'; +import { type ActiveLayerSummary } from '@/models/v4/mapping/mappingResults'; +import { useMapsStore } from '@/stores/maps/store'; + +export default function MapsHome() { + const { t } = useTranslation(); + + const getCustomMapTypeLabel = (type: number): string => { + switch (type) { + case CustomMapType.Outdoor: + return t('maps.outdoor'); + case CustomMapType.Event: + return t('maps.event'); + case CustomMapType.General: + return t('maps.general'); + default: + return t('common.unknown'); + } + }; + const indoorMaps = useMapsStore((state) => state.indoorMaps); + const customMaps = useMapsStore((state) => state.customMaps); + const activeLayers = useMapsStore((state) => state.activeLayers); + const layerToggles = useMapsStore((state) => state.layerToggles); + const isLoading = useMapsStore((state) => state.isLoading); + const isLoadingLayers = useMapsStore((state) => state.isLoadingLayers); + const error = useMapsStore((state) => state.error); + const fetchIndoorMaps = useMapsStore((state) => state.fetchIndoorMaps); + const fetchCustomMaps = useMapsStore((state) => state.fetchCustomMaps); + const fetchActiveLayers = useMapsStore((state) => state.fetchActiveLayers); + const toggleLayer = useMapsStore((state) => state.toggleLayer); + + const [showLayers, setShowLayers] = useState(false); + + useFocusEffect( + useCallback(() => { + fetchIndoorMaps(); + fetchCustomMaps(); + fetchActiveLayers(); + }, [fetchIndoorMaps, fetchCustomMaps, fetchActiveLayers]) + ); + + const handleRefresh = () => { + fetchIndoorMaps(); + fetchCustomMaps(); + fetchActiveLayers(); + }; + + const renderCustomMapCard = (map: CustomMapResultData) => ( + router.push(`/maps/custom/${map.CustomMapId}`)}> + + + + + + + {map.Name} + {map.Description ? ( + + {map.Description} + + ) : null} + + + {getCustomMapTypeLabel(map.Type)} + + + + + ); + + const renderIndoorMapCard = (map: IndoorMapResultData) => ( + router.push(`/maps/indoor/${map.IndoorMapId}`)}> + + + + + + + {map.Name} + {map.Description ? ( + + {map.Description} + + ) : null} + + {t('maps.floor_count', { count: map.Floors?.length ?? 0 })} + + + + {t('maps.indoor')} + + + + + ); + + const renderLayerToggle = (layer: ActiveLayerSummary) => ( + + + + {layer.Name} + + toggleLayer(layer.LayerId)} /> + + ); + + const sections = [ + ...(customMaps.length > 0 + ? [ + { + title: t('maps.custom_maps'), + data: customMaps, + renderItem: ({ item }: { item: CustomMapResultData }) => renderCustomMapCard(item), + }, + ] + : []), + ...(indoorMaps.length > 0 + ? [ + { + title: t('maps.indoor_maps'), + data: indoorMaps, + renderItem: ({ item }: { item: IndoorMapResultData }) => renderIndoorMapCard(item), + }, + ] + : []), + ]; + + const renderContent = () => { + if (isLoading) { + return ; + } + + if (error) { + return ; + } + + if (customMaps.length === 0 && indoorMaps.length === 0) { + return ; + } + + return ( + + 'CustomMapId' in item ? item.CustomMapId : item.IndoorMapId + } + renderSectionHeader={({ section }) => ( + {section.title} + )} + refreshControl={} + contentContainerStyle={{ paddingBottom: 20 }} + /> + ); + }; + + return ( + + + + {/* Search bar */} + router.push('/maps/search')}> + + + + + + + + + {/* Layer toggles */} + {activeLayers.length > 0 ? ( + setShowLayers(!showLayers)}> + + + + {t('maps.active_layers')} + + + {t('maps.layers_on', { active: Object.values(layerToggles).filter(Boolean).length, total: activeLayers.length })} + + + + ) : null} + + {showLayers && activeLayers.length > 0 ? ( + + {isLoadingLayers ? ( + + ) : ( + activeLayers.map(renderLayerToggle) + )} + + ) : null} + + {/* Main content */} + {renderContent()} + + + ); +} diff --git a/src/app/maps/indoor/[id].tsx b/src/app/maps/indoor/[id].tsx new file mode 100644 index 00000000..95afc4c5 --- /dev/null +++ b/src/app/maps/indoor/[id].tsx @@ -0,0 +1,292 @@ +import { router, useLocalSearchParams } from 'expo-router'; +import { Building2, Search, Send, X } from 'lucide-react-native'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Pressable, ScrollView, View } from 'react-native'; + +import { getFloorImageUrl } from '@/api/mapping/mapping'; +import Mapbox from '@/components/maps/mapbox'; +import { Loading } from '@/components/common/loading'; +import ZeroState from '@/components/common/zero-state'; +import { Badge, BadgeText } from '@/components/ui/badge'; +import { Box } from '@/components/ui/box'; +import { Button, ButtonIcon, ButtonText } from '@/components/ui/button'; +import { Card } from '@/components/ui/card'; +import { HStack } from '@/components/ui/hstack'; +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 { type IndoorMapFloorResultData } from '@/models/v4/mapping/indoorMapResultData'; +import { useMapsStore } from '@/stores/maps/store'; + +export default function IndoorMapViewer() { + const { t } = useTranslation(); + const { id } = useLocalSearchParams<{ id: string }>(); + const currentIndoorMap = useMapsStore((state) => state.currentIndoorMap); + const currentFloor = useMapsStore((state) => state.currentFloor); + const currentFloorId = useMapsStore((state) => state.currentFloorId); + const currentZonesGeoJSON = useMapsStore((state) => state.currentZonesGeoJSON); + const isLoading = useMapsStore((state) => state.isLoading); + const isLoadingGeoJSON = useMapsStore((state) => state.isLoadingGeoJSON); + const error = useMapsStore((state) => state.error); + const fetchIndoorMap = useMapsStore((state) => state.fetchIndoorMap); + const setCurrentFloor = useMapsStore((state) => state.setCurrentFloor); + const clearCurrentMap = useMapsStore((state) => state.clearCurrentMap); + + const [zoneSearchQuery, setZoneSearchQuery] = useState(''); + const [selectedZone, setSelectedZone] = useState | null>(null); + + useEffect(() => { + if (id) { + fetchIndoorMap(id); + } + return () => { + clearCurrentMap(); + }; + }, [id, fetchIndoorMap, clearCurrentMap]); + + const sortedFloors = useMemo(() => { + if (!currentIndoorMap?.Floors) return []; + return [...currentIndoorMap.Floors].sort((a, b) => a.FloorOrder - b.FloorOrder); + }, [currentIndoorMap]); + + const handleFloorSelect = useCallback( + (floorId: string) => { + setSelectedZone(null); + setCurrentFloor(floorId); + }, + [setCurrentFloor] + ); + + const handleZonePress = useCallback((event: any) => { + if (event.features && event.features.length > 0) { + const feature = event.features[0]; + setSelectedZone(feature.properties ?? null); + } + }, []); + + const handleDispatchToZone = useCallback(() => { + if (selectedZone) { + router.push({ + pathname: '/call/new' as any, + params: { + zoneName: (selectedZone.name as string) || (selectedZone.Name as string) || '', + zoneId: (selectedZone.id as string) || (selectedZone.Id as string) || '', + }, + }); + } + }, [selectedZone]); + + const floorImageUrl = useMemo(() => { + if (!currentFloorId) return null; + return getFloorImageUrl(currentFloorId); + }, [currentFloorId]); + + const filteredZonesGeoJSON = useMemo(() => { + if (!currentZonesGeoJSON || !zoneSearchQuery.trim()) return currentZonesGeoJSON; + + const query = zoneSearchQuery.toLowerCase(); + return { + ...currentZonesGeoJSON, + features: currentZonesGeoJSON.features.filter((feature) => { + const name = ((feature.properties?.name as string) || (feature.properties?.Name as string) || '').toLowerCase(); + return name.includes(query); + }), + }; + }, [currentZonesGeoJSON, zoneSearchQuery]); + + if (isLoading && !currentIndoorMap) { + return ( + + + + ); + } + + if (error || !currentIndoorMap) { + return ( + + + + ); + } + + const isZoneDispatchable = + selectedZone && (selectedZone.IsDispatchable === true || selectedZone.isDispatchable === true); + + return ( + + {/* Floor selector tab bar */} + + + + {sortedFloors.map((floor: IndoorMapFloorResultData) => { + const isActive = floor.IndoorMapFloorId === currentFloorId; + return ( + handleFloorSelect(floor.IndoorMapFloorId)} + className={`rounded-full px-4 py-2 ${ + isActive + ? 'bg-blue-600 dark:bg-blue-500' + : 'bg-gray-100 dark:bg-gray-700' + }`} + > + + {floor.Name} + + + ); + })} + + + + + {/* Zone search bar */} + + + + + + + {zoneSearchQuery ? ( + setZoneSearchQuery('')}> + + + ) : null} + + + + {/* Map */} + + {isLoadingGeoJSON ? ( + + + + ) : ( + + + + {/* Floor plan image overlay */} + {floorImageUrl && currentFloor && currentIndoorMap.BoundsNELatitude ? ( + + + + ) : null} + + {/* Zone polygons */} + {filteredZonesGeoJSON ? ( + + + + + + ) : null} + + )} + + {/* Zone detail popover */} + {selectedZone ? ( + + + + + + + + {(selectedZone.name as string) || (selectedZone.Name as string) || 'Zone'} + + + {selectedZone.type || selectedZone.Type ? ( + + + {(selectedZone.type as string) || (selectedZone.Type as string)} + + + ) : null} + {selectedZone.description || selectedZone.Description ? ( + + {(selectedZone.description as string) || (selectedZone.Description as string)} + + ) : null} + {isZoneDispatchable ? ( + + ) : null} + + setSelectedZone(null)} className="p-1"> + + + + + + ) : null} + + + ); +} diff --git a/src/app/maps/search.tsx b/src/app/maps/search.tsx new file mode 100644 index 00000000..d64194a9 --- /dev/null +++ b/src/app/maps/search.tsx @@ -0,0 +1,244 @@ +import { router } from 'expo-router'; +import { Building2, Map, Search, X } from 'lucide-react-native'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { FlatList, Pressable, View } from 'react-native'; + +import { Loading } from '@/components/common/loading'; +import ZeroState from '@/components/common/zero-state'; +import { Box } from '@/components/ui/box'; +import { Card } from '@/components/ui/card'; +import { FocusAwareStatusBar } from '@/components/ui/focus-aware-status-bar'; +import { HStack } from '@/components/ui/hstack'; +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 { type UnifiedSearchResultItem } from '@/models/v4/mapping/mappingResults'; +import { useMapsStore } from '@/stores/maps/store'; + +type SearchFilter = 'all' | 'indoor' | 'custom'; + +const SEGMENTS: { labelKey: string; value: SearchFilter }[] = [ + { labelKey: 'maps.all', value: 'all' }, + { labelKey: 'maps.indoor', value: 'indoor' }, + { labelKey: 'maps.custom', value: 'custom' }, +]; + +export default function MapSearch() { + const { t } = useTranslation(); + const searchResults = useMapsStore((state) => state.searchResults); + const isLoading = useMapsStore((state) => state.isLoading); + const searchMapFeatures = useMapsStore((state) => state.searchMapFeatures); + const clearSearch = useMapsStore((state) => state.clearSearch); + + const [query, setQuery] = useState(''); + const [activeFilter, setActiveFilter] = useState('all'); + const debounceTimerRef = useRef | null>(null); + + useEffect(() => { + return () => { + clearSearch(); + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + }; + }, [clearSearch]); + + const handleQueryChange = useCallback( + (text: string) => { + setQuery(text); + + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + + if (text.trim().length === 0) { + clearSearch(); + return; + } + + debounceTimerRef.current = setTimeout(() => { + searchMapFeatures(text.trim(), activeFilter); + }, 300); + }, + [activeFilter, searchMapFeatures, clearSearch] + ); + + const handleFilterChange = useCallback( + (filter: SearchFilter) => { + setActiveFilter(filter); + if (query.trim().length > 0) { + searchMapFeatures(query.trim(), filter); + } + }, + [query, searchMapFeatures] + ); + + const handleResultPress = useCallback((item: UnifiedSearchResultItem) => { + if (item.Type === 'indoor_zone') { + router.push(`/maps/indoor/${item.MapId}`); + } else if (item.Type === 'custom_region') { + router.push(`/maps/custom/${item.MapId}`); + } + }, []); + + const getResultIcon = (type: string) => { + switch (type) { + case 'indoor_zone': + return Building2; + case 'custom_region': + return Map; + default: + return Search; + } + }; + + const getResultTypeLabel = (type: string) => { + switch (type) { + case 'indoor_zone': + return t('maps.indoor_zone'); + case 'custom_region': + return t('maps.custom_region'); + default: + return t('maps.feature'); + } + }; + + const renderResultItem = ({ item }: { item: UnifiedSearchResultItem }) => { + const ResultIcon = getResultIcon(item.Type); + const isIndoor = item.Type === 'indoor_zone'; + + return ( + handleResultPress(item)}> + + + + + + + + {item.Name} + + + {getResultTypeLabel(item.Type)} + {item.FloorId ? ` - ${t('maps.floor')}` : ''} + + + + + + ); + }; + + const renderContent = () => { + if (query.trim().length === 0) { + return ( + + ); + } + + if (isLoading) { + return ; + } + + if (searchResults.length === 0) { + return ( + + ); + } + + return ( + `${item.Type}-${item.Id}`} + contentContainerStyle={{ paddingBottom: 20 }} + /> + ); + }; + + return ( + + + + {/* Search input */} + + + + + + {query ? ( + { + setQuery(''); + clearSearch(); + }} + > + + + ) : null} + + + {/* Segmented control */} + + {SEGMENTS.map((segment) => { + const isActive = segment.value === activeFilter; + return ( + handleFilterChange(segment.value)} + className={`flex-1 items-center rounded-md px-3 py-2 ${ + isActive + ? 'bg-white shadow-sm dark:bg-gray-600' + : '' + }`} + > + + {t(segment.labelKey)} + + + ); + })} + + + {/* Results */} + {renderContent()} + + + ); +} diff --git a/src/app/routes/_layout.tsx b/src/app/routes/_layout.tsx new file mode 100644 index 00000000..4e1d7544 --- /dev/null +++ b/src/app/routes/_layout.tsx @@ -0,0 +1,25 @@ +import { Stack } from 'expo-router'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +export default function RoutesLayout() { + const { t } = useTranslation(); + + return ( + + + + + + + + + + + ); +} diff --git a/src/app/routes/active.tsx b/src/app/routes/active.tsx new file mode 100644 index 00000000..0d9e3c65 --- /dev/null +++ b/src/app/routes/active.tsx @@ -0,0 +1,505 @@ +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { Compass, LogOut, Navigation, SkipForward } from 'lucide-react-native'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Alert, Modal, ScrollView, StyleSheet, TextInput, TouchableOpacity } from 'react-native'; + +import Mapbox from '@/components/maps/mapbox'; +import { RouteDeviationBanner } from '@/components/routes/route-deviation-banner'; +import { StopCard } from '@/components/routes/stop-card'; +import { StopMarker } from '@/components/routes/stop-marker'; +import { Box } from '@/components/ui/box'; +import { Button, ButtonText } from '@/components/ui/button'; +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 { 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); + +const POLL_INTERVAL_MS = 30_000; +const GEOFENCE_CIRCLE_STEPS = 64; + +/** + * Parse route geometry string into a GeoJSON Feature suitable for Mapbox rendering. + */ +const parseRouteGeometry = (geometry: string): GeoJSON.Feature | null => { + if (!geometry) return null; + try { + const parsed = JSON.parse(geometry); + if (parsed.type === 'Feature' || parsed.type === 'FeatureCollection') return parsed; + if (parsed.type === 'LineString' || parsed.type === 'MultiLineString') { + return { type: 'Feature', properties: {}, geometry: parsed }; + } + if (Array.isArray(parsed)) { + return { type: 'Feature', properties: {}, geometry: { type: 'LineString', coordinates: parsed } }; + } + return null; + } catch { + return null; + } +}; + +/** + * Build a GeoJSON circle polygon for a geofence around a coordinate. + */ +const buildGeofenceCircle = ( + lng: number, + lat: number, + radiusMeters: number, + steps: number = GEOFENCE_CIRCLE_STEPS +): GeoJSON.Feature => { + const coords: number[][] = []; + const distanceLat = radiusMeters / 111_320; + const distanceLng = radiusMeters / (111_320 * Math.cos((lat * Math.PI) / 180)); + + for (let i = 0; i <= steps; i++) { + const angle = (i / steps) * 2 * Math.PI; + coords.push([lng + distanceLng * Math.cos(angle), lat + distanceLat * Math.sin(angle)]); + } + + return { + type: 'Feature', + properties: {}, + geometry: { type: 'Polygon', coordinates: [coords] }, + }; +}; + +export default function ActiveRouteScreen() { + const { t } = useTranslation(); + const router = useRouter(); + const { planId, instanceId } = useLocalSearchParams<{ planId: string; instanceId: string }>(); + const cameraRef = useRef(null); + const [isMapReady, setIsMapReady] = useState(false); + const [skipModalVisible, setSkipModalVisible] = useState(false); + const [skipReason, setSkipReason] = useState(''); + + // --- Stores --- + const activeUnitId = useCoreStore((s) => s.activeUnitId); + const latitude = useLocationStore((s) => s.latitude); + const longitude = useLocationStore((s) => s.longitude); + + const activeInstance = useRoutesStore((s) => s.activeInstance); + const instanceStops = useRoutesStore((s) => s.instanceStops); + const directions = useRoutesStore((s) => s.directions); + const deviations = useRoutesStore((s) => s.deviations); + const isLoadingStops = useRoutesStore((s) => s.isLoadingStops); + const fetchStopsForInstance = useRoutesStore((s) => s.fetchStopsForInstance); + const fetchDirections = useRoutesStore((s) => s.fetchDirections); + const fetchRouteProgress = useRoutesStore((s) => s.fetchRouteProgress); + const fetchDeviations = useRoutesStore((s) => s.fetchDeviations); + const endRouteInstance = useRoutesStore((s) => s.endRouteInstance); + const checkIn = useRoutesStore((s) => s.checkIn); + const checkOut = useRoutesStore((s) => s.checkOut); + const skip = useRoutesStore((s) => s.skip); + const ackDeviation = useRoutesStore((s) => s.ackDeviation); + + // 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. + const resolvedInstanceId = (() => { + const id = (instanceId && instanceId !== 'undefined') ? instanceId : activeInstance?.RouteInstanceId; + return id || undefined; + })(); + + // --- Derived data --- + const currentStop = useMemo( + () => + instanceStops.find( + (s) => s.Status === RouteStopStatus.Pending || s.Status === RouteStopStatus.InProgress + ) ?? null, + [instanceStops] + ); + + const routeColor = activeInstance?.RouteColor || '#3b82f6'; + + const progressPercent = useMemo(() => { + if (instanceStops.length === 0) { + const total = activeInstance?.StopsTotal ?? 0; + const completed = activeInstance?.StopsCompleted ?? 0; + return total > 0 ? Math.round((completed / total) * 100) : 0; + } + const done = instanceStops.filter( + (s) => s.Status === RouteStopStatus.Completed || s.Status === RouteStopStatus.Skipped + ).length; + return Math.round((done / instanceStops.length) * 100); + }, [instanceStops, activeInstance]); + + const routeGeoJSON = useMemo(() => { + // Prefer directions geometry, fall back to instance actual route geometry + if (directions?.RouteGeometry) { + return parseRouteGeometry(directions.RouteGeometry); + } + if (activeInstance?.ActualRouteGeometry) { + return parseRouteGeometry(activeInstance.ActualRouteGeometry); + } + return null; + }, [directions?.RouteGeometry, activeInstance?.ActualRouteGeometry]); + + const geofenceGeoJSON = useMemo(() => { + if (!currentStop) return null; + if (!currentStop.Longitude || !currentStop.Latitude || !isFinite(currentStop.Longitude) || !isFinite(currentStop.Latitude)) return null; + const radius = currentStop.GeofenceRadiusMeters || 100; + return buildGeofenceCircle(currentStop.Longitude, currentStop.Latitude, radius); + }, [currentStop]); + + // --- Initial data fetch --- + useEffect(() => { + if (!resolvedInstanceId) return; + fetchStopsForInstance(resolvedInstanceId); + fetchDirections(resolvedInstanceId); + fetchDeviations(); + }, [resolvedInstanceId, fetchStopsForInstance, fetchDirections, fetchDeviations]); + + // --- Polling for progress --- + useEffect(() => { + if (!resolvedInstanceId) return; + const interval = setInterval(() => { + fetchRouteProgress(resolvedInstanceId); + fetchStopsForInstance(resolvedInstanceId); + fetchDeviations(); + }, POLL_INTERVAL_MS); + return () => clearInterval(interval); + }, [resolvedInstanceId, fetchRouteProgress, fetchStopsForInstance, fetchDeviations]); + + // --- Center on user location as soon as map is ready --- + useEffect(() => { + if (!isMapReady || !cameraRef.current) return; + if (latitude != null && longitude != null) { + cameraRef.current.flyTo([longitude, latitude], 500); + } + }, [isMapReady]); // eslint-disable-line react-hooks/exhaustive-deps + + // --- Fit bounds to stops + user location once stops are loaded --- + useEffect(() => { + if (!isMapReady || instanceStops.length === 0 || !cameraRef.current) return; + + const validStops = instanceStops.filter( + (s) => s.Latitude != null && s.Longitude != null && isFinite(s.Latitude) && isFinite(s.Longitude) + ); + if (validStops.length === 0) return; + + const lngs = validStops.map((s) => s.Longitude); + const lats = validStops.map((s) => s.Latitude); + + if (latitude != null && longitude != null && isFinite(latitude) && isFinite(longitude)) { + lngs.push(longitude); + lats.push(latitude); + } + + const ne: [number, number] = [Math.max(...lngs), Math.max(...lats)]; + const sw: [number, number] = [Math.min(...lngs), Math.min(...lats)]; + + cameraRef.current.fitBounds(ne, sw, [60, 60, 60, 60], 800); + }, [isMapReady, instanceStops]); // eslint-disable-line react-hooks/exhaustive-deps + + // --- Actions --- + const handleCheckIn = useCallback(() => { + if (!currentStop || !activeUnitId) return; + checkIn(currentStop.RouteInstanceStopId, activeUnitId, latitude ?? 0, longitude ?? 0); + }, [currentStop, activeUnitId, latitude, longitude, checkIn]); + + const handleCheckOut = useCallback(() => { + if (!currentStop || !activeUnitId) return; + checkOut(currentStop.RouteInstanceStopId, activeUnitId); + }, [currentStop, activeUnitId, checkOut]); + + const handleSkip = useCallback(() => { + if (!currentStop) return; + setSkipReason(''); + setSkipModalVisible(true); + }, [currentStop]); + + const handleSkipConfirm = useCallback(() => { + if (!currentStop) return; + setSkipModalVisible(false); + skip(currentStop.RouteInstanceStopId, skipReason.trim() || t('routes.skipped_by_driver')); + }, [currentStop, skipReason, skip, t]); + + const handleEndRoute = useCallback(() => { + if (!resolvedInstanceId) return; + Alert.alert(t('routes.end_route'), t('routes.end_route_confirm'), [ + { text: t('common.cancel'), style: 'cancel' }, + { + text: t('routes.end_route'), + style: 'destructive', + onPress: async () => { + await endRouteInstance(resolvedInstanceId); + router.back(); + }, + }, + ]); + }, [resolvedInstanceId, endRouteInstance, router]); + + const handleDirections = useCallback(() => { + if (!resolvedInstanceId) return; + router.push(`/routes/directions?instanceId=${resolvedInstanceId}`); + }, [resolvedInstanceId, router]); + + const handleDeviationPress = useCallback(() => { + // Could navigate to a deviations detail screen in the future + }, []); + + return ( + + {/* Map area - 60% height */} + + setIsMapReady(true)} + > + + + {/* Route polyline */} + {routeGeoJSON ? ( + + + + ) : null} + + {/* Geofence circle around next pending stop */} + {geofenceGeoJSON ? ( + + + + + ) : null} + + {/* Stop markers */} + {instanceStops.filter((s) => s.Latitude != null && s.Longitude != null && isFinite(s.Latitude) && isFinite(s.Longitude)).map((stop) => ( + + + + ))} + + + {/* Map overlay buttons */} + + + + + + + + {/* Progress indicator */} + {activeInstance ? ( + + + {progressPercent}% {t('routes.completed')} + + + ) : null} + + + {/* Bottom area */} + + + {/* Deviation banner */} + {deviations.length > 0 ? ( + + + + ) : null} + + {/* Current stop card */} + {currentStop ? ( + + + {t('routes.current_step')} + + + + ) : ( + + + {t('routes.stops_completed')} + + + )} + + {/* ETA to next stop */} + {activeInstance?.EtaToNextStop ? ( + + + + + {t('routes.eta_to_next')}: {activeInstance.EtaToNextStop} + + + + ) : null} + + {/* Directions button */} + + + + + {/* Stop list */} + + + {t('routes.stops')} + + {instanceStops.map((stop) => ( + + ))} + + + {/* End Route button */} + + + + + + + {/* Skip reason modal */} + setSkipModalVisible(false)} + > + + + + {t('routes.skip')} — {currentStop?.Name} + + + {t('routes.skip_reason')} + + + + setSkipModalVisible(false)} + > + + {t('common.cancel')} + + + + + {t('routes.skip')} + + + + + + + + ); +} + +const styles = StyleSheet.create({ + map: { + flex: 1, + }, + skipInput: { + borderWidth: 1, + borderColor: '#d1d5db', + borderRadius: 8, + padding: 10, + fontSize: 14, + color: '#111827', + textAlignVertical: 'top', + minHeight: 80, + }, + cancelBtn: { + flex: 1, + paddingVertical: 10, + borderRadius: 8, + borderWidth: 1, + borderColor: '#d1d5db', + alignItems: 'center', + }, + skipBtn: { + flex: 1, + paddingVertical: 10, + borderRadius: 8, + backgroundColor: '#eab308', + alignItems: 'center', + }, +}); diff --git a/src/app/routes/directions.tsx b/src/app/routes/directions.tsx new file mode 100644 index 00000000..b8b9c6a3 --- /dev/null +++ b/src/app/routes/directions.tsx @@ -0,0 +1,314 @@ +import { useLocalSearchParams } from 'expo-router'; +import { + CheckCircleIcon, + ClockIcon, + ExternalLinkIcon, + MapIcon, + MapPinIcon, + NavigationIcon, +} from 'lucide-react-native'; +import { useColorScheme } from 'nativewind'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Linking, Platform, ScrollView, StyleSheet, View } from 'react-native'; + +import { + Camera, + LineLayer, + MapView, + PointAnnotation, + ShapeSource, + StyleURL, + UserLocation, +} from '@/components/maps/mapbox'; +import { Loading } from '@/components/common/loading'; +import { Box } from '@/components/ui/box'; +import { Button, ButtonIcon, ButtonText } from '@/components/ui/button'; +import { Heading } from '@/components/ui/heading'; +import { HStack } from '@/components/ui/hstack'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; +import { useRoutesStore } from '@/stores/routes/store'; +import { RouteStopStatus } from '@/models/v4/routes/routeInstanceStopResultData'; + +const openInMaps = (lat: number, lon: number, label: string) => { + const url = Platform.select({ + ios: `maps://app?daddr=${lat},${lon}&q=${encodeURIComponent(label)}`, + android: `google.navigation:q=${lat},${lon}`, + default: `https://www.google.com/maps/dir/?api=1&destination=${lat},${lon}`, + }); + if (url) Linking.openURL(url); +}; + +const formatDistance = (meters: number | null | undefined): string => { + if (meters == null) return ''; + if (meters < 1000) return `${Math.round(meters)} m`; + return `${(meters / 1000).toFixed(1)} km`; +}; + +const formatDuration = (seconds: number | null | undefined): string => { + if (seconds == null) return ''; + if (seconds < 60) return `${Math.round(seconds)}s`; + const mins = Math.round(seconds / 60); + if (mins < 60) return `${mins} min`; + const hours = Math.floor(mins / 60); + return `${hours}h ${mins % 60}m`; +}; + +const statusColor: Record = { + [RouteStopStatus.Pending]: '#9ca3af', + [RouteStopStatus.InProgress]: '#3b82f6', + [RouteStopStatus.Completed]: '#22c55e', + [RouteStopStatus.Skipped]: '#eab308', +}; + +export default function RouteDirectionsScreen() { + const { t } = useTranslation(); + const { instanceId } = useLocalSearchParams<{ instanceId: string }>(); + const { colorScheme } = useColorScheme(); + const cameraRef = useRef(null); + const [isMapReady, setIsMapReady] = useState(false); + + const directions = useRoutesStore((s) => s.directions); + const isLoadingDirections = useRoutesStore((s) => s.isLoadingDirections); + const error = useRoutesStore((s) => s.error); + const fetchDirections = useRoutesStore((s) => s.fetchDirections); + const activeInstance = useRoutesStore((s) => s.activeInstance); + const instanceStops = useRoutesStore((s) => s.instanceStops); + + const resolvedInstanceId = (() => { + const id = (instanceId && instanceId !== 'undefined') ? instanceId : activeInstance?.RouteInstanceId; + return id || undefined; + })(); + + useEffect(() => { + if (resolvedInstanceId) { + fetchDirections(resolvedInstanceId); + } + }, [resolvedInstanceId, fetchDirections]); + + // Build route GeoJSON from Geometry field or stop coordinates + const routeGeoJson = useMemo(() => { + if (directions?.Geometry) { + try { + const parsed = JSON.parse(directions.Geometry); + return parsed; + } catch { + // fall through + } + } + const validStops = instanceStops.filter( + (s) => s.Latitude != null && s.Longitude != null && isFinite(s.Latitude) && isFinite(s.Longitude) + ); + if (validStops.length < 2) return null; + const sorted = [...validStops].sort((a, b) => a.StopOrder - b.StopOrder); + return { + type: 'Feature' as const, + geometry: { + type: 'LineString' as const, + coordinates: sorted.map((s) => [s.Longitude, s.Latitude]), + }, + properties: {}, + }; + }, [directions, instanceStops]); + + // Sorted stops for display + const sortedStops = useMemo( + () => [...instanceStops].sort((a, b) => a.StopOrder - b.StopOrder), + [instanceStops] + ); + + const destination = useMemo(() => { + const last = sortedStops[sortedStops.length - 1]; + if (last?.Latitude != null && last?.Longitude != null) { + return { lat: last.Latitude, lon: last.Longitude, name: last.Name }; + } + return null; + }, [sortedStops]); + + // Fit map to stops once ready + useEffect(() => { + if (!isMapReady || sortedStops.length === 0 || !cameraRef.current) return; + const valid = sortedStops.filter( + (s) => s.Latitude != null && s.Longitude != null && isFinite(s.Latitude) && isFinite(s.Longitude) + ); + if (valid.length === 0) return; + const lngs = valid.map((s) => s.Longitude); + const lats = valid.map((s) => s.Latitude); + const ne: [number, number] = [Math.max(...lngs), Math.max(...lats)]; + const sw: [number, number] = [Math.min(...lngs), Math.min(...lats)]; + cameraRef.current.fitBounds(ne, sw, [60, 60, 60, 60], 600); + }, [isMapReady, sortedStops]); + + const handleOpenInMaps = useCallback(() => { + if (destination) { + openInMaps(destination.lat, destination.lon, destination.name ?? t('routes.destination')); + } + }, [destination, t]); + + if (isLoadingDirections && sortedStops.length === 0) { + return ( + + + + ); + } + + if (sortedStops.length === 0) { + return ( + + + + {error ?? t('routes.no_directions')} + + + ); + } + + return ( + + {/* Map */} + + setIsMapReady(true)} + > + + + + {routeGeoJson && ( + + + + )} + + {sortedStops + .filter((s) => s.Latitude != null && s.Longitude != null && isFinite(s.Latitude) && isFinite(s.Longitude)) + .map((stop) => ( + + + + ))} + + + + {/* Stop list panel */} + + {/* Summary */} + {(directions?.EstimatedDistanceMeters || directions?.EstimatedDurationSeconds) ? ( + + {directions.EstimatedDistanceMeters ? ( + + {formatDistance(directions.EstimatedDistanceMeters)} + + ) : null} + {directions.EstimatedDurationSeconds ? ( + + {formatDuration(directions.EstimatedDurationSeconds)} + + ) : null} + + ) : null} + + {/* Stops */} + + {sortedStops.map((stop, index) => { + const color = statusColor[stop.Status] ?? '#9ca3af'; + const isLast = index === sortedStops.length - 1; + return ( + + {/* Order badge */} + + {stop.StopOrder} + + + {stop.Name} + {stop.Address ? ( + + + + {stop.Address} + + + ) : null} + + {stop.Status === RouteStopStatus.Completed ? ( + + ) : stop.Status === RouteStopStatus.InProgress ? ( + + ) : null} + + ); + })} + + + {/* Actions */} + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { flex: 1 }, + mapContainer: { flex: 1 }, + map: { flex: 1 }, + marker: { + width: 16, + height: 16, + borderRadius: 8, + borderWidth: 2, + borderColor: '#fff', + }, + badge: { + width: 24, + height: 24, + borderRadius: 12, + alignItems: 'center', + justifyContent: 'center', + marginTop: 1, + }, +}); diff --git a/src/app/routes/history/[planId].tsx b/src/app/routes/history/[planId].tsx new file mode 100644 index 00000000..6bb5e5a7 --- /dev/null +++ b/src/app/routes/history/[planId].tsx @@ -0,0 +1,202 @@ +import { useFocusEffect } from '@react-navigation/native'; +import { useLocalSearchParams, router, Stack } from 'expo-router'; +import { Clock, MapPin, Navigation, RefreshCcwDotIcon } from 'lucide-react-native'; +import React, { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Pressable, RefreshControl, View } from 'react-native'; + +import { Loading } from '@/components/common/loading'; +import ZeroState from '@/components/common/zero-state'; +import { Badge, BadgeText } from '@/components/ui/badge'; +import { Box } from '@/components/ui/box'; +import { FlatList } from '@/components/ui/flat-list'; +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 { + type RouteInstanceResultData, + RouteInstanceStatus, +} from '@/models/v4/routes/routeInstanceResultData'; +import { useRoutesStore } from '@/stores/routes/store'; + +const STATUS_COLORS: Record = { + [RouteInstanceStatus.Completed]: '#22c55e', + [RouteInstanceStatus.Cancelled]: '#ef4444', + [RouteInstanceStatus.Active]: '#3b82f6', + [RouteInstanceStatus.Paused]: '#eab308', + [RouteInstanceStatus.Pending]: '#9ca3af', +}; + +const STATUS_LABELS: Record = { + [RouteInstanceStatus.Pending]: 'Pending', + [RouteInstanceStatus.Active]: 'Active', + [RouteInstanceStatus.Paused]: 'Paused', + [RouteInstanceStatus.Completed]: 'Completed', + [RouteInstanceStatus.Cancelled]: 'Cancelled', +}; + +function formatDuration(seconds: number): string { + if (!seconds || seconds <= 0) return '--'; + const hrs = Math.floor(seconds / 3600); + const mins = Math.floor((seconds % 3600) / 60); + if (hrs > 0) return `${hrs}h ${mins}m`; + return `${mins}m`; +} + +function formatDistance(meters: number): string { + if (!meters || meters <= 0) return '--'; + if (meters >= 1000) return `${(meters / 1000).toFixed(1)} km`; + return `${Math.round(meters)} m`; +} + +function formatDate(dateStr: string): string { + if (!dateStr) return '--'; + try { + const date = new Date(dateStr); + return date.toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + } catch { + return dateStr; + } +} + +export default function RouteHistory() { + const { t } = useTranslation(); + const { planId } = useLocalSearchParams<{ planId: string }>(); + const routeHistory = useRoutesStore((state) => state.routeHistory); + const fetchRouteHistory = useRoutesStore((state) => state.fetchRouteHistory); + const isLoading = useRoutesStore((state) => state.isLoading); + const error = useRoutesStore((state) => state.error); + + useFocusEffect( + useCallback(() => { + if (planId) { + fetchRouteHistory(planId); + } + }, [planId, fetchRouteHistory]) + ); + + const handleRefresh = () => { + if (planId) { + fetchRouteHistory(planId); + } + }; + + const handleInstancePress = (instance: RouteInstanceResultData) => { + router.push(`/routes/history/instance/${instance.RouteInstanceId}`); + }; + + const renderItem = ({ item }: { item: RouteInstanceResultData }) => { + const statusColor = STATUS_COLORS[item.Status] ?? '#9ca3af'; + const statusLabel = STATUS_LABELS[item.Status] ?? 'Unknown'; + const completedDate = item.CompletedOn || item.CancelledOn || item.StartedOn; + + return ( + handleInstancePress(item)}> + + + + {/* Date */} + + + + {formatDate(completedDate)} + + + + {/* Unit Name */} + + {item.UnitName || t('routes.unit')} + + + {/* Stats row */} + + + + + {formatDistance(item.TotalDistanceMeters)} + + + + + + + {formatDuration(item.TotalDurationSeconds)} + + + + + + + {item.CurrentStopIndex ?? 0} stops + + + + + + {/* Status badge */} + + + {statusLabel} + + + + + {/* Progress bar */} + {item.ProgressPercentage != null && item.ProgressPercentage > 0 && ( + + + + )} + + + ); + }; + + const renderContent = () => { + if (isLoading) { + return ; + } + + if (error) { + return ; + } + + return ( + + testID="route-history-list" + data={routeHistory} + renderItem={renderItem} + keyExtractor={(item: RouteInstanceResultData) => item.RouteInstanceId} + refreshControl={} + ListEmptyComponent={ + + } + contentContainerStyle={{ paddingBottom: 20 }} + /> + ); + }; + + return ( + + + {renderContent()} + + ); +} diff --git a/src/app/routes/history/instance/[id].tsx b/src/app/routes/history/instance/[id].tsx new file mode 100644 index 00000000..acb21b09 --- /dev/null +++ b/src/app/routes/history/instance/[id].tsx @@ -0,0 +1,524 @@ +import { useFocusEffect } from '@react-navigation/native'; +import { useLocalSearchParams, Stack } from 'expo-router'; +import { + AlertTriangle, + CheckCircle, + Clock, + MapPin, + Navigation, + SkipForward, + XCircle, +} from 'lucide-react-native'; +import React, { useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ScrollView, View } from 'react-native'; + +import { Loading } from '@/components/common/loading'; +import ZeroState from '@/components/common/zero-state'; +import Mapbox from '@/components/maps/mapbox'; +import { Badge, BadgeText } from '@/components/ui/badge'; +import { Box } from '@/components/ui/box'; +import { Heading } from '@/components/ui/heading'; +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 { + 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'; +import { useRoutesStore } from '@/stores/routes/store'; + +// --- Helpers --- + +const parseRouteGeometry = (geometry: string) => { + if (!geometry) return null; + try { + const parsed = JSON.parse(geometry); + if (parsed.type === 'Feature' || parsed.type === 'FeatureCollection') return parsed; + if (parsed.type === 'LineString' || parsed.type === 'MultiLineString') { + return { type: 'Feature', properties: {}, geometry: parsed }; + } + return null; + } catch { + return null; + } +}; + +const STATUS_COLORS: Record = { + [RouteInstanceStatus.Completed]: '#22c55e', + [RouteInstanceStatus.Cancelled]: '#ef4444', + [RouteInstanceStatus.Active]: '#3b82f6', + [RouteInstanceStatus.Paused]: '#eab308', + [RouteInstanceStatus.Pending]: '#9ca3af', +}; + +const STATUS_LABEL_KEYS: Record = { + [RouteInstanceStatus.Pending]: 'routes.pending', + [RouteInstanceStatus.Active]: 'routes.active', + [RouteInstanceStatus.Paused]: 'routes.paused', + [RouteInstanceStatus.Completed]: 'routes.completed', + [RouteInstanceStatus.Cancelled]: 'routes.cancel_route', +}; + +const STOP_STATUS_COLORS: Record = { + [RouteStopStatus.Pending]: '#9ca3af', + [RouteStopStatus.InProgress]: '#3b82f6', + [RouteStopStatus.Completed]: '#22c55e', + [RouteStopStatus.Skipped]: '#f59e0b', +}; + +const DEVIATION_TYPE_LABELS: Record = { + [RouteDeviationType.OffRoute]: 'Off Route', + [RouteDeviationType.MissedStop]: 'Missed Stop', + [RouteDeviationType.UnexpectedStop]: 'Unexpected Stop', + [RouteDeviationType.SpeedViolation]: 'Speed Violation', + [RouteDeviationType.Other]: 'Other', +}; + +function formatDuration(seconds: number): string { + if (!seconds || seconds <= 0) return '--'; + const hrs = Math.floor(seconds / 3600); + const mins = Math.floor((seconds % 3600) / 60); + if (hrs > 0) return `${hrs}h ${mins}m`; + return `${mins}m`; +} + +function formatDistance(meters: number): string { + if (!meters || meters <= 0) return '--'; + if (meters >= 1000) return `${(meters / 1000).toFixed(1)} km`; + return `${Math.round(meters)} m`; +} + +function formatDate(dateStr: string): string { + if (!dateStr) return '--'; + try { + const date = new Date(dateStr); + return date.toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + } catch { + return dateStr; + } +} + +function getStopIcon(status: number) { + switch (status) { + case RouteStopStatus.Completed: + return CheckCircle; + case RouteStopStatus.Skipped: + return SkipForward; + case RouteStopStatus.InProgress: + return Navigation; + default: + return MapPin; + } +} + +// --- Component --- + +export default function RouteInstanceDetail() { + const { t } = useTranslation(); + const { id: instanceId } = useLocalSearchParams<{ id: string }>(); + const activeInstance = useRoutesStore((state) => state.activeInstance); + const instanceStops = useRoutesStore((state) => state.instanceStops); + const deviations = useRoutesStore((state) => state.deviations); + const activePlan = useRoutesStore((state) => state.activePlan); + const isLoading = useRoutesStore((state) => state.isLoading); + const isLoadingStops = useRoutesStore((state) => state.isLoadingStops); + const error = useRoutesStore((state) => state.error); + const fetchRouteProgress = useRoutesStore((state) => state.fetchRouteProgress); + const fetchStopsForInstance = useRoutesStore((state) => state.fetchStopsForInstance); + const fetchDeviations = useRoutesStore((state) => state.fetchDeviations); + const fetchRoutePlan = useRoutesStore((state) => state.fetchRoutePlan); + + useFocusEffect( + useCallback(() => { + if (instanceId) { + fetchRouteProgress(instanceId); + fetchStopsForInstance(instanceId); + fetchDeviations(); + } + }, [instanceId, fetchRouteProgress, fetchStopsForInstance, fetchDeviations]) + ); + + // Fetch the plan when the instance loads so we can show planned geometry + useFocusEffect( + useCallback(() => { + if (activeInstance?.RoutePlanId) { + fetchRoutePlan(activeInstance.RoutePlanId); + } + }, [activeInstance?.RoutePlanId, fetchRoutePlan]) + ); + + // Parse route geometries + const actualRouteGeoJSON = useMemo( + () => (activeInstance ? parseRouteGeometry(activeInstance.ActualRouteGeometry) : null), + [activeInstance?.ActualRouteGeometry] + ); + + const plannedRouteGeoJSON = useMemo( + () => (activePlan ? parseRouteGeometry(activePlan.MapboxRouteGeometry) : null), + [activePlan?.MapboxRouteGeometry] + ); + + // Build stop markers GeoJSON + const stopMarkersGeoJSON = useMemo(() => { + if (!instanceStops || instanceStops.length === 0) return null; + return { + type: 'FeatureCollection' as const, + features: instanceStops + .filter((s) => s.Latitude && s.Longitude) + .map((stop) => ({ + type: 'Feature' as const, + properties: { + id: stop.RouteInstanceStopId, + name: stop.Name || `Stop ${stop.StopOrder}`, + status: stop.Status, + color: STOP_STATUS_COLORS[stop.Status] ?? '#9ca3af', + }, + geometry: { + type: 'Point' as const, + coordinates: [stop.Longitude, stop.Latitude], + }, + })), + }; + }, [instanceStops]); + + // Calculate map bounds from actual route or stops + const mapBounds = useMemo(() => { + const coords: [number, number][] = []; + + // Collect coordinates from stops + instanceStops?.forEach((s) => { + if (s.Latitude && s.Longitude) { + coords.push([s.Longitude, s.Latitude]); + } + }); + + if (coords.length < 2) return null; + + const lngs = coords.map((c) => c[0]); + const lats = coords.map((c) => c[1]); + return { + ne: [Math.max(...lngs) + 0.005, Math.max(...lats) + 0.005] as [number, number], + sw: [Math.min(...lngs) - 0.005, Math.min(...lats) - 0.005] as [number, number], + }; + }, [instanceStops]); + + // Summary stats + const completedStops = instanceStops.filter( + (s) => s.Status === RouteStopStatus.Completed + ).length; + const totalStops = instanceStops.length; + const instanceDeviations = deviations.filter( + (d) => d.RouteInstanceId === instanceId + ); + + if (isLoading && !activeInstance) { + return ( + + + + + ); + } + + if (error && !activeInstance) { + return ( + + + + + ); + } + + if (!activeInstance) { + return ( + + + + + ); + } + + const statusColor = STATUS_COLORS[activeInstance.Status] ?? '#9ca3af'; + const statusLabel = t(STATUS_LABEL_KEYS[activeInstance.Status] ?? 'common.unknown'); + + return ( + + + + {/* Map */} + + + {/* Camera fitted to bounds */} + {mapBounds ? ( + + ) : ( + + )} + + {/* Planned route - dashed line */} + {plannedRouteGeoJSON && ( + + + + )} + + {/* Actual route - solid line */} + {actualRouteGeoJSON && ( + + + + )} + + {/* Stop markers color-coded by status */} + {stopMarkersGeoJSON && ( + + + + )} + + + + {/* Summary Stats */} + + + + {activeInstance.RoutePlanName || t('routes.route_summary')} + + + + {statusLabel} + + + + + + + + + {formatDistance(activeInstance.TotalDistanceMeters)} + + {t('routes.distance')} + + + + + + {formatDuration(activeInstance.TotalDurationSeconds)} + + {t('routes.duration')} + + + + + + {completedStops}/{totalStops} + + {t('routes.stops')} + + + + + + {instanceDeviations.length} + + {t('routes.deviations')} + + + + {/* Dates */} + + + {t('routes.in_progress')} + + {formatDate(activeInstance.StartedOn)} + + + {activeInstance.CompletedOn ? ( + + {t('routes.completed')} + + {formatDate(activeInstance.CompletedOn)} + + + ) : null} + {activeInstance.CancelledOn ? ( + + {t('routes.cancel_route')} + + {formatDate(activeInstance.CancelledOn)} + + + ) : null} + + + + {/* Stops List */} + {isLoadingStops ? ( + + + + ) : instanceStops.length > 0 ? ( + + + {t('routes.stops')} + + {instanceStops + .sort((a, b) => a.StopOrder - b.StopOrder) + .map((stop) => ( + + ))} + + ) : null} + + {/* Deviations List */} + {instanceDeviations.length > 0 && ( + + + {t('routes.deviations')} + + {instanceDeviations.map((deviation) => ( + + ))} + + )} + + + ); +} + +// --- Sub-components --- + +function StopCard({ stop }: { stop: RouteInstanceStopResultData }) { + const statusColor = STOP_STATUS_COLORS[stop.Status] ?? '#9ca3af'; + const StopIcon = getStopIcon(stop.Status); + + return ( + + + + + + + + {stop.Name || `Stop ${stop.StopOrder}`} + + {stop.Address ? ( + + {stop.Address} + + ) : null} + + {stop.CheckedInOn ? ( + + In: {formatDate(stop.CheckedInOn)} + + ) : null} + {stop.CheckedOutOn ? ( + + Out: {formatDate(stop.CheckedOutOn)} + + ) : null} + {stop.SkippedOn ? ( + + Skipped: {formatDate(stop.SkippedOn)} + + ) : null} + + + + #{stop.StopOrder} + + + + ); +} + +function DeviationCard({ deviation }: { deviation: RouteDeviationResultData }) { + const { t } = useTranslation(); + const typeLabel = DEVIATION_TYPE_LABELS[deviation.Type] ?? t('routes.deviation'); + + return ( + + + + + + + {typeLabel} + + {deviation.IsAcknowledged && ( + + + {t('routes.acknowledge')} + + + )} + + {deviation.Description ? ( + + {deviation.Description} + + ) : null} + + {formatDate(deviation.OccurredOn)} + + + + + ); +} diff --git a/src/app/routes/index.tsx b/src/app/routes/index.tsx new file mode 100644 index 00000000..3c90d037 --- /dev/null +++ b/src/app/routes/index.tsx @@ -0,0 +1,174 @@ +import { router } from 'expo-router'; +import { Navigation, PlusIcon, RefreshCcwDotIcon, Search, X } from 'lucide-react-native'; +import React, { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Pressable, RefreshControl, View } from 'react-native'; + +import { RouteCard } from '@/components/routes/route-card'; +import { Loading } from '@/components/common/loading'; +import ZeroState from '@/components/common/zero-state'; +import { Badge, BadgeText } from '@/components/ui/badge'; +import { Box } from '@/components/ui/box'; +import { Fab, FabIcon } from '@/components/ui/fab'; +import { FlatList } from '@/components/ui/flat-list'; +import { HStack } from '@/components/ui/hstack'; +import { Input, InputField, InputIcon, InputSlot } from '@/components/ui/input'; +import { Text } from '@/components/ui/text'; +import { type RoutePlanResultData } from '@/models/v4/routes/routePlanResultData'; +import { useCoreStore } from '@/stores/app/core-store'; +import { useRoutesStore } from '@/stores/routes/store'; +import { useUnitsStore } from '@/stores/units/store'; + +export default function RouteList() { + const { t } = useTranslation(); + const routePlans = useRoutesStore((state) => state.routePlans); + const isLoading = useRoutesStore((state) => state.isLoading); + const error = useRoutesStore((state) => state.error); + const activeInstance = useRoutesStore((state) => state.activeInstance); + const fetchAllRoutePlans = useRoutesStore((state) => state.fetchAllRoutePlans); + const fetchActiveRoute = useRoutesStore((state) => state.fetchActiveRoute); + const activeUnitId = useCoreStore((state) => state.activeUnitId); + const activeUnit = useCoreStore((state) => state.activeUnit); + const units = useUnitsStore((state) => state.units); + const fetchUnits = useUnitsStore((state) => state.fetchUnits); + const [searchQuery, setSearchQuery] = useState(''); + + const unitMap = useMemo( + () => Object.fromEntries(units.map((u) => [u.UnitId, u.Name])), + [units] + ); + + useEffect(() => { + fetchAllRoutePlans(); + if (activeUnitId) { + fetchActiveRoute(activeUnitId); + } + if (units.length === 0) { + fetchUnits(); + } + }, [activeUnitId, fetchAllRoutePlans, fetchActiveRoute, fetchUnits, units.length]); + + const handleRefresh = () => { + fetchAllRoutePlans(); + if (activeUnitId) { + fetchActiveRoute(activeUnitId); + } + }; + + const handleRoutePress = (route: RoutePlanResultData) => { + if (activeInstance && activeInstance.RoutePlanId === route.RoutePlanId) { + router.push(`/routes/active?planId=${route.RoutePlanId}`); + } else { + router.push(`/routes/start?planId=${route.RoutePlanId}`); + } + }; + + const filteredRoutes = useMemo(() => { + const active = routePlans.filter((route) => route.RouteStatus === 1); + if (!searchQuery) return active; + const q = searchQuery.toLowerCase(); + return active.filter((route) => { + const isRouteMyUnit = route.UnitId != null && String(route.UnitId) === String(activeUnitId); + const unitName = route.UnitId != null ? (unitMap[route.UnitId] || (isRouteMyUnit ? (activeUnit?.Name ?? '') : '')) : ''; + return ( + route.Name.toLowerCase().includes(q) || + (route.Description?.toLowerCase() || '').includes(q) || + unitName.toLowerCase().includes(q) + ); + }); + }, [routePlans, searchQuery, unitMap, activeUnitId, activeUnit]); + + const renderContent = () => { + if (isLoading) { + return ; + } + + if (error) { + return ; + } + + return ( + + testID="routes-list" + data={filteredRoutes} + ListHeaderComponent={ + activeInstance ? ( + router.push(`/routes/active?planId=${activeInstance.RoutePlanId}`)}> + + + + + + {activeInstance.RoutePlanName || t('routes.active_route')} + + + + {t('routes.active')} + + + + {t('routes.active_route')} + + + + ) : null + } + renderItem={({ item }: { item: RoutePlanResultData }) => { + const isMyUnit = item.UnitId != null && String(item.UnitId) === String(activeUnitId); + const unitName = item.UnitId != null + ? (unitMap[item.UnitId] || (isMyUnit ? (activeUnit?.Name ?? '') : '')) + : ''; + return ( + handleRoutePress(item)}> + + + ); + }} + keyExtractor={(item: RoutePlanResultData) => item.RoutePlanId} + refreshControl={} + ListEmptyComponent={ + + } + contentContainerStyle={{ paddingBottom: 20 }} + /> + ); + }; + + return ( + + + {/* Search / filter */} + + + + + + {searchQuery ? ( + setSearchQuery('')}> + + + ) : null} + + + {renderContent()} + + router.push('/routes/start')} testID="new-route-fab"> + + + + + ); +} diff --git a/src/app/routes/start.tsx b/src/app/routes/start.tsx new file mode 100644 index 00000000..6cc169d1 --- /dev/null +++ b/src/app/routes/start.tsx @@ -0,0 +1,430 @@ +import { router, useLocalSearchParams } from 'expo-router'; +import { Clock, Info, MapPin, Navigation, Phone, Play, Truck, User } from 'lucide-react-native'; +import { useColorScheme } from 'nativewind'; +import React, { useEffect, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Alert, Pressable, ScrollView, StyleSheet, Text as RNText, View } from 'react-native'; + +import { Camera, MapView, PointAnnotation, StyleURL } from '@/components/maps/mapbox'; +import { Loading } from '@/components/common/loading'; +import ZeroState from '@/components/common/zero-state'; +import { Box } from '@/components/ui/box'; +import { Button, ButtonIcon, ButtonText } from '@/components/ui/button'; +import { Divider } from '@/components/ui/divider'; +import { FocusAwareStatusBar } from '@/components/ui/focus-aware-status-bar'; +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 { type RouteStopResultData } from '@/models/v4/routes/routePlanResultData'; +import { useCoreStore } from '@/stores/app/core-store'; +import { useRoutesStore } from '@/stores/routes/store'; +import { useUnitsStore } from '@/stores/units/store'; + +const formatDateTime = (isoString: string | null | undefined): string | null => { + if (!isoString) return null; + try { + const d = new Date(isoString); + if (isNaN(d.getTime())) return null; + return d.toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); + } catch { + return null; + } +}; + +const formatDistance = (meters: number) => { + if (meters >= 1000) return `${(meters / 1000).toFixed(1)} km`; + return `${Math.round(meters)} m`; +}; + +const formatDuration = (seconds: number) => { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + if (hours > 0) return `${hours}h ${minutes}m`; + return `${minutes}m`; +}; + +interface StopMarkerProps { + order: number; + name: string; + color: string; +} + +function StopMarker({ order, name, color }: StopMarkerProps) { + return ( + + + {order} + + + + {name} + + + + ); +} + +export default function RouteViewScreen() { + const { planId } = useLocalSearchParams<{ planId: string }>(); + const activePlan = useRoutesStore((state) => state.activePlan); + const isLoading = useRoutesStore((state) => state.isLoading); + const error = useRoutesStore((state) => state.error); + const fetchRoutePlan = useRoutesStore((state) => state.fetchRoutePlan); + const startRouteInstance = useRoutesStore((state) => state.startRouteInstance); + const activeUnitId = useCoreStore((state) => state.activeUnitId); + const activeUnit = useCoreStore((state) => state.activeUnit); + const units = useUnitsStore((state) => state.units); + const { t } = useTranslation(); + const { colorScheme } = useColorScheme(); + + const unitMap = useMemo( + () => Object.fromEntries(units.map((u) => [u.UnitId, u.Name])), + [units] + ); + + useEffect(() => { + if (planId) fetchRoutePlan(planId); + }, [planId, fetchRoutePlan]); + + const sortedStops = useMemo( + () => [...(activePlan?.Stops || [])].sort((a, b) => a.StopOrder - b.StopOrder), + [activePlan] + ); + + const mapData = useMemo(() => { + const valid = sortedStops.filter((s) => s.Latitude && s.Longitude); + if (valid.length === 0) return null; + + const lats = valid.map((s) => s.Latitude); + const lngs = valid.map((s) => s.Longitude); + const centerLat = (Math.min(...lats) + Math.max(...lats)) / 2; + const centerLng = (Math.min(...lngs) + Math.max(...lngs)) / 2; + const span = Math.max(Math.max(...lats) - Math.min(...lats), Math.max(...lngs) - Math.min(...lngs)); + const zoom = span === 0 ? 14 : Math.max(6, Math.min(14, Math.round(Math.log2(0.5 / span)) + 10)); + + return { center: [centerLng, centerLat] as [number, number], zoom, stops: valid }; + }, [sortedStops]); + + const markerColor = activePlan?.RouteColor || '#3b82f6'; + + const assignedUnitName = activePlan?.UnitId != null + ? (unitMap[activePlan.UnitId] || (String(activePlan.UnitId) === String(activeUnitId) ? (activeUnit?.Name ?? '') : '')) + : null; + + // A unit can start this route if: + // 1. The route has no pre-assigned unit (will assign on start), OR + // 2. The route is assigned to the current active unit + // UnitId from API is a number; activeUnitId is a string — compare as strings. + const canStart = !!activeUnitId && (!activePlan?.UnitId || String(activePlan.UnitId) === String(activeUnitId)); + + const handleStartRoute = async () => { + if (!planId || !activeUnitId) return; + try { + await startRouteInstance(planId, activeUnitId); + router.replace(`/routes/active?planId=${planId}`); + } catch { + Alert.alert(t('common.error'), t('common.errorOccurred')); + } + }; + + if (isLoading) { + return ( + + + + + ); + } + + if (error || !activePlan) { + return ( + + + + + ); + } + + const renderStopRow = (stop: RouteStopResultData, index: number) => { + const plannedArrival = formatDateTime(stop.PlannedArrival); + const plannedDeparture = formatDateTime(stop.PlannedDeparture); + + return ( + + + {/* Order badge */} + + {stop.StopOrder} + + + + {stop.Name} + + {stop.Address ? ( + + + + {stop.Address} + + + ) : null} + + {plannedArrival ? ( + + + + {t('routes.planned_arrival')}: {plannedArrival} + + + ) : null} + + {plannedDeparture ? ( + + + + {t('routes.planned_departure')}: {plannedDeparture} + + + ) : null} + + {stop.DwellTimeMinutes > 0 ? ( + + + + {stop.DwellTimeMinutes} {t('routes.dwell_time')} + + + ) : null} + + {stop.Notes ? ( + {stop.Notes} + ) : null} + + {stop.ContactId ? ( + + router.push(`/routes/stop/contact?stopId=${stop.RouteStopId}` as any) + } + > + + + + {t('routes.view_contact')} + + + + ) : null} + + + {index < sortedStops.length - 1 ? : null} + + ); + }; + + return ( + + + + + {/* Interactive stop map */} + {mapData ? ( + + + + {mapData.stops.map((stop) => ( + + + + ))} + + + ) : null} + + + {/* Route header */} + + + + {activePlan.Name} + + + {/* Description */} + {activePlan.Description ? ( + {activePlan.Description} + ) : null} + + {/* Assigned unit */} + + + + {assignedUnitName || t('routes.unassigned')} + + + + + + {sortedStops.length} + {t('routes.stops')} + + + {(activePlan.EstimatedDistanceMeters ?? 0) > 0 ? ( + + + {formatDistance(activePlan.EstimatedDistanceMeters!)} + + {t('routes.distance')} + + ) : null} + + {(activePlan.EstimatedDurationSeconds ?? 0) > 0 ? ( + + + {formatDuration(activePlan.EstimatedDurationSeconds!)} + + {t('routes.duration')} + + ) : null} + + + {activePlan.ScheduleInfo ? ( + + + {activePlan.ScheduleInfo} + + ) : null} + + + {/* Warning when route is assigned to a different unit */} + {activePlan.UnitId && String(activePlan.UnitId) !== String(activeUnitId) ? ( + + + + + {t('routes.assigned_other_unit')} + + + + ) : null} + + {/* Stops list */} + + + {t('routes.stops')} ({sortedStops.length}) + + + + {sortedStops.length > 0 ? ( + {sortedStops.map((stop, index) => renderStopRow(stop, index))} + ) : ( + + {t('routes.no_stops')} + + )} + + + + + {/* Start Route button — only shown when this unit can start */} + {canStart ? ( + + + + ) : null} + + ); +} + +const styles = StyleSheet.create({ + mapContainer: { + height: 260, + width: '100%', + }, + map: { + flex: 1, + }, + markerContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + }, + markerCircle: { + width: 28, + height: 28, + borderRadius: 14, + alignItems: 'center', + justifyContent: 'center', + borderWidth: 2, + borderColor: 'white', + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.35, + shadowRadius: 2, + elevation: 3, + }, + markerNumber: { + color: 'white', + fontSize: 11, + fontWeight: 'bold', + }, + markerLabel: { + backgroundColor: 'rgba(255, 255, 255, 0.92)', + paddingHorizontal: 5, + paddingVertical: 2, + borderRadius: 4, + maxWidth: 110, + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.2, + shadowRadius: 1, + elevation: 2, + }, + markerLabelText: { + fontSize: 10, + fontWeight: '600', + color: '#1f2937', + }, + stopBadge: { + width: 32, + height: 32, + borderRadius: 16, + alignItems: 'center', + justifyContent: 'center', + flexShrink: 0, + marginTop: 2, + }, + stopBadgeText: { + color: 'white', + fontSize: 13, + fontWeight: 'bold', + }, +}); diff --git a/src/app/routes/stop/[id].tsx b/src/app/routes/stop/[id].tsx new file mode 100644 index 00000000..eddd3e17 --- /dev/null +++ b/src/app/routes/stop/[id].tsx @@ -0,0 +1,421 @@ +import { format } from 'date-fns'; +import { Stack, router, useLocalSearchParams } from 'expo-router'; +import { + CheckCircleIcon, + ClockIcon, + LogInIcon, + LogOutIcon, + MapPinIcon, + SkipForwardIcon, + UserIcon, +} from 'lucide-react-native'; +import { useColorScheme } from 'nativewind'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Alert, ScrollView, StyleSheet, View } from 'react-native'; + +import { + Camera, + FillLayer, + LineLayer, + MapView, + PointAnnotation, + ShapeSource, + StyleURL, +} from '@/components/maps/mapbox'; +import { Loading } from '@/components/common/loading'; +import { Badge, BadgeText } from '@/components/ui/badge'; +import { Box } from '@/components/ui/box'; +import { Button, ButtonIcon, ButtonText } from '@/components/ui/button'; +import { Heading } from '@/components/ui/heading'; +import { HStack } from '@/components/ui/hstack'; +import { Input, InputField } from '@/components/ui/input'; +import { Pressable } from '@/components/ui/pressable'; +import { Text } from '@/components/ui/text'; +import { Textarea, TextareaInput } from '@/components/ui/textarea'; +import { VStack } from '@/components/ui/vstack'; +import { useLocationStore } from '@/stores/app/location-store'; +import { useRoutesStore } from '@/stores/routes/store'; +import { RouteStopStatus } from '@/models/v4/routes/routeInstanceStopResultData'; + +const STOP_TYPE_LABELS: Record = { + 0: 'Standard', + 1: 'Pickup', + 2: 'Dropoff', + 3: 'Service', + 4: 'Inspection', +}; + +const PRIORITY_CONFIG: Record = { + 0: { label: 'Normal', action: 'muted' }, + 1: { label: 'Low', action: 'info' }, + 2: { label: 'Medium', action: 'warning' }, + 3: { label: 'High', action: 'error' }, + 4: { label: 'Critical', action: 'error' }, +}; + +const STATUS_LABELS: Record = { + [RouteStopStatus.Pending]: 'Pending', + [RouteStopStatus.InProgress]: 'In Progress', + [RouteStopStatus.Completed]: 'Completed', + [RouteStopStatus.Skipped]: 'Skipped', +}; + +/** + * Build a GeoJSON circle polygon for the geofence overlay. + */ +const buildGeofenceGeoJson = (lat: number, lon: number, radiusMeters: number) => { + const points = 64; + const coords: number[][] = []; + const earthRadius = 6371000; + for (let i = 0; i <= points; i++) { + const angle = (i * 2 * Math.PI) / points; + const dLat = (radiusMeters / earthRadius) * Math.cos(angle); + const dLon = + (radiusMeters / (earthRadius * Math.cos((lat * Math.PI) / 180))) * Math.sin(angle); + coords.push([lon + (dLon * 180) / Math.PI, lat + (dLat * 180) / Math.PI]); + } + return { + type: 'Feature' as const, + geometry: { + type: 'Polygon' as const, + coordinates: [coords], + }, + properties: {}, + }; +}; + +export default function StopDetailScreen() { + const { t } = useTranslation(); + const { id } = useLocalSearchParams<{ id: string }>(); + const { colorScheme } = useColorScheme(); + + const instanceStops = useRoutesStore((s) => s.instanceStops); + const checkIn = useRoutesStore((s) => s.checkIn); + const checkOut = useRoutesStore((s) => s.checkOut); + const skip = useRoutesStore((s) => s.skip); + const updateNotes = useRoutesStore((s) => s.updateNotes); + const isLoadingStops = useRoutesStore((s) => s.isLoadingStops); + + const userLat = useLocationStore((s) => s.latitude); + const userLon = useLocationStore((s) => s.longitude); + + const stop = useMemo( + () => instanceStops.find((s) => s.RouteInstanceStopId === id) ?? null, + [instanceStops, id] + ); + + const [notes, setNotes] = useState(stop?.Notes ?? ''); + const [isSavingNotes, setIsSavingNotes] = useState(false); + + useEffect(() => { + if (stop) { + setNotes(stop.Notes ?? ''); + } + }, [stop]); + + const geofenceGeoJson = useMemo(() => { + if (!stop || !stop.Latitude || !stop.Longitude || !stop.GeofenceRadiusMeters) return null; + return buildGeofenceGeoJson(stop.Latitude, stop.Longitude, stop.GeofenceRadiusMeters); + }, [stop]); + + const priorityConfig = PRIORITY_CONFIG[stop?.Priority ?? 0] ?? PRIORITY_CONFIG[0]; + const stopTypeLabel = STOP_TYPE_LABELS[stop?.StopType ?? 0] ?? 'Unknown'; + const statusLabel = STATUS_LABELS[stop?.Status ?? 0] ?? 'Unknown'; + + const handleSaveNotes = useCallback(async () => { + if (!stop) return; + setIsSavingNotes(true); + try { + await updateNotes(stop.RouteInstanceStopId, notes); + } finally { + setIsSavingNotes(false); + } + }, [stop, notes, updateNotes]); + + const handleCheckIn = useCallback(async () => { + if (!stop) return; + const lat = userLat ?? 0; + const lon = userLon ?? 0; + // UnitId is required but we pass empty; the store action handles it + await checkIn(stop.RouteInstanceStopId, '', lat, lon); + }, [stop, userLat, userLon, checkIn]); + + const handleCheckOut = useCallback(async () => { + if (!stop) return; + await checkOut(stop.RouteInstanceStopId, ''); + }, [stop, checkOut]); + + const handleSkip = useCallback(() => { + if (!stop) return; + Alert.prompt( + t('routes.skip'), + t('routes.skip_reason_placeholder'), + [ + { text: t('common.cancel'), style: 'cancel' }, + { + text: t('routes.skip'), + style: 'destructive', + onPress: (reason?: string) => { + skip(stop.RouteInstanceStopId, reason ?? ''); + }, + }, + ], + 'plain-text' + ); + }, [stop, skip]); + + const handleContactPress = useCallback(() => { + if (!stop?.ContactId) return; + router.push({ pathname: '/routes/stop/contact' as any, params: { stopId: stop.RouteInstanceStopId } }); + }, [stop]); + + if (isLoadingStops) { + return ( + <> + + + + + + ); + } + + if (!stop) { + return ( + <> + + + + {t('routes.no_routes_description')} + + + + ); + } + + const canCheckIn = stop.Status === RouteStopStatus.Pending; + const canCheckOut = stop.Status === RouteStopStatus.InProgress; + const canSkip = + stop.Status === RouteStopStatus.Pending || stop.Status === RouteStopStatus.InProgress; + + return ( + <> + + + {/* Header */} + + {stop.Name} + {stop.Address ? ( + {stop.Address} + ) : null} + + {/* Badges */} + + + {priorityConfig.label} + + + {stopTypeLabel} + + + {statusLabel} + + + + + {/* Planned times */} + + + + Schedule + + + + {t('routes.planned_arrival')} + + {stop.PlannedArrival + ? format(new Date(stop.PlannedArrival), 'MMM d, h:mm a') + : '--'} + + + + {t('routes.planned_departure')} + + {stop.PlannedDeparture + ? format(new Date(stop.PlannedDeparture), 'MMM d, h:mm a') + : '--'} + + + + {stop.DwellTimeMinutes > 0 && ( + + {t('routes.dwell_time')}: {stop.DwellTimeMinutes} min + + )} + + + {/* Mini Map with geofence */} + {stop.Latitude && stop.Longitude ? ( + + + + + + + + + {geofenceGeoJson && ( + + + + + )} + + + ) : null} + + {/* Contact card */} + {stop.ContactId ? ( + + + + + + + + {t('routes.contact')} + + {t('routes.contact_details')} + + + {t('calls.view_details')} + + + + ) : null} + + {/* Notes */} + + {t('routes.notes')} + + + + + {/* Status action buttons */} + + {canCheckIn && ( + + )} + {canCheckOut && ( + + )} + {canSkip && ( + + )} + {stop.Status === RouteStopStatus.Completed && ( + + + {t('routes.completed')} + + )} + + + {/* Bottom spacing */} + + + + ); +} + +const styles = StyleSheet.create({ + map: { + flex: 1, + }, + markerContainer: { + alignItems: 'center', + justifyContent: 'center', + }, +}); diff --git a/src/app/routes/stop/contact.tsx b/src/app/routes/stop/contact.tsx new file mode 100644 index 00000000..b06fa8af --- /dev/null +++ b/src/app/routes/stop/contact.tsx @@ -0,0 +1,366 @@ +import { Stack, useLocalSearchParams } from 'expo-router'; +import { + BuildingIcon, + ExternalLinkIcon, + MapPinIcon, + PhoneIcon, + UserIcon, +} from 'lucide-react-native'; +import { useColorScheme } from 'nativewind'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Linking, Platform, ScrollView, StyleSheet, View } from 'react-native'; + +import { + Camera, + MapView, + PointAnnotation, + StyleURL, +} from '@/components/maps/mapbox'; +import { Loading } from '@/components/common/loading'; +import { Box } from '@/components/ui/box'; +import { Button, ButtonIcon, ButtonText } from '@/components/ui/button'; +import { Heading } from '@/components/ui/heading'; +import { HStack } from '@/components/ui/hstack'; +import { Pressable } from '@/components/ui/pressable'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; +import { getStopContact } from '@/api/routes/routes'; +import type { ContactResultData } from '@/models/v4/contacts/contactResultData'; + +/** + * Parse a GPS coordinate string like "lat,lon" into { lat, lon }. + */ +const parseGps = (coords?: string): { lat: number; lon: number } | null => { + if (!coords) return null; + const parts = coords.split(','); + if (parts.length !== 2) return null; + const lat = parseFloat(parts[0].trim()); + const lon = parseFloat(parts[1].trim()); + if (isNaN(lat) || isNaN(lon)) return null; + return { lat, lon }; +}; + +const openInMaps = (lat: number, lon: number, label: string) => { + const url = Platform.select({ + ios: `maps://app?daddr=${lat},${lon}&q=${encodeURIComponent(label)}`, + android: `google.navigation:q=${lat},${lon}`, + default: `https://www.google.com/maps/dir/?api=1&destination=${lat},${lon}`, + }); + if (url) Linking.openURL(url); +}; + +const callPhone = (number: string) => { + const cleaned = number.replace(/[^\d+]/g, ''); + Linking.openURL(`tel:${cleaned}`); +}; + +interface PhoneRowProps { + label: string; + number: string | null | undefined; + colorScheme: string; +} + +function PhoneRow({ label, number, colorScheme }: PhoneRowProps) { + if (!number) return null; + return ( + callPhone(number)}> + + + + + + {label} + {number} + + + + + ); +} + +export default function StopContactScreen() { + const { t } = useTranslation(); + const { stopId } = useLocalSearchParams<{ stopId: string }>(); + const { colorScheme } = useColorScheme(); + + const [contact, setContact] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!stopId) return; + let cancelled = false; + setIsLoading(true); + setError(null); + + getStopContact(stopId) + .then((result) => { + if (!cancelled) { + setContact(result.Data ?? null); + setIsLoading(false); + } + }) + .catch((err) => { + if (!cancelled) { + setError(t('common.errorOccurred')); + setIsLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, [stopId]); + + const locationGps = useMemo(() => parseGps(contact?.LocationGpsCoordinates), [contact]); + const entranceGps = useMemo(() => parseGps(contact?.EntranceGpsCoordinates), [contact]); + const exitGps = useMemo(() => parseGps(contact?.ExitGpsCoordinates), [contact]); + + // Determine map center from available coordinates + const mapCenter = useMemo(() => { + if (locationGps) return [locationGps.lon, locationGps.lat] as [number, number]; + if (entranceGps) return [entranceGps.lon, entranceGps.lat] as [number, number]; + if (exitGps) return [exitGps.lon, exitGps.lat] as [number, number]; + return null; + }, [locationGps, entranceGps, exitGps]); + + const displayName = useMemo(() => { + if (!contact) return ''; + if (contact.CompanyName) return contact.CompanyName; + const parts = [contact.FirstName, contact.MiddleName, contact.LastName].filter(Boolean); + if (parts.length > 0) return parts.join(' '); + return contact.Name ?? t('routes.no_contact'); + }, [contact]); + + const fullAddress = useMemo(() => { + if (!contact) return null; + const parts = [contact.Address, contact.City, contact.State, contact.Zip].filter(Boolean); + return parts.length > 0 ? parts.join(', ') : null; + }, [contact]); + + const handleAddressPress = useCallback(() => { + if (locationGps) { + openInMaps(locationGps.lat, locationGps.lon, displayName); + } + }, [locationGps, displayName]); + + if (isLoading) { + return ( + <> + + + + + + ); + } + + if (error || !contact) { + return ( + <> + + + + + {error ?? t('routes.no_contact')} + + + + ); + } + + return ( + <> + + + {/* Header */} + + + {contact.CompanyName ? ( + + ) : ( + + )} + + {displayName} + {contact.CompanyName && contact.FirstName && ( + + {[contact.FirstName, contact.LastName].filter(Boolean).join(' ')} + + )} + {contact.Email && ( + {contact.Email} + )} + + + {/* Phone numbers */} + + + + + + + + + + {/* Address */} + {fullAddress && ( + + + + + + {t('routes.address')} + {fullAddress} + + {locationGps && } + + + + )} + + {/* Mini Map */} + {mapCenter && ( + + + + {locationGps && ( + + + + + + )} + {entranceGps && ( + + + + + + )} + {exitGps && ( + + + + + + )} + + {/* Legend */} + + {locationGps && ( + + + Location + + )} + {entranceGps && ( + + + Entrance + + )} + {exitGps && ( + + + Exit + + )} + + + )} + + {/* Description / Notes */} + {contact.Description && ( + + {t('routes.description')} + {contact.Description} + + )} + + {contact.Notes && ( + + {t('routes.notes')} + {contact.Notes} + + )} + + {/* Bottom spacing */} + + + + ); +} + +const styles = StyleSheet.create({ + map: { + flex: 1, + }, + markerLocation: { + alignItems: 'center', + justifyContent: 'center', + }, + markerEntrance: { + alignItems: 'center', + justifyContent: 'center', + }, + markerExit: { + alignItems: 'center', + justifyContent: 'center', + }, + legendDot: { + width: 8, + height: 8, + borderRadius: 4, + }, +}); diff --git a/src/components/maps/map-view.web.tsx b/src/components/maps/map-view.web.tsx index 4e1989bb..bfd1aeb2 100644 --- a/src/components/maps/map-view.web.tsx +++ b/src/components/maps/map-view.web.tsx @@ -629,6 +629,9 @@ export const LineLayer: React.FC = () => null; export const FillLayer: React.FC = () => null; export const Images: React.FC = () => null; export const Callout: React.FC = ({ children }) => <>{children}; +export const RasterLayer: React.FC = () => null; +export const RasterSource: React.FC = ({ children }) => <>{children}; +export const ImageSource: React.FC = ({ children }) => <>{children}; // Default export matching native structure export default { @@ -644,6 +647,9 @@ export default { FillLayer, Images, Callout, + RasterLayer, + RasterSource, + ImageSource, StyleURL, UserTrackingMode, setAccessToken, diff --git a/src/components/maps/mapbox.native.ts b/src/components/maps/mapbox.native.ts index c7a30d2c..f478d527 100644 --- a/src/components/maps/mapbox.native.ts +++ b/src/components/maps/mapbox.native.ts @@ -17,6 +17,9 @@ export const LineLayer = Mapbox.LineLayer; export const FillLayer = Mapbox.FillLayer; export const Images = Mapbox.Images; export const Callout = Mapbox.Callout; +export const RasterLayer = Mapbox.RasterLayer; +export const RasterSource = Mapbox.RasterSource; +export const ImageSource = Mapbox.ImageSource; // Export style URL constants export const StyleURL = Mapbox.StyleURL; @@ -41,6 +44,9 @@ const MapboxExports = { FillLayer: Mapbox.FillLayer, Images: Mapbox.Images, Callout: Mapbox.Callout, + RasterLayer: Mapbox.RasterLayer, + RasterSource: Mapbox.RasterSource, + ImageSource: Mapbox.ImageSource, StyleURL: Mapbox.StyleURL, UserTrackingMode: Mapbox.UserTrackingMode, setAccessToken: Mapbox.setAccessToken, diff --git a/src/components/maps/mapbox.web.ts b/src/components/maps/mapbox.web.ts index e1dfdc37..c7a5cc97 100644 --- a/src/components/maps/mapbox.web.ts +++ b/src/components/maps/mapbox.web.ts @@ -17,6 +17,9 @@ export const LineLayer = MapboxWeb.LineLayer; export const FillLayer = MapboxWeb.FillLayer; export const Images = MapboxWeb.Images; export const Callout = MapboxWeb.Callout; +export const RasterLayer = MapboxWeb.RasterLayer; +export const RasterSource = MapboxWeb.RasterSource; +export const ImageSource = MapboxWeb.ImageSource; // Export style URL constants export const StyleURL = MapboxWeb.StyleURL; @@ -42,6 +45,9 @@ const MapboxExports = { FillLayer, Images, Callout, + RasterLayer, + RasterSource, + ImageSource, StyleURL, UserTrackingMode, setAccessToken, diff --git a/src/components/routes/route-card.tsx b/src/components/routes/route-card.tsx new file mode 100644 index 00000000..cc6c0b3c --- /dev/null +++ b/src/components/routes/route-card.tsx @@ -0,0 +1,122 @@ +import { Clock, MapPin, Navigation, Truck } from 'lucide-react-native'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Badge, BadgeText } from '@/components/ui/badge'; +import { Box } from '@/components/ui/box'; +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 { type RoutePlanResultData } from '@/models/v4/routes/routePlanResultData'; + +interface RouteCardProps { + route: RoutePlanResultData; + isActive?: boolean; + unitName?: string; + isMyUnit?: boolean; +} + +export const RouteCard: React.FC = ({ + route, + isActive = false, + unitName, + isMyUnit = false, +}) => { + const { t } = useTranslation(); + + const formatDistance = (meters: number) => { + if (meters >= 1000) return `${(meters / 1000).toFixed(1)} km`; + return `${Math.round(meters)} m`; + }; + + const formatDuration = (seconds: number) => { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + if (hours > 0) return `${hours}h ${minutes}m`; + return `${minutes}m`; + }; + + const bgClass = isMyUnit + ? 'mb-2 rounded-xl bg-blue-50 p-4 shadow-sm dark:bg-blue-900/20' + : 'mb-2 rounded-xl bg-white p-4 shadow-sm dark:bg-gray-800'; + + const borderColor = isMyUnit ? '#3b82f6' : (route.RouteColor || '#94a3b8'); + + return ( + + + + + + {route.Name} + + + {route.Description ? ( + + {route.Description} + + ) : null} + + {/* Unit assignment chip */} + + + + {unitName || t('routes.unassigned')} + + + + + + + + {t('routes.stop_count', { count: route.StopsCount ?? 0 })} + + + + {(route.EstimatedDistanceMeters ?? 0) > 0 ? ( + + + + {formatDistance(route.EstimatedDistanceMeters!)} + + + ) : null} + + {(route.EstimatedDurationSeconds ?? 0) > 0 ? ( + + + + {formatDuration(route.EstimatedDurationSeconds!)} + + + ) : null} + + + + + {isActive ? ( + + {t('routes.active')} + + ) : null} + {isMyUnit && !isActive ? ( + + {t('routes.my_unit')} + + ) : null} + + + + {route.ScheduleInfo ? ( + + + {route.ScheduleInfo} + + ) : null} + + ); +}; diff --git a/src/components/routes/route-deviation-banner.tsx b/src/components/routes/route-deviation-banner.tsx new file mode 100644 index 00000000..0947ab21 --- /dev/null +++ b/src/components/routes/route-deviation-banner.tsx @@ -0,0 +1,53 @@ +import { AlertTriangle, X } from 'lucide-react-native'; +import React from 'react'; +import { Pressable } from 'react-native'; + +import { Box } from '@/components/ui/box'; +import { HStack } from '@/components/ui/hstack'; +import { Icon } from '@/components/ui/icon'; +import { Text } from '@/components/ui/text'; +import { type RouteDeviationResultData } from '@/models/v4/routes/routeDeviationResultData'; + +interface RouteDeviationBannerProps { + deviations: RouteDeviationResultData[]; + onPress: () => void; + onDismiss: (deviationId: string) => void; +} + +export const RouteDeviationBanner: React.FC = ({ deviations, onPress, onDismiss }) => { + if (deviations.length === 0) { + return null; + } + + const latestDeviation = deviations[0]; + + return ( + + + + + + + {latestDeviation.Description} + + + + {deviations.length > 1 ? ( + + +{deviations.length - 1} + + ) : null} + + { + e.stopPropagation?.(); + onDismiss(latestDeviation.RouteDeviationId); + }} + > + + + + + + ); +}; diff --git a/src/components/routes/stop-card.tsx b/src/components/routes/stop-card.tsx new file mode 100644 index 00000000..43787273 --- /dev/null +++ b/src/components/routes/stop-card.tsx @@ -0,0 +1,102 @@ +import { CheckCircle, Circle, Clock, MapPin, Phone, SkipForward, User } from 'lucide-react-native'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Badge, BadgeText } from '@/components/ui/badge'; +import { Box } from '@/components/ui/box'; +import { Button, ButtonText } from '@/components/ui/button'; +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 { type RouteInstanceStopResultData, RouteStopStatus } from '@/models/v4/routes/routeInstanceStopResultData'; + +interface StopCardProps { + stop: RouteInstanceStopResultData; + isCurrent?: boolean; + onCheckIn?: () => void; + onCheckOut?: () => void; + onSkip?: () => void; + onPress?: () => void; +} + +const statusConfig = { + [RouteStopStatus.Pending]: { color: '#9ca3af', labelKey: 'routes.pending', icon: Circle }, + [RouteStopStatus.InProgress]: { color: '#3b82f6', labelKey: 'routes.in_progress', icon: Clock }, + [RouteStopStatus.Completed]: { color: '#22c55e', labelKey: 'routes.completed', icon: CheckCircle }, + [RouteStopStatus.Skipped]: { color: '#eab308', labelKey: 'routes.skipped', icon: SkipForward }, +}; + +export const StopCard: React.FC = ({ stop, isCurrent = false, onCheckIn, onCheckOut, onSkip, onPress }) => { + const { t } = useTranslation(); + const config = statusConfig[stop.Status as RouteStopStatus] || statusConfig[RouteStopStatus.Pending]; + + return ( + + + + + + + + + + {stop.Name} + + {t(config.labelKey)} + + + + {stop.Address ? ( + + + + {stop.Address} + + + ) : null} + + {stop.ContactId ? ( + + + {t('routes.contact')} + + ) : null} + + {stop.PlannedArrival ? ( + + + {t('routes.eta')}: {stop.PlannedArrival} + + ) : null} + + + + #{stop.StopOrder} + + + {/* Action buttons for current stop */} + {isCurrent && stop.Status !== RouteStopStatus.Completed && stop.Status !== RouteStopStatus.Skipped ? ( + + {stop.Status === RouteStopStatus.Pending ? ( + + ) : null} + + {stop.Status === RouteStopStatus.InProgress ? ( + + ) : null} + + + + ) : null} + + ); +}; diff --git a/src/components/routes/stop-marker.tsx b/src/components/routes/stop-marker.tsx new file mode 100644 index 00000000..ee7e927d --- /dev/null +++ b/src/components/routes/stop-marker.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { StyleSheet, View } from 'react-native'; + +import { Text } from '@/components/ui/text'; +import { RouteStopStatus } from '@/models/v4/routes/routeInstanceStopResultData'; + +interface StopMarkerProps { + stopOrder: number; + status: number; +} + +const statusColors = { + [RouteStopStatus.Pending]: '#9ca3af', + [RouteStopStatus.InProgress]: '#3b82f6', + [RouteStopStatus.Completed]: '#22c55e', + [RouteStopStatus.Skipped]: '#eab308', +}; + +export const StopMarker: React.FC = ({ stopOrder, status }) => { + const color = statusColors[status as RouteStopStatus] || statusColors[RouteStopStatus.Pending]; + + return ( + + {stopOrder} + + ); +}; + +const styles = StyleSheet.create({ + container: { + width: 28, + height: 28, + borderRadius: 14, + alignItems: 'center', + justifyContent: 'center', + borderWidth: 2, + borderColor: '#ffffff', + elevation: 3, + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.2, + shadowRadius: 2, + }, + text: { + color: '#ffffff', + fontSize: 12, + fontWeight: 'bold', + }, +}); diff --git a/src/components/settings/language-item.tsx b/src/components/settings/language-item.tsx index 8e78c86a..bd008280 100644 --- a/src/components/settings/language-item.tsx +++ b/src/components/settings/language-item.tsx @@ -24,6 +24,13 @@ export const LanguageItem = () => { () => [ { label: translate('settings.english'), value: 'en' }, { label: translate('settings.spanish'), value: 'es' }, + { label: translate('settings.swedish'), value: 'sv' }, + { label: translate('settings.german'), value: 'de' }, + { label: translate('settings.french'), value: 'fr' }, + { label: translate('settings.italian'), value: 'it' }, + { label: translate('settings.polish'), value: 'pl' }, + { label: translate('settings.ukrainian'), value: 'uk' }, + { label: translate('settings.arabic'), value: 'ar' }, ], [] ); @@ -38,7 +45,7 @@ export const LanguageItem = () => { setLanguage(val as Language)} - selectedValue={language ?? i18nInstance.language} - > + - @@ -406,6 +322,27 @@ export default function StopDetailScreen() { {/* Bottom spacing */} + + {/* Skip reason modal */} + setSkipModalVisible(false)}> + + + + {t('routes.skip')} — {stop.Name} + + {t('routes.skip_reason')} + + + setSkipModalVisible(false)}> + {t('common.cancel')} + + + {t('routes.skip')} + + + + + ); } @@ -418,4 +355,29 @@ const styles = StyleSheet.create({ alignItems: 'center', justifyContent: 'center', }, + skipInput: { + borderWidth: 1, + borderColor: '#d1d5db', + borderRadius: 8, + padding: 10, + fontSize: 14, + color: '#111827', + textAlignVertical: 'top', + minHeight: 80, + }, + cancelBtn: { + flex: 1, + paddingVertical: 10, + borderRadius: 8, + borderWidth: 1, + borderColor: '#d1d5db', + alignItems: 'center', + }, + skipBtn: { + flex: 1, + paddingVertical: 10, + borderRadius: 8, + backgroundColor: '#eab308', + alignItems: 'center', + }, }); diff --git a/src/app/routes/stop/contact.tsx b/src/app/routes/stop/contact.tsx index b06fa8af..79b10b5a 100644 --- a/src/app/routes/stop/contact.tsx +++ b/src/app/routes/stop/contact.tsx @@ -1,23 +1,13 @@ import { Stack, useLocalSearchParams } from 'expo-router'; -import { - BuildingIcon, - ExternalLinkIcon, - MapPinIcon, - PhoneIcon, - UserIcon, -} from 'lucide-react-native'; +import { BuildingIcon, ExternalLinkIcon, MapPinIcon, PhoneIcon, UserIcon } from 'lucide-react-native'; import { useColorScheme } from 'nativewind'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Linking, Platform, ScrollView, StyleSheet, View } from 'react-native'; -import { - Camera, - MapView, - PointAnnotation, - StyleURL, -} from '@/components/maps/mapbox'; +import { getStopContact } from '@/api/routes/routes'; import { Loading } from '@/components/common/loading'; +import { Camera, MapView, PointAnnotation, StyleURL } from '@/components/maps/mapbox'; import { Box } from '@/components/ui/box'; import { Button, ButtonIcon, ButtonText } from '@/components/ui/button'; import { Heading } from '@/components/ui/heading'; @@ -25,7 +15,6 @@ import { HStack } from '@/components/ui/hstack'; import { Pressable } from '@/components/ui/pressable'; import { Text } from '@/components/ui/text'; import { VStack } from '@/components/ui/vstack'; -import { getStopContact } from '@/api/routes/routes'; import type { ContactResultData } from '@/models/v4/contacts/contactResultData'; /** @@ -65,11 +54,7 @@ function PhoneRow({ label, number, colorScheme }: PhoneRowProps) { if (!number) return null; return ( callPhone(number)}> - + @@ -115,7 +100,7 @@ export default function StopContactScreen() { return () => { cancelled = true; }; - }, [stopId]); + }, [stopId, t]); const locationGps = useMemo(() => parseGps(contact?.LocationGpsCoordinates), [contact]); const entranceGps = useMemo(() => parseGps(contact?.EntranceGpsCoordinates), [contact]); @@ -135,7 +120,7 @@ export default function StopContactScreen() { const parts = [contact.FirstName, contact.MiddleName, contact.LastName].filter(Boolean); if (parts.length > 0) return parts.join(' '); return contact.Name ?? t('routes.no_contact'); - }, [contact]); + }, [contact, t]); const fullAddress = useMemo(() => { if (!contact) return null; @@ -144,10 +129,11 @@ export default function StopContactScreen() { }, [contact]); const handleAddressPress = useCallback(() => { - if (locationGps) { - openInMaps(locationGps.lat, locationGps.lon, displayName); + const coords = locationGps ?? entranceGps ?? exitGps; + if (coords) { + openInMaps(coords.lat, coords.lon, displayName); } - }, [locationGps, displayName]); + }, [locationGps, entranceGps, exitGps, displayName]); if (isLoading) { return ( @@ -166,9 +152,7 @@ export default function StopContactScreen() { - - {error ?? t('routes.no_contact')} - + {error ?? t('routes.no_contact')} ); @@ -183,37 +167,17 @@ export default function StopContactScreen() { headerBackTitle: '', }} /> - + {/* Header */} - - - {contact.CompanyName ? ( - - ) : ( - - )} - + + {contact.CompanyName ? : } {displayName} - {contact.CompanyName && contact.FirstName && ( - - {[contact.FirstName, contact.LastName].filter(Boolean).join(' ')} - - )} - {contact.Email && ( - {contact.Email} - )} + {contact.CompanyName && contact.FirstName && {[contact.FirstName, contact.LastName].filter(Boolean).join(' ')}} + {contact.Email && {contact.Email}} {/* Phone numbers */} - + @@ -225,18 +189,14 @@ export default function StopContactScreen() { {/* Address */} {fullAddress && ( - + {t('routes.address')} {fullAddress} - {locationGps && } + {mapCenter && } @@ -245,43 +205,24 @@ export default function StopContactScreen() { {/* Mini Map */} {mapCenter && ( - - + + {locationGps && ( - + )} {entranceGps && ( - + )} {exitGps && ( - + @@ -314,22 +255,14 @@ export default function StopContactScreen() { {/* Description / Notes */} {contact.Description && ( - + {t('routes.description')} {contact.Description} )} {contact.Notes && ( - + {t('routes.notes')} {contact.Notes} diff --git a/src/components/maps/map-view.web.tsx b/src/components/maps/map-view.web.tsx index bfd1aeb2..b8d3d53b 100644 --- a/src/components/maps/map-view.web.tsx +++ b/src/components/maps/map-view.web.tsx @@ -1,11 +1,11 @@ /** - * Web implementation of map components using mapbox-gl + * Web/Electron implementation of map components using mapbox-gl * This file is used on web and Electron platforms */ import 'mapbox-gl/dist/mapbox-gl.css'; import mapboxgl from 'mapbox-gl'; -import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react'; +import React, { forwardRef, useCallback, useContext, useEffect, useImperativeHandle, useRef, useState } from 'react'; // @ts-ignore - react-dom/client types may not be available import { createRoot } from 'react-dom/client'; @@ -17,6 +17,9 @@ mapboxgl.accessToken = Env.UNIT_MAPBOX_PUBKEY; // Context to share map instance with child components export const MapContext = React.createContext(null); +// Context to share source ID from source components (ShapeSource, ImageSource, RasterSource) to layer children +const SourceContext = React.createContext(null); + // StyleURL constants matching native Mapbox SDK export const StyleURL = { Street: 'mapbox://styles/mapbox/streets-v12', @@ -39,6 +42,106 @@ export const setAccessToken = (token: string) => { mapboxgl.accessToken = token; }; +// --- Style conversion helpers --- + +function toLinePaint(style: any) { + const p: Record = {}; + if (style?.lineColor !== undefined) p['line-color'] = style.lineColor; + if (style?.lineWidth !== undefined) p['line-width'] = style.lineWidth; + if (style?.lineOpacity !== undefined) p['line-opacity'] = style.lineOpacity; + if (style?.lineDasharray !== undefined) p['line-dasharray'] = style.lineDasharray; + if (style?.lineBlur !== undefined) p['line-blur'] = style.lineBlur; + if (style?.lineOffset !== undefined) p['line-offset'] = style.lineOffset; + return p; +} + +function toLineLayout(style: any) { + const l: Record = {}; + if (style?.lineCap !== undefined) l['line-cap'] = style.lineCap; + if (style?.lineJoin !== undefined) l['line-join'] = style.lineJoin; + return l; +} + +function toFillPaint(style: any) { + const p: Record = {}; + if (style?.fillColor !== undefined) p['fill-color'] = style.fillColor; + if (style?.fillOpacity !== undefined) p['fill-opacity'] = style.fillOpacity; + if (style?.fillOutlineColor !== undefined) p['fill-outline-color'] = style.fillOutlineColor; + if (style?.fillPattern !== undefined) p['fill-pattern'] = style.fillPattern; + return p; +} + +function toCirclePaint(style: any) { + const p: Record = {}; + if (style?.circleRadius !== undefined) p['circle-radius'] = style.circleRadius; + if (style?.circleColor !== undefined) p['circle-color'] = style.circleColor; + if (style?.circleOpacity !== undefined) p['circle-opacity'] = style.circleOpacity; + if (style?.circleStrokeColor !== undefined) p['circle-stroke-color'] = style.circleStrokeColor; + if (style?.circleStrokeWidth !== undefined) p['circle-stroke-width'] = style.circleStrokeWidth; + if (style?.circleStrokeOpacity !== undefined) p['circle-stroke-opacity'] = style.circleStrokeOpacity; + if (style?.circleBlur !== undefined) p['circle-blur'] = style.circleBlur; + return p; +} + +function toSymbolPaint(style: any) { + const p: Record = {}; + if (style?.textColor !== undefined) p['text-color'] = style.textColor; + if (style?.textHaloColor !== undefined) p['text-halo-color'] = style.textHaloColor; + if (style?.textHaloWidth !== undefined) p['text-halo-width'] = style.textHaloWidth; + if (style?.textOpacity !== undefined) p['text-opacity'] = style.textOpacity; + if (style?.iconColor !== undefined) p['icon-color'] = style.iconColor; + if (style?.iconOpacity !== undefined) p['icon-opacity'] = style.iconOpacity; + return p; +} + +function toSymbolLayout(style: any) { + const l: Record = {}; + if (style?.textField !== undefined) l['text-field'] = style.textField; + if (style?.textSize !== undefined) l['text-size'] = style.textSize; + if (style?.textFont !== undefined) l['text-font'] = style.textFont; + if (style?.textOffset !== undefined) l['text-offset'] = style.textOffset; + if (style?.textAnchor !== undefined) l['text-anchor'] = style.textAnchor; + if (style?.textAllowOverlap !== undefined) l['text-allow-overlap'] = style.textAllowOverlap; + if (style?.textIgnorePlacement !== undefined) l['text-ignore-placement'] = style.textIgnorePlacement; + if (style?.textMaxWidth !== undefined) l['text-max-width'] = style.textMaxWidth; + if (style?.iconImage !== undefined) l['icon-image'] = style.iconImage; + if (style?.iconSize !== undefined) l['icon-size'] = style.iconSize; + if (style?.iconAnchor !== undefined) l['icon-anchor'] = style.iconAnchor; + if (style?.iconOffset !== undefined) l['icon-offset'] = style.iconOffset; + if (style?.iconAllowOverlap !== undefined) l['icon-allow-overlap'] = style.iconAllowOverlap; + if (style?.symbolPlacement !== undefined) l['symbol-placement'] = style.symbolPlacement; + if (style?.symbolSpacing !== undefined) l['symbol-spacing'] = style.symbolSpacing; + return l; +} + +function toRasterPaint(style: any) { + const p: Record = {}; + if (style?.rasterOpacity !== undefined) p['raster-opacity'] = style.rasterOpacity; + if (style?.rasterFadeDuration !== undefined) p['raster-fade-duration'] = style.rasterFadeDuration; + if (style?.rasterBrightnessMin !== undefined) p['raster-brightness-min'] = style.rasterBrightnessMin; + if (style?.rasterBrightnessMax !== undefined) p['raster-brightness-max'] = style.rasterBrightnessMax; + if (style?.rasterSaturation !== undefined) p['raster-saturation'] = style.rasterSaturation; + if (style?.rasterContrast !== undefined) p['raster-contrast'] = style.rasterContrast; + return p; +} + +// Safe layer/source removal helpers +function safeRemoveLayer(map: any, id: string) { + try { + if (map && !map.__removed && map.getLayer(id)) map.removeLayer(id); + } catch { + /* ignore */ + } +} + +function safeRemoveSource(map: any, id: string) { + try { + if (map && !map.__removed && map.getSource(id)) map.removeSource(id); + } catch { + /* ignore */ + } +} + // MapView Props interface interface MapViewProps { style?: React.CSSProperties; @@ -176,32 +279,22 @@ export const MapView = forwardRef( map.current = newMap; // Patch unproject to gracefully handle NaN results. - // mapbox-gl's internal mouse event handlers (mouseout, mousemove, etc.) - // call map.unproject() which throws "Invalid LngLat object: (NaN, NaN)" - // when the canvas/transform is in an invalid state (zero-size, mid-resize). - // These DOM events fire synchronously and can't be caught by error events - // or the _render patch below. const origUnproject = newMap.unproject.bind(newMap); newMap.unproject = (point: unknown) => { try { return origUnproject(point); } catch { - // Return a safe fallback LngLat (0,0) instead of crashing return new mapboxgl.LngLat(0, 0); } }; - // Patch easeTo / flyTo to catch "Invalid LngLat object: (NaN, NaN)" - // errors that occur when resetNorth or other compass interactions read - // a corrupted transform center (e.g. after resize or animation race). + // Patch easeTo / flyTo to catch "Invalid LngLat object: (NaN, NaN)" errors const origEaseTo = newMap.easeTo.bind(newMap); newMap.easeTo = function (options: any, eventData?: any) { try { return origEaseTo(options, eventData); } catch (e: any) { - if (e?.message?.includes('Invalid LngLat')) { - return this; - } + if (e?.message?.includes('Invalid LngLat')) return this; throw e; } }; @@ -211,17 +304,12 @@ export const MapView = forwardRef( try { return origFlyTo(options, eventData); } catch (e: any) { - if (e?.message?.includes('Invalid LngLat')) { - return this; - } + if (e?.message?.includes('Invalid LngLat')) return this; throw e; } }; - // Patch the internal _render method to gracefully handle zero-size - // containers. mapbox-gl v3 crashes in _calcMatrices → fromInvProjectionMatrix - // ("null is not an object evaluating r[3]") when the canvas has 0×0 - // dimensions (e.g. during route transitions or before layout completes). + // Patch the internal _render method to gracefully handle zero-size containers. const origRender = newMap._render; if (typeof origRender === 'function') { newMap._render = function (...args: unknown[]) { @@ -229,34 +317,26 @@ export const MapView = forwardRef( // eslint-disable-next-line react/no-this-in-sfc const canvas = this.getCanvas?.(); if (canvas && (canvas.width === 0 || canvas.height === 0)) { - return this; // skip frame when canvas is zero-sized + return this; } return origRender.apply(this, args); } catch { - // Suppress projection-matrix errors from zero-size containers return this; } }; } - // Suppress non-fatal mapbox-gl error events (e.g. "Invalid LngLat object: (NaN, NaN)") - // that occur when mouse events fire while the map canvas is resizing. + // Suppress non-fatal mapbox-gl error events newMap.on('error', (e: { error?: Error }) => { const msg = e.error?.message ?? ''; - if (msg.includes('Invalid LngLat')) { - return; - } + if (msg.includes('Invalid LngLat')) return; console.warn('[MapView.web] mapbox-gl error:', e.error); }); } catch (e) { - // mapbox-gl can throw during initialization if the container is not - // properly laid out (e.g. zero-size canvas). We silently ignore this; - // the map will simply not render rather than crash the app. console.warn('[MapView.web] Failed to initialize mapbox-gl:', e); } return () => { - // Mark the map as removed so child components can detect it if (map.current) { (map.current as any).__removed = true; map.current.remove(); @@ -267,14 +347,9 @@ export const MapView = forwardRef( }, [hasSize]); // Keep the map canvas in sync with container size changes. - // Also do an immediate resize so the canvas matches the container - // before any mouse events can fire (prevents NaN unproject errors). useEffect(() => { if (!map.current || !mapContainer.current) return; - // Only resize when the container has non-zero dimensions. - // Calling resize() with a zero-size container sets invalid dimensions - // on mapbox's internal transform, causing _calcMatrices to crash. const safeResize = () => { const el = mapContainer.current; if (el && el.clientWidth > 0 && el.clientHeight > 0) { @@ -286,7 +361,6 @@ export const MapView = forwardRef( } }; - // Immediate resize to sync canvas with current container size safeResize(); const ro = new ResizeObserver(() => safeResize()); @@ -335,11 +409,15 @@ interface CameraProps { followUserMode?: string; followZoomLevel?: number; followPitch?: number; + /** Fit map to bounds: {ne: [lng, lat], sw: [lng, lat]} */ + bounds?: { ne: [number, number]; sw: [number, number] }; + /** Padding for bounds fitting */ + padding?: { paddingTop?: number; paddingBottom?: number; paddingLeft?: number; paddingRight?: number }; } // Camera component -export const Camera = forwardRef(({ centerCoordinate, zoomLevel, heading, pitch, animationDuration = 1000, animationMode, followUserLocation, followZoomLevel }, ref) => { - const map = React.useContext(MapContext); +export const Camera = forwardRef(({ centerCoordinate, zoomLevel, heading, pitch, animationDuration = 1000, animationMode, followUserLocation, followZoomLevel, bounds, padding }, ref) => { + const map = useContext(MapContext); const geolocateControl = useRef(null); const hasInitialized = useRef(false); @@ -347,7 +425,6 @@ export const Camera = forwardRef(({ centerCoordinate, zoomLeve setCamera: (options: { centerCoordinate?: [number, number]; zoomLevel?: number; heading?: number; pitch?: number; animationDuration?: number }) => { if (!map) return; - // Validate coordinates before passing to mapbox if (options.centerCoordinate && (!isFinite(options.centerCoordinate[0]) || !isFinite(options.centerCoordinate[1]))) { return; } @@ -363,27 +440,73 @@ export const Camera = forwardRef(({ centerCoordinate, zoomLeve { _programmatic: true } ); }, - flyTo: (options: any) => { + + /** flyTo supports both array form flyTo([lng, lat], duration) and options-object form */ + flyTo: (coordinatesOrOptions: any, duration?: number) => { if (!map) return; - // Validate center if provided - if (options.center && Array.isArray(options.center) && (!isFinite(options.center[0]) || !isFinite(options.center[1]))) { - return; + if (Array.isArray(coordinatesOrOptions)) { + // Native Mapbox Camera API: flyTo([lng, lat], animationDuration) + const [lng, lat] = coordinatesOrOptions; + if (!isFinite(lng) || !isFinite(lat)) return; + map.flyTo({ center: [lng, lat] as [number, number], duration: duration || 1000 }, { _programmatic: true }); + } else { + // Options-object form: flyTo({ center, zoom, ... }) + const opts = coordinatesOrOptions; + if (opts?.center && Array.isArray(opts.center) && (!isFinite(opts.center[0]) || !isFinite(opts.center[1]))) return; + map.flyTo(opts, { _programmatic: true }); } + }, + + /** fitBounds(ne, sw, padding?, duration?) — matches native Mapbox Camera API */ + fitBounds: (ne: [number, number], sw: [number, number], pad?: number | number[], duration?: number) => { + if (!map) return; - map.flyTo(options, { _programmatic: true }); + const paddingObj = Array.isArray(pad) ? { top: pad[0] ?? 60, right: pad[1] ?? 60, bottom: pad[2] ?? 60, left: pad[3] ?? 60 } : { top: pad ?? 60, right: pad ?? 60, bottom: pad ?? 60, left: pad ?? 60 }; + + try { + map.fitBounds( + [ + [sw[0], sw[1]], + [ne[0], ne[1]], + ], + { padding: paddingObj, duration: duration || 1000 }, + { _programmatic: true } + ); + } catch { + // ignore projection errors + } }, })); + // Handle bounds prop (declarative camera fitting) + useEffect(() => { + if (!map || !bounds) return; + + const pad = padding ? { top: padding.paddingTop ?? 40, right: padding.paddingRight ?? 40, bottom: padding.paddingBottom ?? 40, left: padding.paddingLeft ?? 40 } : { top: 40, right: 40, bottom: 40, left: 40 }; + + try { + map.fitBounds( + [ + [bounds.sw[0], bounds.sw[1]], + [bounds.ne[0], bounds.ne[1]], + ], + { padding: pad, duration: animationDuration ?? 0 }, + { _programmatic: true } + ); + } catch { + // ignore projection errors + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [map, bounds, padding]); + + // Handle centerCoordinate / zoomLevel changes useEffect(() => { if (!map) return; if (centerCoordinate && centerCoordinate.length === 2 && isFinite(centerCoordinate[0]) && isFinite(centerCoordinate[1])) { if (!hasInitialized.current) { hasInitialized.current = true; - // Use jumpTo (instant, no animation) for the initial camera position. - // MapView initializes at a default center; Camera is responsible for - // snapping to the correct location on first render on web. try { map.jumpTo({ center: centerCoordinate as [number, number], zoom: zoomLevel, bearing: heading, pitch: pitch }, { _programmatic: true }); } catch { @@ -392,7 +515,6 @@ export const Camera = forwardRef(({ centerCoordinate, zoomLeve return; } - // For subsequent coordinate/zoom changes, animate to the new position. const cameraOptions = { center: centerCoordinate as [number, number], zoom: zoomLevel, @@ -413,32 +535,27 @@ export const Camera = forwardRef(({ centerCoordinate, zoomLeve } }, [map, centerCoordinate, zoomLevel, heading, pitch, animationDuration, animationMode]); + // Handle followUserLocation useEffect(() => { if (!map || !followUserLocation) return; let triggerTimeoutId: any; - // Add geolocate control for following user if (!geolocateControl.current) { geolocateControl.current = new mapboxgl.GeolocateControl({ - positionOptions: { - enableHighAccuracy: true, - }, + positionOptions: { enableHighAccuracy: true }, trackUserLocation: true, showUserHeading: true, }); map.addControl(geolocateControl.current); } - // Trigger tracking after control is added triggerTimeoutId = setTimeout(() => { geolocateControl.current?.trigger(); }, 100); return () => { - if (triggerTimeoutId) { - clearTimeout(triggerTimeoutId); - } + if (triggerTimeoutId) clearTimeout(triggerTimeoutId); if (geolocateControl.current) { try { map.removeControl(geolocateControl.current); @@ -467,37 +584,30 @@ interface PointAnnotationProps { // PointAnnotation component export const PointAnnotation: React.FC = ({ id, coordinate, title, children, anchor = { x: 0.5, y: 0.5 }, onSelected }) => { - const map = React.useContext(MapContext); + const map = useContext(MapContext); const markerRef = useRef(null); const containerRef = useRef(null); const containerRootRef = useRef(null); - // Create marker once when map/id/coordinate are available + // Create marker once when map/id are available useEffect(() => { if (!map || !coordinate) return; - // Create a container for React children const container = document.createElement('div'); container.style.cursor = 'pointer'; containerRef.current = container; - // Create a persistent React root for rendering children const root = createRoot(container); containerRootRef.current = root; - // Determine marker options based on anchor prop - const markerOptions: any = { - element: container, - }; + const markerOptions: any = { element: container }; - // Handle anchor prop - if it's a string, use it as mapbox anchor if (typeof anchor === 'string') { markerOptions.anchor = anchor as any; } markerRef.current = new mapboxgl.Marker(markerOptions).setLngLat(coordinate).addTo(map); - // If anchor is an {x, y} object, convert to pixel offset if (typeof anchor === 'object' && anchor !== null && 'x' in anchor && 'y' in anchor) { const rect = container.getBoundingClientRect(); const xOffset = (anchor.x - 0.5) * rect.width; @@ -510,22 +620,18 @@ export const PointAnnotation: React.FC = ({ id, coordinate } return () => { - // Unmount React root if (containerRootRef.current) { containerRootRef.current.unmount(); containerRootRef.current = null; } - - // Remove marker from map markerRef.current?.remove(); markerRef.current = null; containerRef.current = null; }; - // Only recreate marker when map, id, or anchor change — NOT children // eslint-disable-next-line react-hooks/exhaustive-deps }, [map, id]); - // Update coordinate when values actually change (by value, not reference) + // Update coordinate when it changes useEffect(() => { if (markerRef.current && coordinate && coordinate.length === 2 && isFinite(coordinate[0]) && isFinite(coordinate[1])) { markerRef.current.setLngLat(coordinate); @@ -533,8 +639,7 @@ export const PointAnnotation: React.FC = ({ id, coordinate // eslint-disable-next-line react-hooks/exhaustive-deps }, [coordinate?.[0], coordinate?.[1]]); - // Render children into the marker's React root whenever children identity changes. - // Using a layout effect with [children] dep so it only fires when children actually change. + // Render children into the marker's React root useEffect(() => { if (containerRootRef.current && children) { containerRootRef.current.render(<>{children}); @@ -555,30 +660,21 @@ export const PointAnnotation: React.FC = ({ id, coordinate return null; }; -// UserLocation Props interface -interface UserLocationProps { - visible?: boolean; - showsUserHeadingIndicator?: boolean; -} - // UserLocation component - handled by GeolocateControl in Camera -export const UserLocation: React.FC = ({ visible = true, showsUserHeadingIndicator = true }) => { - const map = React.useContext(MapContext); +export const UserLocation: React.FC<{ visible?: boolean; showsUserHeadingIndicator?: boolean }> = ({ visible = true, showsUserHeadingIndicator = true }) => { + const map = useContext(MapContext); useEffect(() => { if (!map || !visible) return; const geolocate = new mapboxgl.GeolocateControl({ - positionOptions: { - enableHighAccuracy: true, - }, + positionOptions: { enableHighAccuracy: true }, trackUserLocation: true, showUserHeading: showsUserHeadingIndicator, }); map.addControl(geolocate); - // Auto-trigger to show user location if (map.loaded()) { geolocate.trigger(); } else { @@ -592,7 +688,7 @@ export const UserLocation: React.FC = ({ visible = true, show map.off('load', onMapLoad); map.removeControl(geolocate); } catch { - // map may already be destroyed during route transitions + /* map may already be destroyed */ } }; } @@ -601,7 +697,7 @@ export const UserLocation: React.FC = ({ visible = true, show try { map.removeControl(geolocate); } catch { - // map may already be destroyed during route transitions + /* map may already be destroyed */ } }; }, [map, visible, showsUserHeadingIndicator]); @@ -609,11 +705,8 @@ export const UserLocation: React.FC = ({ visible = true, show return null; }; -// MarkerView component (simplified for web) -export const MarkerView: React.FC<{ - coordinate: [number, number]; - children?: React.ReactNode; -}> = ({ coordinate, children }) => { +// MarkerView component +export const MarkerView: React.FC<{ coordinate: [number, number]; children?: React.ReactNode }> = ({ coordinate, children }) => { return ( {children} @@ -621,17 +714,357 @@ export const MarkerView: React.FC<{ ); }; -// Placeholder components for compatibility -export const ShapeSource: React.FC = ({ children }) => <>{children}; -export const SymbolLayer: React.FC = () => null; -export const CircleLayer: React.FC = () => null; -export const LineLayer: React.FC = () => null; -export const FillLayer: React.FC = () => null; +// --- Source components --- + +interface ShapeSourceProps { + id: string; + shape?: GeoJSON.GeoJSON | null; + children?: React.ReactNode; + onPress?: (event: { features: any[] }) => void; +} + +/** + * ShapeSource — adds a GeoJSON source to the map and provides its ID to child layers via SourceContext. + * Layers (LineLayer, FillLayer, etc.) wait for sourceReady before adding themselves. + */ +export const ShapeSource: React.FC = ({ id, shape, children, onPress }) => { + const map = useContext(MapContext); + const [sourceReady, setSourceReady] = useState(false); + // Use a ref so the click handler always sees the latest onPress without re-registering + const onPressRef = useRef(onPress); + onPressRef.current = onPress; + + // Add/update GeoJSON source + useEffect(() => { + if (!map) return; + + const data: GeoJSON.GeoJSON = shape || { type: 'FeatureCollection', features: [] }; + + try { + if (map.getSource(id)) { + (map.getSource(id) as any).setData(data); + } else { + map.addSource(id, { type: 'geojson', data }); + } + setSourceReady(true); + } catch (e) { + console.warn('[ShapeSource] Failed to add source:', id, e); + } + + return () => { + setSourceReady(false); + // Defer source removal so child layer cleanups run first + setTimeout(() => safeRemoveSource(map, id), 0); + }; + }, [map, id]); // eslint-disable-line react-hooks/exhaustive-deps + + // Update source data when shape changes (without removing/re-adding source) + useEffect(() => { + if (!map || !sourceReady) return; + try { + const src = map.getSource(id) as any; + if (src) src.setData(shape || { type: 'FeatureCollection', features: [] }); + } catch { + /* ignore */ + } + }, [map, id, shape, sourceReady]); + + // Feature click handler — queries rendered features from this source + useEffect(() => { + if (!map || !onPress) return; + + const handleClick = (e: any) => { + if (!onPressRef.current) return; + const pt = e.point; + const bbox: [[number, number], [number, number]] = [ + [pt.x - 8, pt.y - 8], + [pt.x + 8, pt.y + 8], + ]; + try { + const features = map.queryRenderedFeatures(bbox).filter((f: any) => f.source === id); + if (features.length > 0) onPressRef.current({ features }); + } catch { + /* ignore */ + } + }; + + map.on('click', handleClick); + return () => { + map.off('click', handleClick); + }; + }, [map, id, onPress]); + + return {children}; +}; + +interface ImageSourceProps { + id: string; + url: string; + coordinates: [[number, number], [number, number], [number, number], [number, number]]; + children?: React.ReactNode; +} + +/** + * ImageSource — overlays a georeferenced image on the map. + * Coordinates: [NW, NE, SE, SW] as [lng, lat] pairs. + */ +export const ImageSource: React.FC = ({ id, url, coordinates, children }) => { + const map = useContext(MapContext); + const [sourceReady, setSourceReady] = useState(false); + + useEffect(() => { + if (!map || !url) return; + + try { + if (map.getSource(id)) { + (map.getSource(id) as any).updateImage({ url, coordinates }); + } else { + map.addSource(id, { type: 'image', url, coordinates }); + } + setSourceReady(true); + } catch (e) { + console.warn('[ImageSource] Failed to add source:', id, e); + } + + return () => { + setSourceReady(false); + setTimeout(() => safeRemoveSource(map, id), 0); + }; + }, [map, id, url]); // eslint-disable-line react-hooks/exhaustive-deps + + // Update coordinates if they change + useEffect(() => { + if (!map || !sourceReady) return; + try { + (map.getSource(id) as any)?.updateImage({ url, coordinates }); + } catch { + /* ignore */ + } + }, [map, id, url, coordinates, sourceReady]); + + return {children}; +}; + +interface RasterSourceProps { + id: string; + tileUrlTemplates?: string[]; + tileSize?: number; + children?: React.ReactNode; +} + +/** + * RasterSource — adds a raster tile source to the map. + */ +export const RasterSource: React.FC = ({ id, tileUrlTemplates, tileSize = 256, children }) => { + const map = useContext(MapContext); + const [sourceReady, setSourceReady] = useState(false); + + useEffect(() => { + if (!map || !tileUrlTemplates?.length) return; + + try { + if (!map.getSource(id)) { + map.addSource(id, { type: 'raster', tiles: tileUrlTemplates, tileSize }); + } + setSourceReady(true); + } catch (e) { + console.warn('[RasterSource] Failed to add source:', id, e); + } + + return () => { + setSourceReady(false); + setTimeout(() => safeRemoveSource(map, id), 0); + }; + }, [map, id, tileUrlTemplates, tileSize]); // eslint-disable-line react-hooks/exhaustive-deps + + return {children}; +}; + +// --- Layer components --- + +interface LayerProps { + id: string; + style?: any; +} + +/** + * LineLayer — renders line geometry from the parent ShapeSource. + */ +export const LineLayer: React.FC = ({ id, style }) => { + const map = useContext(MapContext); + const sourceId = useContext(SourceContext); // null until source is ready + + useEffect(() => { + if (!map || !sourceId) return; + + if (!map.getLayer(id)) { + try { + map.addLayer({ id, type: 'line', source: sourceId, paint: toLinePaint(style), layout: toLineLayout(style) }); + } catch (e) { + console.warn('[LineLayer] Failed to add layer:', id, e); + } + } + + return () => safeRemoveLayer(map, id); + }, [map, sourceId, id]); // eslint-disable-line react-hooks/exhaustive-deps + + // Update paint when style changes + useEffect(() => { + if (!map || !sourceId || !map.getLayer(id)) return; + try { + const paint = toLinePaint(style); + Object.entries(paint).forEach(([key, val]) => map.setPaintProperty(id, key, val)); + const layout = toLineLayout(style); + Object.entries(layout).forEach(([key, val]) => map.setLayoutProperty(id, key, val)); + } catch { + /* ignore */ + } + }, [map, sourceId, id, style]); + + return null; +}; + +/** + * FillLayer — renders fill/polygon geometry from the parent ShapeSource. + */ +export const FillLayer: React.FC = ({ id, style }) => { + const map = useContext(MapContext); + const sourceId = useContext(SourceContext); + + useEffect(() => { + if (!map || !sourceId) return; + + if (!map.getLayer(id)) { + try { + map.addLayer({ id, type: 'fill', source: sourceId, paint: toFillPaint(style) }); + } catch (e) { + console.warn('[FillLayer] Failed to add layer:', id, e); + } + } + + return () => safeRemoveLayer(map, id); + }, [map, sourceId, id]); // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + if (!map || !sourceId || !map.getLayer(id)) return; + try { + const paint = toFillPaint(style); + Object.entries(paint).forEach(([key, val]) => map.setPaintProperty(id, key, val)); + } catch { + /* ignore */ + } + }, [map, sourceId, id, style]); + + return null; +}; + +/** + * CircleLayer — renders point geometry as circles from the parent ShapeSource. + */ +export const CircleLayer: React.FC = ({ id, style }) => { + const map = useContext(MapContext); + const sourceId = useContext(SourceContext); + + useEffect(() => { + if (!map || !sourceId) return; + + if (!map.getLayer(id)) { + try { + map.addLayer({ id, type: 'circle', source: sourceId, paint: toCirclePaint(style) }); + } catch (e) { + console.warn('[CircleLayer] Failed to add layer:', id, e); + } + } + + return () => safeRemoveLayer(map, id); + }, [map, sourceId, id]); // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + if (!map || !sourceId || !map.getLayer(id)) return; + try { + const paint = toCirclePaint(style); + Object.entries(paint).forEach(([key, val]) => map.setPaintProperty(id, key, val)); + } catch { + /* ignore */ + } + }, [map, sourceId, id, style]); + + return null; +}; + +/** + * SymbolLayer — renders labels and icons from the parent ShapeSource. + */ +export const SymbolLayer: React.FC = ({ id, style }) => { + const map = useContext(MapContext); + const sourceId = useContext(SourceContext); + + useEffect(() => { + if (!map || !sourceId) return; + + if (!map.getLayer(id)) { + try { + map.addLayer({ id, type: 'symbol', source: sourceId, paint: toSymbolPaint(style), layout: toSymbolLayout(style) }); + } catch (e) { + console.warn('[SymbolLayer] Failed to add layer:', id, e); + } + } + + return () => safeRemoveLayer(map, id); + }, [map, sourceId, id]); // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + if (!map || !sourceId || !map.getLayer(id)) return; + try { + const paint = toSymbolPaint(style); + Object.entries(paint).forEach(([key, val]) => map.setPaintProperty(id, key, val)); + const layout = toSymbolLayout(style); + Object.entries(layout).forEach(([key, val]) => map.setLayoutProperty(id, key, val)); + } catch { + /* ignore */ + } + }, [map, sourceId, id, style]); + + return null; +}; + +/** + * RasterLayer — renders raster tiles or image overlays from the parent ImageSource/RasterSource. + */ +export const RasterLayer: React.FC = ({ id, style }) => { + const map = useContext(MapContext); + const sourceId = useContext(SourceContext); + + useEffect(() => { + if (!map || !sourceId) return; + + if (!map.getLayer(id)) { + try { + map.addLayer({ id, type: 'raster', source: sourceId, paint: toRasterPaint(style) }); + } catch (e) { + console.warn('[RasterLayer] Failed to add layer:', id, e); + } + } + + return () => safeRemoveLayer(map, id); + }, [map, sourceId, id]); // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + if (!map || !sourceId || !map.getLayer(id)) return; + try { + const paint = toRasterPaint(style); + Object.entries(paint).forEach(([key, val]) => map.setPaintProperty(id, key, val)); + } catch { + /* ignore */ + } + }, [map, sourceId, id, style]); + + return null; +}; + +// Passthrough / no-op components for API compatibility export const Images: React.FC = () => null; export const Callout: React.FC = ({ children }) => <>{children}; -export const RasterLayer: React.FC = () => null; -export const RasterSource: React.FC = ({ children }) => <>{children}; -export const ImageSource: React.FC = ({ children }) => <>{children}; // Default export matching native structure export default { diff --git a/src/components/routes/route-card.tsx b/src/components/routes/route-card.tsx index cc6c0b3c..f511eae3 100644 --- a/src/components/routes/route-card.tsx +++ b/src/components/routes/route-card.tsx @@ -17,12 +17,7 @@ interface RouteCardProps { isMyUnit?: boolean; } -export const RouteCard: React.FC = ({ - route, - isActive = false, - unitName, - isMyUnit = false, -}) => { +export const RouteCard: React.FC = ({ route, isActive = false, unitName, isMyUnit = false }) => { const { t } = useTranslation(); const formatDistance = (meters: number) => { @@ -37,17 +32,12 @@ export const RouteCard: React.FC = ({ return `${minutes}m`; }; - const bgClass = isMyUnit - ? 'mb-2 rounded-xl bg-blue-50 p-4 shadow-sm dark:bg-blue-900/20' - : 'mb-2 rounded-xl bg-white p-4 shadow-sm dark:bg-gray-800'; + const bgClass = isMyUnit ? 'mb-2 rounded-xl bg-blue-50 p-4 shadow-sm dark:bg-blue-900/20' : 'mb-2 rounded-xl bg-white p-4 shadow-sm dark:bg-gray-800'; - const borderColor = isMyUnit ? '#3b82f6' : (route.RouteColor || '#94a3b8'); + const borderColor = isMyUnit ? '#3b82f6' : route.RouteColor || '#94a3b8'; return ( - + @@ -64,34 +54,26 @@ export const RouteCard: React.FC = ({ {/* Unit assignment chip */} - - {unitName || t('routes.unassigned')} - + {unitName || t('routes.unassigned')} - - {t('routes.stop_count', { count: route.StopsCount ?? 0 })} - + {t('routes.stop_count', { count: route.StopsCount ?? 0 })} {(route.EstimatedDistanceMeters ?? 0) > 0 ? ( - - {formatDistance(route.EstimatedDistanceMeters!)} - + {formatDistance(route.EstimatedDistanceMeters!)} ) : null} {(route.EstimatedDurationSeconds ?? 0) > 0 ? ( - - {formatDuration(route.EstimatedDurationSeconds!)} - + {formatDuration(route.EstimatedDurationSeconds!)} ) : null} diff --git a/src/components/routes/stop-card.tsx b/src/components/routes/stop-card.tsx index 43787273..e5c72594 100644 --- a/src/components/routes/stop-card.tsx +++ b/src/components/routes/stop-card.tsx @@ -32,9 +32,7 @@ export const StopCard: React.FC = ({ stop, isCurrent = false, onC const config = statusConfig[stop.Status as RouteStopStatus] || statusConfig[RouteStopStatus.Pending]; return ( - + @@ -68,7 +66,9 @@ export const StopCard: React.FC = ({ stop, isCurrent = false, onC {stop.PlannedArrival ? ( - {t('routes.eta')}: {stop.PlannedArrival} + + {t('routes.eta')}: {stop.PlannedArrival} + ) : null} diff --git a/src/lib/logging/index.tsx b/src/lib/logging/index.tsx index 7947061c..60475690 100644 --- a/src/lib/logging/index.tsx +++ b/src/lib/logging/index.tsx @@ -2,7 +2,33 @@ import * as Sentry from '@sentry/react-native'; import { Platform } from 'react-native'; import { consoleTransport, logger as rnLogger } from 'react-native-logs'; -import type { LogEntry, Logger, LogLevel } from './types'; +import type { LogContext, LogEntry, Logger, LogLevel } from './types'; + +const SENSITIVE_KEYS = new Set(['token', 'password', 'passwd', 'secret', 'apikey', 'authorization', 'auth', 'cred', 'credentials', 'email', 'ssn']); + +const isSensitiveKey = (key: string): boolean => SENSITIVE_KEYS.has(key.toLowerCase()); + +const sanitizeValue = (key: string, value: unknown, depth: number): unknown => { + if (isSensitiveKey(key)) return '[REDACTED]'; + if (depth > 0 && value !== null && typeof value === 'object' && !Array.isArray(value)) { + return sanitizeObject(value as Record, depth - 1); + } + if (typeof value === 'function' || typeof value === 'symbol') return String(value); + return value; +}; + +const sanitizeObject = (obj: Record, depth: number): Record => { + const result: Record = {}; + for (const key of Object.keys(obj)) { + result[key] = sanitizeValue(key, obj[key], depth); + } + return result; +}; + +export const sanitizeLogContext = (context: LogContext | undefined): LogContext => { + if (!context) return {}; + return sanitizeObject(context as Record, 2); +}; // On web, async: true wraps every log call in setTimeout which — combined with // Sentry's setTimeout instrumentation — creates unbounded memory growth. @@ -83,11 +109,12 @@ class LogService { public error(entry: LogEntry): void { this.log('error', entry); if (!isJest) { - const err = entry.context?.error; + const sanitized = sanitizeLogContext(entry.context); + const err = sanitized.error; if (err instanceof Error) { - Sentry.captureException(err, { extra: { message: entry.message, ...entry.context } }); + Sentry.captureException(err, { extra: { message: entry.message, ...sanitized } }); } else { - Sentry.captureMessage(entry.message, { level: 'error', extra: entry.context }); + Sentry.captureMessage(entry.message, { level: 'error', extra: sanitized }); } } } diff --git a/src/models/v4/mapping/mappingResults.ts b/src/models/v4/mapping/mappingResults.ts index c67841f6..04d3fea5 100644 --- a/src/models/v4/mapping/mappingResults.ts +++ b/src/models/v4/mapping/mappingResults.ts @@ -2,7 +2,7 @@ import { type FeatureCollection } from 'geojson'; import { BaseV4Request } from '../baseV4Request'; import { type CustomMapResultData } from './customMapResultData'; -import { type IndoorMapResultData, type IndoorMapFloorResultData } from './indoorMapResultData'; +import { type IndoorMapFloorResultData, type IndoorMapResultData } from './indoorMapResultData'; export class GetIndoorMapsResult extends BaseV4Request { public Data: IndoorMapResultData[] = []; diff --git a/src/models/v4/routes/routeInstanceResultData.ts b/src/models/v4/routes/routeInstanceResultData.ts index b3834988..99a475f9 100644 --- a/src/models/v4/routes/routeInstanceResultData.ts +++ b/src/models/v4/routes/routeInstanceResultData.ts @@ -1,3 +1,5 @@ +import type { RouteInstanceStopResultData } from './routeInstanceStopResultData'; + export interface RouteInstanceResultData { RouteInstanceId: string; RoutePlanId: string; @@ -10,6 +12,9 @@ export interface RouteInstanceResultData { TotalDurationSeconds?: number | null; ActualStartOn?: string | null; ActualEndOn?: string | null; + StartedOn?: string | null; + CompletedOn?: string | null; + CancelledOn?: string | null; AddedOn?: string; Notes?: string | null; // Fields that may come from progress/other endpoints @@ -27,7 +32,7 @@ export interface ActiveRouteForUnitData { CompletedStops: number; PendingStops: number; SkippedStops: number; - Stops: import('./routeInstanceStopResultData').RouteInstanceStopResultData[]; + Stops: RouteInstanceStopResultData[]; } export enum RouteInstanceStatus { diff --git a/src/stores/maps/store.ts b/src/stores/maps/store.ts index 2c5f4d0a..65068bc5 100644 --- a/src/stores/maps/store.ts +++ b/src/stores/maps/store.ts @@ -1,17 +1,7 @@ import { type FeatureCollection } from 'geojson'; import { create } from 'zustand'; -import { - getAllActiveLayers, - getCustomMap, - getCustomMaps, - getIndoorMap, - getIndoorMapFloor, - getIndoorMapZonesGeoJSON, - getIndoorMaps, - getMapLayerGeoJSON, - searchAllMapFeatures, -} from '@/api/mapping/mapping'; +import { getAllActiveLayers, getCustomMap, getCustomMaps, getIndoorMap, getIndoorMapFloor, getIndoorMaps, getIndoorMapZonesGeoJSON, getMapLayerGeoJSON, searchAllMapFeatures } from '@/api/mapping/mapping'; import { type CustomMapResultData } from '@/models/v4/mapping/customMapResultData'; import { type IndoorMapFloorResultData, type IndoorMapResultData } from '@/models/v4/mapping/indoorMapResultData'; import { type ActiveLayerSummary, type UnifiedSearchResultItem } from '@/models/v4/mapping/mappingResults'; @@ -66,6 +56,12 @@ interface MapsState { clearCurrentMap: () => void; } +// Module-level request tokens. Incremented before each fetch and on clearCurrentMap +// so in-flight responses from a previous navigation can detect they are stale. +let _indoorMapToken = 0; +let _floorToken = 0; +let _customMapToken = 0; + export const useMapsStore = create((set, get) => ({ // Initial state activeLayers: [], @@ -147,9 +143,11 @@ export const useMapsStore = create((set, get) => ({ }, fetchIndoorMap: async (mapId: string) => { + const token = ++_indoorMapToken; set({ isLoading: true, error: null }); try { const response = await getIndoorMap(mapId); + if (token !== _indoorMapToken) return; const map = response.Data; set({ currentIndoorMap: map, isLoading: false }); @@ -159,20 +157,25 @@ export const useMapsStore = create((set, get) => ({ await get().setCurrentFloor(sortedFloors[0].IndoorMapFloorId); } } catch (error) { + if (token !== _indoorMapToken) return; set({ error: 'Failed to fetch indoor map', isLoading: false }); } }, setCurrentFloor: async (floorId: string) => { - set({ currentFloorId: floorId, isLoadingGeoJSON: true }); + const token = ++_floorToken; + set({ isLoadingGeoJSON: true }); try { const [floorResponse, zonesResponse] = await Promise.all([getIndoorMapFloor(floorId), getIndoorMapZonesGeoJSON(floorId)]); + if (token !== _floorToken) return; set({ + currentFloorId: floorId, currentFloor: floorResponse.Data, currentZonesGeoJSON: zonesResponse.Data, isLoadingGeoJSON: false, }); } catch (error) { + if (token !== _floorToken) return; set({ error: 'Failed to load floor data', isLoadingGeoJSON: false }); } }, @@ -192,11 +195,14 @@ export const useMapsStore = create((set, get) => ({ }, fetchCustomMap: async (mapId: string) => { + const token = ++_customMapToken; set({ isLoading: true, error: null }); try { const response = await getCustomMap(mapId); + if (token !== _customMapToken) return; set({ currentCustomMap: response.Data, isLoading: false }); } catch (error) { + if (token !== _customMapToken) return; set({ error: 'Failed to fetch custom map', isLoading: false }); } }, @@ -220,12 +226,17 @@ export const useMapsStore = create((set, get) => ({ clearSearch: () => set({ searchResults: [], searchQuery: '' }), // --- State Management --- - clearCurrentMap: () => + clearCurrentMap: () => { + // Invalidate any in-flight fetches so their results are discarded on arrival. + ++_indoorMapToken; + ++_floorToken; + ++_customMapToken; set({ currentIndoorMap: null, currentFloorId: null, currentFloor: null, currentZonesGeoJSON: null, currentCustomMap: null, - }), + }); + }, })); diff --git a/src/stores/routes/store.ts b/src/stores/routes/store.ts index 8f7025bf..c582900f 100644 --- a/src/stores/routes/store.ts +++ b/src/stores/routes/store.ts @@ -146,6 +146,7 @@ export const useRoutesStore = create((set, get) => ({ set({ activeInstance: response.Data, isLoading: false, isTracking: true }); } catch (error) { set({ error: 'Failed to start route', isLoading: false }); + throw error; } }, @@ -163,6 +164,7 @@ export const useRoutesStore = create((set, get) => ({ }); } catch (error) { set({ error: 'Failed to end route', isLoading: false }); + throw error; } }, @@ -204,6 +206,7 @@ export const useRoutesStore = create((set, get) => ({ }); } catch (error) { set({ error: 'Failed to cancel route', isLoading: false }); + throw error; } }, @@ -216,12 +219,27 @@ export const useRoutesStore = create((set, get) => ({ set({ activeInstance: data.Instance, instanceStops: Array.isArray(data.Stops) ? data.Stops : [], + directions: null, + deviations: [], + isTracking: true, }); } else { - set({ activeInstance: null }); + set({ + activeInstance: null, + instanceStops: [], + directions: null, + deviations: [], + isTracking: false, + }); } } catch (error) { - set({ activeInstance: null }); + set({ + activeInstance: null, + instanceStops: [], + directions: null, + deviations: [], + isTracking: false, + }); } }, @@ -268,9 +286,7 @@ export const useRoutesStore = create((set, get) => ({ }); const { instanceStops } = get(); set({ - instanceStops: instanceStops.map((s) => - s.RouteInstanceStopId === stopId ? { ...s, Status: 1, CheckedInOn: new Date().toISOString() } : s - ), + instanceStops: instanceStops.map((s) => (s.RouteInstanceStopId === stopId ? { ...s, Status: 1, CheckedInOn: new Date().toISOString() } : s)), }); } catch (error) { set({ error: 'Failed to check in at stop' }); @@ -282,9 +298,7 @@ export const useRoutesStore = create((set, get) => ({ await checkOutFromStop({ RouteInstanceStopId: stopId, UnitId: unitId }); const { instanceStops } = get(); set({ - instanceStops: instanceStops.map((s) => - s.RouteInstanceStopId === stopId ? { ...s, Status: 2, CheckedOutOn: new Date().toISOString() } : s - ), + instanceStops: instanceStops.map((s) => (s.RouteInstanceStopId === stopId ? { ...s, Status: 2, CheckedOutOn: new Date().toISOString() } : s)), }); } catch (error) { set({ error: 'Failed to check out from stop' }); @@ -296,9 +310,7 @@ export const useRoutesStore = create((set, get) => ({ await skipStop({ RouteInstanceStopId: stopId, Reason: reason }); const { instanceStops } = get(); set({ - instanceStops: instanceStops.map((s) => - s.RouteInstanceStopId === stopId ? { ...s, Status: 3, SkippedOn: new Date().toISOString() } : s - ), + instanceStops: instanceStops.map((s) => (s.RouteInstanceStopId === stopId ? { ...s, Status: 3, SkippedOn: new Date().toISOString() } : s)), }); } catch (error) { set({ error: 'Failed to skip stop' }); diff --git a/src/translations/ar.json b/src/translations/ar.json index 741fff80..3d2e3606 100644 --- a/src/translations/ar.json +++ b/src/translations/ar.json @@ -503,6 +503,35 @@ "set_as_current_call": "تعيين كمكالمة حالية", "view_call_details": "عرض تفاصيل المكالمة" }, + "maps": { + "active_layers": "الطبقات النشطة", + "all": "الكل", + "custom": "مخصص", + "custom_maps": "خرائط مخصصة", + "custom_region": "منطقة مخصصة", + "dispatch_to_zone": "إرسال إلى هذه المنطقة", + "error_loading": "فشل في تحميل بيانات الخريطة", + "event": "حدث", + "feature": "ميزة", + "floor": "طابق", + "floor_count": "{{count}} طوابق", + "general": "عام", + "indoor": "داخلي", + "indoor_maps": "خرائط داخلية", + "indoor_zone": "منطقة داخلية", + "layer_toggles": "التحكم بالطبقات", + "layers_on": "{{active}} / {{total}} نشط", + "loading": "جارٍ تحميل الخرائط...", + "no_maps": "لا توجد خرائط", + "no_maps_description": "لا توجد خرائط متاحة لقسمك.", + "no_results": "لم يتم العثور على نتائج", + "outdoor": "خارجي", + "search": "البحث في الخرائط...", + "search_maps": "البحث في الخرائط", + "search_placeholder": "البحث عن مواقع، مناطق...", + "title": "الخرائط", + "zone_details": "تفاصيل المنطقة" + }, "notes": { "actions": { "add": "إضافة ملاحظة", @@ -562,6 +591,104 @@ "tap_to_manage": "انقر لإدارة الأدوار", "unassigned": "غير معين" }, + "routes": { + "acknowledge": "تأكيد", + "active": "نشطة", + "active_route": "المسار النشط", + "address": "العنوان", + "all_routes": "جميع المسارات", + "assigned_other_unit": "هذا المسار مخصص لوحدة أخرى ولا يمكن بدؤه من هذه الوحدة", + "cancel_route": "إلغاء المسار", + "cancel_route_confirm": "هل أنت متأكد من إلغاء هذا المسار؟", + "check_in": "تسجيل الوصول", + "check_out": "تسجيل المغادرة", + "completed": "مكتمل", + "contact": "جهة الاتصال", + "contact_details": "تفاصيل جهة الاتصال", + "current_step": "الخطوة الحالية", + "description": "الوصف", + "destination": "وجهة المسار", + "deviation": "انحراف المسار", + "deviations": "الانحرافات", + "directions": "الاتجاهات", + "distance": "المسافة", + "duration": "المدة", + "dwell_time": "وقت التوقف", + "end_route": "إنهاء المسار", + "end_route_confirm": "هل أنت متأكد من إنهاء هذا المسار؟", + "estimated_distance": "المسافة المقدرة", + "estimated_duration": "المدة المقدرة", + "eta": "الوقت المتوقع للوصول", + "eta_to_next": "الوقت المتوقع للمحطة التالية", + "geofence_radius": "نطاق السياج الجغرافي", + "history": "سجل المسار", + "in_progress": "قيد التنفيذ", + "instance_detail": "تفاصيل المسار", + "loading": "جارٍ تحميل المسارات...", + "loading_directions": "جارٍ تحميل الاتجاهات...", + "loading_stops": "جارٍ تحميل المحطات...", + "min": "دق", + "my_unit": "وحدتي", + "next_step": "الخطوة التالية", + "no_contact": "لا تتوفر معلومات الاتصال", + "no_deviations": "لم يتم تسجيل أي انحرافات", + "no_directions": "لا تتوفر اتجاهات", + "no_history": "لا يوجد سجل مسارات", + "no_history_description": "ستظهر المسارات المكتملة هنا.", + "no_routes": "لا توجد مسارات", + "no_routes_description": "لا توجد خطط مسارات متاحة لوحدتك.", + "no_routes_description_all": "لا توجد خطط مسار متاحة.", + "no_stops": "لا توجد محطات", + "notes": "ملاحظات", + "notes_placeholder": "أدخل ملاحظات لهذه المحطة...", + "open_in_maps": "فتح في الخرائط", + "pause_route": "إيقاف المسار مؤقتاً", + "paused": "متوقف مؤقتاً", + "pending": "معلق", + "planned_arrival": "الوصول المخطط", + "planned_departure": "المغادرة المخططة", + "priority": "الأولوية", + "priority_critical": "حرج", + "priority_high": "عالٍ", + "priority_low": "منخفض", + "priority_medium": "متوسط", + "priority_normal": "عادي", + "progress": "{{percent}}% مكتمل", + "remaining_steps": "الخطوات المتبقية", + "resume_route": "استئناف المسار", + "route_summary": "ملخص المسار", + "schedule": "جدول", + "search": "البحث في المسارات...", + "select_unit": "اختر الوحدة", + "skip": "تخطي", + "skip_reason": "سبب التخطي", + "skip_reason_placeholder": "أدخل سبب تخطي هذه المحطة", + "skipped": "تم التخطي", + "skipped_by_driver": "تم التخطي من قبل السائق", + "start_route": "بدء المسار", + "step_of": "الخطوة {{current}} من {{total}}", + "stop_contact": "جهة اتصال المحطة", + "stop_count": "{{count}} محطات", + "stop_detail": "تفاصيل المحطة", + "stop_type_dropoff": "تسليم", + "stop_type_inspection": "تفتيش", + "stop_type_pickup": "استلام", + "stop_type_service": "خدمة", + "stop_type_standard": "قياسي", + "stops": "المحطات", + "stops_completed": "المحطات المكتملة", + "summary_stats": "الملخص", + "title": "المسارات", + "total": "الإجمالي", + "total_distance": "المسافة الإجمالية", + "total_duration": "المدة الإجمالية", + "type": "النوع", + "unassigned": "غير معين", + "unit": "الوحدة", + "unit_required": "يجب اختيار وحدة لبدء المسار", + "view_contact": "عرض جهة الاتصال", + "view_route": "عرض المسار" + }, "settings": { "about": "حول التطبيق", "account": "الحساب", @@ -570,12 +697,6 @@ "app_info": "معلومات التطبيق", "app_name": "اسم التطبيق", "arabic": "عربي", - "swedish": "السويدية", - "german": "الألمانية", - "french": "الفرنسية", - "italian": "الإيطالية", - "polish": "البولندية", - "ukrainian": "الأوكرانية", "audio_device_selection": { "bluetooth_device": "جهاز بلوتوث", "current_selection": "الاختيار الحالي", @@ -602,10 +723,13 @@ "enter_server_url": "أدخل عنوان URL لواجهة برمجة تطبيقات Resgrid (مثال: https://api.resgrid.com)", "enter_username": "أدخل اسم المستخدم الخاص بك", "environment": "البيئة", + "french": "الفرنسية", "general": "عام", "generale": "عام", + "german": "الألمانية", "github": "جيثب", "help_center": "مركز المساعدة", + "italian": "الإيطالية", "keep_alive": "إبقاء الجهاز نشطاً", "keep_alive_warning": "تحذير: تفعيل إبقاء الجهاز نشطاً سيمنع جهازك من الدخول في وضع السكون وقد يزيد بشكل كبير من استنزاف البطارية.", "keep_screen_on": "إبقاء الشاشة مضاءة", @@ -622,6 +746,7 @@ "notifications_description": "تفعيل الإشعارات لتلقي التنبيهات والتحديثات", "notifications_enable": "تفعيل الإشعارات", "password": "كلمة المرور", + "polish": "البولندية", "preferences": "التفضيلات", "privacy": "سياسة الخصوصية", "privacy_policy": "سياسة الخصوصية", @@ -636,6 +761,7 @@ "status_page": "حالة النظام", "support": "الدعم", "support_us": "ادعمنا", + "swedish": "السويدية", "terms": "شروط الخدمة", "theme": { "dark": "مظلم", @@ -644,6 +770,7 @@ "title": "المظهر" }, "title": "الإعدادات", + "ukrainian": "الأوكرانية", "unit_selected_successfully": "تم اختيار {{unitName}} بنجاح", "unit_selection": "اختيار الوحدة", "unit_selection_failed": "فشل في اختيار الوحدة. يرجى المحاولة مرة أخرى.", @@ -676,121 +803,6 @@ "stations_tab": "المحطات", "status_saved_successfully": "تم حفظ الحالة بنجاح!" }, - "maps": { - "active_layers": "الطبقات النشطة", - "all": "الكل", - "custom": "مخصص", - "custom_maps": "خرائط مخصصة", - "dispatch_to_zone": "إرسال إلى هذه المنطقة", - "error_loading": "فشل في تحميل بيانات الخريطة", - "floor": "طابق", - "indoor": "داخلي", - "indoor_maps": "خرائط داخلية", - "layer_toggles": "التحكم بالطبقات", - "loading": "جارٍ تحميل الخرائط...", - "no_maps": "لا توجد خرائط", - "no_maps_description": "لا توجد خرائط متاحة لقسمك.", - "no_results": "لم يتم العثور على نتائج", - "search": "البحث في الخرائط...", - "search_maps": "البحث في الخرائط", - "search_placeholder": "البحث عن مواقع، مناطق...", - "title": "الخرائط", - "zone_details": "تفاصيل المنطقة", - "outdoor": "خارجي", - "event": "حدث", - "general": "عام", - "feature": "ميزة", - "indoor_zone": "منطقة داخلية", - "custom_region": "منطقة مخصصة", - "floor_count": "{{count}} طوابق", - "layers_on": "{{active}} / {{total}} نشط" - }, - "routes": { - "acknowledge": "تأكيد", - "active": "نشطة", - "active_route": "المسار النشط", - "address": "العنوان", - "cancel_route": "إلغاء المسار", - "cancel_route_confirm": "هل أنت متأكد من إلغاء هذا المسار؟", - "check_in": "تسجيل الوصول", - "check_out": "تسجيل المغادرة", - "completed": "مكتمل", - "contact": "جهة الاتصال", - "contact_details": "تفاصيل جهة الاتصال", - "current_step": "الخطوة الحالية", - "description": "الوصف", - "deviation": "انحراف المسار", - "deviations": "الانحرافات", - "directions": "الاتجاهات", - "distance": "المسافة", - "duration": "المدة", - "dwell_time": "وقت التوقف", - "end_route": "إنهاء المسار", - "end_route_confirm": "هل أنت متأكد من إنهاء هذا المسار؟", - "estimated_distance": "المسافة المقدرة", - "estimated_duration": "المدة المقدرة", - "eta_to_next": "الوقت المتوقع للمحطة التالية", - "geofence_radius": "نطاق السياج الجغرافي", - "history": "سجل المسار", - "in_progress": "قيد التنفيذ", - "instance_detail": "تفاصيل المسار", - "loading": "جارٍ تحميل المسارات...", - "loading_directions": "جارٍ تحميل الاتجاهات...", - "loading_stops": "جارٍ تحميل المحطات...", - "next_step": "الخطوة التالية", - "no_contact": "لا تتوفر معلومات الاتصال", - "no_deviations": "لم يتم تسجيل أي انحرافات", - "no_directions": "لا تتوفر اتجاهات", - "no_history": "لا يوجد سجل مسارات", - "no_history_description": "ستظهر المسارات المكتملة هنا.", - "no_routes": "لا توجد مسارات", - "no_routes_description": "لا توجد خطط مسارات متاحة لوحدتك.", - "no_stops": "لا توجد محطات", - "notes": "ملاحظات", - "notes_placeholder": "أدخل ملاحظات لهذه المحطة...", - "open_in_maps": "فتح في الخرائط", - "pause_route": "إيقاف المسار مؤقتاً", - "paused": "متوقف مؤقتاً", - "pending": "معلق", - "planned_arrival": "الوصول المخطط", - "planned_departure": "المغادرة المخططة", - "priority": "الأولوية", - "progress": "{{percent}}% مكتمل", - "remaining_steps": "الخطوات المتبقية", - "resume_route": "استئناف المسار", - "route_summary": "ملخص المسار", - "search": "البحث في المسارات...", - "skip": "تخطي", - "skip_reason": "سبب التخطي", - "skip_reason_placeholder": "أدخل سبب تخطي هذه المحطة", - "skipped": "تم التخطي", - "start_route": "بدء المسار", - "stop_contact": "جهة اتصال المحطة", - "stop_count": "{{count}} محطات", - "stop_detail": "تفاصيل المحطة", - "stops": "المحطات", - "stops_completed": "المحطات المكتملة", - "summary_stats": "الملخص", - "title": "المسارات", - "total_distance": "المسافة الإجمالية", - "total_duration": "المدة الإجمالية", - "type": "النوع", - "unit": "الوحدة", - "destination": "وجهة المسار", - "total": "الإجمالي", - "step_of": "الخطوة {{current}} من {{total}}", - "eta": "الوقت المتوقع للوصول", - "skipped_by_driver": "تم التخطي من قبل السائق", - "my_unit": "وحدتي", - "all_routes": "جميع المسارات", - "no_routes_description_all": "لا توجد خطط مسار متاحة.", - "select_unit": "اختر الوحدة", - "unit_required": "يجب اختيار وحدة لبدء المسار", - "view_route": "عرض المسار", - "view_contact": "عرض جهة الاتصال", - "assigned_other_unit": "هذا المسار مخصص لوحدة أخرى ولا يمكن بدؤه من هذه الوحدة", - "unassigned": "غير معين" - }, "tabs": { "calls": "المكالمات", "contacts": "جهات الاتصال", @@ -801,4 +813,4 @@ "settings": "الإعدادات" }, "welcome": "مرحبًا بك في موقع تطبيق obytes" -} \ No newline at end of file +} diff --git a/src/translations/de.json b/src/translations/de.json index 65b3a81a..43ed1c36 100644 --- a/src/translations/de.json +++ b/src/translations/de.json @@ -503,6 +503,35 @@ "set_as_current_call": "Als aktuellen Anruf setzen", "view_call_details": "Anrufdetails anzeigen" }, + "maps": { + "active_layers": "Aktive Ebenen", + "all": "Alle", + "custom": "Benutzerdefiniert", + "custom_maps": "Benutzerdefinierte Karten", + "custom_region": "Benutzerdefinierte Region", + "dispatch_to_zone": "In diese Zone disponieren", + "error_loading": "Kartendaten konnten nicht geladen werden", + "event": "Ereignis", + "feature": "Merkmal", + "floor": "Etage", + "floor_count": "{{count}} Etagen", + "general": "Allgemein", + "indoor": "Innenbereich", + "indoor_maps": "Innenbereichskarten", + "indoor_zone": "Innenbereichszone", + "layer_toggles": "Ebenenumschalter", + "layers_on": "{{active}} / {{total}} aktiv", + "loading": "Karten werden geladen...", + "no_maps": "Keine Karten", + "no_maps_description": "Für Ihre Abteilung sind keine Karten verfügbar.", + "no_results": "Keine Ergebnisse gefunden", + "outdoor": "Außenbereich", + "search": "Karten suchen...", + "search_maps": "Karten suchen", + "search_placeholder": "Standorte, Zonen, Regionen suchen...", + "title": "Karten", + "zone_details": "Zonendetails" + }, "notes": { "actions": { "add": "Notiz hinzufügen", @@ -562,6 +591,104 @@ "tap_to_manage": "Tippen, um Rollen zu verwalten", "unassigned": "Nicht zugewiesen" }, + "routes": { + "acknowledge": "Bestätigen", + "active": "Aktiv", + "active_route": "Aktive Route", + "address": "Adresse", + "all_routes": "Alle Routen", + "assigned_other_unit": "Diese Route ist einer anderen Einheit zugewiesen und kann nicht von dieser Einheit gestartet werden", + "cancel_route": "Route abbrechen", + "cancel_route_confirm": "Sind Sie sicher, dass Sie diese Route abbrechen möchten?", + "check_in": "Einchecken", + "check_out": "Auschecken", + "completed": "Abgeschlossen", + "contact": "Kontakt", + "contact_details": "Kontaktdetails", + "current_step": "Aktueller Schritt", + "description": "Beschreibung", + "destination": "Routenziel", + "deviation": "Routenabweichung", + "deviations": "Abweichungen", + "directions": "Wegbeschreibung", + "distance": "Entfernung", + "duration": "Dauer", + "dwell_time": "Aufenthaltszeit", + "end_route": "Route beenden", + "end_route_confirm": "Sind Sie sicher, dass Sie diese Route beenden möchten?", + "estimated_distance": "Geschätzte Entfernung", + "estimated_duration": "Geschätzte Dauer", + "eta": "ETA", + "eta_to_next": "ETA bis zum nächsten Stopp", + "geofence_radius": "Geofence-Radius", + "history": "Routenverlauf", + "in_progress": "In Bearbeitung", + "instance_detail": "Routeninstanz", + "loading": "Routen werden geladen...", + "loading_directions": "Wegbeschreibung wird geladen...", + "loading_stops": "Stopps werden geladen...", + "min": "Min", + "my_unit": "Meine Einheit", + "next_step": "Nächster Schritt", + "no_contact": "Keine Kontaktinformationen verfügbar", + "no_deviations": "Keine Abweichungen aufgezeichnet", + "no_directions": "Keine Wegbeschreibung verfügbar", + "no_history": "Kein Routenverlauf verfügbar", + "no_history_description": "Abgeschlossene Routen werden hier angezeigt.", + "no_routes": "Keine Routen", + "no_routes_description": "Für Ihre Einheit sind keine Routenpläne verfügbar.", + "no_routes_description_all": "Keine Routenpläne verfügbar.", + "no_stops": "Keine Stopps verfügbar", + "notes": "Notizen", + "notes_placeholder": "Notizen für diesen Stopp eingeben...", + "open_in_maps": "In Karten öffnen", + "pause_route": "Route pausieren", + "paused": "Pausiert", + "pending": "Ausstehend", + "planned_arrival": "Geplante Ankunft", + "planned_departure": "Geplante Abfahrt", + "priority": "Priorität", + "priority_critical": "Kritisch", + "priority_high": "Hoch", + "priority_low": "Niedrig", + "priority_medium": "Mittel", + "priority_normal": "Normal", + "progress": "{{percent}}% abgeschlossen", + "remaining_steps": "Verbleibende Schritte", + "resume_route": "Route fortsetzen", + "route_summary": "Routenzusammenfassung", + "schedule": "Zeitplan", + "search": "Routen suchen...", + "select_unit": "Einheit auswählen", + "skip": "Überspringen", + "skip_reason": "Grund zum Überspringen", + "skip_reason_placeholder": "Grund für das Überspringen dieses Stopps eingeben", + "skipped": "Übersprungen", + "skipped_by_driver": "Vom Fahrer übersprungen", + "start_route": "Route starten", + "step_of": "Schritt {{current}} von {{total}}", + "stop_contact": "Stoppkontakt", + "stop_count": "{{count}} Stopps", + "stop_detail": "Stoppdetails", + "stop_type_dropoff": "Lieferung", + "stop_type_inspection": "Inspektion", + "stop_type_pickup": "Abholung", + "stop_type_service": "Service", + "stop_type_standard": "Standard", + "stops": "Stopps", + "stops_completed": "Stopps abgeschlossen", + "summary_stats": "Zusammenfassung", + "title": "Routen", + "total": "Gesamt", + "total_distance": "Gesamtentfernung", + "total_duration": "Gesamtdauer", + "type": "Typ", + "unassigned": "Nicht zugewiesen", + "unit": "Einheit", + "unit_required": "Es muss eine Einheit ausgewählt werden, um die Route zu starten", + "view_contact": "Kontakt anzeigen", + "view_route": "Route anzeigen" + }, "settings": { "about": "Über", "account": "Konto", @@ -570,12 +697,6 @@ "app_info": "App-Info", "app_name": "App-Name", "arabic": "Arabisch", - "swedish": "Schwedisch", - "german": "Deutsch", - "french": "Französisch", - "italian": "Italienisch", - "polish": "Polnisch", - "ukrainian": "Ukrainisch", "audio_device_selection": { "bluetooth_device": "Bluetooth-Gerät", "current_selection": "Aktuelle Auswahl", @@ -602,10 +723,13 @@ "enter_server_url": "Resgrid API URL eingeben (z. B. https://api.resgrid.com)", "enter_username": "Benutzernamen eingeben", "environment": "Umgebung", + "french": "Französisch", "general": "Allgemein", "generale": "Allgemein", + "german": "Deutsch", "github": "Github", "help_center": "Hilfecenter", + "italian": "Italienisch", "keep_alive": "Keep Alive", "keep_alive_warning": "Warnung: Die Aktivierung von Keep Alive verhindert, dass das Gerät in den Ruhezustand wechselt, und kann den Akkuverbrauch erheblich erhöhen.", "keep_screen_on": "Bildschirm eingeschaltet lassen", @@ -622,6 +746,7 @@ "notifications_description": "Benachrichtigungen aktivieren, um Warnungen und Updates zu erhalten", "notifications_enable": "Benachrichtigungen aktivieren", "password": "Passwort", + "polish": "Polnisch", "preferences": "Einstellungen", "privacy": "Datenschutzrichtlinie", "privacy_policy": "Datenschutzrichtlinie", @@ -636,6 +761,7 @@ "status_page": "Systemstatus", "support": "Support", "support_us": "Unterstützen Sie uns", + "swedish": "Schwedisch", "terms": "Nutzungsbedingungen", "theme": { "dark": "Dunkel", @@ -644,6 +770,7 @@ "title": "Design" }, "title": "Einstellungen", + "ukrainian": "Ukrainisch", "unit_selected_successfully": "{{unitName}} erfolgreich ausgewählt", "unit_selection": "Einheitenauswahl", "unit_selection_failed": "Einheit konnte nicht ausgewählt werden. Bitte erneut versuchen.", @@ -676,121 +803,6 @@ "stations_tab": "Stationen", "status_saved_successfully": "Status erfolgreich gespeichert!" }, - "maps": { - "active_layers": "Aktive Ebenen", - "all": "Alle", - "custom": "Benutzerdefiniert", - "custom_maps": "Benutzerdefinierte Karten", - "dispatch_to_zone": "In diese Zone disponieren", - "error_loading": "Kartendaten konnten nicht geladen werden", - "floor": "Etage", - "indoor": "Innenbereich", - "indoor_maps": "Innenbereichskarten", - "layer_toggles": "Ebenenumschalter", - "loading": "Karten werden geladen...", - "no_maps": "Keine Karten", - "no_maps_description": "Für Ihre Abteilung sind keine Karten verfügbar.", - "no_results": "Keine Ergebnisse gefunden", - "search": "Karten suchen...", - "search_maps": "Karten suchen", - "search_placeholder": "Standorte, Zonen, Regionen suchen...", - "title": "Karten", - "zone_details": "Zonendetails", - "outdoor": "Außenbereich", - "event": "Ereignis", - "general": "Allgemein", - "feature": "Merkmal", - "indoor_zone": "Innenbereichszone", - "custom_region": "Benutzerdefinierte Region", - "floor_count": "{{count}} Etagen", - "layers_on": "{{active}} / {{total}} aktiv" - }, - "routes": { - "acknowledge": "Bestätigen", - "active": "Aktiv", - "active_route": "Aktive Route", - "address": "Adresse", - "cancel_route": "Route abbrechen", - "cancel_route_confirm": "Sind Sie sicher, dass Sie diese Route abbrechen möchten?", - "check_in": "Einchecken", - "check_out": "Auschecken", - "completed": "Abgeschlossen", - "contact": "Kontakt", - "contact_details": "Kontaktdetails", - "current_step": "Aktueller Schritt", - "description": "Beschreibung", - "deviation": "Routenabweichung", - "deviations": "Abweichungen", - "directions": "Wegbeschreibung", - "distance": "Entfernung", - "duration": "Dauer", - "dwell_time": "Aufenthaltszeit", - "end_route": "Route beenden", - "end_route_confirm": "Sind Sie sicher, dass Sie diese Route beenden möchten?", - "estimated_distance": "Geschätzte Entfernung", - "estimated_duration": "Geschätzte Dauer", - "eta_to_next": "ETA bis zum nächsten Stopp", - "geofence_radius": "Geofence-Radius", - "history": "Routenverlauf", - "in_progress": "In Bearbeitung", - "instance_detail": "Routeninstanz", - "loading": "Routen werden geladen...", - "loading_directions": "Wegbeschreibung wird geladen...", - "loading_stops": "Stopps werden geladen...", - "next_step": "Nächster Schritt", - "no_contact": "Keine Kontaktinformationen verfügbar", - "no_deviations": "Keine Abweichungen aufgezeichnet", - "no_directions": "Keine Wegbeschreibung verfügbar", - "no_history": "Kein Routenverlauf verfügbar", - "no_history_description": "Abgeschlossene Routen werden hier angezeigt.", - "no_routes": "Keine Routen", - "no_routes_description": "Für Ihre Einheit sind keine Routenpläne verfügbar.", - "no_stops": "Keine Stopps verfügbar", - "notes": "Notizen", - "notes_placeholder": "Notizen für diesen Stopp eingeben...", - "open_in_maps": "In Karten öffnen", - "pause_route": "Route pausieren", - "paused": "Pausiert", - "pending": "Ausstehend", - "planned_arrival": "Geplante Ankunft", - "planned_departure": "Geplante Abfahrt", - "priority": "Priorität", - "progress": "{{percent}}% abgeschlossen", - "remaining_steps": "Verbleibende Schritte", - "resume_route": "Route fortsetzen", - "route_summary": "Routenzusammenfassung", - "search": "Routen suchen...", - "skip": "Überspringen", - "skip_reason": "Grund zum Überspringen", - "skip_reason_placeholder": "Grund für das Überspringen dieses Stopps eingeben", - "skipped": "Übersprungen", - "start_route": "Route starten", - "stop_contact": "Stoppkontakt", - "stop_count": "{{count}} Stopps", - "stop_detail": "Stoppdetails", - "stops": "Stopps", - "stops_completed": "Stopps abgeschlossen", - "summary_stats": "Zusammenfassung", - "title": "Routen", - "total_distance": "Gesamtentfernung", - "total_duration": "Gesamtdauer", - "type": "Typ", - "unit": "Einheit", - "destination": "Routenziel", - "total": "Gesamt", - "step_of": "Schritt {{current}} von {{total}}", - "eta": "ETA", - "skipped_by_driver": "Vom Fahrer übersprungen", - "my_unit": "Meine Einheit", - "all_routes": "Alle Routen", - "no_routes_description_all": "Keine Routenpläne verfügbar.", - "select_unit": "Einheit auswählen", - "unit_required": "Es muss eine Einheit ausgewählt werden, um die Route zu starten", - "view_route": "Route anzeigen", - "view_contact": "Kontakt anzeigen", - "assigned_other_unit": "Diese Route ist einer anderen Einheit zugewiesen und kann nicht von dieser Einheit gestartet werden", - "unassigned": "Nicht zugewiesen" - }, "tabs": { "calls": "Anrufe", "contacts": "Kontakte", @@ -801,4 +813,4 @@ "settings": "Einstellungen" }, "welcome": "Willkommen bei obytes app site" -} \ No newline at end of file +} diff --git a/src/translations/en.json b/src/translations/en.json index 65f48f9e..95ab15c4 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -503,6 +503,35 @@ "set_as_current_call": "Set as Current Call", "view_call_details": "View Call Details" }, + "maps": { + "active_layers": "Active Layers", + "all": "All", + "custom": "Custom", + "custom_maps": "Custom Maps", + "custom_region": "Custom Region", + "dispatch_to_zone": "Dispatch to this zone", + "error_loading": "Failed to load map data", + "event": "Event", + "feature": "Feature", + "floor": "Floor", + "floor_count": "{{count}} floors", + "general": "General", + "indoor": "Indoor", + "indoor_maps": "Indoor Maps", + "indoor_zone": "Indoor Zone", + "layer_toggles": "Layer Toggles", + "layers_on": "{{active}} / {{total}} active", + "loading": "Loading maps...", + "no_maps": "No Maps", + "no_maps_description": "No maps are available for your department.", + "no_results": "No results found", + "outdoor": "Outdoor", + "search": "Search maps...", + "search_maps": "Search Maps", + "search_placeholder": "Search for locations, zones, regions...", + "title": "Maps", + "zone_details": "Zone Details" + }, "notes": { "actions": { "add": "Add Note", @@ -562,6 +591,104 @@ "tap_to_manage": "Tap to manage roles", "unassigned": "Unassigned" }, + "routes": { + "acknowledge": "Acknowledge", + "active": "Active", + "active_route": "Active Route", + "address": "Address", + "all_routes": "All Routes", + "assigned_other_unit": "This route is assigned to another unit and cannot be started from this unit", + "cancel_route": "Cancel Route", + "cancel_route_confirm": "Are you sure you want to cancel this route?", + "check_in": "Check In", + "check_out": "Check Out", + "completed": "Completed", + "contact": "Contact", + "contact_details": "Contact Details", + "current_step": "Current Step", + "description": "Description", + "destination": "Route Destination", + "deviation": "Route Deviation", + "deviations": "Deviations", + "directions": "Directions", + "distance": "Distance", + "duration": "Duration", + "dwell_time": "Dwell Time", + "end_route": "End Route", + "end_route_confirm": "Are you sure you want to end this route?", + "estimated_distance": "Estimated Distance", + "estimated_duration": "Estimated Duration", + "eta": "ETA", + "eta_to_next": "ETA to next stop", + "geofence_radius": "Geofence Radius", + "history": "Route History", + "in_progress": "In Progress", + "instance_detail": "Route Instance", + "loading": "Loading routes...", + "loading_directions": "Loading directions...", + "loading_stops": "Loading stops...", + "min": "min", + "my_unit": "My Unit", + "next_step": "Next Step", + "no_contact": "No contact information available", + "no_deviations": "No deviations recorded", + "no_directions": "No directions available", + "no_history": "No route history available", + "no_history_description": "Completed routes will appear here.", + "no_routes": "No Routes", + "no_routes_description": "No route plans are available for your unit.", + "no_routes_description_all": "No route plans are available.", + "no_stops": "No stops available", + "notes": "Notes", + "notes_placeholder": "Enter notes for this stop...", + "open_in_maps": "Open in Maps", + "pause_route": "Pause Route", + "paused": "Paused", + "pending": "Pending", + "planned_arrival": "Planned Arrival", + "planned_departure": "Planned Departure", + "priority": "Priority", + "priority_critical": "Critical", + "priority_high": "High", + "priority_low": "Low", + "priority_medium": "Medium", + "priority_normal": "Normal", + "progress": "{{percent}}% complete", + "remaining_steps": "Remaining Steps", + "resume_route": "Resume Route", + "route_summary": "Route Summary", + "schedule": "Schedule", + "search": "Search routes...", + "select_unit": "Select Unit", + "skip": "Skip", + "skip_reason": "Skip Reason", + "skip_reason_placeholder": "Enter reason for skipping this stop", + "skipped": "Skipped", + "skipped_by_driver": "Skipped by driver", + "start_route": "Start Route", + "step_of": "Step {{current}} of {{total}}", + "stop_contact": "Stop Contact", + "stop_count": "{{count}} stops", + "stop_detail": "Stop Detail", + "stop_type_dropoff": "Dropoff", + "stop_type_inspection": "Inspection", + "stop_type_pickup": "Pickup", + "stop_type_service": "Service", + "stop_type_standard": "Standard", + "stops": "Stops", + "stops_completed": "Stops Completed", + "summary_stats": "Summary", + "title": "Routes", + "total": "Total", + "total_distance": "Total Distance", + "total_duration": "Total Duration", + "type": "Type", + "unassigned": "Unassigned", + "unit": "Unit", + "unit_required": "A unit must be selected to start the route", + "view_contact": "View Contact", + "view_route": "View Route" + }, "settings": { "about": "About", "account": "Account", @@ -570,12 +697,6 @@ "app_info": "App Info", "app_name": "App Name", "arabic": "Arabic", - "swedish": "Swedish", - "german": "German", - "french": "French", - "italian": "Italian", - "polish": "Polish", - "ukrainian": "Ukrainian", "audio_device_selection": { "bluetooth_device": "Bluetooth Device", "current_selection": "Current Selection", @@ -602,10 +723,13 @@ "enter_server_url": "Enter Resgrid API URL (e.g., https://api.resgrid.com)", "enter_username": "Enter your username", "environment": "Environment", + "french": "French", "general": "General", "generale": "General", + "german": "German", "github": "Github", "help_center": "Help Center", + "italian": "Italian", "keep_alive": "Keep Alive", "keep_alive_warning": "Warning: Enabling keep alive will prevent your device from sleeping and may significantly increase battery drain.", "keep_screen_on": "Keep Screen On", @@ -622,6 +746,7 @@ "notifications_description": "Enable notifications to receive alerts and updates", "notifications_enable": "Enable Notifications", "password": "Password", + "polish": "Polish", "preferences": "Preferences", "privacy": "Privacy Policy", "privacy_policy": "Privacy Policy", @@ -636,6 +761,7 @@ "status_page": "System Status", "support": "Support", "support_us": "Support Us", + "swedish": "Swedish", "terms": "Terms of Service", "theme": { "dark": "Dark", @@ -644,6 +770,7 @@ "title": "Theme" }, "title": "Settings", + "ukrainian": "Ukrainian", "unit_selected_successfully": "{{unitName}} selected successfully", "unit_selection": "Unit Selection", "unit_selection_failed": "Failed to select unit. Please try again.", @@ -676,121 +803,6 @@ "stations_tab": "Stations", "status_saved_successfully": "Status saved successfully!" }, - "maps": { - "active_layers": "Active Layers", - "all": "All", - "custom": "Custom", - "custom_maps": "Custom Maps", - "dispatch_to_zone": "Dispatch to this zone", - "error_loading": "Failed to load map data", - "floor": "Floor", - "indoor": "Indoor", - "indoor_maps": "Indoor Maps", - "layer_toggles": "Layer Toggles", - "loading": "Loading maps...", - "no_maps": "No Maps", - "no_maps_description": "No maps are available for your department.", - "no_results": "No results found", - "search": "Search maps...", - "search_maps": "Search Maps", - "search_placeholder": "Search for locations, zones, regions...", - "title": "Maps", - "zone_details": "Zone Details", - "outdoor": "Outdoor", - "event": "Event", - "general": "General", - "feature": "Feature", - "indoor_zone": "Indoor Zone", - "custom_region": "Custom Region", - "floor_count": "{{count}} floors", - "layers_on": "{{active}} / {{total}} active" - }, - "routes": { - "acknowledge": "Acknowledge", - "active": "Active", - "active_route": "Active Route", - "address": "Address", - "cancel_route": "Cancel Route", - "cancel_route_confirm": "Are you sure you want to cancel this route?", - "check_in": "Check In", - "check_out": "Check Out", - "completed": "Completed", - "contact": "Contact", - "contact_details": "Contact Details", - "current_step": "Current Step", - "description": "Description", - "deviation": "Route Deviation", - "deviations": "Deviations", - "directions": "Directions", - "distance": "Distance", - "duration": "Duration", - "dwell_time": "Dwell Time", - "end_route": "End Route", - "end_route_confirm": "Are you sure you want to end this route?", - "estimated_distance": "Estimated Distance", - "estimated_duration": "Estimated Duration", - "eta_to_next": "ETA to next stop", - "geofence_radius": "Geofence Radius", - "history": "Route History", - "in_progress": "In Progress", - "instance_detail": "Route Instance", - "loading": "Loading routes...", - "loading_directions": "Loading directions...", - "loading_stops": "Loading stops...", - "next_step": "Next Step", - "no_contact": "No contact information available", - "no_deviations": "No deviations recorded", - "no_directions": "No directions available", - "no_history": "No route history available", - "no_history_description": "Completed routes will appear here.", - "my_unit": "My Unit", - "all_routes": "All Routes", - "no_routes": "No Routes", - "no_routes_description": "No route plans are available for your unit.", - "no_routes_description_all": "No route plans are available.", - "no_stops": "No stops available", - "notes": "Notes", - "notes_placeholder": "Enter notes for this stop...", - "open_in_maps": "Open in Maps", - "pause_route": "Pause Route", - "paused": "Paused", - "pending": "Pending", - "planned_arrival": "Planned Arrival", - "planned_departure": "Planned Departure", - "priority": "Priority", - "progress": "{{percent}}% complete", - "remaining_steps": "Remaining Steps", - "resume_route": "Resume Route", - "route_summary": "Route Summary", - "search": "Search routes...", - "skip": "Skip", - "skip_reason": "Skip Reason", - "skip_reason_placeholder": "Enter reason for skipping this stop", - "skipped": "Skipped", - "start_route": "Start Route", - "stop_contact": "Stop Contact", - "stop_count": "{{count}} stops", - "stop_detail": "Stop Detail", - "stops": "Stops", - "stops_completed": "Stops Completed", - "summary_stats": "Summary", - "title": "Routes", - "total_distance": "Total Distance", - "total_duration": "Total Duration", - "type": "Type", - "unit": "Unit", - "destination": "Route Destination", - "total": "Total", - "step_of": "Step {{current}} of {{total}}", - "eta": "ETA", - "skipped_by_driver": "Skipped by driver", - "select_unit": "Select Unit", - "unit_required": "A unit must be selected to start the route", - "view_route": "View Route", - "view_contact": "View Contact", - "assigned_other_unit": "This route is assigned to another unit and cannot be started from this unit", - "unassigned": "Unassigned" - }, "tabs": { "calls": "Calls", "contacts": "Contacts", diff --git a/src/translations/es.json b/src/translations/es.json index e44ab84c..5ce3df5f 100644 --- a/src/translations/es.json +++ b/src/translations/es.json @@ -503,6 +503,35 @@ "set_as_current_call": "Establecer como llamada actual", "view_call_details": "Ver detalles de la llamada" }, + "maps": { + "active_layers": "Capas Activas", + "all": "Todos", + "custom": "Personalizado", + "custom_maps": "Mapas Personalizados", + "custom_region": "Región Personalizada", + "dispatch_to_zone": "Despachar a esta zona", + "error_loading": "Error al cargar datos del mapa", + "event": "Evento", + "feature": "Característica", + "floor": "Piso", + "floor_count": "{{count}} pisos", + "general": "General", + "indoor": "Interior", + "indoor_maps": "Mapas Interiores", + "indoor_zone": "Zona Interior", + "layer_toggles": "Controles de Capas", + "layers_on": "{{active}} / {{total}} activos", + "loading": "Cargando mapas...", + "no_maps": "Sin Mapas", + "no_maps_description": "No hay mapas disponibles para su departamento.", + "no_results": "No se encontraron resultados", + "outdoor": "Exterior", + "search": "Buscar mapas...", + "search_maps": "Buscar Mapas", + "search_placeholder": "Buscar ubicaciones, zonas, regiones...", + "title": "Mapas", + "zone_details": "Detalles de Zona" + }, "notes": { "actions": { "add": "Añadir nota", @@ -562,6 +591,104 @@ "tap_to_manage": "Toca para gestionar roles", "unassigned": "Sin asignar" }, + "routes": { + "acknowledge": "Reconocer", + "active": "Activa", + "active_route": "Ruta Activa", + "address": "Dirección", + "all_routes": "Todas las Rutas", + "assigned_other_unit": "Esta ruta está asignada a otra unidad y no puede iniciarse desde esta unidad", + "cancel_route": "Cancelar Ruta", + "cancel_route_confirm": "¿Está seguro de que desea cancelar esta ruta?", + "check_in": "Registrar Entrada", + "check_out": "Registrar Salida", + "completed": "Completado", + "contact": "Contacto", + "contact_details": "Detalles del Contacto", + "current_step": "Paso Actual", + "description": "Descripción", + "destination": "Destino de Ruta", + "deviation": "Desviación de Ruta", + "deviations": "Desviaciones", + "directions": "Direcciones", + "distance": "Distancia", + "duration": "Duración", + "dwell_time": "Tiempo de Permanencia", + "end_route": "Finalizar Ruta", + "end_route_confirm": "¿Está seguro de que desea finalizar esta ruta?", + "estimated_distance": "Distancia Estimada", + "estimated_duration": "Duración Estimada", + "eta": "ETA", + "eta_to_next": "ETA a la siguiente parada", + "geofence_radius": "Radio de Geocerca", + "history": "Historial de Ruta", + "in_progress": "En Progreso", + "instance_detail": "Instancia de Ruta", + "loading": "Cargando rutas...", + "loading_directions": "Cargando direcciones...", + "loading_stops": "Cargando paradas...", + "min": "min", + "my_unit": "Mi Unidad", + "next_step": "Siguiente Paso", + "no_contact": "No hay información de contacto disponible", + "no_deviations": "No se registraron desviaciones", + "no_directions": "No hay direcciones disponibles", + "no_history": "No hay historial de rutas disponible", + "no_history_description": "Las rutas completadas aparecerán aquí.", + "no_routes": "Sin Rutas", + "no_routes_description": "No hay planes de ruta disponibles para su unidad.", + "no_routes_description_all": "No hay planes de ruta disponibles.", + "no_stops": "No hay paradas disponibles", + "notes": "Notas", + "notes_placeholder": "Ingrese notas para esta parada...", + "open_in_maps": "Abrir en Mapas", + "pause_route": "Pausar Ruta", + "paused": "Pausada", + "pending": "Pendiente", + "planned_arrival": "Llegada Planificada", + "planned_departure": "Salida Planificada", + "priority": "Prioridad", + "priority_critical": "Crítica", + "priority_high": "Alta", + "priority_low": "Baja", + "priority_medium": "Media", + "priority_normal": "Normal", + "progress": "{{percent}}% completado", + "remaining_steps": "Pasos Restantes", + "resume_route": "Reanudar Ruta", + "route_summary": "Resumen de Ruta", + "schedule": "Horario", + "search": "Buscar rutas...", + "select_unit": "Seleccionar unidad", + "skip": "Omitir", + "skip_reason": "Razón de Omisión", + "skip_reason_placeholder": "Ingrese la razón para omitir esta parada", + "skipped": "Omitida", + "skipped_by_driver": "Omitido por el conductor", + "start_route": "Iniciar Ruta", + "step_of": "Paso {{current}} de {{total}}", + "stop_contact": "Contacto de Parada", + "stop_count": "{{count}} paradas", + "stop_detail": "Detalle de Parada", + "stop_type_dropoff": "Entrega", + "stop_type_inspection": "Inspección", + "stop_type_pickup": "Recogida", + "stop_type_service": "Servicio", + "stop_type_standard": "Estándar", + "stops": "Paradas", + "stops_completed": "Paradas Completadas", + "summary_stats": "Resumen", + "title": "Rutas", + "total": "Total", + "total_distance": "Distancia Total", + "total_duration": "Duración Total", + "type": "Tipo", + "unassigned": "Sin asignar", + "unit": "Unidad", + "unit_required": "Se debe seleccionar una unidad para iniciar la ruta", + "view_contact": "Ver Contacto", + "view_route": "Ver Ruta" + }, "settings": { "about": "Acerca de", "account": "Cuenta", @@ -570,12 +697,6 @@ "app_info": "Información de la aplicación", "app_name": "Nombre de la aplicación", "arabic": "Árabe", - "swedish": "Sueco", - "german": "Alemán", - "french": "Francés", - "italian": "Italiano", - "polish": "Polaco", - "ukrainian": "Ucraniano", "audio_device_selection": { "bluetooth_device": "Dispositivo Bluetooth", "current_selection": "Selección actual", @@ -602,10 +723,13 @@ "enter_server_url": "Introduce la URL de la API de Resgrid (ej: https://api.resgrid.com)", "enter_username": "Introduce tu nombre de usuario", "environment": "Entorno", + "french": "Francés", "general": "General", "generale": "General", + "german": "Alemán", "github": "Github", "help_center": "Centro de ayuda", + "italian": "Italiano", "keep_alive": "Mantener Activo", "keep_alive_warning": "Advertencia: Habilitar mantener activo evitará que su dispositivo entre en suspensión y puede aumentar significativamente el consumo de batería.", "keep_screen_on": "Mantener pantalla encendida", @@ -622,6 +746,7 @@ "notifications_description": "Activa las notificaciones para recibir alertas y actualizaciones", "notifications_enable": "Activar notificaciones", "password": "Contraseña", + "polish": "Polaco", "preferences": "Preferencias", "privacy": "Política de privacidad", "privacy_policy": "Política de privacidad", @@ -636,6 +761,7 @@ "status_page": "Estado del sistema", "support": "Soporte", "support_us": "Apóyanos", + "swedish": "Sueco", "terms": "Términos de servicio", "theme": { "dark": "Oscuro", @@ -644,6 +770,7 @@ "title": "Tema" }, "title": "Configuración", + "ukrainian": "Ucraniano", "unit_selected_successfully": "{{unitName}} seleccionada exitosamente", "unit_selection": "Selección de unidad", "unit_selection_failed": "Error al seleccionar la unidad. Inténtalo de nuevo.", @@ -676,121 +803,6 @@ "stations_tab": "Estaciones", "status_saved_successfully": "¡Estado guardado exitosamente!" }, - "maps": { - "active_layers": "Capas Activas", - "all": "Todos", - "custom": "Personalizado", - "custom_maps": "Mapas Personalizados", - "dispatch_to_zone": "Despachar a esta zona", - "error_loading": "Error al cargar datos del mapa", - "floor": "Piso", - "indoor": "Interior", - "indoor_maps": "Mapas Interiores", - "layer_toggles": "Controles de Capas", - "loading": "Cargando mapas...", - "no_maps": "Sin Mapas", - "no_maps_description": "No hay mapas disponibles para su departamento.", - "no_results": "No se encontraron resultados", - "search": "Buscar mapas...", - "search_maps": "Buscar Mapas", - "search_placeholder": "Buscar ubicaciones, zonas, regiones...", - "title": "Mapas", - "zone_details": "Detalles de Zona", - "outdoor": "Exterior", - "event": "Evento", - "general": "General", - "feature": "Característica", - "indoor_zone": "Zona Interior", - "custom_region": "Región Personalizada", - "floor_count": "{{count}} pisos", - "layers_on": "{{active}} / {{total}} activos" - }, - "routes": { - "acknowledge": "Reconocer", - "active": "Activa", - "active_route": "Ruta Activa", - "address": "Dirección", - "cancel_route": "Cancelar Ruta", - "cancel_route_confirm": "¿Está seguro de que desea cancelar esta ruta?", - "check_in": "Registrar Entrada", - "check_out": "Registrar Salida", - "completed": "Completado", - "contact": "Contacto", - "contact_details": "Detalles del Contacto", - "current_step": "Paso Actual", - "description": "Descripción", - "deviation": "Desviación de Ruta", - "deviations": "Desviaciones", - "directions": "Direcciones", - "distance": "Distancia", - "duration": "Duración", - "dwell_time": "Tiempo de Permanencia", - "end_route": "Finalizar Ruta", - "end_route_confirm": "¿Está seguro de que desea finalizar esta ruta?", - "estimated_distance": "Distancia Estimada", - "estimated_duration": "Duración Estimada", - "eta_to_next": "ETA a la siguiente parada", - "geofence_radius": "Radio de Geocerca", - "history": "Historial de Ruta", - "in_progress": "En Progreso", - "instance_detail": "Instancia de Ruta", - "loading": "Cargando rutas...", - "loading_directions": "Cargando direcciones...", - "loading_stops": "Cargando paradas...", - "next_step": "Siguiente Paso", - "no_contact": "No hay información de contacto disponible", - "no_deviations": "No se registraron desviaciones", - "no_directions": "No hay direcciones disponibles", - "no_history": "No hay historial de rutas disponible", - "no_history_description": "Las rutas completadas aparecerán aquí.", - "no_routes": "Sin Rutas", - "no_routes_description": "No hay planes de ruta disponibles para su unidad.", - "no_stops": "No hay paradas disponibles", - "notes": "Notas", - "notes_placeholder": "Ingrese notas para esta parada...", - "open_in_maps": "Abrir en Mapas", - "pause_route": "Pausar Ruta", - "paused": "Pausada", - "pending": "Pendiente", - "planned_arrival": "Llegada Planificada", - "planned_departure": "Salida Planificada", - "priority": "Prioridad", - "progress": "{{percent}}% completado", - "remaining_steps": "Pasos Restantes", - "resume_route": "Reanudar Ruta", - "route_summary": "Resumen de Ruta", - "search": "Buscar rutas...", - "skip": "Omitir", - "skip_reason": "Razón de Omisión", - "skip_reason_placeholder": "Ingrese la razón para omitir esta parada", - "skipped": "Omitida", - "start_route": "Iniciar Ruta", - "stop_contact": "Contacto de Parada", - "stop_count": "{{count}} paradas", - "stop_detail": "Detalle de Parada", - "stops": "Paradas", - "stops_completed": "Paradas Completadas", - "summary_stats": "Resumen", - "title": "Rutas", - "total_distance": "Distancia Total", - "total_duration": "Duración Total", - "type": "Tipo", - "unit": "Unidad", - "destination": "Destino de Ruta", - "total": "Total", - "step_of": "Paso {{current}} de {{total}}", - "eta": "ETA", - "skipped_by_driver": "Omitido por el conductor", - "my_unit": "Mi Unidad", - "all_routes": "Todas las Rutas", - "no_routes_description_all": "No hay planes de ruta disponibles.", - "select_unit": "Seleccionar unidad", - "unit_required": "Se debe seleccionar una unidad para iniciar la ruta", - "view_route": "Ver Ruta", - "view_contact": "Ver Contacto", - "assigned_other_unit": "Esta ruta está asignada a otra unidad y no puede iniciarse desde esta unidad", - "unassigned": "Sin asignar" - }, "tabs": { "calls": "Llamadas", "contacts": "Contactos", @@ -801,4 +813,4 @@ "settings": "Configuración" }, "welcome": "Bienvenido al sitio de la aplicación obytes" -} \ No newline at end of file +} diff --git a/src/translations/fr.json b/src/translations/fr.json index 91ad03ee..ff7c668d 100644 --- a/src/translations/fr.json +++ b/src/translations/fr.json @@ -503,6 +503,35 @@ "set_as_current_call": "Définir comme appel actuel", "view_call_details": "Voir les détails de l'appel" }, + "maps": { + "active_layers": "Couches actives", + "all": "Tous", + "custom": "Personnalisé", + "custom_maps": "Cartes personnalisées", + "custom_region": "Région personnalisée", + "dispatch_to_zone": "Envoyer dans cette zone", + "error_loading": "Échec du chargement des données de la carte", + "event": "Événement", + "feature": "Fonctionnalité", + "floor": "Étage", + "floor_count": "{{count}} étages", + "general": "Général", + "indoor": "Intérieur", + "indoor_maps": "Cartes intérieures", + "indoor_zone": "Zone intérieure", + "layer_toggles": "Commutateurs de couches", + "layers_on": "{{active}} / {{total}} actifs", + "loading": "Chargement des cartes...", + "no_maps": "Aucune carte", + "no_maps_description": "Aucune carte disponible pour votre département.", + "no_results": "Aucun résultat trouvé", + "outdoor": "Extérieur", + "search": "Rechercher des cartes...", + "search_maps": "Rechercher des cartes", + "search_placeholder": "Rechercher des lieux, zones, régions...", + "title": "Cartes", + "zone_details": "Détails de la zone" + }, "notes": { "actions": { "add": "Ajouter une note", @@ -562,6 +591,104 @@ "tap_to_manage": "Appuyer pour gérer les rôles", "unassigned": "Non attribué" }, + "routes": { + "acknowledge": "Accuser réception", + "active": "Actif", + "active_route": "Itinéraire actif", + "address": "Adresse", + "all_routes": "Tous les Itinéraires", + "assigned_other_unit": "Cet itinéraire est assigné à une autre unité et ne peut pas être démarré depuis cette unité", + "cancel_route": "Annuler l'itinéraire", + "cancel_route_confirm": "Êtes-vous sûr de vouloir annuler cet itinéraire ?", + "check_in": "Arrivée", + "check_out": "Départ", + "completed": "Terminé", + "contact": "Contact", + "contact_details": "Détails du contact", + "current_step": "Étape actuelle", + "description": "Description", + "destination": "Destination de l'itinéraire", + "deviation": "Déviation d'itinéraire", + "deviations": "Déviations", + "directions": "Itinéraire", + "distance": "Distance", + "duration": "Durée", + "dwell_time": "Temps de séjour", + "end_route": "Terminer l'itinéraire", + "end_route_confirm": "Êtes-vous sûr de vouloir terminer cet itinéraire ?", + "estimated_distance": "Distance estimée", + "estimated_duration": "Durée estimée", + "eta": "ETA", + "eta_to_next": "ETA vers le prochain arrêt", + "geofence_radius": "Rayon de géorepérage", + "history": "Historique des itinéraires", + "in_progress": "En cours", + "instance_detail": "Instance d'itinéraire", + "loading": "Chargement des itinéraires...", + "loading_directions": "Chargement de l'itinéraire...", + "loading_stops": "Chargement des arrêts...", + "min": "min", + "my_unit": "Mon Unité", + "next_step": "Étape suivante", + "no_contact": "Aucune information de contact disponible", + "no_deviations": "Aucune déviation enregistrée", + "no_directions": "Aucun itinéraire disponible", + "no_history": "Aucun historique d'itinéraire disponible", + "no_history_description": "Les itinéraires terminés apparaîtront ici.", + "no_routes": "Aucun itinéraire", + "no_routes_description": "Aucun plan d'itinéraire disponible pour votre unité.", + "no_routes_description_all": "Aucun plan d'itinéraire disponible.", + "no_stops": "Aucun arrêt disponible", + "notes": "Notes", + "notes_placeholder": "Saisir des notes pour cet arrêt...", + "open_in_maps": "Ouvrir dans les cartes", + "pause_route": "Mettre l'itinéraire en pause", + "paused": "En pause", + "pending": "En attente", + "planned_arrival": "Arrivée prévue", + "planned_departure": "Départ prévu", + "priority": "Priorité", + "priority_critical": "Critique", + "priority_high": "Élevé", + "priority_low": "Faible", + "priority_medium": "Moyen", + "priority_normal": "Normal", + "progress": "{{percent}}% terminé", + "remaining_steps": "Étapes restantes", + "resume_route": "Reprendre l'itinéraire", + "route_summary": "Résumé de l'itinéraire", + "schedule": "Calendrier", + "search": "Rechercher des itinéraires...", + "select_unit": "Sélectionner l'unité", + "skip": "Ignorer", + "skip_reason": "Raison de l'ignorance", + "skip_reason_placeholder": "Saisir la raison d'ignorer cet arrêt", + "skipped": "Ignoré", + "skipped_by_driver": "Ignoré par le conducteur", + "start_route": "Démarrer l'itinéraire", + "step_of": "Étape {{current}} sur {{total}}", + "stop_contact": "Contact de l'arrêt", + "stop_count": "{{count}} arrêts", + "stop_detail": "Détail de l'arrêt", + "stop_type_dropoff": "Dépôt", + "stop_type_inspection": "Inspection", + "stop_type_pickup": "Ramassage", + "stop_type_service": "Service", + "stop_type_standard": "Standard", + "stops": "Arrêts", + "stops_completed": "Arrêts terminés", + "summary_stats": "Résumé", + "title": "Itinéraires", + "total": "Total", + "total_distance": "Distance totale", + "total_duration": "Durée totale", + "type": "Type", + "unassigned": "Non assigné", + "unit": "Unité", + "unit_required": "Une unité doit être sélectionnée pour démarrer l'itinéraire", + "view_contact": "Voir le contact", + "view_route": "Voir l'itinéraire" + }, "settings": { "about": "À propos", "account": "Compte", @@ -570,12 +697,6 @@ "app_info": "Infos de l'application", "app_name": "Nom de l'application", "arabic": "Arabe", - "swedish": "Suédois", - "german": "Allemand", - "french": "Français", - "italian": "Italien", - "polish": "Polonais", - "ukrainian": "Ukrainien", "audio_device_selection": { "bluetooth_device": "Appareil Bluetooth", "current_selection": "Sélection actuelle", @@ -602,10 +723,13 @@ "enter_server_url": "Saisir l'URL de l'API Resgrid (ex. https://api.resgrid.com)", "enter_username": "Saisir votre nom d'utilisateur", "environment": "Environnement", + "french": "Français", "general": "Général", "generale": "Général", + "german": "Allemand", "github": "Github", "help_center": "Centre d'aide", + "italian": "Italien", "keep_alive": "Maintenir actif", "keep_alive_warning": "Avertissement : L'activation de 'Maintenir actif' empêchera votre appareil de se mettre en veille et peut augmenter considérablement la consommation de batterie.", "keep_screen_on": "Garder l'écran allumé", @@ -622,6 +746,7 @@ "notifications_description": "Activez les notifications pour recevoir des alertes et des mises à jour", "notifications_enable": "Activer les notifications", "password": "Mot de passe", + "polish": "Polonais", "preferences": "Préférences", "privacy": "Politique de confidentialité", "privacy_policy": "Politique de confidentialité", @@ -636,6 +761,7 @@ "status_page": "État du système", "support": "Support", "support_us": "Soutenez-nous", + "swedish": "Suédois", "terms": "Conditions d'utilisation", "theme": { "dark": "Sombre", @@ -644,6 +770,7 @@ "title": "Thème" }, "title": "Paramètres", + "ukrainian": "Ukrainien", "unit_selected_successfully": "{{unitName}} sélectionné avec succès", "unit_selection": "Sélection de l'unité", "unit_selection_failed": "Échec de la sélection de l'unité. Veuillez réessayer.", @@ -676,121 +803,6 @@ "stations_tab": "Stations", "status_saved_successfully": "Statut enregistré avec succès !" }, - "maps": { - "active_layers": "Couches actives", - "all": "Tous", - "custom": "Personnalisé", - "custom_maps": "Cartes personnalisées", - "dispatch_to_zone": "Envoyer dans cette zone", - "error_loading": "Échec du chargement des données de la carte", - "floor": "Étage", - "indoor": "Intérieur", - "indoor_maps": "Cartes intérieures", - "layer_toggles": "Commutateurs de couches", - "loading": "Chargement des cartes...", - "no_maps": "Aucune carte", - "no_maps_description": "Aucune carte disponible pour votre département.", - "no_results": "Aucun résultat trouvé", - "search": "Rechercher des cartes...", - "search_maps": "Rechercher des cartes", - "search_placeholder": "Rechercher des lieux, zones, régions...", - "title": "Cartes", - "zone_details": "Détails de la zone", - "outdoor": "Extérieur", - "event": "Événement", - "general": "Général", - "feature": "Fonctionnalité", - "indoor_zone": "Zone intérieure", - "custom_region": "Région personnalisée", - "floor_count": "{{count}} étages", - "layers_on": "{{active}} / {{total}} actifs" - }, - "routes": { - "acknowledge": "Accuser réception", - "active": "Actif", - "active_route": "Itinéraire actif", - "address": "Adresse", - "cancel_route": "Annuler l'itinéraire", - "cancel_route_confirm": "Êtes-vous sûr de vouloir annuler cet itinéraire ?", - "check_in": "Arrivée", - "check_out": "Départ", - "completed": "Terminé", - "contact": "Contact", - "contact_details": "Détails du contact", - "current_step": "Étape actuelle", - "description": "Description", - "deviation": "Déviation d'itinéraire", - "deviations": "Déviations", - "directions": "Itinéraire", - "distance": "Distance", - "duration": "Durée", - "dwell_time": "Temps de séjour", - "end_route": "Terminer l'itinéraire", - "end_route_confirm": "Êtes-vous sûr de vouloir terminer cet itinéraire ?", - "estimated_distance": "Distance estimée", - "estimated_duration": "Durée estimée", - "eta_to_next": "ETA vers le prochain arrêt", - "geofence_radius": "Rayon de géorepérage", - "history": "Historique des itinéraires", - "in_progress": "En cours", - "instance_detail": "Instance d'itinéraire", - "loading": "Chargement des itinéraires...", - "loading_directions": "Chargement de l'itinéraire...", - "loading_stops": "Chargement des arrêts...", - "next_step": "Étape suivante", - "no_contact": "Aucune information de contact disponible", - "no_deviations": "Aucune déviation enregistrée", - "no_directions": "Aucun itinéraire disponible", - "no_history": "Aucun historique d'itinéraire disponible", - "no_history_description": "Les itinéraires terminés apparaîtront ici.", - "no_routes": "Aucun itinéraire", - "no_routes_description": "Aucun plan d'itinéraire disponible pour votre unité.", - "no_stops": "Aucun arrêt disponible", - "notes": "Notes", - "notes_placeholder": "Saisir des notes pour cet arrêt...", - "open_in_maps": "Ouvrir dans les cartes", - "pause_route": "Mettre l'itinéraire en pause", - "paused": "En pause", - "pending": "En attente", - "planned_arrival": "Arrivée prévue", - "planned_departure": "Départ prévu", - "priority": "Priorité", - "progress": "{{percent}}% terminé", - "remaining_steps": "Étapes restantes", - "resume_route": "Reprendre l'itinéraire", - "route_summary": "Résumé de l'itinéraire", - "search": "Rechercher des itinéraires...", - "skip": "Ignorer", - "skip_reason": "Raison de l'ignorance", - "skip_reason_placeholder": "Saisir la raison d'ignorer cet arrêt", - "skipped": "Ignoré", - "start_route": "Démarrer l'itinéraire", - "stop_contact": "Contact de l'arrêt", - "stop_count": "{{count}} arrêts", - "stop_detail": "Détail de l'arrêt", - "stops": "Arrêts", - "stops_completed": "Arrêts terminés", - "summary_stats": "Résumé", - "title": "Itinéraires", - "total_distance": "Distance totale", - "total_duration": "Durée totale", - "type": "Type", - "unit": "Unité", - "destination": "Destination de l'itinéraire", - "total": "Total", - "step_of": "Étape {{current}} sur {{total}}", - "eta": "ETA", - "skipped_by_driver": "Ignoré par le conducteur", - "my_unit": "Mon Unité", - "all_routes": "Tous les Itinéraires", - "no_routes_description_all": "Aucun plan d'itinéraire disponible.", - "select_unit": "Sélectionner l'unité", - "unit_required": "Une unité doit être sélectionnée pour démarrer l'itinéraire", - "view_route": "Voir l'itinéraire", - "view_contact": "Voir le contact", - "assigned_other_unit": "Cet itinéraire est assigné à une autre unité et ne peut pas être démarré depuis cette unité", - "unassigned": "Non assigné" - }, "tabs": { "calls": "Appels", "contacts": "Contacts", @@ -801,4 +813,4 @@ "settings": "Paramètres" }, "welcome": "Bienvenue sur l'application obytes" -} \ No newline at end of file +} diff --git a/src/translations/it.json b/src/translations/it.json index b4c5b85d..ee3425f4 100644 --- a/src/translations/it.json +++ b/src/translations/it.json @@ -503,6 +503,35 @@ "set_as_current_call": "Imposta come chiamata corrente", "view_call_details": "Visualizza dettagli chiamata" }, + "maps": { + "active_layers": "Livelli attivi", + "all": "Tutti", + "custom": "Personalizzato", + "custom_maps": "Mappe personalizzate", + "custom_region": "Regione personalizzata", + "dispatch_to_zone": "Invia a questa zona", + "error_loading": "Impossibile caricare i dati della mappa", + "event": "Evento", + "feature": "Caratteristica", + "floor": "Piano", + "floor_count": "{{count}} piani", + "general": "Generale", + "indoor": "Interno", + "indoor_maps": "Mappe interne", + "indoor_zone": "Zona interna", + "layer_toggles": "Interruttori livelli", + "layers_on": "{{active}} / {{total}} attivi", + "loading": "Caricamento mappe...", + "no_maps": "Nessuna mappa", + "no_maps_description": "Nessuna mappa disponibile per il tuo dipartimento.", + "no_results": "Nessun risultato trovato", + "outdoor": "Esterno", + "search": "Cerca mappe...", + "search_maps": "Cerca mappe", + "search_placeholder": "Cerca posizioni, zone, regioni...", + "title": "Mappe", + "zone_details": "Dettagli zona" + }, "notes": { "actions": { "add": "Aggiungi nota", @@ -562,6 +591,104 @@ "tap_to_manage": "Tocca per gestire i ruoli", "unassigned": "Non assegnato" }, + "routes": { + "acknowledge": "Conferma", + "active": "Attivo", + "active_route": "Percorso attivo", + "address": "Indirizzo", + "all_routes": "Tutti i Percorsi", + "assigned_other_unit": "Questo percorso è assegnato a un'altra unità e non può essere avviato da questa unità", + "cancel_route": "Annulla percorso", + "cancel_route_confirm": "Sei sicuro di voler annullare questo percorso?", + "check_in": "Check-in", + "check_out": "Check-out", + "completed": "Completato", + "contact": "Contatto", + "contact_details": "Dettagli contatto", + "current_step": "Passo corrente", + "description": "Descrizione", + "destination": "Destinazione percorso", + "deviation": "Deviazione percorso", + "deviations": "Deviazioni", + "directions": "Indicazioni", + "distance": "Distanza", + "duration": "Durata", + "dwell_time": "Tempo di sosta", + "end_route": "Termina percorso", + "end_route_confirm": "Sei sicuro di voler terminare questo percorso?", + "estimated_distance": "Distanza stimata", + "estimated_duration": "Durata stimata", + "eta": "ETA", + "eta_to_next": "ETA prossima fermata", + "geofence_radius": "Raggio geofence", + "history": "Cronologia percorsi", + "in_progress": "In corso", + "instance_detail": "Istanza percorso", + "loading": "Caricamento percorsi...", + "loading_directions": "Caricamento indicazioni...", + "loading_stops": "Caricamento fermate...", + "min": "min", + "my_unit": "La Mia Unità", + "next_step": "Passo successivo", + "no_contact": "Nessuna informazione di contatto disponibile", + "no_deviations": "Nessuna deviazione registrata", + "no_directions": "Nessuna indicazione disponibile", + "no_history": "Nessuna cronologia percorsi disponibile", + "no_history_description": "I percorsi completati appariranno qui.", + "no_routes": "Nessun percorso", + "no_routes_description": "Nessun piano di percorso disponibile per la tua unità.", + "no_routes_description_all": "Nessun piano di percorso disponibile.", + "no_stops": "Nessuna fermata disponibile", + "notes": "Note", + "notes_placeholder": "Inserisci note per questa fermata...", + "open_in_maps": "Apri nelle mappe", + "pause_route": "Metti in pausa percorso", + "paused": "In pausa", + "pending": "In attesa", + "planned_arrival": "Arrivo pianificato", + "planned_departure": "Partenza pianificata", + "priority": "Priorità", + "priority_critical": "Critico", + "priority_high": "Alto", + "priority_low": "Basso", + "priority_medium": "Medio", + "priority_normal": "Normale", + "progress": "{{percent}}% completato", + "remaining_steps": "Passi rimanenti", + "resume_route": "Riprendi percorso", + "route_summary": "Riepilogo percorso", + "schedule": "Programma", + "search": "Cerca percorsi...", + "select_unit": "Seleziona unità", + "skip": "Salta", + "skip_reason": "Motivo salto", + "skip_reason_placeholder": "Inserisci il motivo per saltare questa fermata", + "skipped": "Saltato", + "skipped_by_driver": "Saltato dal conducente", + "start_route": "Avvia percorso", + "step_of": "Passo {{current}} di {{total}}", + "stop_contact": "Contatto fermata", + "stop_count": "{{count}} fermate", + "stop_detail": "Dettaglio fermata", + "stop_type_dropoff": "Consegna", + "stop_type_inspection": "Ispezione", + "stop_type_pickup": "Raccolta", + "stop_type_service": "Servizio", + "stop_type_standard": "Standard", + "stops": "Fermate", + "stops_completed": "Fermate completate", + "summary_stats": "Riepilogo", + "title": "Percorsi", + "total": "Totale", + "total_distance": "Distanza totale", + "total_duration": "Durata totale", + "type": "Tipo", + "unassigned": "Non assegnato", + "unit": "Unità", + "unit_required": "È necessario selezionare un'unità per avviare il percorso", + "view_contact": "Visualizza contatto", + "view_route": "Visualizza percorso" + }, "settings": { "about": "Informazioni", "account": "Account", @@ -570,12 +697,6 @@ "app_info": "Info app", "app_name": "Nome app", "arabic": "Arabo", - "swedish": "Svedese", - "german": "Tedesco", - "french": "Francese", - "italian": "Italiano", - "polish": "Polacco", - "ukrainian": "Ucraino", "audio_device_selection": { "bluetooth_device": "Dispositivo Bluetooth", "current_selection": "Selezione corrente", @@ -602,10 +723,13 @@ "enter_server_url": "Inserisci URL API Resgrid (es. https://api.resgrid.com)", "enter_username": "Inserisci il nome utente", "environment": "Ambiente", + "french": "Francese", "general": "Generale", "generale": "Generale", + "german": "Tedesco", "github": "Github", "help_center": "Centro assistenza", + "italian": "Italiano", "keep_alive": "Keep Alive", "keep_alive_warning": "Attenzione: L'abilitazione del keep alive impedisce al dispositivo di andare in standby e può aumentare significativamente il consumo della batteria.", "keep_screen_on": "Tieni lo schermo acceso", @@ -622,6 +746,7 @@ "notifications_description": "Abilita le notifiche per ricevere avvisi e aggiornamenti", "notifications_enable": "Abilita notifiche", "password": "Password", + "polish": "Polacco", "preferences": "Preferenze", "privacy": "Informativa sulla privacy", "privacy_policy": "Informativa sulla privacy", @@ -636,6 +761,7 @@ "status_page": "Stato sistema", "support": "Supporto", "support_us": "Supportaci", + "swedish": "Svedese", "terms": "Termini di servizio", "theme": { "dark": "Scuro", @@ -644,6 +770,7 @@ "title": "Tema" }, "title": "Impostazioni", + "ukrainian": "Ucraino", "unit_selected_successfully": "{{unitName}} selezionata con successo", "unit_selection": "Selezione unità", "unit_selection_failed": "Impossibile selezionare l'unità. Riprovare.", @@ -676,121 +803,6 @@ "stations_tab": "Stazioni", "status_saved_successfully": "Stato salvato con successo!" }, - "maps": { - "active_layers": "Livelli attivi", - "all": "Tutti", - "custom": "Personalizzato", - "custom_maps": "Mappe personalizzate", - "dispatch_to_zone": "Invia a questa zona", - "error_loading": "Impossibile caricare i dati della mappa", - "floor": "Piano", - "indoor": "Interno", - "indoor_maps": "Mappe interne", - "layer_toggles": "Interruttori livelli", - "loading": "Caricamento mappe...", - "no_maps": "Nessuna mappa", - "no_maps_description": "Nessuna mappa disponibile per il tuo dipartimento.", - "no_results": "Nessun risultato trovato", - "search": "Cerca mappe...", - "search_maps": "Cerca mappe", - "search_placeholder": "Cerca posizioni, zone, regioni...", - "title": "Mappe", - "zone_details": "Dettagli zona", - "outdoor": "Esterno", - "event": "Evento", - "general": "Generale", - "feature": "Caratteristica", - "indoor_zone": "Zona interna", - "custom_region": "Regione personalizzata", - "floor_count": "{{count}} piani", - "layers_on": "{{active}} / {{total}} attivi" - }, - "routes": { - "acknowledge": "Conferma", - "active": "Attivo", - "active_route": "Percorso attivo", - "address": "Indirizzo", - "cancel_route": "Annulla percorso", - "cancel_route_confirm": "Sei sicuro di voler annullare questo percorso?", - "check_in": "Check-in", - "check_out": "Check-out", - "completed": "Completato", - "contact": "Contatto", - "contact_details": "Dettagli contatto", - "current_step": "Passo corrente", - "description": "Descrizione", - "deviation": "Deviazione percorso", - "deviations": "Deviazioni", - "directions": "Indicazioni", - "distance": "Distanza", - "duration": "Durata", - "dwell_time": "Tempo di sosta", - "end_route": "Termina percorso", - "end_route_confirm": "Sei sicuro di voler terminare questo percorso?", - "estimated_distance": "Distanza stimata", - "estimated_duration": "Durata stimata", - "eta_to_next": "ETA prossima fermata", - "geofence_radius": "Raggio geofence", - "history": "Cronologia percorsi", - "in_progress": "In corso", - "instance_detail": "Istanza percorso", - "loading": "Caricamento percorsi...", - "loading_directions": "Caricamento indicazioni...", - "loading_stops": "Caricamento fermate...", - "next_step": "Passo successivo", - "no_contact": "Nessuna informazione di contatto disponibile", - "no_deviations": "Nessuna deviazione registrata", - "no_directions": "Nessuna indicazione disponibile", - "no_history": "Nessuna cronologia percorsi disponibile", - "no_history_description": "I percorsi completati appariranno qui.", - "no_routes": "Nessun percorso", - "no_routes_description": "Nessun piano di percorso disponibile per la tua unità.", - "no_stops": "Nessuna fermata disponibile", - "notes": "Note", - "notes_placeholder": "Inserisci note per questa fermata...", - "open_in_maps": "Apri nelle mappe", - "pause_route": "Metti in pausa percorso", - "paused": "In pausa", - "pending": "In attesa", - "planned_arrival": "Arrivo pianificato", - "planned_departure": "Partenza pianificata", - "priority": "Priorità", - "progress": "{{percent}}% completato", - "remaining_steps": "Passi rimanenti", - "resume_route": "Riprendi percorso", - "route_summary": "Riepilogo percorso", - "search": "Cerca percorsi...", - "skip": "Salta", - "skip_reason": "Motivo salto", - "skip_reason_placeholder": "Inserisci il motivo per saltare questa fermata", - "skipped": "Saltato", - "start_route": "Avvia percorso", - "stop_contact": "Contatto fermata", - "stop_count": "{{count}} fermate", - "stop_detail": "Dettaglio fermata", - "stops": "Fermate", - "stops_completed": "Fermate completate", - "summary_stats": "Riepilogo", - "title": "Percorsi", - "total_distance": "Distanza totale", - "total_duration": "Durata totale", - "type": "Tipo", - "unit": "Unità", - "destination": "Destinazione percorso", - "total": "Totale", - "step_of": "Passo {{current}} di {{total}}", - "eta": "ETA", - "skipped_by_driver": "Saltato dal conducente", - "my_unit": "La Mia Unità", - "all_routes": "Tutti i Percorsi", - "no_routes_description_all": "Nessun piano di percorso disponibile.", - "select_unit": "Seleziona unità", - "unit_required": "È necessario selezionare un'unità per avviare il percorso", - "view_route": "Visualizza percorso", - "view_contact": "Visualizza contatto", - "assigned_other_unit": "Questo percorso è assegnato a un'altra unità e non può essere avviato da questa unità", - "unassigned": "Non assegnato" - }, "tabs": { "calls": "Chiamate", "contacts": "Contatti", @@ -801,4 +813,4 @@ "settings": "Impostazioni" }, "welcome": "Benvenuto nell'app obytes" -} \ No newline at end of file +} diff --git a/src/translations/pl.json b/src/translations/pl.json index 1f72c42f..8634dc0f 100644 --- a/src/translations/pl.json +++ b/src/translations/pl.json @@ -503,6 +503,35 @@ "set_as_current_call": "Ustaw jako bieżące zgłoszenie", "view_call_details": "Wyświetl szczegóły zgłoszenia" }, + "maps": { + "active_layers": "Aktywne warstwy", + "all": "Wszystkie", + "custom": "Niestandardowe", + "custom_maps": "Niestandardowe mapy", + "custom_region": "Niestandardowy region", + "dispatch_to_zone": "Wyślij do tej strefy", + "error_loading": "Nie udało się załadować danych mapy", + "event": "Zdarzenie", + "feature": "Cecha", + "floor": "Piętro", + "floor_count": "{{count}} pięter", + "general": "Ogólne", + "indoor": "Wewnętrzne", + "indoor_maps": "Mapy wewnętrzne", + "indoor_zone": "Strefa wewnętrzna", + "layer_toggles": "Przełączniki warstw", + "layers_on": "{{active}} / {{total}} aktywne", + "loading": "Ładowanie map...", + "no_maps": "Brak map", + "no_maps_description": "Brak map dostępnych dla Twojego działu.", + "no_results": "Nie znaleziono wyników", + "outdoor": "Zewnętrzne", + "search": "Szukaj map...", + "search_maps": "Szukaj map", + "search_placeholder": "Szukaj lokalizacji, stref, regionów...", + "title": "Mapy", + "zone_details": "Szczegóły strefy" + }, "notes": { "actions": { "add": "Dodaj notatkę", @@ -562,6 +591,104 @@ "tap_to_manage": "Dotknij, aby zarządzać rolami", "unassigned": "Nieprzypisany" }, + "routes": { + "acknowledge": "Potwierdź", + "active": "Aktywna", + "active_route": "Aktywna trasa", + "address": "Adres", + "all_routes": "Wszystkie Trasy", + "assigned_other_unit": "Ta trasa jest przypisana do innej jednostki i nie może być rozpoczęta z tej jednostki", + "cancel_route": "Anuluj trasę", + "cancel_route_confirm": "Czy na pewno chcesz anulować tę trasę?", + "check_in": "Zamelduj się", + "check_out": "Wymelduj się", + "completed": "Ukończono", + "contact": "Kontakt", + "contact_details": "Szczegóły kontaktu", + "current_step": "Bieżący krok", + "description": "Opis", + "destination": "Cel trasy", + "deviation": "Odchylenie trasy", + "deviations": "Odchylenia", + "directions": "Wskazówki", + "distance": "Odległość", + "duration": "Czas trwania", + "dwell_time": "Czas postoju", + "end_route": "Zakończ trasę", + "end_route_confirm": "Czy na pewno chcesz zakończyć tę trasę?", + "estimated_distance": "Szacowana odległość", + "estimated_duration": "Szacowany czas", + "eta": "ETA", + "eta_to_next": "ETA do następnego postoju", + "geofence_radius": "Promień geofence", + "history": "Historia tras", + "in_progress": "W toku", + "instance_detail": "Instancja trasy", + "loading": "Ładowanie tras...", + "loading_directions": "Ładowanie wskazówek...", + "loading_stops": "Ładowanie postojów...", + "min": "min", + "my_unit": "Moja Jednostka", + "next_step": "Następny krok", + "no_contact": "Brak dostępnych danych kontaktowych", + "no_deviations": "Brak zarejestrowanych odchyleń", + "no_directions": "Brak dostępnych wskazówek", + "no_history": "Brak historii tras", + "no_history_description": "Ukończone trasy będą tutaj widoczne.", + "no_routes": "Brak tras", + "no_routes_description": "Brak planów tras dostępnych dla Twojej jednostki.", + "no_routes_description_all": "Brak dostępnych planów tras.", + "no_stops": "Brak dostępnych postojów", + "notes": "Notatki", + "notes_placeholder": "Wpisz notatki dla tego postoju...", + "open_in_maps": "Otwórz w mapach", + "pause_route": "Wstrzymaj trasę", + "paused": "Wstrzymano", + "pending": "Oczekujące", + "planned_arrival": "Planowany przyjazd", + "planned_departure": "Planowany odjazd", + "priority": "Priorytet", + "priority_critical": "Krytyczny", + "priority_high": "Wysoki", + "priority_low": "Niski", + "priority_medium": "Średni", + "priority_normal": "Normalny", + "progress": "{{percent}}% ukończone", + "remaining_steps": "Pozostałe kroki", + "resume_route": "Wznów trasę", + "route_summary": "Podsumowanie trasy", + "schedule": "Harmonogram", + "search": "Szukaj tras...", + "select_unit": "Wybierz jednostkę", + "skip": "Pomiń", + "skip_reason": "Powód pominięcia", + "skip_reason_placeholder": "Wpisz powód pominięcia tego postoju", + "skipped": "Pominięto", + "skipped_by_driver": "Pominięty przez kierowcę", + "start_route": "Rozpocznij trasę", + "step_of": "Krok {{current}} z {{total}}", + "stop_contact": "Kontakt postoju", + "stop_count": "{{count}} postojów", + "stop_detail": "Szczegóły postoju", + "stop_type_dropoff": "Dostawa", + "stop_type_inspection": "Inspekcja", + "stop_type_pickup": "Odbiór", + "stop_type_service": "Serwis", + "stop_type_standard": "Standard", + "stops": "Postoje", + "stops_completed": "Postoje ukończone", + "summary_stats": "Podsumowanie", + "title": "Trasy", + "total": "Razem", + "total_distance": "Całkowita odległość", + "total_duration": "Całkowity czas", + "type": "Typ", + "unassigned": "Nieprzypisany", + "unit": "Jednostka", + "unit_required": "Aby rozpocząć trasę, należy wybrać jednostkę", + "view_contact": "Wyświetl kontakt", + "view_route": "Wyświetl trasę" + }, "settings": { "about": "O aplikacji", "account": "Konto", @@ -570,12 +697,6 @@ "app_info": "Informacje o aplikacji", "app_name": "Nazwa aplikacji", "arabic": "Arabski", - "swedish": "Szwedzki", - "german": "Niemiecki", - "french": "Francuski", - "italian": "Włoski", - "polish": "Polski", - "ukrainian": "Ukraiński", "audio_device_selection": { "bluetooth_device": "Urządzenie Bluetooth", "current_selection": "Bieżący wybór", @@ -602,10 +723,13 @@ "enter_server_url": "Wpisz URL API Resgrid (np. https://api.resgrid.com)", "enter_username": "Wpisz nazwę użytkownika", "environment": "Środowisko", + "french": "Francuski", "general": "Ogólne", "generale": "Ogólne", + "german": "Niemiecki", "github": "Github", "help_center": "Centrum pomocy", + "italian": "Włoski", "keep_alive": "Keep Alive", "keep_alive_warning": "Ostrzeżenie: Włączenie keep alive zapobiega przejściu urządzenia w tryb uśpienia i może znacznie zwiększyć zużycie baterii.", "keep_screen_on": "Utrzymuj ekran włączony", @@ -622,6 +746,7 @@ "notifications_description": "Włącz powiadomienia, aby otrzymywać alerty i aktualizacje", "notifications_enable": "Włącz powiadomienia", "password": "Hasło", + "polish": "Polski", "preferences": "Preferencje", "privacy": "Polityka prywatności", "privacy_policy": "Polityka prywatności", @@ -636,6 +761,7 @@ "status_page": "Stan systemu", "support": "Wsparcie", "support_us": "Wesprzyj nas", + "swedish": "Szwedzki", "terms": "Warunki usługi", "theme": { "dark": "Ciemny", @@ -644,6 +770,7 @@ "title": "Motyw" }, "title": "Ustawienia", + "ukrainian": "Ukraiński", "unit_selected_successfully": "{{unitName}} wybrana pomyślnie", "unit_selection": "Wybór jednostki", "unit_selection_failed": "Nie udało się wybrać jednostki. Spróbuj ponownie.", @@ -676,121 +803,6 @@ "stations_tab": "Stacje", "status_saved_successfully": "Status zapisany pomyślnie!" }, - "maps": { - "active_layers": "Aktywne warstwy", - "all": "Wszystkie", - "custom": "Niestandardowe", - "custom_maps": "Niestandardowe mapy", - "dispatch_to_zone": "Wyślij do tej strefy", - "error_loading": "Nie udało się załadować danych mapy", - "floor": "Piętro", - "indoor": "Wewnętrzne", - "indoor_maps": "Mapy wewnętrzne", - "layer_toggles": "Przełączniki warstw", - "loading": "Ładowanie map...", - "no_maps": "Brak map", - "no_maps_description": "Brak map dostępnych dla Twojego działu.", - "no_results": "Nie znaleziono wyników", - "search": "Szukaj map...", - "search_maps": "Szukaj map", - "search_placeholder": "Szukaj lokalizacji, stref, regionów...", - "title": "Mapy", - "zone_details": "Szczegóły strefy", - "outdoor": "Zewnętrzne", - "event": "Zdarzenie", - "general": "Ogólne", - "feature": "Cecha", - "indoor_zone": "Strefa wewnętrzna", - "custom_region": "Niestandardowy region", - "floor_count": "{{count}} pięter", - "layers_on": "{{active}} / {{total}} aktywne" - }, - "routes": { - "acknowledge": "Potwierdź", - "active": "Aktywna", - "active_route": "Aktywna trasa", - "address": "Adres", - "cancel_route": "Anuluj trasę", - "cancel_route_confirm": "Czy na pewno chcesz anulować tę trasę?", - "check_in": "Zamelduj się", - "check_out": "Wymelduj się", - "completed": "Ukończono", - "contact": "Kontakt", - "contact_details": "Szczegóły kontaktu", - "current_step": "Bieżący krok", - "description": "Opis", - "deviation": "Odchylenie trasy", - "deviations": "Odchylenia", - "directions": "Wskazówki", - "distance": "Odległość", - "duration": "Czas trwania", - "dwell_time": "Czas postoju", - "end_route": "Zakończ trasę", - "end_route_confirm": "Czy na pewno chcesz zakończyć tę trasę?", - "estimated_distance": "Szacowana odległość", - "estimated_duration": "Szacowany czas", - "eta_to_next": "ETA do następnego postoju", - "geofence_radius": "Promień geofence", - "history": "Historia tras", - "in_progress": "W toku", - "instance_detail": "Instancja trasy", - "loading": "Ładowanie tras...", - "loading_directions": "Ładowanie wskazówek...", - "loading_stops": "Ładowanie postojów...", - "next_step": "Następny krok", - "no_contact": "Brak dostępnych danych kontaktowych", - "no_deviations": "Brak zarejestrowanych odchyleń", - "no_directions": "Brak dostępnych wskazówek", - "no_history": "Brak historii tras", - "no_history_description": "Ukończone trasy będą tutaj widoczne.", - "no_routes": "Brak tras", - "no_routes_description": "Brak planów tras dostępnych dla Twojej jednostki.", - "no_stops": "Brak dostępnych postojów", - "notes": "Notatki", - "notes_placeholder": "Wpisz notatki dla tego postoju...", - "open_in_maps": "Otwórz w mapach", - "pause_route": "Wstrzymaj trasę", - "paused": "Wstrzymano", - "pending": "Oczekujące", - "planned_arrival": "Planowany przyjazd", - "planned_departure": "Planowany odjazd", - "priority": "Priorytet", - "progress": "{{percent}}% ukończone", - "remaining_steps": "Pozostałe kroki", - "resume_route": "Wznów trasę", - "route_summary": "Podsumowanie trasy", - "search": "Szukaj tras...", - "skip": "Pomiń", - "skip_reason": "Powód pominięcia", - "skip_reason_placeholder": "Wpisz powód pominięcia tego postoju", - "skipped": "Pominięto", - "start_route": "Rozpocznij trasę", - "stop_contact": "Kontakt postoju", - "stop_count": "{{count}} postojów", - "stop_detail": "Szczegóły postoju", - "stops": "Postoje", - "stops_completed": "Postoje ukończone", - "summary_stats": "Podsumowanie", - "title": "Trasy", - "total_distance": "Całkowita odległość", - "total_duration": "Całkowity czas", - "type": "Typ", - "unit": "Jednostka", - "destination": "Cel trasy", - "total": "Razem", - "step_of": "Krok {{current}} z {{total}}", - "eta": "ETA", - "skipped_by_driver": "Pominięty przez kierowcę", - "my_unit": "Moja Jednostka", - "all_routes": "Wszystkie Trasy", - "no_routes_description_all": "Brak dostępnych planów tras.", - "select_unit": "Wybierz jednostkę", - "unit_required": "Aby rozpocząć trasę, należy wybrać jednostkę", - "view_route": "Wyświetl trasę", - "view_contact": "Wyświetl kontakt", - "assigned_other_unit": "Ta trasa jest przypisana do innej jednostki i nie może być rozpoczęta z tej jednostki", - "unassigned": "Nieprzypisany" - }, "tabs": { "calls": "Zgłoszenia", "contacts": "Kontakty", @@ -801,4 +813,4 @@ "settings": "Ustawienia" }, "welcome": "Witamy w aplikacji obytes" -} \ No newline at end of file +} diff --git a/src/translations/sv.json b/src/translations/sv.json index 544047f6..369574b5 100644 --- a/src/translations/sv.json +++ b/src/translations/sv.json @@ -503,6 +503,35 @@ "set_as_current_call": "Ange som aktuellt samtal", "view_call_details": "Visa samtalsdetaljer" }, + "maps": { + "active_layers": "Aktiva lager", + "all": "Alla", + "custom": "Anpassad", + "custom_maps": "Anpassade kartor", + "custom_region": "Anpassad region", + "dispatch_to_zone": "Skicka till denna zon", + "error_loading": "Det gick inte att ladda kartdata", + "event": "Händelse", + "feature": "Funktion", + "floor": "Våning", + "floor_count": "{{count}} våningar", + "general": "Allmänt", + "indoor": "Inomhus", + "indoor_maps": "Inomhuskartor", + "indoor_zone": "Inomhuszon", + "layer_toggles": "Lagerväxling", + "layers_on": "{{active}} / {{total}} aktiva", + "loading": "Laddar kartor...", + "no_maps": "Inga kartor", + "no_maps_description": "Inga kartor är tillgängliga för din avdelning.", + "no_results": "Inga resultat hittades", + "outdoor": "Utomhus", + "search": "Sök kartor...", + "search_maps": "Sök kartor", + "search_placeholder": "Sök efter platser, zoner, regioner...", + "title": "Kartor", + "zone_details": "Zondetaljer" + }, "notes": { "actions": { "add": "Lägg till anteckning", @@ -562,6 +591,104 @@ "tap_to_manage": "Tryck för att hantera roller", "unassigned": "Otilldelad" }, + "routes": { + "acknowledge": "Bekräfta", + "active": "Aktiv", + "active_route": "Aktiv rutt", + "address": "Adress", + "all_routes": "Alla Rutter", + "assigned_other_unit": "Denna rutt är tilldelad en annan enhet och kan inte startas från denna enhet", + "cancel_route": "Avbryt rutt", + "cancel_route_confirm": "Är du säker på att du vill avbryta denna rutt?", + "check_in": "Checka in", + "check_out": "Checka ut", + "completed": "Slutförd", + "contact": "Kontakt", + "contact_details": "Kontaktdetaljer", + "current_step": "Aktuellt steg", + "description": "Beskrivning", + "destination": "Ruttmål", + "deviation": "Ruttavvikelse", + "deviations": "Avvikelser", + "directions": "Vägbeskrivning", + "distance": "Avstånd", + "duration": "Varaktighet", + "dwell_time": "Uppehållstid", + "end_route": "Avsluta rutt", + "end_route_confirm": "Är du säker på att du vill avsluta denna rutt?", + "estimated_distance": "Beräknat avstånd", + "estimated_duration": "Beräknad varaktighet", + "eta": "ETA", + "eta_to_next": "ETA till nästa stopp", + "geofence_radius": "Geostängselsradie", + "history": "Rutthistorik", + "in_progress": "Pågår", + "instance_detail": "Ruttinstans", + "loading": "Laddar rutter...", + "loading_directions": "Laddar vägbeskrivning...", + "loading_stops": "Laddar stopp...", + "min": "min", + "my_unit": "Min Enhet", + "next_step": "Nästa steg", + "no_contact": "Ingen kontaktinformation tillgänglig", + "no_deviations": "Inga avvikelser registrerade", + "no_directions": "Ingen vägbeskrivning tillgänglig", + "no_history": "Ingen rutthistorik tillgänglig", + "no_history_description": "Slutförda rutter visas här.", + "no_routes": "Inga rutter", + "no_routes_description": "Inga ruttplaner är tillgängliga för din enhet.", + "no_routes_description_all": "Inga ruttplaner är tillgängliga.", + "no_stops": "Inga stopp tillgängliga", + "notes": "Anteckningar", + "notes_placeholder": "Ange anteckningar för detta stopp...", + "open_in_maps": "Öppna i kartor", + "pause_route": "Pausa rutt", + "paused": "Pausad", + "pending": "Väntande", + "planned_arrival": "Planerad ankomst", + "planned_departure": "Planerad avgång", + "priority": "Prioritet", + "priority_critical": "Kritisk", + "priority_high": "Hög", + "priority_low": "Låg", + "priority_medium": "Medel", + "priority_normal": "Normal", + "progress": "{{percent}}% slutfört", + "remaining_steps": "Återstående steg", + "resume_route": "Återuppta rutt", + "route_summary": "Ruttsammanfattning", + "schedule": "Schema", + "search": "Sök rutter...", + "select_unit": "Välj enhet", + "skip": "Hoppa över", + "skip_reason": "Anledning att hoppa över", + "skip_reason_placeholder": "Ange anledning till att hoppa över detta stopp", + "skipped": "Överhoppad", + "skipped_by_driver": "Hoppades över av föraren", + "start_route": "Starta rutt", + "step_of": "Steg {{current}} av {{total}}", + "stop_contact": "Stoppkontakt", + "stop_count": "{{count}} stopp", + "stop_detail": "Stoppdetaljer", + "stop_type_dropoff": "Avlämning", + "stop_type_inspection": "Inspektion", + "stop_type_pickup": "Upphämtning", + "stop_type_service": "Service", + "stop_type_standard": "Standard", + "stops": "Stopp", + "stops_completed": "Stopp slutförda", + "summary_stats": "Sammanfattning", + "title": "Rutter", + "total": "Totalt", + "total_distance": "Totalt avstånd", + "total_duration": "Total varaktighet", + "type": "Typ", + "unassigned": "Ej tilldelad", + "unit": "Enhet", + "unit_required": "En enhet måste väljas för att starta rutten", + "view_contact": "Visa kontakt", + "view_route": "Visa rutt" + }, "settings": { "about": "Om", "account": "Konto", @@ -570,12 +697,6 @@ "app_info": "Appinfo", "app_name": "Appnamn", "arabic": "Arabiska", - "swedish": "Svenska", - "german": "Tyska", - "french": "Franska", - "italian": "Italienska", - "polish": "Polska", - "ukrainian": "Ukrainska", "audio_device_selection": { "bluetooth_device": "Bluetooth-enhet", "current_selection": "Aktuellt val", @@ -602,10 +723,13 @@ "enter_server_url": "Ange Resgrid API URL (t.ex. https://api.resgrid.com)", "enter_username": "Ange ditt användarnamn", "environment": "Miljö", + "french": "Franska", "general": "Allmänt", "generale": "Allmänt", + "german": "Tyska", "github": "Github", "help_center": "Hjälpcenter", + "italian": "Italienska", "keep_alive": "Håll vid liv", "keep_alive_warning": "Varning: Att aktivera keep alive förhindrar att enheten går i vila och kan öka batteritömningen avsevärt.", "keep_screen_on": "Håll skärmen på", @@ -622,6 +746,7 @@ "notifications_description": "Aktivera aviseringar för att ta emot varningar och uppdateringar", "notifications_enable": "Aktivera aviseringar", "password": "Lösenord", + "polish": "Polska", "preferences": "Inställningar", "privacy": "Integritetspolicy", "privacy_policy": "Integritetspolicy", @@ -636,6 +761,7 @@ "status_page": "Systemstatus", "support": "Support", "support_us": "Stöd oss", + "swedish": "Svenska", "terms": "Användarvillkor", "theme": { "dark": "Mörkt", @@ -644,6 +770,7 @@ "title": "Tema" }, "title": "Inställningar", + "ukrainian": "Ukrainska", "unit_selected_successfully": "{{unitName}} valdes framgångsrikt", "unit_selection": "Enhetsval", "unit_selection_failed": "Det gick inte att välja enhet. Försök igen.", @@ -676,121 +803,6 @@ "stations_tab": "Stationer", "status_saved_successfully": "Status sparades framgångsrikt!" }, - "maps": { - "active_layers": "Aktiva lager", - "all": "Alla", - "custom": "Anpassad", - "custom_maps": "Anpassade kartor", - "dispatch_to_zone": "Skicka till denna zon", - "error_loading": "Det gick inte att ladda kartdata", - "floor": "Våning", - "indoor": "Inomhus", - "indoor_maps": "Inomhuskartor", - "layer_toggles": "Lagerväxling", - "loading": "Laddar kartor...", - "no_maps": "Inga kartor", - "no_maps_description": "Inga kartor är tillgängliga för din avdelning.", - "no_results": "Inga resultat hittades", - "search": "Sök kartor...", - "search_maps": "Sök kartor", - "search_placeholder": "Sök efter platser, zoner, regioner...", - "title": "Kartor", - "zone_details": "Zondetaljer", - "outdoor": "Utomhus", - "event": "Händelse", - "general": "Allmänt", - "feature": "Funktion", - "indoor_zone": "Inomhuszon", - "custom_region": "Anpassad region", - "floor_count": "{{count}} våningar", - "layers_on": "{{active}} / {{total}} aktiva" - }, - "routes": { - "acknowledge": "Bekräfta", - "active": "Aktiv", - "active_route": "Aktiv rutt", - "address": "Adress", - "cancel_route": "Avbryt rutt", - "cancel_route_confirm": "Är du säker på att du vill avbryta denna rutt?", - "check_in": "Checka in", - "check_out": "Checka ut", - "completed": "Slutförd", - "contact": "Kontakt", - "contact_details": "Kontaktdetaljer", - "current_step": "Aktuellt steg", - "description": "Beskrivning", - "deviation": "Ruttavvikelse", - "deviations": "Avvikelser", - "directions": "Vägbeskrivning", - "distance": "Avstånd", - "duration": "Varaktighet", - "dwell_time": "Uppehållstid", - "end_route": "Avsluta rutt", - "end_route_confirm": "Är du säker på att du vill avsluta denna rutt?", - "estimated_distance": "Beräknat avstånd", - "estimated_duration": "Beräknad varaktighet", - "eta_to_next": "ETA till nästa stopp", - "geofence_radius": "Geostängselsradie", - "history": "Rutthistorik", - "in_progress": "Pågår", - "instance_detail": "Ruttinstans", - "loading": "Laddar rutter...", - "loading_directions": "Laddar vägbeskrivning...", - "loading_stops": "Laddar stopp...", - "next_step": "Nästa steg", - "no_contact": "Ingen kontaktinformation tillgänglig", - "no_deviations": "Inga avvikelser registrerade", - "no_directions": "Ingen vägbeskrivning tillgänglig", - "no_history": "Ingen rutthistorik tillgänglig", - "no_history_description": "Slutförda rutter visas här.", - "no_routes": "Inga rutter", - "no_routes_description": "Inga ruttplaner är tillgängliga för din enhet.", - "no_stops": "Inga stopp tillgängliga", - "notes": "Anteckningar", - "notes_placeholder": "Ange anteckningar för detta stopp...", - "open_in_maps": "Öppna i kartor", - "pause_route": "Pausa rutt", - "paused": "Pausad", - "pending": "Väntande", - "planned_arrival": "Planerad ankomst", - "planned_departure": "Planerad avgång", - "priority": "Prioritet", - "progress": "{{percent}}% slutfört", - "remaining_steps": "Återstående steg", - "resume_route": "Återuppta rutt", - "route_summary": "Ruttsammanfattning", - "search": "Sök rutter...", - "skip": "Hoppa över", - "skip_reason": "Anledning att hoppa över", - "skip_reason_placeholder": "Ange anledning till att hoppa över detta stopp", - "skipped": "Överhoppad", - "start_route": "Starta rutt", - "stop_contact": "Stoppkontakt", - "stop_count": "{{count}} stopp", - "stop_detail": "Stoppdetaljer", - "stops": "Stopp", - "stops_completed": "Stopp slutförda", - "summary_stats": "Sammanfattning", - "title": "Rutter", - "total_distance": "Totalt avstånd", - "total_duration": "Total varaktighet", - "type": "Typ", - "unit": "Enhet", - "destination": "Ruttmål", - "total": "Totalt", - "step_of": "Steg {{current}} av {{total}}", - "eta": "ETA", - "skipped_by_driver": "Hoppades över av föraren", - "my_unit": "Min Enhet", - "all_routes": "Alla Rutter", - "no_routes_description_all": "Inga ruttplaner är tillgängliga.", - "select_unit": "Välj enhet", - "unit_required": "En enhet måste väljas för att starta rutten", - "view_route": "Visa rutt", - "view_contact": "Visa kontakt", - "assigned_other_unit": "Denna rutt är tilldelad en annan enhet och kan inte startas från denna enhet", - "unassigned": "Ej tilldelad" - }, "tabs": { "calls": "Samtal", "contacts": "Kontakter", @@ -801,4 +813,4 @@ "settings": "Inställningar" }, "welcome": "Välkommen till obytes app site" -} \ No newline at end of file +} diff --git a/src/translations/uk.json b/src/translations/uk.json index 87b23626..858cf0b8 100644 --- a/src/translations/uk.json +++ b/src/translations/uk.json @@ -503,6 +503,35 @@ "set_as_current_call": "Встановити як поточний виклик", "view_call_details": "Переглянути деталі виклику" }, + "maps": { + "active_layers": "Активні шари", + "all": "Всі", + "custom": "Власні", + "custom_maps": "Власні карти", + "custom_region": "Власний регіон", + "dispatch_to_zone": "Відправити до цієї зони", + "error_loading": "Не вдалося завантажити дані карти", + "event": "Подія", + "feature": "Об'єкт", + "floor": "Поверх", + "floor_count": "{{count}} поверхів", + "general": "Загальне", + "indoor": "Всередині", + "indoor_maps": "Карти приміщень", + "indoor_zone": "Внутрішня зона", + "layer_toggles": "Перемикачі шарів", + "layers_on": "{{active}} / {{total}} активних", + "loading": "Завантаження карт...", + "no_maps": "Немає карт", + "no_maps_description": "Для вашого підрозділу немає доступних карт.", + "no_results": "Результатів не знайдено", + "outdoor": "Зовні", + "search": "Пошук карт...", + "search_maps": "Пошук карт", + "search_placeholder": "Пошук місць, зон, регіонів...", + "title": "Карти", + "zone_details": "Деталі зони" + }, "notes": { "actions": { "add": "Додати примітку", @@ -562,6 +591,104 @@ "tap_to_manage": "Торкніться для управління ролями", "unassigned": "Не призначено" }, + "routes": { + "acknowledge": "Підтвердити", + "active": "Активний", + "active_route": "Активний маршрут", + "address": "Адреса", + "all_routes": "Всі маршрути", + "assigned_other_unit": "Цей маршрут призначено іншому підрозділу і не може бути розпочато з цього підрозділу", + "cancel_route": "Скасувати маршрут", + "cancel_route_confirm": "Ви впевнені, що хочете скасувати цей маршрут?", + "check_in": "Зареєструватися", + "check_out": "Від'їхати", + "completed": "Завершено", + "contact": "Контакт", + "contact_details": "Деталі контакту", + "current_step": "Поточний крок", + "description": "Опис", + "destination": "Пункт призначення маршруту", + "deviation": "Відхилення маршруту", + "deviations": "Відхилення", + "directions": "Маршрут", + "distance": "Відстань", + "duration": "Тривалість", + "dwell_time": "Час стоянки", + "end_route": "Завершити маршрут", + "end_route_confirm": "Ви впевнені, що хочете завершити цей маршрут?", + "estimated_distance": "Орієнтовна відстань", + "estimated_duration": "Орієнтовний час", + "eta": "ОЧП", + "eta_to_next": "Орієнтовний час до наступної зупинки", + "geofence_radius": "Радіус геозони", + "history": "Історія маршрутів", + "in_progress": "В процесі", + "instance_detail": "Екземпляр маршруту", + "loading": "Завантаження маршрутів...", + "loading_directions": "Завантаження маршруту...", + "loading_stops": "Завантаження зупинок...", + "min": "хв", + "my_unit": "Мій підрозділ", + "next_step": "Наступний крок", + "no_contact": "Немає контактної інформації", + "no_deviations": "Відхилень не зареєстровано", + "no_directions": "Маршрут недоступний", + "no_history": "Немає історії маршрутів", + "no_history_description": "Завершені маршрути з'являться тут.", + "no_routes": "Немає маршрутів", + "no_routes_description": "Для вашого підрозділу немає доступних планів маршрутів.", + "no_routes_description_all": "Немає доступних планів маршрутів.", + "no_stops": "Зупинок немає", + "notes": "Примітки", + "notes_placeholder": "Введіть примітки для цієї зупинки...", + "open_in_maps": "Відкрити в картах", + "pause_route": "Призупинити маршрут", + "paused": "Призупинено", + "pending": "Очікує", + "planned_arrival": "Запланований приїзд", + "planned_departure": "Запланований від'їзд", + "priority": "Пріоритет", + "priority_critical": "Критичний", + "priority_high": "Високий", + "priority_low": "Низький", + "priority_medium": "Середній", + "priority_normal": "Нормальний", + "progress": "{{percent}}% завершено", + "remaining_steps": "Кроки, що залишилися", + "resume_route": "Відновити маршрут", + "route_summary": "Підсумок маршруту", + "schedule": "Розклад", + "search": "Пошук маршрутів...", + "select_unit": "Вибрати підрозділ", + "skip": "Пропустити", + "skip_reason": "Причина пропуску", + "skip_reason_placeholder": "Введіть причину пропуску цієї зупинки", + "skipped": "Пропущено", + "skipped_by_driver": "Пропущено водієм", + "start_route": "Почати маршрут", + "step_of": "Крок {{current}} з {{total}}", + "stop_contact": "Контакт зупинки", + "stop_count": "{{count}} зупинок", + "stop_detail": "Деталі зупинки", + "stop_type_dropoff": "Доставка", + "stop_type_inspection": "Інспекція", + "stop_type_pickup": "Збір", + "stop_type_service": "Обслуговування", + "stop_type_standard": "Стандарт", + "stops": "Зупинки", + "stops_completed": "Зупинки завершено", + "summary_stats": "Підсумок", + "title": "Маршрути", + "total": "Всього", + "total_distance": "Загальна відстань", + "total_duration": "Загальний час", + "type": "Тип", + "unassigned": "Не призначено", + "unit": "Підрозділ", + "unit_required": "Для початку маршруту необхідно вибрати підрозділ", + "view_contact": "Переглянути контакт", + "view_route": "Переглянути маршрут" + }, "settings": { "about": "Про додаток", "account": "Обліковий запис", @@ -570,12 +697,6 @@ "app_info": "Інформація про додаток", "app_name": "Назва додатку", "arabic": "Арабська", - "swedish": "Шведська", - "german": "Німецька", - "french": "Французька", - "italian": "Італійська", - "polish": "Польська", - "ukrainian": "Українська", "audio_device_selection": { "bluetooth_device": "Bluetooth-пристрій", "current_selection": "Поточний вибір", @@ -602,10 +723,13 @@ "enter_server_url": "Введіть URL API Resgrid (напр. https://api.resgrid.com)", "enter_username": "Введіть ім'я користувача", "environment": "Середовище", + "french": "Французька", "general": "Загальне", "generale": "Загальне", + "german": "Німецька", "github": "Github", "help_center": "Центр допомоги", + "italian": "Італійська", "keep_alive": "Keep Alive", "keep_alive_warning": "Увага: Увімкнення keep alive запобігає переходу пристрою в режим сну та може значно збільшити споживання заряду батареї.", "keep_screen_on": "Тримати екран увімкненим", @@ -622,6 +746,7 @@ "notifications_description": "Увімкніть сповіщення для отримання попереджень та оновлень", "notifications_enable": "Увімкнути сповіщення", "password": "Пароль", + "polish": "Польська", "preferences": "Налаштування", "privacy": "Політика конфіденційності", "privacy_policy": "Політика конфіденційності", @@ -636,6 +761,7 @@ "status_page": "Стан системи", "support": "Підтримка", "support_us": "Підтримати нас", + "swedish": "Шведська", "terms": "Умови використання", "theme": { "dark": "Темна", @@ -644,6 +770,7 @@ "title": "Тема" }, "title": "Налаштування", + "ukrainian": "Українська", "unit_selected_successfully": "{{unitName}} успішно вибрано", "unit_selection": "Вибір підрозділу", "unit_selection_failed": "Не вдалося вибрати підрозділ. Спробуйте ще раз.", @@ -676,121 +803,6 @@ "stations_tab": "Станції", "status_saved_successfully": "Статус успішно збережено!" }, - "maps": { - "active_layers": "Активні шари", - "all": "Всі", - "custom": "Власні", - "custom_maps": "Власні карти", - "dispatch_to_zone": "Відправити до цієї зони", - "error_loading": "Не вдалося завантажити дані карти", - "floor": "Поверх", - "indoor": "Всередині", - "indoor_maps": "Карти приміщень", - "layer_toggles": "Перемикачі шарів", - "loading": "Завантаження карт...", - "no_maps": "Немає карт", - "no_maps_description": "Для вашого підрозділу немає доступних карт.", - "no_results": "Результатів не знайдено", - "search": "Пошук карт...", - "search_maps": "Пошук карт", - "search_placeholder": "Пошук місць, зон, регіонів...", - "title": "Карти", - "zone_details": "Деталі зони", - "outdoor": "Зовні", - "event": "Подія", - "general": "Загальне", - "feature": "Об'єкт", - "indoor_zone": "Внутрішня зона", - "custom_region": "Власний регіон", - "floor_count": "{{count}} поверхів", - "layers_on": "{{active}} / {{total}} активних" - }, - "routes": { - "acknowledge": "Підтвердити", - "active": "Активний", - "active_route": "Активний маршрут", - "address": "Адреса", - "cancel_route": "Скасувати маршрут", - "cancel_route_confirm": "Ви впевнені, що хочете скасувати цей маршрут?", - "check_in": "Зареєструватися", - "check_out": "Від'їхати", - "completed": "Завершено", - "contact": "Контакт", - "contact_details": "Деталі контакту", - "current_step": "Поточний крок", - "description": "Опис", - "deviation": "Відхилення маршруту", - "deviations": "Відхилення", - "directions": "Маршрут", - "distance": "Відстань", - "duration": "Тривалість", - "dwell_time": "Час стоянки", - "end_route": "Завершити маршрут", - "end_route_confirm": "Ви впевнені, що хочете завершити цей маршрут?", - "estimated_distance": "Орієнтовна відстань", - "estimated_duration": "Орієнтовний час", - "eta_to_next": "Орієнтовний час до наступної зупинки", - "geofence_radius": "Радіус геозони", - "history": "Історія маршрутів", - "in_progress": "В процесі", - "instance_detail": "Екземпляр маршруту", - "loading": "Завантаження маршрутів...", - "loading_directions": "Завантаження маршруту...", - "loading_stops": "Завантаження зупинок...", - "next_step": "Наступний крок", - "no_contact": "Немає контактної інформації", - "no_deviations": "Відхилень не зареєстровано", - "no_directions": "Маршрут недоступний", - "no_history": "Немає історії маршрутів", - "no_history_description": "Завершені маршрути з'являться тут.", - "no_routes": "Немає маршрутів", - "no_routes_description": "Для вашого підрозділу немає доступних планів маршрутів.", - "no_stops": "Зупинок немає", - "notes": "Примітки", - "notes_placeholder": "Введіть примітки для цієї зупинки...", - "open_in_maps": "Відкрити в картах", - "pause_route": "Призупинити маршрут", - "paused": "Призупинено", - "pending": "Очікує", - "planned_arrival": "Запланований приїзд", - "planned_departure": "Запланований від'їзд", - "priority": "Пріоритет", - "progress": "{{percent}}% завершено", - "remaining_steps": "Кроки, що залишилися", - "resume_route": "Відновити маршрут", - "route_summary": "Підсумок маршруту", - "search": "Пошук маршрутів...", - "skip": "Пропустити", - "skip_reason": "Причина пропуску", - "skip_reason_placeholder": "Введіть причину пропуску цієї зупинки", - "skipped": "Пропущено", - "start_route": "Почати маршрут", - "stop_contact": "Контакт зупинки", - "stop_count": "{{count}} зупинок", - "stop_detail": "Деталі зупинки", - "stops": "Зупинки", - "stops_completed": "Зупинки завершено", - "summary_stats": "Підсумок", - "title": "Маршрути", - "total_distance": "Загальна відстань", - "total_duration": "Загальний час", - "type": "Тип", - "unit": "Підрозділ", - "destination": "Пункт призначення маршруту", - "total": "Всього", - "step_of": "Крок {{current}} з {{total}}", - "eta": "ОЧП", - "skipped_by_driver": "Пропущено водієм", - "my_unit": "Мій підрозділ", - "all_routes": "Всі маршрути", - "no_routes_description_all": "Немає доступних планів маршрутів.", - "select_unit": "Вибрати підрозділ", - "unit_required": "Для початку маршруту необхідно вибрати підрозділ", - "view_route": "Переглянути маршрут", - "view_contact": "Переглянути контакт", - "assigned_other_unit": "Цей маршрут призначено іншому підрозділу і не може бути розпочато з цього підрозділу", - "unassigned": "Не призначено" - }, "tabs": { "calls": "Виклики", "contacts": "Контакти", @@ -801,4 +813,4 @@ "settings": "Налаштування" }, "welcome": "Ласкаво просимо до додатку obytes" -} \ No newline at end of file +} From 23ad2dd3d5002a7ecfae65d9c81b0fdc7a6f7ccf Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Fri, 20 Mar 2026 08:39:04 -0700 Subject: [PATCH 3/3] RU-T49 PR#232 fixes --- CLAUDE.md | 155 +++++++++++++++++++++++ src/app/maps/custom/[id].tsx | 2 +- src/app/maps/indoor/[id].tsx | 2 +- src/app/routes/history/instance/[id].tsx | 21 +-- src/app/routes/stop/contact.tsx | 18 +-- src/models/v4/mapping/mappingResults.ts | 4 +- src/stores/routes/store.ts | 12 +- src/translations/ar.json | 12 ++ src/translations/de.json | 12 ++ src/translations/en.json | 12 ++ src/translations/es.json | 12 ++ src/translations/fr.json | 12 ++ src/translations/it.json | 12 ++ src/translations/pl.json | 12 ++ src/translations/sv.json | 12 ++ src/translations/uk.json | 12 ++ 16 files changed, 296 insertions(+), 26 deletions(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..f545094b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,155 @@ + +# Dual-Graph Context Policy + +This project uses a local dual-graph MCP server for efficient context retrieval. + +## MANDATORY: Always follow this order + +1. **Call `graph_continue` first** — before any file exploration, grep, or code reading. + +2. **If `graph_continue` returns `needs_project=true`**: call `graph_scan` with the + current project directory (`pwd`). Do NOT ask the user. + +3. **If `graph_continue` returns `skip=true`**: project has fewer than 5 files. + Do NOT do broad or recursive exploration. Read only specific files if their names + are mentioned, or ask the user what to work on. + +4. **Read `recommended_files`** using `graph_read` — **one call per file**. + - `graph_read` accepts a single `file` parameter (string). Call it separately for each + recommended file. Do NOT pass an array or batch multiple files into one call. + - `recommended_files` may contain `file::symbol` entries (e.g. `src/auth.ts::handleLogin`). + Pass them verbatim to `graph_read(file: "src/auth.ts::handleLogin")` — it reads only + that symbol's lines, not the full file. + - Example: if `recommended_files` is `["src/auth.ts::handleLogin", "src/db.ts"]`, + call `graph_read(file: "src/auth.ts::handleLogin")` and `graph_read(file: "src/db.ts")` + as two separate calls (they can be parallel). + +5. **Check `confidence` and obey the caps strictly:** + - `confidence=high` -> Stop. Do NOT grep or explore further. + - `confidence=medium` -> If recommended files are insufficient, call `fallback_rg` + at most `max_supplementary_greps` time(s) with specific terms, then `graph_read` + at most `max_supplementary_files` additional file(s). Then stop. + - `confidence=low` -> Call `fallback_rg` at most `max_supplementary_greps` time(s), + then `graph_read` at most `max_supplementary_files` file(s). Then stop. + +## Token Usage + +A `token-counter` MCP is available for tracking live token usage. + +- To check how many tokens a large file or text will cost **before** reading it: + `count_tokens({text: ""})` +- To log actual usage after a task completes (if the user asks): + `log_usage({input_tokens: , output_tokens: , description: ""})` +- To show the user their running session cost: + `get_session_stats()` + +Live dashboard URL is printed at startup next to "Token usage". + +## Rules + +- Do NOT use `rg`, `grep`, or bash file exploration before calling `graph_continue`. +- Do NOT do broad/recursive exploration at any confidence level. +- `max_supplementary_greps` and `max_supplementary_files` are hard caps - never exceed them. +- Do NOT dump full chat history. +- Do NOT call `graph_retrieve` more than once per turn. +- After edits, call `graph_register_edit` with the changed files. Use `file::symbol` notation (e.g. `src/auth.ts::handleLogin`) when the edit targets a specific function, class, or hook. + +## Context Store + +Whenever you make a decision, identify a task, note a next step, fact, or blocker during a conversation, append it to `.dual-graph/context-store.json`. + +**Entry format:** +```json +{"type": "decision|task|next|fact|blocker", "content": "one sentence max 15 words", "tags": ["topic"], "files": ["relevant/file.ts"], "date": "YYYY-MM-DD"} +``` + +**To append:** Read the file → add the new entry to the array → Write it back → call `graph_register_edit` on `.dual-graph/context-store.json`. + +**Rules:** +- Only log things worth remembering across sessions (not every minor detail) +- `content` must be under 15 words +- `files` lists the files this decision/task relates to (can be empty) +- Log immediately when the item arises — not at session end + +## Session End + +When the user signals they are done (e.g. "bye", "done", "wrap up", "end session"), proactively update `CONTEXT.md` in the project root with: +- **Current Task**: one sentence on what was being worked on +- **Key Decisions**: bullet list, max 3 items +- **Next Steps**: bullet list, max 3 items + +Keep `CONTEXT.md` under 20 lines total. Do NOT summarize the full conversation — only what's needed to resume next session. + +--- + +# Project: Resgrid Unit (React Native / Expo) + +## Tech Stack + +TypeScript · React Native · Expo (managed, prebuild) · Zustand · React Query · React Hook Form · react-i18next · react-native-mmkv · Axios · @rnmapbox/maps · gluestack-ui · lucide-react-native + +## Code Style + +- Write concise, type-safe TypeScript. Avoid `any`; use precise types and interfaces for props/state. +- Use functional components and hooks; never class components. Use `React.FC` for typed components. +- Enable strict mode in `tsconfig.json`. +- Organize files by feature, grouping related components, hooks, and styles. +- All components must be mobile-friendly and responsive, supporting both iOS and Android. +- This is an Expo managed project using prebuild — **do not make native code changes** outside Expo prebuild capabilities. + +## Naming Conventions + +- Variables and functions: `camelCase` (e.g., `isFetchingData`, `handleUserInput`) +- Components: `PascalCase` (e.g., `UserProfile`, `ChatScreen`) +- Files and directories: `lowercase-hyphenated` (e.g., `user-profile.tsx`, `chat-screen/`) + +## Styling + +- Use `gluestack-ui` components from `components/ui` when available. +- For anything without a Gluestack component, use `StyleSheet.create()` or Styled Components. +- Support both **dark mode and light mode**. +- Follow WCAG accessibility guidelines for mobile. + +## Performance + +- Minimize `useEffect`, `useState`, and heavy computation inside render methods. +- Use `React.memo()` for components with static props. +- Optimize `FlatList` with `removeClippedSubviews`, `maxToRenderPerBatch`, `windowSize`, and `getItemLayout` when items have a consistent size. +- Avoid anonymous functions in `renderItem` or event handlers. + +## Internationalization + +- All user-visible text **must** be wrapped in `t()` from `react-i18next`. +- Translation dictionary files live in `src/translations/`. + +## Libraries — use these, not alternatives + +| Purpose | Library | +|---|---| +| Package manager | `yarn` | +| State management | `zustand` | +| Data fetching | `react-query` | +| Forms | `react-hook-form` | +| i18n | `react-i18next` | +| Local storage | `react-native-mmkv` | +| Secure storage | Expo SecureStore | +| HTTP | `axios` | +| Maps / navigation | `@rnmapbox/maps` | +| Icons | `lucide-react-native` (use directly in markup, not via gluestack Icon wrapper) | + +## Conditional Rendering + +Use ternary `? :` for conditional rendering — **never `&&`**. + +## Testing + +- Use Jest. Generate tests for all new components, services, and logic. +- Ensure tests run without errors before considering a task done. + +## Best Practices + +- Follow React Native's threading model for smooth UI performance. +- Use React Navigation for navigation and deep linking. +- Handle errors gracefully and provide user feedback. +- Implement proper offline support. +- Optimize for low-end devices. diff --git a/src/app/maps/custom/[id].tsx b/src/app/maps/custom/[id].tsx index 1a081ecc..fc6f0ad4 100644 --- a/src/app/maps/custom/[id].tsx +++ b/src/app/maps/custom/[id].tsx @@ -183,7 +183,7 @@ export default function CustomMapViewer() { - {(selectedFeature.name as string) || (selectedFeature.Name as string) || 'Region'} + {(selectedFeature.name as string) || (selectedFeature.Name as string) || t('maps.region')} {selectedFeature.description || selectedFeature.Description ? ( {(selectedFeature.description as string) || (selectedFeature.Description as string)} ) : null} diff --git a/src/app/maps/indoor/[id].tsx b/src/app/maps/indoor/[id].tsx index c0dd0744..be338243 100644 --- a/src/app/maps/indoor/[id].tsx +++ b/src/app/maps/indoor/[id].tsx @@ -219,7 +219,7 @@ export default function IndoorMapViewer() { - {(selectedZone.name as string) || (selectedZone.Name as string) || 'Zone'} + {(selectedZone.name as string) || (selectedZone.Name as string) || t('maps.zone')} {selectedZone.type || selectedZone.Type ? ( diff --git a/src/app/routes/history/instance/[id].tsx b/src/app/routes/history/instance/[id].tsx index ec0e1f0f..5dc8867d 100644 --- a/src/app/routes/history/instance/[id].tsx +++ b/src/app/routes/history/instance/[id].tsx @@ -60,12 +60,12 @@ const STOP_STATUS_COLORS: Record = { [RouteStopStatus.Skipped]: '#f59e0b', }; -const DEVIATION_TYPE_LABELS: Record = { - [RouteDeviationType.OffRoute]: 'Off Route', - [RouteDeviationType.MissedStop]: 'Missed Stop', - [RouteDeviationType.UnexpectedStop]: 'Unexpected Stop', - [RouteDeviationType.SpeedViolation]: 'Speed Violation', - [RouteDeviationType.Other]: 'Other', +const DEVIATION_TYPE_KEYS: Record = { + [RouteDeviationType.OffRoute]: 'routes.deviation_type_off_route', + [RouteDeviationType.MissedStop]: 'routes.deviation_type_missed_stop', + [RouteDeviationType.UnexpectedStop]: 'routes.deviation_type_unexpected_stop', + [RouteDeviationType.SpeedViolation]: 'routes.deviation_type_speed_violation', + [RouteDeviationType.Other]: 'routes.deviation_type_other', }; function formatDuration(seconds: number): string { @@ -399,6 +399,7 @@ export default function RouteInstanceDetail() { // --- Sub-components --- function StopCard({ stop }: { stop: RouteInstanceStopResultData }) { + const { t } = useTranslation(); const statusColor = STOP_STATUS_COLORS[stop.Status] ?? '#9ca3af'; const StopIcon = getStopIcon(stop.Status); @@ -416,9 +417,9 @@ function StopCard({ stop }: { stop: RouteInstanceStopResultData }) { ) : null} - {stop.CheckedInOn ? In: {formatDate(stop.CheckedInOn)} : null} - {stop.CheckedOutOn ? Out: {formatDate(stop.CheckedOutOn)} : null} - {stop.SkippedOn ? Skipped: {formatDate(stop.SkippedOn)} : null} + {stop.CheckedInOn ? {t('routes.check_in')}: {formatDate(stop.CheckedInOn)} : null} + {stop.CheckedOutOn ? {t('routes.check_out')}: {formatDate(stop.CheckedOutOn)} : null} + {stop.SkippedOn ? {t('routes.skipped')}: {formatDate(stop.SkippedOn)} : null} @@ -431,7 +432,7 @@ function StopCard({ stop }: { stop: RouteInstanceStopResultData }) { function DeviationCard({ deviation }: { deviation: RouteDeviationResultData }) { const { t } = useTranslation(); - const typeLabel = DEVIATION_TYPE_LABELS[deviation.Type] ?? t('routes.deviation'); + const typeLabel = DEVIATION_TYPE_KEYS[deviation.Type] ? t(DEVIATION_TYPE_KEYS[deviation.Type]) : t('routes.deviation'); return ( diff --git a/src/app/routes/stop/contact.tsx b/src/app/routes/stop/contact.tsx index 79b10b5a..5bb4b9cd 100644 --- a/src/app/routes/stop/contact.tsx +++ b/src/app/routes/stop/contact.tsx @@ -178,12 +178,12 @@ export default function StopContactScreen() { {/* Phone numbers */} - - - - - - + + + + + + {/* Address */} @@ -234,19 +234,19 @@ export default function StopContactScreen() { {locationGps && ( - Location + {t('routes.location')} )} {entranceGps && ( - Entrance + {t('routes.entrance')} )} {exitGps && ( - Exit + {t('routes.exit')} )} diff --git a/src/models/v4/mapping/mappingResults.ts b/src/models/v4/mapping/mappingResults.ts index 04d3fea5..4a2f9af0 100644 --- a/src/models/v4/mapping/mappingResults.ts +++ b/src/models/v4/mapping/mappingResults.ts @@ -1,7 +1,7 @@ import { type FeatureCollection } from 'geojson'; import { BaseV4Request } from '../baseV4Request'; -import { type CustomMapResultData } from './customMapResultData'; +import { type CustomMapLayerResultData, type CustomMapResultData } from './customMapResultData'; import { type IndoorMapFloorResultData, type IndoorMapResultData } from './indoorMapResultData'; export class GetIndoorMapsResult extends BaseV4Request { @@ -25,7 +25,7 @@ export class GetCustomMapResult extends BaseV4Request { } export class GetCustomMapLayerResult extends BaseV4Request { - public Data: CustomMapResultData = {} as CustomMapResultData; + public Data: CustomMapLayerResultData = {} as CustomMapLayerResultData; } export class GetGeoJSONResult extends BaseV4Request { diff --git a/src/stores/routes/store.ts b/src/stores/routes/store.ts index c582900f..58ca1553 100644 --- a/src/stores/routes/store.ts +++ b/src/stores/routes/store.ts @@ -65,9 +65,9 @@ interface RoutesState { fetchDirections: (instanceId: string) => Promise; // Actions - Stop Interactions - checkIn: (stopId: string, unitId: string, lat: number, lon: number) => Promise; - checkOut: (stopId: string, unitId: string) => Promise; - skip: (stopId: string, reason: string) => Promise; + checkIn: (stopId: string, unitId: string, lat: number, lon: number) => Promise; + checkOut: (stopId: string, unitId: string) => Promise; + skip: (stopId: string, reason: string) => Promise; performGeofenceCheckIn: (unitId: string, lat: number, lon: number) => Promise; updateNotes: (stopId: string, notes: string) => Promise; @@ -288,8 +288,10 @@ export const useRoutesStore = create((set, get) => ({ set({ instanceStops: instanceStops.map((s) => (s.RouteInstanceStopId === stopId ? { ...s, Status: 1, CheckedInOn: new Date().toISOString() } : s)), }); + return true; } catch (error) { set({ error: 'Failed to check in at stop' }); + return false; } }, @@ -300,8 +302,10 @@ export const useRoutesStore = create((set, get) => ({ set({ instanceStops: instanceStops.map((s) => (s.RouteInstanceStopId === stopId ? { ...s, Status: 2, CheckedOutOn: new Date().toISOString() } : s)), }); + return true; } catch (error) { set({ error: 'Failed to check out from stop' }); + return false; } }, @@ -312,8 +316,10 @@ export const useRoutesStore = create((set, get) => ({ set({ instanceStops: instanceStops.map((s) => (s.RouteInstanceStopId === stopId ? { ...s, Status: 3, SkippedOn: new Date().toISOString() } : s)), }); + return true; } catch (error) { set({ error: 'Failed to skip stop' }); + return false; } }, diff --git a/src/translations/ar.json b/src/translations/ar.json index 3d2e3606..ef026ff6 100644 --- a/src/translations/ar.json +++ b/src/translations/ar.json @@ -515,6 +515,7 @@ "feature": "ميزة", "floor": "طابق", "floor_count": "{{count}} طوابق", + "floor_count_one": "{{count}} طابق", "general": "عام", "indoor": "داخلي", "indoor_maps": "خرائط داخلية", @@ -526,10 +527,12 @@ "no_maps_description": "لا توجد خرائط متاحة لقسمك.", "no_results": "لم يتم العثور على نتائج", "outdoor": "خارجي", + "region": "منطقة", "search": "البحث في الخرائط...", "search_maps": "البحث في الخرائط", "search_placeholder": "البحث عن مواقع، مناطق...", "title": "الخرائط", + "zone": "منطقة", "zone_details": "تفاصيل المنطقة" }, "notes": { @@ -609,6 +612,11 @@ "description": "الوصف", "destination": "وجهة المسار", "deviation": "انحراف المسار", + "deviation_type_missed_stop": "توقف فائت", + "deviation_type_off_route": "خارج المسار", + "deviation_type_other": "أخرى", + "deviation_type_speed_violation": "مخالفة سرعة", + "deviation_type_unexpected_stop": "توقف غير متوقع", "deviations": "الانحرافات", "directions": "الاتجاهات", "distance": "المسافة", @@ -616,10 +624,12 @@ "dwell_time": "وقت التوقف", "end_route": "إنهاء المسار", "end_route_confirm": "هل أنت متأكد من إنهاء هذا المسار؟", + "entrance": "مدخل", "estimated_distance": "المسافة المقدرة", "estimated_duration": "المدة المقدرة", "eta": "الوقت المتوقع للوصول", "eta_to_next": "الوقت المتوقع للمحطة التالية", + "exit": "مخرج", "geofence_radius": "نطاق السياج الجغرافي", "history": "سجل المسار", "in_progress": "قيد التنفيذ", @@ -627,6 +637,7 @@ "loading": "جارٍ تحميل المسارات...", "loading_directions": "جارٍ تحميل الاتجاهات...", "loading_stops": "جارٍ تحميل المحطات...", + "location": "الموقع", "min": "دق", "my_unit": "وحدتي", "next_step": "الخطوة التالية", @@ -669,6 +680,7 @@ "step_of": "الخطوة {{current}} من {{total}}", "stop_contact": "جهة اتصال المحطة", "stop_count": "{{count}} محطات", + "stop_count_one": "{{count}} محطة", "stop_detail": "تفاصيل المحطة", "stop_type_dropoff": "تسليم", "stop_type_inspection": "تفتيش", diff --git a/src/translations/de.json b/src/translations/de.json index 43ed1c36..cd32d76c 100644 --- a/src/translations/de.json +++ b/src/translations/de.json @@ -515,6 +515,7 @@ "feature": "Merkmal", "floor": "Etage", "floor_count": "{{count}} Etagen", + "floor_count_one": "{{count}} Etage", "general": "Allgemein", "indoor": "Innenbereich", "indoor_maps": "Innenbereichskarten", @@ -526,10 +527,12 @@ "no_maps_description": "Für Ihre Abteilung sind keine Karten verfügbar.", "no_results": "Keine Ergebnisse gefunden", "outdoor": "Außenbereich", + "region": "Region", "search": "Karten suchen...", "search_maps": "Karten suchen", "search_placeholder": "Standorte, Zonen, Regionen suchen...", "title": "Karten", + "zone": "Zone", "zone_details": "Zonendetails" }, "notes": { @@ -609,6 +612,11 @@ "description": "Beschreibung", "destination": "Routenziel", "deviation": "Routenabweichung", + "deviation_type_missed_stop": "Verpasster Halt", + "deviation_type_off_route": "Außerhalb der Route", + "deviation_type_other": "Sonstiges", + "deviation_type_speed_violation": "Geschwindigkeitsverstoß", + "deviation_type_unexpected_stop": "Unerwarteter Halt", "deviations": "Abweichungen", "directions": "Wegbeschreibung", "distance": "Entfernung", @@ -616,10 +624,12 @@ "dwell_time": "Aufenthaltszeit", "end_route": "Route beenden", "end_route_confirm": "Sind Sie sicher, dass Sie diese Route beenden möchten?", + "entrance": "Eingang", "estimated_distance": "Geschätzte Entfernung", "estimated_duration": "Geschätzte Dauer", "eta": "ETA", "eta_to_next": "ETA bis zum nächsten Stopp", + "exit": "Ausgang", "geofence_radius": "Geofence-Radius", "history": "Routenverlauf", "in_progress": "In Bearbeitung", @@ -627,6 +637,7 @@ "loading": "Routen werden geladen...", "loading_directions": "Wegbeschreibung wird geladen...", "loading_stops": "Stopps werden geladen...", + "location": "Standort", "min": "Min", "my_unit": "Meine Einheit", "next_step": "Nächster Schritt", @@ -669,6 +680,7 @@ "step_of": "Schritt {{current}} von {{total}}", "stop_contact": "Stoppkontakt", "stop_count": "{{count}} Stopps", + "stop_count_one": "{{count}} Stopp", "stop_detail": "Stoppdetails", "stop_type_dropoff": "Lieferung", "stop_type_inspection": "Inspektion", diff --git a/src/translations/en.json b/src/translations/en.json index 95ab15c4..c16527fc 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -515,6 +515,7 @@ "feature": "Feature", "floor": "Floor", "floor_count": "{{count}} floors", + "floor_count_one": "{{count}} floor", "general": "General", "indoor": "Indoor", "indoor_maps": "Indoor Maps", @@ -526,10 +527,12 @@ "no_maps_description": "No maps are available for your department.", "no_results": "No results found", "outdoor": "Outdoor", + "region": "Region", "search": "Search maps...", "search_maps": "Search Maps", "search_placeholder": "Search for locations, zones, regions...", "title": "Maps", + "zone": "Zone", "zone_details": "Zone Details" }, "notes": { @@ -609,6 +612,11 @@ "description": "Description", "destination": "Route Destination", "deviation": "Route Deviation", + "deviation_type_missed_stop": "Missed Stop", + "deviation_type_off_route": "Off Route", + "deviation_type_other": "Other", + "deviation_type_speed_violation": "Speed Violation", + "deviation_type_unexpected_stop": "Unexpected Stop", "deviations": "Deviations", "directions": "Directions", "distance": "Distance", @@ -616,10 +624,12 @@ "dwell_time": "Dwell Time", "end_route": "End Route", "end_route_confirm": "Are you sure you want to end this route?", + "entrance": "Entrance", "estimated_distance": "Estimated Distance", "estimated_duration": "Estimated Duration", "eta": "ETA", "eta_to_next": "ETA to next stop", + "exit": "Exit", "geofence_radius": "Geofence Radius", "history": "Route History", "in_progress": "In Progress", @@ -627,6 +637,7 @@ "loading": "Loading routes...", "loading_directions": "Loading directions...", "loading_stops": "Loading stops...", + "location": "Location", "min": "min", "my_unit": "My Unit", "next_step": "Next Step", @@ -669,6 +680,7 @@ "step_of": "Step {{current}} of {{total}}", "stop_contact": "Stop Contact", "stop_count": "{{count}} stops", + "stop_count_one": "{{count}} stop", "stop_detail": "Stop Detail", "stop_type_dropoff": "Dropoff", "stop_type_inspection": "Inspection", diff --git a/src/translations/es.json b/src/translations/es.json index 5ce3df5f..60973596 100644 --- a/src/translations/es.json +++ b/src/translations/es.json @@ -515,6 +515,7 @@ "feature": "Característica", "floor": "Piso", "floor_count": "{{count}} pisos", + "floor_count_one": "{{count}} piso", "general": "General", "indoor": "Interior", "indoor_maps": "Mapas Interiores", @@ -526,10 +527,12 @@ "no_maps_description": "No hay mapas disponibles para su departamento.", "no_results": "No se encontraron resultados", "outdoor": "Exterior", + "region": "Región", "search": "Buscar mapas...", "search_maps": "Buscar Mapas", "search_placeholder": "Buscar ubicaciones, zonas, regiones...", "title": "Mapas", + "zone": "Zona", "zone_details": "Detalles de Zona" }, "notes": { @@ -609,6 +612,11 @@ "description": "Descripción", "destination": "Destino de Ruta", "deviation": "Desviación de Ruta", + "deviation_type_missed_stop": "Parada Perdida", + "deviation_type_off_route": "Fuera de Ruta", + "deviation_type_other": "Otro", + "deviation_type_speed_violation": "Infracción de Velocidad", + "deviation_type_unexpected_stop": "Parada Inesperada", "deviations": "Desviaciones", "directions": "Direcciones", "distance": "Distancia", @@ -616,10 +624,12 @@ "dwell_time": "Tiempo de Permanencia", "end_route": "Finalizar Ruta", "end_route_confirm": "¿Está seguro de que desea finalizar esta ruta?", + "entrance": "Entrada", "estimated_distance": "Distancia Estimada", "estimated_duration": "Duración Estimada", "eta": "ETA", "eta_to_next": "ETA a la siguiente parada", + "exit": "Salida", "geofence_radius": "Radio de Geocerca", "history": "Historial de Ruta", "in_progress": "En Progreso", @@ -627,6 +637,7 @@ "loading": "Cargando rutas...", "loading_directions": "Cargando direcciones...", "loading_stops": "Cargando paradas...", + "location": "Ubicación", "min": "min", "my_unit": "Mi Unidad", "next_step": "Siguiente Paso", @@ -669,6 +680,7 @@ "step_of": "Paso {{current}} de {{total}}", "stop_contact": "Contacto de Parada", "stop_count": "{{count}} paradas", + "stop_count_one": "{{count}} parada", "stop_detail": "Detalle de Parada", "stop_type_dropoff": "Entrega", "stop_type_inspection": "Inspección", diff --git a/src/translations/fr.json b/src/translations/fr.json index ff7c668d..a61feb5d 100644 --- a/src/translations/fr.json +++ b/src/translations/fr.json @@ -515,6 +515,7 @@ "feature": "Fonctionnalité", "floor": "Étage", "floor_count": "{{count}} étages", + "floor_count_one": "{{count}} étage", "general": "Général", "indoor": "Intérieur", "indoor_maps": "Cartes intérieures", @@ -526,10 +527,12 @@ "no_maps_description": "Aucune carte disponible pour votre département.", "no_results": "Aucun résultat trouvé", "outdoor": "Extérieur", + "region": "Région", "search": "Rechercher des cartes...", "search_maps": "Rechercher des cartes", "search_placeholder": "Rechercher des lieux, zones, régions...", "title": "Cartes", + "zone": "Zone", "zone_details": "Détails de la zone" }, "notes": { @@ -609,6 +612,11 @@ "description": "Description", "destination": "Destination de l'itinéraire", "deviation": "Déviation d'itinéraire", + "deviation_type_missed_stop": "Arrêt manqué", + "deviation_type_off_route": "Hors itinéraire", + "deviation_type_other": "Autre", + "deviation_type_speed_violation": "Excès de vitesse", + "deviation_type_unexpected_stop": "Arrêt inattendu", "deviations": "Déviations", "directions": "Itinéraire", "distance": "Distance", @@ -616,10 +624,12 @@ "dwell_time": "Temps de séjour", "end_route": "Terminer l'itinéraire", "end_route_confirm": "Êtes-vous sûr de vouloir terminer cet itinéraire ?", + "entrance": "Entrée", "estimated_distance": "Distance estimée", "estimated_duration": "Durée estimée", "eta": "ETA", "eta_to_next": "ETA vers le prochain arrêt", + "exit": "Sortie", "geofence_radius": "Rayon de géorepérage", "history": "Historique des itinéraires", "in_progress": "En cours", @@ -627,6 +637,7 @@ "loading": "Chargement des itinéraires...", "loading_directions": "Chargement de l'itinéraire...", "loading_stops": "Chargement des arrêts...", + "location": "Emplacement", "min": "min", "my_unit": "Mon Unité", "next_step": "Étape suivante", @@ -669,6 +680,7 @@ "step_of": "Étape {{current}} sur {{total}}", "stop_contact": "Contact de l'arrêt", "stop_count": "{{count}} arrêts", + "stop_count_one": "{{count}} arrêt", "stop_detail": "Détail de l'arrêt", "stop_type_dropoff": "Dépôt", "stop_type_inspection": "Inspection", diff --git a/src/translations/it.json b/src/translations/it.json index ee3425f4..65c69cc4 100644 --- a/src/translations/it.json +++ b/src/translations/it.json @@ -515,6 +515,7 @@ "feature": "Caratteristica", "floor": "Piano", "floor_count": "{{count}} piani", + "floor_count_one": "{{count}} piano", "general": "Generale", "indoor": "Interno", "indoor_maps": "Mappe interne", @@ -526,10 +527,12 @@ "no_maps_description": "Nessuna mappa disponibile per il tuo dipartimento.", "no_results": "Nessun risultato trovato", "outdoor": "Esterno", + "region": "Regione", "search": "Cerca mappe...", "search_maps": "Cerca mappe", "search_placeholder": "Cerca posizioni, zone, regioni...", "title": "Mappe", + "zone": "Zona", "zone_details": "Dettagli zona" }, "notes": { @@ -609,6 +612,11 @@ "description": "Descrizione", "destination": "Destinazione percorso", "deviation": "Deviazione percorso", + "deviation_type_missed_stop": "Fermata mancata", + "deviation_type_off_route": "Fuori percorso", + "deviation_type_other": "Altro", + "deviation_type_speed_violation": "Violazione velocità", + "deviation_type_unexpected_stop": "Fermata inattesa", "deviations": "Deviazioni", "directions": "Indicazioni", "distance": "Distanza", @@ -616,10 +624,12 @@ "dwell_time": "Tempo di sosta", "end_route": "Termina percorso", "end_route_confirm": "Sei sicuro di voler terminare questo percorso?", + "entrance": "Ingresso", "estimated_distance": "Distanza stimata", "estimated_duration": "Durata stimata", "eta": "ETA", "eta_to_next": "ETA prossima fermata", + "exit": "Uscita", "geofence_radius": "Raggio geofence", "history": "Cronologia percorsi", "in_progress": "In corso", @@ -627,6 +637,7 @@ "loading": "Caricamento percorsi...", "loading_directions": "Caricamento indicazioni...", "loading_stops": "Caricamento fermate...", + "location": "Posizione", "min": "min", "my_unit": "La Mia Unità", "next_step": "Passo successivo", @@ -669,6 +680,7 @@ "step_of": "Passo {{current}} di {{total}}", "stop_contact": "Contatto fermata", "stop_count": "{{count}} fermate", + "stop_count_one": "{{count}} fermata", "stop_detail": "Dettaglio fermata", "stop_type_dropoff": "Consegna", "stop_type_inspection": "Ispezione", diff --git a/src/translations/pl.json b/src/translations/pl.json index 8634dc0f..0e8f510d 100644 --- a/src/translations/pl.json +++ b/src/translations/pl.json @@ -515,6 +515,7 @@ "feature": "Cecha", "floor": "Piętro", "floor_count": "{{count}} pięter", + "floor_count_one": "{{count}} piętro", "general": "Ogólne", "indoor": "Wewnętrzne", "indoor_maps": "Mapy wewnętrzne", @@ -526,10 +527,12 @@ "no_maps_description": "Brak map dostępnych dla Twojego działu.", "no_results": "Nie znaleziono wyników", "outdoor": "Zewnętrzne", + "region": "Region", "search": "Szukaj map...", "search_maps": "Szukaj map", "search_placeholder": "Szukaj lokalizacji, stref, regionów...", "title": "Mapy", + "zone": "Strefa", "zone_details": "Szczegóły strefy" }, "notes": { @@ -609,6 +612,11 @@ "description": "Opis", "destination": "Cel trasy", "deviation": "Odchylenie trasy", + "deviation_type_missed_stop": "Pominięty przystanek", + "deviation_type_off_route": "Poza trasą", + "deviation_type_other": "Inne", + "deviation_type_speed_violation": "Naruszenie prędkości", + "deviation_type_unexpected_stop": "Nieoczekiwany przystanek", "deviations": "Odchylenia", "directions": "Wskazówki", "distance": "Odległość", @@ -616,10 +624,12 @@ "dwell_time": "Czas postoju", "end_route": "Zakończ trasę", "end_route_confirm": "Czy na pewno chcesz zakończyć tę trasę?", + "entrance": "Wejście", "estimated_distance": "Szacowana odległość", "estimated_duration": "Szacowany czas", "eta": "ETA", "eta_to_next": "ETA do następnego postoju", + "exit": "Wyjście", "geofence_radius": "Promień geofence", "history": "Historia tras", "in_progress": "W toku", @@ -627,6 +637,7 @@ "loading": "Ładowanie tras...", "loading_directions": "Ładowanie wskazówek...", "loading_stops": "Ładowanie postojów...", + "location": "Lokalizacja", "min": "min", "my_unit": "Moja Jednostka", "next_step": "Następny krok", @@ -669,6 +680,7 @@ "step_of": "Krok {{current}} z {{total}}", "stop_contact": "Kontakt postoju", "stop_count": "{{count}} postojów", + "stop_count_one": "{{count}} przystanek", "stop_detail": "Szczegóły postoju", "stop_type_dropoff": "Dostawa", "stop_type_inspection": "Inspekcja", diff --git a/src/translations/sv.json b/src/translations/sv.json index 369574b5..198a852d 100644 --- a/src/translations/sv.json +++ b/src/translations/sv.json @@ -515,6 +515,7 @@ "feature": "Funktion", "floor": "Våning", "floor_count": "{{count}} våningar", + "floor_count_one": "{{count}} våning", "general": "Allmänt", "indoor": "Inomhus", "indoor_maps": "Inomhuskartor", @@ -526,10 +527,12 @@ "no_maps_description": "Inga kartor är tillgängliga för din avdelning.", "no_results": "Inga resultat hittades", "outdoor": "Utomhus", + "region": "Region", "search": "Sök kartor...", "search_maps": "Sök kartor", "search_placeholder": "Sök efter platser, zoner, regioner...", "title": "Kartor", + "zone": "Zon", "zone_details": "Zondetaljer" }, "notes": { @@ -609,6 +612,11 @@ "description": "Beskrivning", "destination": "Ruttmål", "deviation": "Ruttavvikelse", + "deviation_type_missed_stop": "Missad hållplats", + "deviation_type_off_route": "Utanför rutten", + "deviation_type_other": "Övrigt", + "deviation_type_speed_violation": "Hastighetsöverträdelse", + "deviation_type_unexpected_stop": "Oväntad hållplats", "deviations": "Avvikelser", "directions": "Vägbeskrivning", "distance": "Avstånd", @@ -616,10 +624,12 @@ "dwell_time": "Uppehållstid", "end_route": "Avsluta rutt", "end_route_confirm": "Är du säker på att du vill avsluta denna rutt?", + "entrance": "Ingång", "estimated_distance": "Beräknat avstånd", "estimated_duration": "Beräknad varaktighet", "eta": "ETA", "eta_to_next": "ETA till nästa stopp", + "exit": "Utgång", "geofence_radius": "Geostängselsradie", "history": "Rutthistorik", "in_progress": "Pågår", @@ -627,6 +637,7 @@ "loading": "Laddar rutter...", "loading_directions": "Laddar vägbeskrivning...", "loading_stops": "Laddar stopp...", + "location": "Plats", "min": "min", "my_unit": "Min Enhet", "next_step": "Nästa steg", @@ -669,6 +680,7 @@ "step_of": "Steg {{current}} av {{total}}", "stop_contact": "Stoppkontakt", "stop_count": "{{count}} stopp", + "stop_count_one": "{{count}} stopp", "stop_detail": "Stoppdetaljer", "stop_type_dropoff": "Avlämning", "stop_type_inspection": "Inspektion", diff --git a/src/translations/uk.json b/src/translations/uk.json index 858cf0b8..14a7fe53 100644 --- a/src/translations/uk.json +++ b/src/translations/uk.json @@ -515,6 +515,7 @@ "feature": "Об'єкт", "floor": "Поверх", "floor_count": "{{count}} поверхів", + "floor_count_one": "{{count}} поверх", "general": "Загальне", "indoor": "Всередині", "indoor_maps": "Карти приміщень", @@ -526,10 +527,12 @@ "no_maps_description": "Для вашого підрозділу немає доступних карт.", "no_results": "Результатів не знайдено", "outdoor": "Зовні", + "region": "Регіон", "search": "Пошук карт...", "search_maps": "Пошук карт", "search_placeholder": "Пошук місць, зон, регіонів...", "title": "Карти", + "zone": "Зона", "zone_details": "Деталі зони" }, "notes": { @@ -609,6 +612,11 @@ "description": "Опис", "destination": "Пункт призначення маршруту", "deviation": "Відхилення маршруту", + "deviation_type_missed_stop": "Пропущена зупинка", + "deviation_type_off_route": "Поза маршрутом", + "deviation_type_other": "Інше", + "deviation_type_speed_violation": "Порушення швидкості", + "deviation_type_unexpected_stop": "Несподівана зупинка", "deviations": "Відхилення", "directions": "Маршрут", "distance": "Відстань", @@ -616,10 +624,12 @@ "dwell_time": "Час стоянки", "end_route": "Завершити маршрут", "end_route_confirm": "Ви впевнені, що хочете завершити цей маршрут?", + "entrance": "Вхід", "estimated_distance": "Орієнтовна відстань", "estimated_duration": "Орієнтовний час", "eta": "ОЧП", "eta_to_next": "Орієнтовний час до наступної зупинки", + "exit": "Вихід", "geofence_radius": "Радіус геозони", "history": "Історія маршрутів", "in_progress": "В процесі", @@ -627,6 +637,7 @@ "loading": "Завантаження маршрутів...", "loading_directions": "Завантаження маршруту...", "loading_stops": "Завантаження зупинок...", + "location": "Розташування", "min": "хв", "my_unit": "Мій підрозділ", "next_step": "Наступний крок", @@ -669,6 +680,7 @@ "step_of": "Крок {{current}} з {{total}}", "stop_contact": "Контакт зупинки", "stop_count": "{{count}} зупинок", + "stop_count_one": "{{count}} зупинка", "stop_detail": "Деталі зупинки", "stop_type_dropoff": "Доставка", "stop_type_inspection": "Інспекція",