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/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/jest-setup.ts b/jest-setup.ts index f4c053b5..7527560f 100644 --- a/jest-setup.ts +++ b/jest-setup.ts @@ -1,5 +1,21 @@ import '@testing-library/react-native/extend-expect'; +// Mock @sentry/react-native — native module (RNSentry) is unavailable in Jest +jest.mock('@sentry/react-native', () => ({ + captureException: jest.fn(), + captureMessage: jest.fn(), + init: jest.fn(), + wrap: jest.fn((fn: any) => fn), + withScope: jest.fn((cb: any) => cb({ setExtra: jest.fn(), setTag: jest.fn() })), + setUser: jest.fn(), + setTag: jest.fn(), + setExtra: jest.fn(), + addBreadcrumb: jest.fn(), + configureScope: jest.fn(), + ReactNavigationInstrumentation: jest.fn(), + ReactNativeTracing: jest.fn(), +})); + // react-hook form setup for testing // @ts-ignore global.window = {}; diff --git a/src/api/mapping/mapping.ts b/src/api/mapping/mapping.ts index aa7d1da0..6c45c38f 100644 --- a/src/api/mapping/mapping.ts +++ b/src/api/mapping/mapping.ts @@ -1,12 +1,59 @@ +import { type FeatureCollection } from 'geojson'; + +import { getBaseApiUrl } from '@/lib/storage/app'; import { type GetMapDataAndMarkersResult } from '@/models/v4/mapping/getMapDataAndMarkersResult'; import { type GetMapLayersResult } from '@/models/v4/mapping/getMapLayersResult'; +import { + type GetAllActiveLayersResult, + type GetCustomMapLayerResult, + 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 +68,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 `${getBaseApiUrl()}/Mapping/GetIndoorMapFloorImage/${encodeURIComponent(floorId)}`; +}; + +export const getCustomMapLayerImageUrl = (layerId: string): string => { + return `${getBaseApiUrl()}/Mapping/GetCustomMapLayerImage/${encodeURIComponent(layerId)}`; +}; + +export const getCustomMapTileUrl = (layerId: string): string => { + return `${getBaseApiUrl()}/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..d04cdd9b --- /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 GetRouteInstancesResult, + type GetRouteInstanceStopsResult, + 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..d2627aed 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,32 @@ 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.setUserData({ custom: { id: userId } }); + } 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'); + 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 +330,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 +375,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 +464,8 @@ export default function TabLayout() { + + diff --git a/src/app/(app)/index.tsx b/src/app/(app)/index.tsx index 0a266a6b..ef9d582c 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,78 @@ 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 +505,75 @@ 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..96ec34e1 --- /dev/null +++ b/src/app/(app)/routes.tsx @@ -0,0 +1,157 @@ +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 { Loading } from '@/components/common/loading'; +import ZeroState from '@/components/common/zero-state'; +import { RouteCard } from '@/components/routes/route-card'; +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/index.tsx b/src/app/login/index.tsx index 563a2d19..218cebf7 100644 --- a/src/app/login/index.tsx +++ b/src/app/login/index.tsx @@ -54,13 +54,7 @@ export default function Login() { return ( <> - setShowServerUrl(true)} - onSsoPress={() => router.push('/login/sso')} - /> + setShowServerUrl(true)} onSsoPress={() => router.push('/login/sso')} /> @@ -44,9 +48,26 @@ export type LoginFormProps = { onSsoPress?: () => void; }; -export const LoginForm = ({ onSubmit = () => { }, isLoading = false, error = undefined, onServerUrlPress, onSsoPress }: 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,28 @@ 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..fc6f0ad4 --- /dev/null +++ b/src/app/maps/custom/[id].tsx @@ -0,0 +1,236 @@ +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 { Loading } from '@/components/common/loading'; +import ZeroState from '@/components/common/zero-state'; +import Mapbox from '@/components/maps/mapbox'; +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?: { 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) || t('maps.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..db250a26 --- /dev/null +++ b/src/app/maps/index.tsx @@ -0,0 +1,205 @@ +import { router, useFocusEffect } 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..be338243 --- /dev/null +++ b/src/app/maps/indoor/[id].tsx @@ -0,0 +1,249 @@ +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 { 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 { 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) || t('maps.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..d9038d72 --- /dev/null +++ b/src/app/maps/search.tsx @@ -0,0 +1,188 @@ +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..789da4aa --- /dev/null +++ b/src/app/routes/active.tsx @@ -0,0 +1,432 @@ +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?.Geometry) { + return parseRouteGeometry(directions.Geometry); + } + if (activeInstance?.ActualRouteGeometry) { + return parseRouteGeometry(activeInstance.ActualRouteGeometry); + } + return null; + }, [directions?.Geometry, 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; + fetchRouteProgress(resolvedInstanceId); + fetchStopsForInstance(resolvedInstanceId); + fetchDirections(resolvedInstanceId); + fetchDeviations(); + }, [resolvedInstanceId, fetchRouteProgress, 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 () => { + try { + await endRouteInstance(resolvedInstanceId); + router.back(); + } catch { + Alert.alert(t('common.error'), t('common.errorOccurred')); + } + }, + }, + ]); + }, [resolvedInstanceId, endRouteInstance, router, t]); + + 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..f7a7ed54 --- /dev/null +++ b/src/app/routes/directions.tsx @@ -0,0 +1,245 @@ +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 { Loading } from '@/components/common/loading'; +import { Camera, LineLayer, MapView, PointAnnotation, ShapeSource, StyleURL, UserLocation } from '@/components/maps/mapbox'; +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 { RouteStopStatus } from '@/models/v4/routes/routeInstanceStopResultData'; +import { useRoutesStore } from '@/stores/routes/store'; + +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..408a1aa9 --- /dev/null +++ b/src/app/routes/history/[planId].tsx @@ -0,0 +1,181 @@ +import { useFocusEffect } from '@react-navigation/native'; +import { router, Stack, useLocalSearchParams } 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 | null | undefined): 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 ?? 0)} + + + + + {formatDuration(item.TotalDurationSeconds ?? 0)} + + + + + {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..5dc8867d --- /dev/null +++ b/src/app/routes/history/instance/[id].tsx @@ -0,0 +1,456 @@ +import { useFocusEffect } from '@react-navigation/native'; +import { Stack, useLocalSearchParams } from 'expo-router'; +import { AlertTriangle, CheckCircle, Clock, MapPin, Navigation, SkipForward, XCircle } from 'lucide-react-native'; +import React, { useCallback, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ScrollView, View } from 'react-native'; + +import { getRoutePlan, getRouteProgress, getStopsForInstance, getUnacknowledgedDeviations } from '@/api/routes/routes'; +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 { type RoutePlanResultData } from '@/models/v4/routes/routePlanResultData'; + +// --- 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_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 { + 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 | null | undefined): 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 }>(); + // Local history state — never touches the global live-route slices + const [activeInstance, setActiveInstance] = useState(null); + const [instanceStops, setInstanceStops] = useState([]); + const [deviations, setDeviations] = useState([]); + const [activePlan, setActivePlan] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [isLoadingStops, setIsLoadingStops] = useState(false); + const [error, setError] = useState(null); + + useFocusEffect( + useCallback(() => { + if (!instanceId) return; + setIsLoading(true); + setError(null); + getRouteProgress(instanceId) + .then((r) => setActiveInstance(r.Data)) + .catch(() => setError('Failed to load instance')) + .finally(() => setIsLoading(false)); + setIsLoadingStops(true); + getStopsForInstance(instanceId) + .then((r) => setInstanceStops(Array.isArray(r.Data) ? r.Data : [])) + .catch(() => {}) + .finally(() => setIsLoadingStops(false)); + getUnacknowledgedDeviations() + .then((r) => setDeviations(Array.isArray(r.Data) ? r.Data : [])) + .catch(() => {}); + }, [instanceId]) + ); + + // Fetch the plan when the instance loads so we can show planned geometry + useFocusEffect( + useCallback(() => { + if (activeInstance?.RoutePlanId) { + getRoutePlan(activeInstance.RoutePlanId) + .then((r) => setActivePlan(r.Data)) + .catch(() => {}); + } + }, [activeInstance?.RoutePlanId]) + ); + + // Parse route geometries + const actualRouteGeoJSON = useMemo(() => (activeInstance?.ActualRouteGeometry ? parseRouteGeometry(activeInstance.ActualRouteGeometry) : null), [activeInstance?.ActualRouteGeometry]); + + const plannedRouteGeoJSON = useMemo(() => (activePlan?.MapboxRouteGeometry ? 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 ?? 0)} + {t('routes.distance')} + + + + + {formatDuration(activeInstance.TotalDurationSeconds ?? 0)} + {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 { t } = useTranslation(); + 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 ? {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} + + + + #{stop.StopOrder} + + + + ); +} + +function DeviationCard({ deviation }: { deviation: RouteDeviationResultData }) { + const { t } = useTranslation(); + const typeLabel = DEVIATION_TYPE_KEYS[deviation.Type] ? t(DEVIATION_TYPE_KEYS[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..f8db0642 --- /dev/null +++ b/src/app/routes/index.tsx @@ -0,0 +1,146 @@ +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 { Loading } from '@/components/common/loading'; +import ZeroState from '@/components/common/zero-state'; +import { RouteCard } from '@/components/routes/route-card'; +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..26942f98 --- /dev/null +++ b/src/app/routes/start.tsx @@ -0,0 +1,371 @@ +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 { Loading } from '@/components/common/loading'; +import ZeroState from '@/components/common/zero-state'; +import { Camera, MapView, PointAnnotation, StyleURL } from '@/components/maps/mapbox'; +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..e928a171 --- /dev/null +++ b/src/app/routes/stop/[id].tsx @@ -0,0 +1,383 @@ +import { format } from 'date-fns'; +import { router, Stack, 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 { Modal, ScrollView, StyleSheet, TextInput, TouchableOpacity, View } from 'react-native'; + +import { Loading } from '@/components/common/loading'; +import { Camera, FillLayer, LineLayer, MapView, PointAnnotation, ShapeSource, StyleURL } from '@/components/maps/mapbox'; +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 { 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'; + +const STOP_TYPE_LABELS: Record = { + 0: 'routes.stop_type_standard', + 1: 'routes.stop_type_pickup', + 2: 'routes.stop_type_dropoff', + 3: 'routes.stop_type_service', + 4: 'routes.stop_type_inspection', +}; + +const PRIORITY_CONFIG: Record = { + 0: { labelKey: 'routes.priority_normal', action: 'muted' }, + 1: { labelKey: 'routes.priority_low', action: 'info' }, + 2: { labelKey: 'routes.priority_medium', action: 'warning' }, + 3: { labelKey: 'routes.priority_high', action: 'error' }, + 4: { labelKey: 'routes.priority_critical', action: 'error' }, +}; + +const STATUS_LABELS: Record = { + [RouteStopStatus.Pending]: 'routes.pending', + [RouteStopStatus.InProgress]: 'routes.in_progress', + [RouteStopStatus.Completed]: 'routes.completed', + [RouteStopStatus.Skipped]: 'routes.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 activeUnitId = useCoreStore((s) => s.activeUnitId); + 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); + const [skipModalVisible, setSkipModalVisible] = useState(false); + const [skipReason, setSkipReason] = useState(''); + + 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] ?? 'common.unknown'; + const statusLabel = STATUS_LABELS[stop?.Status ?? 0] ?? 'common.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 || !activeUnitId) return; + const lat = userLat ?? 0; + const lon = userLon ?? 0; + await checkIn(stop.RouteInstanceStopId, activeUnitId, lat, lon); + }, [stop, activeUnitId, userLat, userLon, checkIn]); + + const handleCheckOut = useCallback(async () => { + if (!stop || !activeUnitId) return; + await checkOut(stop.RouteInstanceStopId, activeUnitId); + }, [stop, activeUnitId, checkOut]); + + const handleSkip = useCallback(() => { + if (!stop) return; + setSkipReason(''); + setSkipModalVisible(true); + }, [stop]); + + const handleSkipConfirm = useCallback(() => { + if (!stop) return; + setSkipModalVisible(false); + skip(stop.RouteInstanceStopId, skipReason.trim() || t('routes.skipped_by_driver')); + setSkipReason(''); + }, [stop, skipReason, skip, t]); + + 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 */} + + + {t(priorityConfig.labelKey)} + + + {t(stopTypeLabel)} + + + {t(statusLabel)} + + + + + {/* Planned times */} + + + + {t('routes.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} {t('routes.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 */} + + + + {/* Skip reason modal */} + setSkipModalVisible(false)}> + + + + {t('routes.skip')} — {stop.Name} + + {t('routes.skip_reason')} + + + setSkipModalVisible(false)}> + {t('common.cancel')} + + + {t('routes.skip')} + + + + + + + ); +} + +const styles = StyleSheet.create({ + map: { + flex: 1, + }, + markerContainer: { + 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 new file mode 100644 index 00000000..5bb4b9cd --- /dev/null +++ b/src/app/routes/stop/contact.tsx @@ -0,0 +1,299 @@ +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 { 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'; +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 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, t]); + + 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, t]); + + 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(() => { + const coords = locationGps ?? entranceGps ?? exitGps; + if (coords) { + openInMaps(coords.lat, coords.lon, displayName); + } + }, [locationGps, entranceGps, exitGps, 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} + + {mapCenter && } + + + + )} + + {/* Mini Map */} + {mapCenter && ( + + + + {locationGps && ( + + + + + + )} + {entranceGps && ( + + + + + + )} + {exitGps && ( + + + + + + )} + + {/* Legend */} + + {locationGps && ( + + + {t('routes.location')} + + )} + {entranceGps && ( + + + {t('routes.entrance')} + + )} + {exitGps && ( + + + {t('routes.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..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,12 +714,355 @@ 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}; @@ -644,6 +1080,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..f511eae3 --- /dev/null +++ b/src/components/routes/route-card.tsx @@ -0,0 +1,104 @@ +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..e5c72594 --- /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 = () => {