diff --git a/src/app/(app)/_layout.tsx b/src/app/(app)/_layout.tsx
index 937c203d..a09fa012 100644
--- a/src/app/(app)/_layout.tsx
+++ b/src/app/(app)/_layout.tsx
@@ -4,7 +4,6 @@ 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 { CloudAlert, 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, type ColorValue, Platform, StyleSheet, useWindowDimensions } from 'react-native';
@@ -17,6 +16,7 @@ import { FocusAwareStatusBar, View } from '@/components/ui';
import { Button, ButtonText } from '@/components/ui/button';
import { Drawer, DrawerBackdrop, DrawerBody, DrawerContent, DrawerFooter, DrawerHeader } from '@/components/ui/drawer';
import { Icon } from '@/components/ui/icon';
+import { CloudAlert, Contact, ListTree, Map, Megaphone, Menu, Navigation, Notebook, Settings } from '@/components/ui/lucide-icons';
import { Pressable } from '@/components/ui/pressable';
import { Text } from '@/components/ui/text';
import { useAnalytics } from '@/hooks/use-analytics';
diff --git a/src/app/(app)/settings.tsx b/src/app/(app)/settings.tsx
index 92d10a03..40e28421 100644
--- a/src/app/(app)/settings.tsx
+++ b/src/app/(app)/settings.tsx
@@ -11,6 +11,7 @@ import { KeepAliveItem } from '@/components/settings/keep-alive-item';
import { LanguageItem } from '@/components/settings/language-item';
import { LoginInfoBottomSheet } from '@/components/settings/login-info-bottom-sheet';
import { ModernNotificationSoundsItem } from '@/components/settings/modern-notification-sounds-item';
+import { RealtimeGeolocationItem } from '@/components/settings/realtime-geolocation-item';
import { ServerUrlBottomSheet } from '@/components/settings/server-url-bottom-sheet';
import { ThemeItem } from '@/components/settings/theme-item';
import { ToggleItem } from '@/components/settings/toggle-item';
@@ -130,6 +131,7 @@ export default function Settings() {
+
diff --git a/src/app/call/new/index.tsx b/src/app/call/new/index.tsx
index 13322d2d..7abe358c 100644
--- a/src/app/call/new/index.tsx
+++ b/src/app/call/new/index.tsx
@@ -2,7 +2,6 @@ import { zodResolver } from '@hookform/resolvers/zod';
import axios from 'axios';
import * as Location from 'expo-location';
import { router, Stack } from 'expo-router';
-import { ChevronDownIcon, PlusIcon, SearchIcon } from 'lucide-react-native';
import { useColorScheme } from 'nativewind';
import React, { useEffect, useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
@@ -26,6 +25,7 @@ import { Card } from '@/components/ui/card';
import { FocusAwareStatusBar } from '@/components/ui/focus-aware-status-bar';
import { FormControl, FormControlError, FormControlLabel, FormControlLabelText } from '@/components/ui/form-control';
import { Input, InputField } from '@/components/ui/input';
+import { ChevronDownIcon, PlusIcon, SearchIcon } from '@/components/ui/lucide-icons';
import { Select, SelectBackdrop, SelectContent, SelectIcon, SelectInput, SelectItem, SelectPortal, SelectTrigger } from '@/components/ui/select';
import { Text } from '@/components/ui/text';
import { Textarea, TextareaInput } from '@/components/ui/textarea';
diff --git a/src/app/login/login-form.tsx b/src/app/login/login-form.tsx
index 1b9c4cf1..d6bc6a7f 100644
--- a/src/app/login/login-form.tsx
+++ b/src/app/login/login-form.tsx
@@ -1,6 +1,4 @@
import { zodResolver } from '@hookform/resolvers/zod';
-import { AlertTriangle, EyeIcon, EyeOffIcon, Globe, ShieldCheck } from 'lucide-react-native';
-import { ChevronDownIcon } from 'lucide-react-native';
import { useColorScheme } from 'nativewind';
import React, { useState } from 'react';
import type { SubmitHandler } from 'react-hook-form';
@@ -14,6 +12,8 @@ import { View } from '@/components/ui';
import { Button, ButtonSpinner, ButtonText } from '@/components/ui/button';
import { FormControl, FormControlError, FormControlErrorIcon, FormControlErrorText, FormControlLabel, FormControlLabelText } from '@/components/ui/form-control';
import { Input, InputField, InputIcon, InputSlot } from '@/components/ui/input';
+import { AlertTriangle, EyeIcon, EyeOffIcon, Globe, ShieldCheck } from '@/components/ui/lucide-icons';
+import { ChevronDownIcon } from '@/components/ui/lucide-icons';
import { Select, SelectBackdrop, SelectContent, SelectDragIndicator, SelectDragIndicatorWrapper, SelectIcon, SelectInput, SelectItem, SelectPortal, SelectTrigger } from '@/components/ui/select';
import { Text } from '@/components/ui/text';
import colors from '@/constants/colors';
diff --git a/src/app/login/sso.tsx b/src/app/login/sso.tsx
index 5b1a64d7..f910884d 100644
--- a/src/app/login/sso.tsx
+++ b/src/app/login/sso.tsx
@@ -1,7 +1,6 @@
import { zodResolver } from '@hookform/resolvers/zod';
import * as Linking from 'expo-linking';
import { useRouter } from 'expo-router';
-import { AlertTriangle, ArrowLeft, ShieldCheck } from 'lucide-react-native';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
@@ -13,6 +12,7 @@ import { FocusAwareStatusBar, View } from '@/components/ui';
import { Button, ButtonSpinner, ButtonText } from '@/components/ui/button';
import { FormControl, FormControlError, FormControlErrorIcon, FormControlErrorText, FormControlLabel, FormControlLabelText } from '@/components/ui/form-control';
import { Input, InputField, InputSlot } from '@/components/ui/input';
+import { AlertTriangle, ArrowLeft, ShieldCheck } from '@/components/ui/lucide-icons';
import { Modal, ModalBackdrop, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/ui/modal';
import { Text } from '@/components/ui/text';
import colors from '@/constants/colors';
diff --git a/src/components/calls/call-notes-modal.tsx b/src/components/calls/call-notes-modal.tsx
index 17cc4f09..3784a8a0 100644
--- a/src/components/calls/call-notes-modal.tsx
+++ b/src/components/calls/call-notes-modal.tsx
@@ -1,10 +1,10 @@
-import { SearchIcon, X } from 'lucide-react-native';
import { useColorScheme } from 'nativewind';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FlatList, Keyboard, Modal, SafeAreaView, StyleSheet, TouchableOpacity, View } from 'react-native';
import { KeyboardAvoidingView } from 'react-native-keyboard-controller';
+import { SearchIcon, X } from '@/components/ui/lucide-icons';
import { useAnalytics } from '@/hooks/use-analytics';
import { useAuthStore } from '@/lib/auth';
import { logger } from '@/lib/logging';
diff --git a/src/components/calls/dispatch-selection-modal.tsx b/src/components/calls/dispatch-selection-modal.tsx
index cdbe51d5..d05836e5 100644
--- a/src/components/calls/dispatch-selection-modal.tsx
+++ b/src/components/calls/dispatch-selection-modal.tsx
@@ -1,4 +1,3 @@
-import { CheckIcon, SearchIcon, UsersIcon, X } from 'lucide-react-native';
import { useColorScheme } from 'nativewind';
import React, { useCallback, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -11,6 +10,7 @@ import { Button, ButtonText } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { HStack } from '@/components/ui/hstack';
import { Input, InputField } from '@/components/ui/input';
+import { CheckIcon, SearchIcon, UsersIcon, X } from '@/components/ui/lucide-icons';
import { Text } from '@/components/ui/text';
import { VStack } from '@/components/ui/vstack';
import { type DispatchSelection, useDispatchStore } from '@/stores/dispatch/store';
diff --git a/src/components/common/loading.tsx b/src/components/common/loading.tsx
index b817c452..88fe196e 100644
--- a/src/components/common/loading.tsx
+++ b/src/components/common/loading.tsx
@@ -1,8 +1,9 @@
-import { Box, Loader2 } from 'lucide-react-native';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { View } from 'react-native';
+import { Box, Loader2 } from '@/components/ui/lucide-icons';
+
import { HStack } from '../ui/hstack';
import { Spinner } from '../ui/spinner';
import { Text } from '../ui/text';
diff --git a/src/components/contacts/contact-card.tsx b/src/components/contacts/contact-card.tsx
index 5e2841de..dab3b3fc 100644
--- a/src/components/contacts/contact-card.tsx
+++ b/src/components/contacts/contact-card.tsx
@@ -1,8 +1,8 @@
-import { BuildingIcon, MailIcon, PhoneIcon, StarIcon, UserIcon } from 'lucide-react-native';
import React from 'react';
import { Pressable, Text, View } from 'react-native';
import { Avatar, AvatarImage } from '@/components/ui/avatar';
+import { BuildingIcon, MailIcon, PhoneIcon, StarIcon, UserIcon } from '@/components/ui/lucide-icons';
import { type ContactResultData, ContactType } from '@/models/v4/contacts/contactResultData';
interface ContactCardProps {
diff --git a/src/components/contacts/contact-details-sheet.tsx b/src/components/contacts/contact-details-sheet.tsx
index b7ce7ee4..f5b90e6c 100644
--- a/src/components/contacts/contact-details-sheet.tsx
+++ b/src/components/contacts/contact-details-sheet.tsx
@@ -1,3 +1,8 @@
+import React, { useCallback, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Linking, Platform, ScrollView, useWindowDimensions, View } from 'react-native';
+
+import { Avatar, AvatarImage } from '@/components/ui/avatar';
import {
BuildingIcon,
CalendarIcon,
@@ -15,12 +20,7 @@ import {
TrashIcon,
UserIcon,
X,
-} from 'lucide-react-native';
-import React, { useCallback, useState } from 'react';
-import { useTranslation } from 'react-i18next';
-import { Linking, Platform, ScrollView, useWindowDimensions, View } from 'react-native';
-
-import { Avatar, AvatarImage } from '@/components/ui/avatar';
+} from '@/components/ui/lucide-icons';
import { useAnalytics } from '@/hooks/use-analytics';
import { logger } from '@/lib/logging';
import { ContactType } from '@/models/v4/contacts/contactResultData';
diff --git a/src/components/notes/note-details-sheet.tsx b/src/components/notes/note-details-sheet.tsx
index 4d5a34de..0a624fd5 100644
--- a/src/components/notes/note-details-sheet.tsx
+++ b/src/components/notes/note-details-sheet.tsx
@@ -1,9 +1,9 @@
-import { Calendar, Tag, X } from 'lucide-react-native';
import React, { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { StyleSheet } from 'react-native';
import { HtmlRenderer } from '@/components/ui/html-renderer';
+import { Calendar, Tag, X } from '@/components/ui/lucide-icons';
import { useAnalytics } from '@/hooks/use-analytics';
import { formatDateForDisplay, parseDateISOString } from '@/lib/utils';
import { useNotesStore } from '@/stores/notes/store';
diff --git a/src/components/notifications/NotificationButton.tsx b/src/components/notifications/NotificationButton.tsx
index 605dd4bc..afe5069d 100644
--- a/src/components/notifications/NotificationButton.tsx
+++ b/src/components/notifications/NotificationButton.tsx
@@ -1,5 +1,6 @@
import { useCounts } from '@novu/react-native';
import { BellIcon } from 'lucide-react-native';
+import { useColorScheme } from 'nativewind';
import React from 'react';
import { useTranslation } from 'react-i18next';
@@ -11,6 +12,7 @@ interface NotificationButtonProps {
export const NotificationButton = ({ onPress }: NotificationButtonProps) => {
const { t } = useTranslation();
+ const { colorScheme } = useColorScheme();
const { counts, isLoading } = useCounts({
filters: [
{
@@ -24,7 +26,7 @@ export const NotificationButton = ({ onPress }: NotificationButtonProps) => {
return (
-
+
{counts?.[0]?.count && counts?.[0]?.count > 0 ? (
diff --git a/src/components/notifications/NotificationDetail.tsx b/src/components/notifications/NotificationDetail.tsx
index 8c6a1d16..e84c4eff 100644
--- a/src/components/notifications/NotificationDetail.tsx
+++ b/src/components/notifications/NotificationDetail.tsx
@@ -1,6 +1,7 @@
import { ArrowLeft, Calendar, ExternalLink, Trash2 } from 'lucide-react-native';
+import { useColorScheme } from 'nativewind';
import React from 'react';
-import { Animated, Appearance, Dimensions, Platform, Pressable, SafeAreaView, StatusBar, StyleSheet, Text, View } from 'react-native';
+import { Animated, Dimensions, Platform, Pressable, SafeAreaView, StatusBar, type StyleProp, StyleSheet, Text, type TextStyle, View, type ViewStyle } from 'react-native';
// Define the interface directly in this file
interface NotificationPayload {
@@ -26,7 +27,93 @@ const { width } = Dimensions.get('window');
const SIDEBAR_WIDTH = Math.min(width * 0.85, 400);
const STATUS_BAR_HEIGHT = Platform.OS === 'ios' ? 44 : StatusBar.currentHeight || 0;
+// Color-dependent rules live here instead of the module-level StyleSheet so they follow the
+// app theme (nativewind) rather than the system theme captured once at app launch.
+const createThemedStyles = (isDark: boolean) =>
+ StyleSheet.create({
+ sidebarContainer: {
+ backgroundColor: isDark ? '#171717' : '#fff',
+ shadowColor: isDark ? '#262626' : '#e5e5e5',
+ },
+ header: {
+ borderBottomColor: isDark ? '#333333' : '#e5e5e5',
+ },
+ headerTitle: {
+ color: isDark ? '#f3f4f6' : '#111827',
+ },
+ dateText: {
+ color: isDark ? '#9ca3af' : '#6b7280',
+ },
+ timeText: {
+ color: isDark ? '#9ca3af' : '#6b7280',
+ },
+ typeTagText: {
+ color: isDark ? '#f3f4f6' : '#111827',
+ },
+ typeTagDefault: {
+ backgroundColor: isDark ? '#374151' : '#e5e7eb',
+ },
+ typeTagInfo: {
+ backgroundColor: isDark ? '#1e40af' : '#dbeafe',
+ },
+ typeTagSuccess: {
+ backgroundColor: isDark ? '#065f46' : '#d1fae5',
+ },
+ typeTagWarning: {
+ backgroundColor: isDark ? '#92400e' : '#fef3c7',
+ },
+ typeTagAlert: {
+ backgroundColor: isDark ? '#991b1b' : '#fee2e2',
+ },
+ title: {
+ color: isDark ? '#f3f4f6' : '#111827',
+ },
+ bodyContainer: {
+ backgroundColor: isDark ? '#262626' : '#f9fafb',
+ },
+ body: {
+ color: isDark ? '#e5e5e5' : '#374151',
+ },
+ metadataDetailsContainer: {
+ backgroundColor: isDark ? '#262626' : '#f9fafb',
+ },
+ metadataTitle: {
+ color: isDark ? '#f3f4f6' : '#111827',
+ },
+ metadataKey: {
+ color: isDark ? '#9ca3af' : '#6b7280',
+ },
+ metadataValue: {
+ color: isDark ? '#e5e5e5' : '#111827',
+ },
+ buttonText: {
+ color: isDark ? '#60a5fa' : '#2563eb',
+ },
+ referenceButton: {
+ backgroundColor: isDark ? '#1e3a8a' : '#dbeafe',
+ borderColor: isDark ? '#3b82f6' : '#60a5fa',
+ },
+ });
+
+type ThemedStyles = ReturnType;
+
+// lucide-react-native icons default to `stroke="currentColor"`, which react-native-svg resolves
+// from the `color` *prop* only — a nativewind `className` (or a `color` key in a style object)
+// lands in `style` and is dropped, so the icons rendered near-black on the dark sidebar.
+const createIconColors = (isDark: boolean) => ({
+ accent: isDark ? '#60a5fa' : '#3b82f6',
+ danger: isDark ? '#f87171' : '#ef4444',
+ muted: isDark ? '#9ca3af' : '#6b7280',
+});
+
+const useThemedStyles = () => {
+ const { colorScheme } = useColorScheme();
+ const isDark = colorScheme === 'dark';
+ return React.useMemo(() => ({ themed: createThemedStyles(isDark), iconColors: createIconColors(isDark) }), [isDark]);
+};
+
export const NotificationDetail = ({ notification, onClose, onDelete, onNavigateToReference }: NotificationDetailProps) => {
+ const { themed, iconColors } = useThemedStyles();
const slideAnim = React.useRef(new Animated.Value(0)).current;
const fadeAnim = React.useRef(new Animated.Value(1)).current;
@@ -79,55 +166,55 @@ export const NotificationDetail = ({ notification, onClose, onDelete, onNavigate
-
+
-
+
-
+
- Notification
+ Notification
-
+
-
- {formattedDate}
+
+ {formattedDate}
- {formattedTime}
+ {formattedTime}
{notification.type ? (
-
- {notification.type}
+
+ {notification.type}
) : null}
- {notification.title ? {notification.title} : null}
+ {notification.title ? {notification.title} : null}
-
- {notification.body}
+
+ {notification.body}
{notification.metadata && Object.keys(notification.metadata).length > 0 ? (
-
- Additional Information
+
+ Additional Information
{Object.entries(notification.metadata).map(([key, value]) => (
- {formatKey(key)}:
- {formatValue(value)}
+ {formatKey(key)}:
+ {formatValue(value)}
))}
) : null}
{notification.referenceType && notification.referenceId ? (
-
-
- View {notification.referenceType}
+
+
+ View {notification.referenceType}
) : null}
@@ -153,19 +240,19 @@ const formatValue = (value: any): string => {
};
// Helper function to get tag style based on notification type
-const getTypeTagStyle = (type: string): any => {
+const getTypeTagStyle = (type: string, themed: ThemedStyles): StyleProp => {
const lowerType = type.toLowerCase();
if (lowerType.includes('alert') || lowerType.includes('emergency')) {
- return styles.typeTagAlert;
+ return themed.typeTagAlert;
} else if (lowerType.includes('warning')) {
- return styles.typeTagWarning;
+ return themed.typeTagWarning;
} else if (lowerType.includes('info')) {
- return styles.typeTagInfo;
+ return themed.typeTagInfo;
} else if (lowerType.includes('success')) {
- return styles.typeTagSuccess;
+ return themed.typeTagSuccess;
} else {
- return styles.typeTagDefault;
+ return themed.typeTagDefault;
}
};
@@ -184,8 +271,6 @@ const styles = StyleSheet.create({
right: 0,
width: SIDEBAR_WIDTH,
height: '100%',
- backgroundColor: Appearance.getColorScheme() === 'dark' ? '#171717' : '#fff',
- shadowColor: Appearance.getColorScheme() === 'dark' ? '#262626' : '#e5e5e5',
shadowOffset: {
width: -2,
height: 0,
@@ -205,14 +290,12 @@ const styles = StyleSheet.create({
padding: 16,
paddingTop: Platform.OS === 'android' ? STATUS_BAR_HEIGHT + 16 : 16,
borderBottomWidth: 1,
- borderBottomColor: Appearance.getColorScheme() === 'dark' ? '#333333' : '#e5e5e5',
},
headerTitle: {
fontSize: 18,
fontWeight: 'bold',
flex: 1,
textAlign: 'center',
- color: Appearance.getColorScheme() === 'dark' ? '#f3f4f6' : '#111827',
},
backButton: {
padding: 8,
@@ -235,12 +318,10 @@ const styles = StyleSheet.create({
},
dateText: {
fontSize: 14,
- color: Appearance.getColorScheme() === 'dark' ? '#9ca3af' : '#6b7280',
marginLeft: 6,
},
timeText: {
fontSize: 14,
- color: Appearance.getColorScheme() === 'dark' ? '#9ca3af' : '#6b7280',
},
typeTag: {
alignSelf: 'flex-start',
@@ -254,29 +335,12 @@ const styles = StyleSheet.create({
fontWeight: '500',
textTransform: 'uppercase',
},
- typeTagDefault: {
- backgroundColor: Appearance.getColorScheme() === 'dark' ? '#374151' : '#e5e7eb',
- },
- typeTagInfo: {
- backgroundColor: Appearance.getColorScheme() === 'dark' ? '#1e40af' : '#dbeafe',
- },
- typeTagSuccess: {
- backgroundColor: Appearance.getColorScheme() === 'dark' ? '#065f46' : '#d1fae5',
- },
- typeTagWarning: {
- backgroundColor: Appearance.getColorScheme() === 'dark' ? '#92400e' : '#fef3c7',
- },
- typeTagAlert: {
- backgroundColor: Appearance.getColorScheme() === 'dark' ? '#991b1b' : '#fee2e2',
- },
title: {
fontSize: 20,
fontWeight: 'bold',
marginBottom: 12,
- color: Appearance.getColorScheme() === 'dark' ? '#f3f4f6' : '#111827',
},
bodyContainer: {
- backgroundColor: Appearance.getColorScheme() === 'dark' ? '#262626' : '#f9fafb',
padding: 16,
borderRadius: 8,
marginBottom: 20,
@@ -284,19 +348,16 @@ const styles = StyleSheet.create({
body: {
fontSize: 16,
lineHeight: 24,
- color: Appearance.getColorScheme() === 'dark' ? '#e5e5e5' : '#374151',
},
metadataDetailsContainer: {
marginTop: 10,
padding: 16,
- backgroundColor: Appearance.getColorScheme() === 'dark' ? '#262626' : '#f9fafb',
borderRadius: 8,
},
metadataTitle: {
fontSize: 16,
fontWeight: '600',
marginBottom: 10,
- color: Appearance.getColorScheme() === 'dark' ? '#f3f4f6' : '#111827',
},
metadataItem: {
flexDirection: 'row',
@@ -306,31 +367,25 @@ const styles = StyleSheet.create({
fontSize: 14,
fontWeight: '500',
marginRight: 8,
- color: Appearance.getColorScheme() === 'dark' ? '#9ca3af' : '#6b7280',
},
metadataValue: {
fontSize: 14,
flex: 1,
- color: Appearance.getColorScheme() === 'dark' ? '#e5e5e5' : '#111827',
},
buttonText: {
fontSize: 16,
fontWeight: '500',
- color: Appearance.getColorScheme() === 'dark' ? '#3b82f6' : '#2563eb',
},
referenceButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginTop: 24,
- backgroundColor: Appearance.getColorScheme() === 'dark' ? '#1e3a8a' : '#dbeafe',
padding: 12,
borderRadius: 8,
borderWidth: 1,
- borderColor: Appearance.getColorScheme() === 'dark' ? '#3b82f6' : '#60a5fa',
},
referenceButtonIcon: {
marginRight: 8,
- color: Appearance.getColorScheme() === 'dark' ? '#3b82f6' : '#2563eb',
},
});
diff --git a/src/components/notifications/NotificationInbox.tsx b/src/components/notifications/NotificationInbox.tsx
index e8a42f7d..90d747cc 100644
--- a/src/components/notifications/NotificationInbox.tsx
+++ b/src/components/notifications/NotificationInbox.tsx
@@ -22,6 +22,16 @@ const STATUS_BAR_HEIGHT = Platform.OS === 'ios' ? 44 : StatusBar.currentHeight |
const getSidebarWidth = (windowWidth: number) => Math.min(windowWidth * 0.85, 400);
+// lucide-react-native icons default to `stroke="currentColor"`, which react-native-svg resolves
+// from the `color` *prop* only — a nativewind `className` lands in `style` and is dropped, so the
+// icons rendered near-black on the dark sidebar. Colors are passed explicitly instead.
+const getIconColors = (isDark: boolean) =>
+ ({
+ accent: isDark ? '#60a5fa' : '#3b82f6',
+ danger: isDark ? '#f87171' : '#ef4444',
+ muted: isDark ? '#9ca3af' : '#6b7280',
+ }) as const;
+
/** Color-dependent style fragments; computed per render from the reactive color scheme. */
const getThemedStyles = (isDark: boolean) =>
({
@@ -29,6 +39,9 @@ const getThemedStyles = (isDark: boolean) =>
backgroundColor: isDark ? '#171717' : '#fff',
shadowColor: isDark ? '#262626' : '#e5e5e5',
},
+ header: {
+ borderBottomColor: isDark ? '#333333' : '#eee',
+ },
selectionCount: {
color: isDark ? '#ffffff' : '#000000',
},
@@ -74,6 +87,7 @@ const NotificationRow = React.memo(
({ notification, unread, isSelectionMode, isSelected, onPress, onLongPress, onNavigateToReference }: NotificationRowProps) => {
const { colorScheme } = useColorScheme();
const themed = React.useMemo(() => getThemedStyles(colorScheme === 'dark'), [colorScheme]);
+ const iconColors = React.useMemo(() => getIconColors(colorScheme === 'dark'), [colorScheme]);
const handlePress = React.useCallback(() => onPress(notification), [onPress, notification]);
const handleLongPress = React.useCallback(() => onLongPress(notification), [onLongPress, notification]);
const handleNavigate = React.useCallback(
@@ -90,9 +104,7 @@ const NotificationRow = React.memo(
{unread ? : null}
{isSelectionMode ? (
-
- {isSelected ? : }
-
+ {isSelected ? : }
) : null}
@@ -106,12 +118,12 @@ const NotificationRow = React.memo(
notification.referenceType && notification.referenceId ? (
-
+
) : (
-
+
)
) : null}
@@ -135,6 +147,7 @@ export const NotificationInbox = ({ isOpen, onClose }: NotificationInboxProps) =
const { width: windowWidth } = useWindowDimensions();
const sidebarWidth = getSidebarWidth(windowWidth);
const themed = React.useMemo(() => getThemedStyles(colorScheme === 'dark'), [colorScheme]);
+ const iconColors = React.useMemo(() => getIconColors(colorScheme === 'dark'), [colorScheme]);
const activeUnitId = useCoreStore((state) => state.activeUnitId);
const config = useCoreStore((state: any) => state.config);
const { notifications, isLoading, fetchMore, hasMore, refetch } = useNotifications();
@@ -369,7 +382,7 @@ export const NotificationInbox = ({ isOpen, onClose }: NotificationInboxProps) =
setSelectedNotification(null)} onDelete={handleDeleteNotification} onNavigateToReference={handleNavigateToReference} />
) : (
<>
-
+
{isSelectionMode ? (
<>
@@ -379,7 +392,7 @@ export const NotificationInbox = ({ isOpen, onClose }: NotificationInboxProps) =
{selectedNotificationIds.size === notifications?.length ? t('notifications.deselect_all') : t('notifications.select_all')}
- {isBackgroundGeolocationEnabled && (
+ {isBackgroundGeolocationEnabled ? (
{t('settings.background_geolocation_warning')}
- )}
+ ) : null}
);
};
diff --git a/src/components/settings/bluetooth-device-item.tsx b/src/components/settings/bluetooth-device-item.tsx
index 6b0d5aa7..6d440cdc 100644
--- a/src/components/settings/bluetooth-device-item.tsx
+++ b/src/components/settings/bluetooth-device-item.tsx
@@ -1,8 +1,8 @@
-import { ChevronRightIcon } from 'lucide-react-native';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { HStack } from '@/components/ui/hstack';
+import { ChevronRightIcon } from '@/components/ui/lucide-icons';
import { Pressable } from '@/components/ui/pressable';
import { Text } from '@/components/ui/text';
import { View } from '@/components/ui/view';
diff --git a/src/components/settings/bluetooth-device-selection-bottom-sheet.tsx b/src/components/settings/bluetooth-device-selection-bottom-sheet.tsx
index 6cb7da7f..fee17c1b 100644
--- a/src/components/settings/bluetooth-device-selection-bottom-sheet.tsx
+++ b/src/components/settings/bluetooth-device-selection-bottom-sheet.tsx
@@ -1,4 +1,3 @@
-import { BluetoothIcon, RefreshCwIcon, WifiIcon } from 'lucide-react-native';
import React, { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, useWindowDimensions } from 'react-native';
@@ -9,6 +8,7 @@ import { Button, ButtonIcon, ButtonText } from '@/components/ui/button';
import { FlatList } from '@/components/ui/flat-list';
import { Heading } from '@/components/ui/heading';
import { HStack } from '@/components/ui/hstack';
+import { BluetoothIcon, RefreshCwIcon, WifiIcon } from '@/components/ui/lucide-icons';
import { Pressable } from '@/components/ui/pressable';
import { Spinner } from '@/components/ui/spinner';
import { Text } from '@/components/ui/text';
diff --git a/src/components/settings/realtime-geolocation-item.tsx b/src/components/settings/realtime-geolocation-item.tsx
new file mode 100644
index 00000000..ca10bd16
--- /dev/null
+++ b/src/components/settings/realtime-geolocation-item.tsx
@@ -0,0 +1,54 @@
+import { Satellite } from 'lucide-react-native';
+import { useColorScheme } from 'nativewind';
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+
+import { useRealtimeGeolocation } from '@/lib/hooks/use-realtime-geolocation';
+import { logger } from '@/lib/logging';
+
+import { Alert, AlertIcon, AlertText } from '../ui/alert';
+import { Switch } from '../ui/switch';
+import { Text } from '../ui/text';
+import { View } from '../ui/view';
+import { VStack } from '../ui/vstack';
+
+export const RealtimeGeolocationItem = () => {
+ const { isRealtimeGeolocationEnabled, setRealtimeGeolocationEnabled, isGeolocationHubConnected } = useRealtimeGeolocation();
+ const { t } = useTranslation();
+ const { colorScheme } = useColorScheme();
+
+ const handleToggle = React.useCallback(
+ async (value: boolean) => {
+ try {
+ await setRealtimeGeolocationEnabled(value);
+ } catch (error) {
+ logger.error({ message: 'Failed to toggle realtime geolocation', context: { error } });
+ }
+ },
+ [setRealtimeGeolocationEnabled]
+ );
+
+ return (
+
+
+
+ {t('settings.realtime_geolocation')}
+
+
+
+
+
+
+ {isRealtimeGeolocationEnabled ? (
+
+
+
+
+ {t('settings.realtime_geolocation_warning')} {isGeolocationHubConnected ? t('settings.geolocation_hub_connected') : t('settings.geolocation_hub_connecting')}
+
+
+
+ ) : null}
+
+ );
+};
diff --git a/src/components/settings/unit-selection-bottom-sheet.tsx b/src/components/settings/unit-selection-bottom-sheet.tsx
index 914a3381..d2c62796 100644
--- a/src/components/settings/unit-selection-bottom-sheet.tsx
+++ b/src/components/settings/unit-selection-bottom-sheet.tsx
@@ -1,7 +1,7 @@
-import { Check } from 'lucide-react-native';
import React from 'react';
import { useTranslation } from 'react-i18next';
+import { Check } from '@/components/ui/lucide-icons';
import { logger } from '@/lib/logging';
import { type UnitResultData } from '@/models/v4/units/unitResultData';
import { useCoreStore } from '@/stores/app/core-store';
diff --git a/src/components/status/__tests__/status-bottom-sheet.test.tsx b/src/components/status/__tests__/status-bottom-sheet.test.tsx
index d1ef81e0..21f9c347 100644
--- a/src/components/status/__tests__/status-bottom-sheet.test.tsx
+++ b/src/components/status/__tests__/status-bottom-sheet.test.tsx
@@ -210,6 +210,19 @@ jest.mock('@/stores/app/location-store', () => {
return { useLocationStore: mockStore };
});
+// A factory mock, not an automock: automocking loads the real module to derive its shape, which
+// would pull expo-location's native surface into this suite.
+jest.mock('@/services/location-fix', () => ({
+ acquireLocationFix: jest.fn(async () => ({
+ outcome: 'acquired',
+ location: {
+ coords: { latitude: 40.7128, longitude: -74.006, accuracy: 5, altitude: 10, altitudeAccuracy: 3, speed: 0, heading: 0 },
+ timestamp: 1700000000000,
+ },
+ })),
+ getLocationFixErrorMessage: jest.fn((outcome: string) => `fix-error:${outcome}`),
+}));
+
jest.mock('@/lib/logging', () => ({
logger: {
debug: jest.fn(),
@@ -315,6 +328,7 @@ describe('StatusBottomSheet', () => {
const mockSetSelectedDestinationType = jest.fn();
const mockSetNote = jest.fn();
const mockFetchDestinationData = jest.fn();
+ const mockAcquireLocationFix = require('@/services/location-fix').acquireLocationFix as jest.Mock;
const mockSaveUnitStatus = jest.fn();
const mockShowToast = jest.fn();
@@ -2649,7 +2663,7 @@ describe('StatusBottomSheet', () => {
expect(mockSetCurrentStep).toHaveBeenCalledWith('add-note');
});
- it('should submit directly from status selection when no destination or note is needed', () => {
+ it('should submit directly from status selection when no destination or note is needed', async () => {
const selectedStatus = {
Id: 1,
Type: 1,
@@ -2680,8 +2694,8 @@ describe('StatusBottomSheet', () => {
const nextButton = screen.getByText('Next');
fireEvent.press(nextButton);
- // Should call submit directly
- expect(mockSaveUnitStatus).toHaveBeenCalled();
+ // Submitting now takes a location fix first, so the save lands a microtask later.
+ await waitFor(() => expect(mockSaveUnitStatus).toHaveBeenCalled());
});
it('should handle previous button from destination step to status selection', () => {
@@ -3077,7 +3091,7 @@ describe('StatusBottomSheet', () => {
expect(screen.getByText('Next')).toBeTruthy();
});
- it('should maintain Next button visibility with many calls and stations in Committed status', () => {
+ it('should maintain Next button visibility with many calls and stations in Committed status', async () => {
const committedStatus = {
Id: 'committed-status',
Text: 'Committed',
@@ -3129,8 +3143,9 @@ describe('StatusBottomSheet', () => {
// Should be able to click Next button without scrolling
fireEvent.press(nextButton);
- // Since no note is required, this should trigger submit
- expect(mockSaveUnitStatus).toHaveBeenCalled();
+ // Since no note is required, this should trigger submit. The save lands a microtask later now
+ // that submitting takes a location fix first.
+ await waitFor(() => expect(mockSaveUnitStatus).toHaveBeenCalled());
});
it('should handle status selection with many statuses without pushing Next button off screen', () => {
@@ -3869,4 +3884,64 @@ describe('StatusBottomSheet', () => {
fireEvent.press(nextButton);
// Should not throw or fail to find the button
});
+
+ describe('GPS-required statuses', () => {
+ const buildGpsRequiredStore = (selectedStatus: Record) => {
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ currentStep: 'select-status',
+ selectedStatus,
+ };
+ return selector ? selector(store) : store;
+ });
+ };
+
+ const gpsRequiredStatus = {
+ Id: 1,
+ Type: 1,
+ StateId: 1,
+ Text: 'Responding',
+ BColor: '#28a745',
+ Color: '#fff',
+ Gps: true,
+ Note: 0,
+ Detail: 0,
+ };
+
+ it.each([['permission-denied'], ['services-disabled'], ['unavailable']])(
+ 'refuses to submit and names the obstacle when the fix fails with %s',
+ async (outcome) => {
+ mockAcquireLocationFix.mockResolvedValueOnce({ outcome, location: null });
+ buildGpsRequiredStore(gpsRequiredStatus);
+
+ render();
+ fireEvent.press(screen.getByText('Next'));
+
+ await waitFor(() => expect(mockShowToast).toHaveBeenCalledWith('error', `fix-error:${outcome}`));
+ expect(mockSaveUnitStatus).not.toHaveBeenCalled();
+ }
+ );
+
+ it('submits with the freshly acquired coordinates when the fix succeeds', async () => {
+ buildGpsRequiredStore(gpsRequiredStatus);
+
+ render();
+ fireEvent.press(screen.getByText('Next'));
+
+ await waitFor(() => expect(mockSaveUnitStatus).toHaveBeenCalled());
+ expect(mockSaveUnitStatus).toHaveBeenCalledWith(expect.objectContaining({ Latitude: '40.7128', Longitude: '-74.006' }));
+ });
+
+ it('still submits a status that does not require GPS when no fix can be taken', async () => {
+ mockAcquireLocationFix.mockResolvedValueOnce({ outcome: 'unavailable', location: null });
+ buildGpsRequiredStore({ ...gpsRequiredStatus, Gps: false });
+
+ render();
+ fireEvent.press(screen.getByText('Next'));
+
+ await waitFor(() => expect(mockSaveUnitStatus).toHaveBeenCalled());
+ });
+ });
});
diff --git a/src/components/status/status-bottom-sheet.tsx b/src/components/status/status-bottom-sheet.tsx
index 4fc7ccab..d5fc245d 100644
--- a/src/components/status/status-bottom-sheet.tsx
+++ b/src/components/status/status-bottom-sheet.tsx
@@ -11,6 +11,7 @@ import { invertColor } from '@/lib/utils';
import { CustomStateDetailTypes, statusDetailAllowsCalls, statusDetailAllowsPois, statusDetailAllowsStations } from '@/models/v4/customStatuses/customStateDetailTypes';
import { DestinationEntityTypes } from '@/models/v4/destinations/destinationEntityTypes';
import { SaveUnitStatusInput, SaveUnitStatusRoleInput } from '@/models/v4/unitStatus/saveUnitStatusInput';
+import { acquireLocationFix, getLocationFixErrorMessage } from '@/services/location-fix';
import { offlineEventManager } from '@/services/offline-event-manager.service';
import { useCoreStore } from '@/stores/app/core-store';
import { useLocationStore } from '@/stores/app/location-store';
@@ -475,15 +476,38 @@ export const StatusBottomSheet = () => {
input.RespondingToType = DestinationEntityTypes.Poi;
}
- // Read the latest GPS fix imperatively (no render subscription)
- const { latitude, longitude, heading, accuracy, speed, altitude, timestamp } = useLocationStore.getState();
+ // Take a fix at submission time rather than trusting whatever the watcher last left in the
+ // store. The watcher only runs once a unit is selected and permission was granted, the store
+ // is never persisted, and permission can be revoked from the OS at any point — so a cached
+ // read is not evidence of anything. It is also the only honest way to enforce a status whose
+ // custom-state definition sets GpsRequired: checking the cache would wave through a status
+ // carrying coordinates from hours ago.
+ const fix = await acquireLocationFix();
+
+ if (fix.outcome !== 'acquired' && selectedStatus.Gps) {
+ showToast('error', getLocationFixErrorMessage(fix.outcome));
+ return;
+ }
+
+ // Read the latest GPS fix imperatively (no render subscription), preferring the fix we just
+ // took and falling back to the watcher for a status that does not require one.
+ const cached = useLocationStore.getState();
+ const coords = fix.location?.coords ?? null;
+ const latitude = coords?.latitude ?? cached.latitude;
+ const longitude = coords?.longitude ?? cached.longitude;
+ const accuracy = coords?.accuracy ?? cached.accuracy;
+ const altitude = coords?.altitude ?? cached.altitude;
+ const altitudeAccuracy = coords?.altitudeAccuracy ?? null;
+ const speed = coords?.speed ?? cached.speed;
+ const heading = coords?.heading ?? cached.heading;
+ const timestamp = fix.location?.timestamp ?? cached.timestamp;
if (latitude !== null && longitude !== null) {
input.Latitude = latitude.toString();
input.Longitude = longitude.toString();
input.Accuracy = accuracy?.toString() || '0';
input.Altitude = altitude?.toString() || '0';
- input.AltitudeAccuracy = '';
+ input.AltitudeAccuracy = altitudeAccuracy?.toString() || '';
input.Speed = speed?.toString() || '0';
input.Heading = heading?.toString() || '0';
diff --git a/src/components/ui/icon/index.tsx b/src/components/ui/icon/index.tsx
index e2bebbb1..1e57167d 100644
--- a/src/components/ui/icon/index.tsx
+++ b/src/components/ui/icon/index.tsx
@@ -23,7 +23,23 @@ const iconStyle = tva({
},
});
-const StyledUIIcon = styled(UIIcon, { className: 'style' });
+// The nativewind v4 -> v5 migration replaced a `cssInterop(UIIcon, ...)` call with a bare
+// `styled(UIIcon, { className: 'style' })`, dropping the `nativeStyleToProp` map. Without it the
+// class-derived color stays in `style`, where neither gluestack's PrimitiveIcon (it reads
+// `classNameColor`) nor react-native-svg (it reads the `color`/`stroke` props) ever looks — so
+// every `` rendered with the icon's own default stroke.
+const StyledUIIcon = styled(UIIcon, {
+ className: {
+ target: 'style',
+ nativeStyleMapping: {
+ height: true,
+ width: true,
+ fill: true,
+ color: 'classNameColor',
+ stroke: true,
+ },
+ },
+});
type IIConProps = IPrimitiveIcon & VariantProps & React.ComponentPropsWithoutRef;
diff --git a/src/components/ui/lucide-icons.tsx b/src/components/ui/lucide-icons.tsx
new file mode 100644
index 00000000..c706f68a
--- /dev/null
+++ b/src/components/ui/lucide-icons.tsx
@@ -0,0 +1,142 @@
+import {
+ AlertCircle as RawAlertCircle,
+ AlertTriangle as RawAlertTriangle,
+ ArrowLeft as RawArrowLeft,
+ Bell as RawBell,
+ BluetoothIcon as RawBluetoothIcon,
+ Box as RawBox,
+ BuildingIcon as RawBuildingIcon,
+ Calendar as RawCalendar,
+ CalendarIcon as RawCalendarIcon,
+ Check as RawCheck,
+ CheckCircle as RawCheckCircle,
+ CheckIcon as RawCheckIcon,
+ ChevronDownIcon as RawChevronDownIcon,
+ ChevronLeft as RawChevronLeft,
+ ChevronRight as RawChevronRight,
+ ChevronRightIcon as RawChevronRightIcon,
+ CloudAlert as RawCloudAlert,
+ Contact as RawContact,
+ Edit2Icon as RawEdit2Icon,
+ EyeIcon as RawEyeIcon,
+ EyeOffIcon as RawEyeOffIcon,
+ Globe as RawGlobe,
+ GlobeIcon as RawGlobeIcon,
+ Headphones as RawHeadphones,
+ HomeIcon as RawHomeIcon,
+ ListTree as RawListTree,
+ Loader2 as RawLoader2,
+ type LucideProps,
+ MailIcon as RawMailIcon,
+ Map as RawMap,
+ MapPinIcon as RawMapPinIcon,
+ Megaphone as RawMegaphone,
+ Menu as RawMenu,
+ MessageCircle as RawMessageCircle,
+ Mic as RawMic,
+ Navigation as RawNavigation,
+ Notebook as RawNotebook,
+ Phone as RawPhone,
+ PhoneIcon as RawPhoneIcon,
+ PlusIcon as RawPlusIcon,
+ RefreshCwIcon as RawRefreshCwIcon,
+ SearchIcon as RawSearchIcon,
+ Settings as RawSettings,
+ SettingsIcon as RawSettingsIcon,
+ ShieldCheck as RawShieldCheck,
+ SmartphoneIcon as RawSmartphoneIcon,
+ Speaker as RawSpeaker,
+ StarIcon as RawStarIcon,
+ Tag as RawTag,
+ TrashIcon as RawTrashIcon,
+ UserIcon as RawUserIcon,
+ Users as RawUsers,
+ UsersIcon as RawUsersIcon,
+ WifiIcon as RawWifiIcon,
+ X as RawX,
+} from 'lucide-react-native';
+import { styled } from 'nativewind';
+import type React from 'react';
+
+/**
+ * lucide icons that understand `className`.
+ *
+ * nativewind v5 dropped the JSX transform: a `className` only has an effect on a component
+ * that has been through `styled()`, and metro's polyfill only covers `react-native` itself.
+ * On a raw lucide icon the class was silently discarded -- which is why `text-*` colours and
+ * `mr-*` spacing had no effect and icons rendered with their default near-black stroke.
+ *
+ * `target: 'style'` keeps layout utilities working, and `nativeStyleMapping` lifts the
+ * resolved colour out of the style object onto lucide's `color` prop, which is where
+ * react-native-svg resolves `currentColor` from.
+ *
+ * Only icons used with a className live here, so the bundle is unchanged; import the rest
+ * straight from `lucide-react-native`.
+ */
+const iconMapping = {
+ className: {
+ target: 'style',
+ nativeStyleMapping: {
+ color: 'color',
+ },
+ },
+} as const;
+
+type LucideIcon = React.ComponentType;
+
+const themed = (Component: T): T => styled(Component as LucideIcon, iconMapping) as unknown as T;
+
+export const AlertCircle = themed(RawAlertCircle);
+export const AlertTriangle = themed(RawAlertTriangle);
+export const ArrowLeft = themed(RawArrowLeft);
+export const Bell = themed(RawBell);
+export const BluetoothIcon = themed(RawBluetoothIcon);
+export const Box = themed(RawBox);
+export const BuildingIcon = themed(RawBuildingIcon);
+export const Calendar = themed(RawCalendar);
+export const CalendarIcon = themed(RawCalendarIcon);
+export const Check = themed(RawCheck);
+export const CheckCircle = themed(RawCheckCircle);
+export const CheckIcon = themed(RawCheckIcon);
+export const ChevronDownIcon = themed(RawChevronDownIcon);
+export const ChevronLeft = themed(RawChevronLeft);
+export const ChevronRight = themed(RawChevronRight);
+export const ChevronRightIcon = themed(RawChevronRightIcon);
+export const CloudAlert = themed(RawCloudAlert);
+export const Contact = themed(RawContact);
+export const Edit2Icon = themed(RawEdit2Icon);
+export const EyeIcon = themed(RawEyeIcon);
+export const EyeOffIcon = themed(RawEyeOffIcon);
+export const Globe = themed(RawGlobe);
+export const GlobeIcon = themed(RawGlobeIcon);
+export const Headphones = themed(RawHeadphones);
+export const HomeIcon = themed(RawHomeIcon);
+export const ListTree = themed(RawListTree);
+export const Loader2 = themed(RawLoader2);
+export const MailIcon = themed(RawMailIcon);
+export const Map = themed(RawMap);
+export const MapPinIcon = themed(RawMapPinIcon);
+export const Megaphone = themed(RawMegaphone);
+export const Menu = themed(RawMenu);
+export const MessageCircle = themed(RawMessageCircle);
+export const Mic = themed(RawMic);
+export const Navigation = themed(RawNavigation);
+export const Notebook = themed(RawNotebook);
+export const Phone = themed(RawPhone);
+export const PhoneIcon = themed(RawPhoneIcon);
+export const PlusIcon = themed(RawPlusIcon);
+export const RefreshCwIcon = themed(RawRefreshCwIcon);
+export const SearchIcon = themed(RawSearchIcon);
+export const Settings = themed(RawSettings);
+export const SettingsIcon = themed(RawSettingsIcon);
+export const ShieldCheck = themed(RawShieldCheck);
+export const SmartphoneIcon = themed(RawSmartphoneIcon);
+export const Speaker = themed(RawSpeaker);
+export const StarIcon = themed(RawStarIcon);
+export const Tag = themed(RawTag);
+export const TrashIcon = themed(RawTrashIcon);
+export const UserIcon = themed(RawUserIcon);
+export const Users = themed(RawUsers);
+export const UsersIcon = themed(RawUsersIcon);
+export const WifiIcon = themed(RawWifiIcon);
+export const X = themed(RawX);
diff --git a/src/components/ui/shared-tabs.tsx b/src/components/ui/shared-tabs.tsx
index fe3bf543..57c4995b 100644
--- a/src/components/ui/shared-tabs.tsx
+++ b/src/components/ui/shared-tabs.tsx
@@ -1,4 +1,3 @@
-import { ChevronLeft, ChevronRight } from 'lucide-react-native';
import { useColorScheme } from 'nativewind';
import React, { useCallback, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -6,6 +5,7 @@ import { type LayoutChangeEvent, type NativeScrollEvent, type NativeSyntheticEve
import { create } from 'zustand';
import { Box } from '@/components/ui/box';
+import { ChevronLeft, ChevronRight } from '@/components/ui/lucide-icons';
import { Pressable } from '@/components/ui/pressable';
import { Text } from '@/components/ui/text';
diff --git a/src/lib/hooks/use-realtime-geolocation.ts b/src/lib/hooks/use-realtime-geolocation.ts
new file mode 100644
index 00000000..d5c3d7da
--- /dev/null
+++ b/src/lib/hooks/use-realtime-geolocation.ts
@@ -0,0 +1,80 @@
+import React from 'react';
+import { useMMKVBoolean } from 'react-native-mmkv';
+
+import { useSignalRStore } from '@/stores/signalr/signalr-store';
+
+import { logger } from '../logging';
+import { storage } from '../storage';
+import { getDefaultRealtimeGeolocationState, getRealtimeGeolocationStorageKey, saveRealtimeGeolocationState } from '../storage/realtime-geolocation';
+
+// Define a type for the location service update function
+type LocationServiceRealtimeUpdater = (enabled: boolean) => Promise;
+
+// Global variable to hold the location service update function
+let locationServiceRealtimeUpdater: LocationServiceRealtimeUpdater | null = null;
+
+/**
+ * Register the location service realtime updater function
+ * This should be called from the location service to register its update function
+ */
+export const registerLocationServiceRealtimeUpdater = (updater: LocationServiceRealtimeUpdater) => {
+ locationServiceRealtimeUpdater = updater;
+};
+
+/**
+ * Hook for managing realtime geolocation functionality.
+ *
+ * Governs whether this unit's position is transmitted while the app is in the foreground, and
+ * whether the app is subscribed to the geolocation hub for other units' positions. Background
+ * transmission is a separate setting — see `useBackgroundGeolocation`.
+ */
+export const useRealtimeGeolocation = () => {
+ const [realtimeGeolocationEnabled, _setRealtimeGeolocationEnabled] = useMMKVBoolean(getRealtimeGeolocationStorageKey(), storage);
+
+ const isGeolocationHubConnected = useSignalRStore((state) => state.isGeolocationHubConnected);
+ const connectGeolocationHub = useSignalRStore((state) => state.connectGeolocationHub);
+ const disconnectGeolocationHub = useSignalRStore((state) => state.disconnectGeolocationHub);
+
+ const setRealtimeGeolocationEnabled = React.useCallback(
+ async (enabled: boolean) => {
+ try {
+ _setRealtimeGeolocationEnabled(enabled);
+ saveRealtimeGeolocationState(enabled);
+
+ // Update the location service if the updater is registered
+ if (locationServiceRealtimeUpdater) {
+ await locationServiceRealtimeUpdater(enabled);
+ }
+
+ // Connect or disconnect from the SignalR geolocation hub
+ if (enabled) {
+ await connectGeolocationHub();
+ } else {
+ await disconnectGeolocationHub();
+ }
+
+ logger.info({
+ message: `Realtime geolocation ${enabled ? 'enabled' : 'disabled'}`,
+ context: { enabled, hubConnected: isGeolocationHubConnected },
+ });
+ } catch (error) {
+ logger.error({
+ message: 'Failed to update realtime geolocation state',
+ context: { error, enabled },
+ });
+ throw error;
+ }
+ },
+ [_setRealtimeGeolocationEnabled, connectGeolocationHub, disconnectGeolocationHub, isGeolocationHubConnected]
+ );
+
+ // `useMMKVBoolean` answers undefined for a key that has never been written, which is every unit
+ // upgrading into this setting. Those units must read as enabled, not as switched off.
+ const isRealtimeGeolocationEnabled = realtimeGeolocationEnabled ?? getDefaultRealtimeGeolocationState();
+
+ return {
+ isRealtimeGeolocationEnabled,
+ setRealtimeGeolocationEnabled,
+ isGeolocationHubConnected,
+ } as const;
+};
diff --git a/src/lib/storage/realtime-geolocation.ts b/src/lib/storage/realtime-geolocation.ts
new file mode 100644
index 00000000..b05c56ce
--- /dev/null
+++ b/src/lib/storage/realtime-geolocation.ts
@@ -0,0 +1,57 @@
+import { logger } from '../logging';
+import { storage } from './index';
+
+const REALTIME_GEOLOCATION_ENABLED = 'REALTIME_GEOLOCATION_ENABLED';
+
+/**
+ * Unlike Responder, this setting defaults to ON.
+ *
+ * Before it existed, the Unit app transmitted the apparatus position on every foreground fix with
+ * no way to switch it off, and dispatch AVL is the reason the app is mounted in the vehicle at all.
+ * Defaulting the new toggle to OFF would silently take every already-installed unit off dispatch's
+ * map on upgrade — a safety regression, not a preference change. Units that want to stop
+ * transmitting now have a switch; everyone else keeps the behaviour they already had.
+ */
+const DEFAULT_REALTIME_GEOLOCATION_ENABLED = true;
+
+/**
+ * Load realtime geolocation state from MMKV storage
+ * This function is used in the location service to avoid circular dependencies
+ */
+export const loadRealtimeGeolocationState = async (): Promise => {
+ try {
+ const realtimeGeolocationEnabled = storage.getBoolean(REALTIME_GEOLOCATION_ENABLED);
+ logger.info({
+ message: 'Realtime geolocation state loaded on startup',
+ context: { enabled: realtimeGeolocationEnabled },
+ });
+ return realtimeGeolocationEnabled ?? DEFAULT_REALTIME_GEOLOCATION_ENABLED;
+ } catch (error) {
+ logger.error({
+ message: 'Failed to load realtime geolocation state on startup',
+ context: { error },
+ });
+ // A read that threw says nothing about the user's choice. Answering `false` here would stop a
+ // unit transmitting because of a storage hiccup, so fall back to the safe default.
+ return DEFAULT_REALTIME_GEOLOCATION_ENABLED;
+ }
+};
+
+/**
+ * Save realtime geolocation state to MMKV storage
+ */
+export const saveRealtimeGeolocationState = (enabled: boolean): void => {
+ storage.set(REALTIME_GEOLOCATION_ENABLED, enabled);
+};
+
+/**
+ * Get the storage key for realtime geolocation
+ */
+export const getRealtimeGeolocationStorageKey = (): string => {
+ return REALTIME_GEOLOCATION_ENABLED;
+};
+
+/**
+ * The value to use when the key has never been written.
+ */
+export const getDefaultRealtimeGeolocationState = (): boolean => DEFAULT_REALTIME_GEOLOCATION_ENABLED;
diff --git a/src/services/__tests__/location-fix.test.ts b/src/services/__tests__/location-fix.test.ts
new file mode 100644
index 00000000..ce60079a
--- /dev/null
+++ b/src/services/__tests__/location-fix.test.ts
@@ -0,0 +1,161 @@
+import * as Location from 'expo-location';
+
+import { translate } from '@/lib/i18n/utils';
+
+import { acquireLocationFix, getLocationFixErrorMessage } from '../location-fix';
+
+jest.mock('@/lib/logging', () => ({
+ logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() },
+}));
+
+jest.mock('@/lib/i18n/utils', () => ({
+ translate: jest.fn((key: string) => key),
+}));
+
+const mockSetLocation = jest.fn();
+jest.mock('@/stores/app/location-store', () => ({
+ useLocationStore: { getState: jest.fn(() => ({ setLocation: mockSetLocation })) },
+}));
+
+jest.mock('expo-location', () => ({
+ getForegroundPermissionsAsync: jest.fn(),
+ requestForegroundPermissionsAsync: jest.fn(),
+ hasServicesEnabledAsync: jest.fn(),
+ getCurrentPositionAsync: jest.fn(),
+ getLastKnownPositionAsync: jest.fn(),
+ Accuracy: { Balanced: 'balanced' },
+}));
+
+const mockLocation = Location as jest.Mocked;
+
+const position = {
+ coords: { latitude: 1, longitude: 2, accuracy: 5, altitude: 10, altitudeAccuracy: 3, speed: 0, heading: 0 },
+ timestamp: 1700000000000,
+} as Location.LocationObject;
+
+const granted = { status: 'granted', canAskAgain: true, granted: true, expires: 'never' } as Location.LocationPermissionResponse;
+const denied = { status: 'denied', canAskAgain: false, granted: false, expires: 'never' } as Location.LocationPermissionResponse;
+const undetermined = { status: 'undetermined', canAskAgain: true, granted: false, expires: 'never' } as Location.LocationPermissionResponse;
+
+describe('acquireLocationFix', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockLocation.getForegroundPermissionsAsync.mockResolvedValue(granted);
+ mockLocation.hasServicesEnabledAsync.mockResolvedValue(true);
+ mockLocation.getCurrentPositionAsync.mockResolvedValue(position);
+ mockLocation.getLastKnownPositionAsync.mockResolvedValue(null);
+ });
+
+ it('returns the live fix and feeds it to the location store', async () => {
+ const result = await acquireLocationFix();
+
+ expect(result).toEqual({ outcome: 'acquired', location: position });
+ expect(mockSetLocation).toHaveBeenCalledWith(position);
+ });
+
+ it('prompts for permission when it has not been decided yet', async () => {
+ mockLocation.getForegroundPermissionsAsync.mockResolvedValue(undetermined);
+ mockLocation.requestForegroundPermissionsAsync.mockResolvedValue(granted);
+
+ const result = await acquireLocationFix();
+
+ expect(mockLocation.requestForegroundPermissionsAsync).toHaveBeenCalled();
+ expect(result.outcome).toBe('acquired');
+ });
+
+ it('reports permission-denied without re-prompting once the user has hard-denied', async () => {
+ mockLocation.getForegroundPermissionsAsync.mockResolvedValue(denied);
+
+ const result = await acquireLocationFix();
+
+ expect(mockLocation.requestForegroundPermissionsAsync).not.toHaveBeenCalled();
+ expect(result).toEqual({ outcome: 'permission-denied', location: null });
+ expect(mockLocation.getCurrentPositionAsync).not.toHaveBeenCalled();
+ });
+
+ it('distinguishes device location services being switched off from a missing fix', async () => {
+ mockLocation.hasServicesEnabledAsync.mockResolvedValue(false);
+
+ const result = await acquireLocationFix();
+
+ expect(result).toEqual({ outcome: 'services-disabled', location: null });
+ expect(mockLocation.getCurrentPositionAsync).not.toHaveBeenCalled();
+ });
+
+ it('falls back to a recent cached fix when the live one fails', async () => {
+ mockLocation.getCurrentPositionAsync.mockRejectedValue(new Error('no signal'));
+ mockLocation.getLastKnownPositionAsync.mockResolvedValue(position);
+
+ const result = await acquireLocationFix();
+
+ expect(result).toEqual({ outcome: 'acquired', location: position });
+ expect(mockLocation.getLastKnownPositionAsync).toHaveBeenCalledWith({ maxAge: 60000 });
+ });
+
+ it('reports unavailable when neither a live nor a cached fix can be had', async () => {
+ mockLocation.getCurrentPositionAsync.mockRejectedValue(new Error('no signal'));
+
+ const result = await acquireLocationFix();
+
+ expect(result).toEqual({ outcome: 'unavailable', location: null });
+ expect(mockSetLocation).not.toHaveBeenCalled();
+ });
+
+ it('gives up on a live fix that never settles rather than hanging the submission', async () => {
+ jest.useFakeTimers();
+ // A position request that never resolves is the realistic indoor Android case.
+ mockLocation.getCurrentPositionAsync.mockReturnValue(new Promise(() => {}) as Promise);
+ mockLocation.getLastKnownPositionAsync.mockResolvedValue(position);
+
+ const pending = acquireLocationFix();
+ // Let the permission and services awaits settle before the timer is armed.
+ await Promise.resolve();
+ await Promise.resolve();
+ await Promise.resolve();
+ jest.advanceTimersByTime(8000);
+
+ await expect(pending).resolves.toEqual({ outcome: 'acquired', location: position });
+ jest.useRealTimers();
+ });
+
+ it('still returns the acquired fix when the store write throws', async () => {
+ mockSetLocation.mockImplementation(() => {
+ throw new Error('store unavailable');
+ });
+
+ await expect(acquireLocationFix()).resolves.toEqual({ outcome: 'acquired', location: position });
+ expect(mockSetLocation).toHaveBeenCalledWith(position);
+ });
+
+ it('treats an unanswerable permission check as denied', async () => {
+ mockLocation.getForegroundPermissionsAsync.mockRejectedValue(new Error('module unavailable'));
+
+ await expect(acquireLocationFix()).resolves.toEqual({ outcome: 'permission-denied', location: null });
+ });
+
+ it('continues past a services check that throws rather than blocking on it', async () => {
+ mockLocation.hasServicesEnabledAsync.mockRejectedValue(new Error('unsupported'));
+
+ await expect(acquireLocationFix()).resolves.toEqual({ outcome: 'acquired', location: position });
+ });
+});
+
+describe('getLocationFixErrorMessage', () => {
+ const mockTranslate = translate as jest.MockedFunction;
+
+ it('names the specific obstacle rather than a generic GPS failure', () => {
+ mockTranslate.mockImplementation(((key: string) => `translated:${key}`) as unknown as typeof translate);
+
+ expect(getLocationFixErrorMessage('permission-denied')).toBe('translated:location.fix_permission_denied');
+ expect(getLocationFixErrorMessage('services-disabled')).toBe('translated:location.fix_services_disabled');
+ expect(getLocationFixErrorMessage('unavailable')).toBe('translated:location.fix_unavailable');
+ });
+
+ it('falls back to English when the key has no translation, rather than showing the key', () => {
+ // i18next echoes the key back for a missing entry; surfacing that to a responder is worse
+ // than an untranslated but readable sentence.
+ mockTranslate.mockImplementation(((key: string) => key) as unknown as typeof translate);
+
+ expect(getLocationFixErrorMessage('unavailable')).toBe('Could not get a location fix. Move to an area with a clearer view of the sky and try again.');
+ });
+});
diff --git a/src/services/__tests__/location-foreground-permissions.test.ts b/src/services/__tests__/location-foreground-permissions.test.ts
index 31e4c0d1..b6ac1000 100644
--- a/src/services/__tests__/location-foreground-permissions.test.ts
+++ b/src/services/__tests__/location-foreground-permissions.test.ts
@@ -12,6 +12,9 @@
*/
// Mock all dependencies first
+jest.mock('@/lib/i18n/utils', () => ({
+ translate: jest.fn((key: string) => key),
+}));
jest.mock('@/api/units/unitLocation', () => ({
setUnitLocation: jest.fn(),
}));
@@ -82,6 +85,13 @@ jest.mock('expo-location', () => {
Accuracy: {
Balanced: 'balanced',
},
+ LocationActivityType: {
+ Other: 1,
+ AutomotiveNavigation: 2,
+ Fitness: 3,
+ OtherNavigation: 4,
+ Airborne: 5,
+ },
};
});
diff --git a/src/services/__tests__/location.test.ts b/src/services/__tests__/location.test.ts
index 8fe11f9e..21905d9a 100644
--- a/src/services/__tests__/location.test.ts
+++ b/src/services/__tests__/location.test.ts
@@ -1,4 +1,7 @@
// Mock all dependencies first
+jest.mock('@/lib/i18n/utils', () => ({
+ translate: jest.fn((key: string) => key),
+}));
jest.mock('@/api/units/unitLocation', () => ({
setUnitLocation: jest.fn(),
}));
@@ -17,6 +20,15 @@ jest.mock('@/lib/storage/background-geolocation', () => ({
loadBackgroundGeolocationState: jest.fn(),
}));
+jest.mock('@/lib/storage/realtime-geolocation', () => ({
+ loadRealtimeGeolocationState: jest.fn(),
+ saveRealtimeGeolocationState: jest.fn(),
+}));
+
+jest.mock('@/lib/hooks/use-realtime-geolocation', () => ({
+ registerLocationServiceRealtimeUpdater: jest.fn(),
+}));
+
jest.mock('@/services/offline-event-manager.service', () => ({
offlineEventManager: {
queueLocationUpdateEvent: jest.fn(),
@@ -67,13 +79,24 @@ jest.mock('expo-location', () => {
Accuracy: {
Balanced: 'balanced',
},
+ LocationActivityType: {
+ Other: 1,
+ AutomotiveNavigation: 2,
+ Fitness: 3,
+ OtherNavigation: 4,
+ Airborne: 5,
+ },
};
});
// TaskManager mocks are now handled in the jest.mock() call
+const registeredTasks: Record Promise> = {};
+
jest.mock('expo-task-manager', () => ({
- defineTask: jest.fn(),
+ defineTask: jest.fn((name: string, handler: (body: unknown) => Promise) => {
+ registeredTasks[name] = handler;
+ }),
isTaskRegisteredAsync: jest.fn(),
}));
@@ -98,6 +121,7 @@ import { setUnitLocation } from '@/api/units/unitLocation';
import { registerLocationServiceUpdater } from '@/lib/hooks/use-background-geolocation';
import { logger } from '@/lib/logging';
import { loadBackgroundGeolocationState } from '@/lib/storage/background-geolocation';
+import { loadRealtimeGeolocationState } from '@/lib/storage/realtime-geolocation';
import { SaveUnitLocationInput } from '@/models/v4/unitLocation/saveUnitLocationInput';
import { offlineEventManager } from '@/services/offline-event-manager.service';
import { isNetworkError } from '@/utils/network';
@@ -110,6 +134,7 @@ const mockSetUnitLocation = setUnitLocation as jest.MockedFunction;
const mockLogger = logger as jest.Mocked;
const mockLoadBackgroundGeolocationState = loadBackgroundGeolocationState as jest.MockedFunction;
+const mockLoadRealtimeGeolocationState = loadRealtimeGeolocationState as jest.MockedFunction;
const mockIsNetworkError = isNetworkError as jest.MockedFunction;
const mockQueueLocationUpdateEvent = offlineEventManager.queueLocationUpdateEvent as jest.Mock;
const mockTaskManager = TaskManager as jest.Mocked;
@@ -197,6 +222,9 @@ describe('LocationService', () => {
// Setup storage mock
mockLoadBackgroundGeolocationState.mockResolvedValue(false);
+ // Realtime defaults to on for units: the app existed for years with unconditional foreground
+ // transmission, and the new toggle must not switch that off on upgrade.
+ mockLoadRealtimeGeolocationState.mockResolvedValue(true);
// Setup API mock
mockSetUnitLocation.mockResolvedValue(mockApiResponse);
@@ -211,6 +239,7 @@ describe('LocationService', () => {
(locationService as any).locationSubscription = null;
(locationService as any).backgroundSubscription = null;
(locationService as any).isBackgroundGeolocationEnabled = false;
+ (locationService as any).isRealtimeGeolocationEnabled = true;
(locationService as any).startPromise = null;
(locationService as any).startBackgroundPromise = null;
});
@@ -441,9 +470,16 @@ describe('LocationService', () => {
accuracy: Location.Accuracy.Balanced,
timeInterval: 15000,
distanceInterval: 10,
+ // Android batches fixes while the screen is off instead of waking the app per fix.
+ deferredUpdatesInterval: 30000,
+ deferredUpdatesDistance: 25,
+ // iOS must not pause a parked apparatus off dispatch's map.
+ pausesUpdatesAutomatically: false,
+ activityType: Location.LocationActivityType.AutomotiveNavigation,
+ showsBackgroundLocationIndicator: true,
foregroundService: {
- notificationTitle: 'Location Tracking',
- notificationBody: 'Tracking your location in the background',
+ notificationTitle: 'location.tracking_notification_title',
+ notificationBody: 'location.tracking_notification_body',
},
});
@@ -897,6 +933,72 @@ describe('LocationService', () => {
});
});
+ describe('Transmission gating', () => {
+ // Realtime Geolocation governs foreground transmission, Background Geolocation governs
+ // background transmission. They are independent, and the OS task fires in both app states, so
+ // a fix has to be attributed to the setting that owns the app state it arrived in.
+ const runTask = async (appState: string) => {
+ (AppState as any).currentState = appState;
+ const handler = registeredTasks['location-updates'];
+ expect(handler).toBeDefined();
+ await handler({ data: { locations: [mockLocationObject] }, error: null });
+ };
+
+ afterEach(() => {
+ (AppState as any).currentState = 'active';
+ });
+
+ it('transmits a backgrounded fix on the background setting alone, with realtime off', async () => {
+ mockLoadBackgroundGeolocationState.mockResolvedValue(true);
+ mockLoadRealtimeGeolocationState.mockResolvedValue(false);
+
+ await runTask('background');
+
+ expect(mockSetUnitLocation).toHaveBeenCalled();
+ });
+
+ it('does not transmit a backgrounded fix when background tracking is off', async () => {
+ mockLoadBackgroundGeolocationState.mockResolvedValue(false);
+ mockLoadRealtimeGeolocationState.mockResolvedValue(true);
+
+ await runTask('background');
+
+ expect(mockSetUnitLocation).not.toHaveBeenCalled();
+ });
+
+ it('governs a foregrounded fix by the realtime setting, not the background one', async () => {
+ mockLoadBackgroundGeolocationState.mockResolvedValue(true);
+ mockLoadRealtimeGeolocationState.mockResolvedValue(false);
+
+ await runTask('active');
+
+ expect(mockSetUnitLocation).not.toHaveBeenCalled();
+ });
+
+ it('always records the fix locally regardless of whether it may be transmitted', async () => {
+ mockLoadBackgroundGeolocationState.mockResolvedValue(false);
+ mockLoadRealtimeGeolocationState.mockResolvedValue(false);
+
+ await runTask('background');
+
+ expect(mockLocationStoreState.setLocation).toHaveBeenCalledWith(mockLocationObject);
+ expect(mockSetUnitLocation).not.toHaveBeenCalled();
+ });
+
+ it('stops transmitting foreground fixes once realtime geolocation is switched off', async () => {
+ await locationService.startLocationUpdates();
+ const onLocation = mockLocation.watchPositionAsync.mock.calls[0][1] as (location: unknown) => void;
+
+ (locationService as any).isRealtimeGeolocationEnabled = false;
+ onLocation(mockLocationObject);
+ await Promise.resolve();
+
+ expect(mockSetUnitLocation).not.toHaveBeenCalled();
+ // The watcher itself keeps running: the map and status sheet still need the position.
+ expect(mockLocationStoreState.setLocation).toHaveBeenCalledWith(mockLocationObject);
+ });
+ });
+
describe('Background Geolocation Setting Updates', () => {
it('should enable background tracking and register task when permissions are granted', async () => {
await locationService.updateBackgroundGeolocationSetting(true);
diff --git a/src/services/location-fix.ts b/src/services/location-fix.ts
new file mode 100644
index 00000000..00c868bd
--- /dev/null
+++ b/src/services/location-fix.ts
@@ -0,0 +1,185 @@
+import * as Location from 'expo-location';
+
+import { translate } from '@/lib/i18n/utils';
+import { logger } from '@/lib/logging';
+import { useLocationStore } from '@/stores/app/location-store';
+
+/**
+ * On-demand location fix, used when a unit status submission needs coordinates.
+ *
+ * The continuous watcher in `@/services/location` only runs once a unit has been selected and the
+ * OS granted permission, and the store it feeds is deliberately not persisted. A submission
+ * therefore cannot assume there is anything cached: it has to ask for a fix at the moment it needs
+ * one, which is also what makes a `Gps`-required status enforceable.
+ */
+export type LocationFixOutcome = 'acquired' | 'permission-denied' | 'services-disabled' | 'unavailable';
+
+export interface LocationFixResult {
+ outcome: LocationFixOutcome;
+ location: Location.LocationObject | null;
+}
+
+/**
+ * `getCurrentPositionAsync` has no timeout of its own — indoors it can sit on the request until
+ * the OS gives up, which on Android is effectively never. A submission must not hang behind it.
+ */
+const FIX_TIMEOUT_MS = 8000;
+
+/** A fix from the last minute is a fine answer for "where are you now" and costs no radio time. */
+const LAST_KNOWN_MAX_AGE_MS = 60 * 1000;
+
+interface TimedFix {
+ promise: Promise;
+ cancel: () => void;
+}
+
+/**
+ * Races the live fix against a timer. The timer is cleared either way: leaving it pending keeps a
+ * Jest fake-timer test from settling and, in the app, holds a needless reference for its duration.
+ */
+const withTimeout = (): TimedFix => {
+ let timer: ReturnType | null = null;
+
+ const timeout = new Promise((resolve) => {
+ timer = setTimeout(() => resolve(null), FIX_TIMEOUT_MS);
+ });
+
+ const cancel = () => {
+ if (timer) {
+ clearTimeout(timer);
+ timer = null;
+ }
+ };
+
+ return {
+ promise: Promise.race([
+ Location.getCurrentPositionAsync({ accuracy: Location.Accuracy.Balanced }).catch((error) => {
+ logger.warn({
+ message: 'Failed to acquire current position',
+ context: { error: error instanceof Error ? error.message : String(error) },
+ });
+ return null;
+ }),
+ timeout,
+ ]),
+ cancel,
+ };
+};
+
+const readLastKnown = async (): Promise => {
+ try {
+ return await Location.getLastKnownPositionAsync({ maxAge: LAST_KNOWN_MAX_AGE_MS });
+ } catch (error) {
+ logger.warn({
+ message: 'Failed to read last known position',
+ context: { error: error instanceof Error ? error.message : String(error) },
+ });
+ return null;
+ }
+};
+
+/**
+ * Ask for a position, prompting for permission if it has not been decided yet.
+ *
+ * `permission-denied` and `unavailable` are kept apart because they are different problems for the
+ * crew to fix: one is a trip to the OS settings, the other is a move to open sky. A caller
+ * enforcing a GPS-required status needs to say which.
+ */
+export const acquireLocationFix = async (): Promise => {
+ let permission: Location.LocationPermissionResponse;
+
+ try {
+ permission = await Location.getForegroundPermissionsAsync();
+
+ // `canAskAgain` is false once the user has hard-denied; prompting again is a no-op that
+ // returns the same denial, so skip straight to reporting it.
+ if (permission.status !== 'granted' && permission.canAskAgain) {
+ permission = await Location.requestForegroundPermissionsAsync();
+ }
+ } catch (error) {
+ logger.warn({
+ message: 'Failed to resolve location permissions for fix',
+ context: { error: error instanceof Error ? error.message : String(error) },
+ });
+ return { outcome: 'permission-denied', location: null };
+ }
+
+ if (permission.status !== 'granted') {
+ logger.info({
+ message: 'Location fix requested without permission',
+ context: { status: permission.status, canAskAgain: permission.canAskAgain },
+ });
+ return { outcome: 'permission-denied', location: null };
+ }
+
+ // Permission can be granted while the device's location services are switched off entirely; the
+ // position call then fails in a way that looks identical to "no signal" unless we check.
+ try {
+ const servicesEnabled = await Location.hasServicesEnabledAsync();
+ if (!servicesEnabled) {
+ logger.info({ message: 'Location services are disabled on the device' });
+ return { outcome: 'services-disabled', location: null };
+ }
+ } catch (error) {
+ // Treat an unanswerable services check as "probably fine" and let the position attempt decide.
+ logger.warn({
+ message: 'Failed to check whether location services are enabled',
+ context: { error: error instanceof Error ? error.message : String(error) },
+ });
+ }
+
+ const timedFix = withTimeout();
+ let location: Location.LocationObject | null;
+ try {
+ location = await timedFix.promise;
+ } finally {
+ timedFix.cancel();
+ }
+
+ // A timed-out live fix is common indoors. A recent cached one is still a truthful answer and is
+ // far better than refusing a GPS-required status outright.
+ if (!location) {
+ location = await readLastKnown();
+ }
+
+ if (!location) {
+ logger.info({ message: 'No location fix available for submission' });
+ return { outcome: 'unavailable', location: null };
+ }
+
+ // Feed the store so the map and anything else reading it benefit from the fix we just paid for.
+ // A store write is a side benefit, not the point of the call: if it throws, the caller still has
+ // a real fix and a GPS-required status must not be refused over it.
+ try {
+ useLocationStore.getState().setLocation(location);
+ } catch (error) {
+ logger.warn({
+ message: 'Failed to write acquired location fix to the store',
+ context: { error: error instanceof Error ? error.message : String(error) },
+ });
+ }
+
+ return { outcome: 'acquired', location };
+};
+
+const FIX_ERROR_KEYS = {
+ 'permission-denied': 'location.fix_permission_denied',
+ 'services-disabled': 'location.fix_services_disabled',
+ unavailable: 'location.fix_unavailable',
+} as const;
+
+const FIX_ERROR_FALLBACKS: Record, string> = {
+ 'permission-denied': 'Location permission is required. Enable location access in your device settings and try again.',
+ 'services-disabled': 'Location services are turned off. Turn them on in your device settings and try again.',
+ unavailable: 'Could not get a location fix. Move to an area with a clearer view of the sky and try again.',
+};
+
+/**
+ * Message for a failed fix, naming the specific obstacle. "GPS is required for this status" leaves
+ * the responder guessing; "turn location services on" tells them what to do about it.
+ */
+export const getLocationFixErrorMessage = (outcome: Exclude): string => {
+ const key = FIX_ERROR_KEYS[outcome];
+ const message = translate(key);
+ return typeof message === 'string' && message.length > 0 && message !== key ? message : FIX_ERROR_FALLBACKS[outcome];
+};
diff --git a/src/services/location.ts b/src/services/location.ts
index 003691ec..17ce5f2e 100644
--- a/src/services/location.ts
+++ b/src/services/location.ts
@@ -5,9 +5,12 @@ import { AppState, type AppStateStatus } from 'react-native';
import { setUnitLocation } from '@/api/units/unitLocation';
import { registerLocationServiceUpdater } from '@/lib/hooks/use-background-geolocation';
+import { registerLocationServiceRealtimeUpdater } from '@/lib/hooks/use-realtime-geolocation';
+import { translate } from '@/lib/i18n/utils';
import { logger } from '@/lib/logging';
import { isWeb } from '@/lib/platform';
import { loadBackgroundGeolocationState } from '@/lib/storage/background-geolocation';
+import { loadRealtimeGeolocationState, saveRealtimeGeolocationState } from '@/lib/storage/realtime-geolocation';
import { SaveUnitLocationInput } from '@/models/v4/unitLocation/saveUnitLocationInput';
import { offlineEventManager } from '@/services/offline-event-manager.service';
import { useCoreStore } from '@/stores/app/core-store';
@@ -23,6 +26,35 @@ const LOCATION_TASK_NAME = 'location-updates';
const REJECTION_BACKOFF_BASE_MS = 30 * 1000;
const REJECTION_BACKOFF_MAX_MS = 15 * 60 * 1000;
+/**
+ * Options for the OS-managed background task.
+ *
+ * `deferredUpdates*` is the significant battery lever on Android: it lets the OS collect fixes
+ * while the screen is off and hand them over in one batch, instead of waking the JS runtime for
+ * every individual fix. iOS ignores it.
+ *
+ * `pausesUpdatesAutomatically` is deliberately false. iOS will otherwise stop updates once it
+ * decides the device has been stationary for a while and only resume on significant motion — an
+ * apparatus parked at a scene would drop off dispatch's map and stay off it.
+ *
+ * `activityType` is AutomotiveNavigation here (Responder uses Other): this app runs on a
+ * vehicle-mounted device, and telling iOS so lets it tune the GPS duty cycle for road travel.
+ */
+const getBackgroundTaskOptions = (): Location.LocationTaskOptions => ({
+ accuracy: Location.Accuracy.Balanced,
+ timeInterval: 15000,
+ distanceInterval: 10,
+ deferredUpdatesInterval: 30000,
+ deferredUpdatesDistance: 25,
+ pausesUpdatesAutomatically: false,
+ activityType: Location.LocationActivityType.AutomotiveNavigation,
+ showsBackgroundLocationIndicator: true,
+ foregroundService: {
+ notificationTitle: translate('location.tracking_notification_title'),
+ notificationBody: translate('location.tracking_notification_body'),
+ },
+});
+
let rejectedUnitId: string | null = null;
let consecutiveRejections = 0;
let nextAttemptAtMs = 0;
@@ -41,8 +73,21 @@ const resetRejectionBackoff = (): void => {
*/
const nonNegativeOrUndefined = (value: number | null | undefined): number | undefined => (typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined);
-// Helper function to send location to API
-const sendLocationToAPI = async (location: Location.LocationObject): Promise => {
+/**
+ * Post a fix to the unit-location endpoint.
+ *
+ * `isTransmissionEnabled` is resolved by the caller because which setting governs a fix depends on
+ * where it came from: a foreground fix is governed by Realtime Geolocation, a background one by
+ * Background Geolocation. Deciding it here would collapse two independent settings into one.
+ */
+const sendLocationToAPI = async (location: Location.LocationObject, isTransmissionEnabled: boolean): Promise => {
+ if (!isTransmissionEnabled) {
+ logger.debug({
+ message: 'Location transmission disabled for this fix, skipping API call',
+ });
+ return;
+ }
+
const { activeUnitId } = useCoreStore.getState();
try {
if (!activeUnitId) {
@@ -171,8 +216,12 @@ if (!isWeb) {
// Update local store
useLocationStore.getState().setLocation(location);
- // Send to API
- await sendLocationToAPI(location);
+ // The OS task keeps delivering while the app is foregrounded, so the fix has to be
+ // attributed to the right setting before it can be sent: foreground fixes belong to
+ // Realtime Geolocation, background ones to Background Geolocation.
+ const isTransmissionEnabled = AppState.currentState === 'active' ? await loadRealtimeGeolocationState() : await loadBackgroundGeolocationState();
+
+ await sendLocationToAPI(location, isTransmissionEnabled);
}
}
});
@@ -184,6 +233,7 @@ class LocationService {
private backgroundSubscription: Location.LocationSubscription | null = null;
private appStateSubscription: { remove: () => void } | null = null;
private isBackgroundGeolocationEnabled = false;
+ private isRealtimeGeolocationEnabled = true;
// Single-flight guards. The subscription fields are only assigned after an
// await, so a plain "already subscribed?" check lets two concurrent starts
// both pass it and create duplicate watchers — and lets a stop run in the gap
@@ -196,6 +246,7 @@ class LocationService {
this.initializeAppStateListener();
// Register this service's update function to avoid circular dependency
registerLocationServiceUpdater(this.updateBackgroundGeolocationSetting.bind(this));
+ registerLocationServiceRealtimeUpdater(this.updateRealtimeGeolocationSetting.bind(this));
}
static getInstance(): LocationService {
@@ -277,6 +328,9 @@ class LocationService {
return;
}
+ // The native path loads this below, but the web path returns before reaching it.
+ this.isRealtimeGeolocationEnabled = await loadRealtimeGeolocationState();
+
if (!this.locationSubscription) {
const watchId = navigator.geolocation.watchPosition(
(pos) => {
@@ -293,7 +347,7 @@ class LocationService {
timestamp: pos.timestamp,
};
useLocationStore.getState().setLocation(loc);
- sendLocationToAPI(loc);
+ sendLocationToAPI(loc, this.isRealtimeGeolocationEnabled);
},
(err) => {
logger.warn({ message: 'Web geolocation error', context: { code: err.code, msg: err.message } });
@@ -307,8 +361,9 @@ class LocationService {
return;
}
- // Load background geolocation setting first
+ // Load both geolocation settings first
this.isBackgroundGeolocationEnabled = await loadBackgroundGeolocationState();
+ this.isRealtimeGeolocationEnabled = await loadRealtimeGeolocationState();
// Only request background permissions if the user has enabled background geolocation
const hasPermissions = await this.requestPermissions(this.isBackgroundGeolocationEnabled);
@@ -327,15 +382,7 @@ class LocationService {
// Check if task is already registered for background updates
const isTaskRegistered = await TaskManager.isTaskRegisteredAsync(LOCATION_TASK_NAME);
if (!isTaskRegistered) {
- await Location.startLocationUpdatesAsync(LOCATION_TASK_NAME, {
- accuracy: Location.Accuracy.Balanced,
- timeInterval: 15000,
- distanceInterval: 10,
- foregroundService: {
- notificationTitle: 'Location Tracking',
- notificationBody: 'Tracking your location in the background',
- },
- });
+ await Location.startLocationUpdatesAsync(LOCATION_TASK_NAME, getBackgroundTaskOptions());
logger.info({
message: 'Background location task registered',
});
@@ -368,7 +415,10 @@ class LocationService {
},
});
useLocationStore.getState().setLocation(location);
- sendLocationToAPI(location); // Send to API for foreground updates
+ // Foreground transmission is governed by Realtime Geolocation. The watcher itself keeps
+ // running either way: the map, route ETAs and the status sheet all read the location
+ // store, and switching transmission off must not blind the app to its own position.
+ sendLocationToAPI(location, this.isRealtimeGeolocationEnabled);
}
);
} else {
@@ -437,7 +487,7 @@ class LocationService {
},
});
useLocationStore.getState().setLocation(location);
- sendLocationToAPI(location); // Send to API for background updates
+ sendLocationToAPI(location, this.isBackgroundGeolocationEnabled); // Background fixes follow the background setting
}
);
@@ -461,6 +511,40 @@ class LocationService {
useLocationStore.getState().setBackgroundEnabled(false);
}
+ async updateRealtimeGeolocationSetting(enabled: boolean): Promise {
+ this.isRealtimeGeolocationEnabled = enabled;
+
+ await saveRealtimeGeolocationState(enabled);
+
+ // Flipping the flag is worthless without a watcher, and one is not guaranteed to exist:
+ // `startLocationUpdates` runs once per unit selection and throws when the permission prompt was
+ // declined. A unit that denied location at launch, granted it in the OS settings and then
+ // switched realtime on would otherwise transmit nothing until the next cold start.
+ //
+ // Restarting is idempotent — `startLocationUpdates` shares its in-flight promise and skips a
+ // subscription that already exists. Failures are logged rather than rethrown: the setting was
+ // saved, and permission can still be granted later.
+ if (enabled) {
+ try {
+ await this.startLocationUpdates();
+ } catch (error) {
+ logger.error({
+ message: 'Failed to start location updates after enabling realtime geolocation',
+ context: { error },
+ });
+ }
+
+ // performStartLocationUpdates re-reads the setting from storage, and a read that threw
+ // answers with the default. The caller's explicit choice wins over that guess.
+ this.isRealtimeGeolocationEnabled = enabled;
+ }
+
+ logger.info({
+ message: `Realtime geolocation setting updated to: ${enabled}`,
+ context: { enabled },
+ });
+ }
+
async updateBackgroundGeolocationSetting(enabled: boolean): Promise {
if (isWeb) return; // Background geolocation not applicable on web
this.isBackgroundGeolocationEnabled = enabled;
@@ -481,15 +565,7 @@ class LocationService {
// Register the task if not already registered
const isTaskRegistered = await TaskManager.isTaskRegisteredAsync(LOCATION_TASK_NAME);
if (!isTaskRegistered) {
- await Location.startLocationUpdatesAsync(LOCATION_TASK_NAME, {
- accuracy: Location.Accuracy.Balanced,
- timeInterval: 15000,
- distanceInterval: 10,
- foregroundService: {
- notificationTitle: 'Location Tracking',
- notificationBody: 'Tracking your location in the background',
- },
- });
+ await Location.startLocationUpdatesAsync(LOCATION_TASK_NAME, getBackgroundTaskOptions());
logger.info({
message: 'Background location task registered after setting change',
});
diff --git a/src/translations/ar.json b/src/translations/ar.json
index 2971db58..e1b2317e 100644
--- a/src/translations/ar.json
+++ b/src/translations/ar.json
@@ -674,6 +674,13 @@
"pleaseWait": "يرجى الانتظار",
"processingRequest": "جاري معالجة طلبك..."
},
+ "location": {
+ "fix_permission_denied": "إذن الموقع مطلوب. فعّل الوصول إلى الموقع في إعدادات جهازك ثم أعد المحاولة.",
+ "fix_services_disabled": "خدمات الموقع معطّلة. فعّلها في إعدادات جهازك ثم أعد المحاولة.",
+ "fix_unavailable": "تعذّر تحديد الموقع. انتقل إلى منطقة ذات رؤية أوضح للسماء ثم أعد المحاولة.",
+ "tracking_notification_body": "تتم مشاركة موقع هذه الوحدة مع إدارتك.",
+ "tracking_notification_title": "تتبع الموقع"
+ },
"login": {
"errorModal": {
"confirmButton": "موافق",
@@ -1033,6 +1040,8 @@
"french": "الفرنسية",
"general": "عام",
"generale": "عام",
+ "geolocation_hub_connected": "تم الاتصال بالمركز.",
+ "geolocation_hub_connecting": "جارٍ الاتصال بالمركز...",
"german": "الألمانية",
"github": "جيثب",
"greek": "اليونانية",
@@ -1062,6 +1071,8 @@
"privacy": "سياسة الخصوصية",
"privacy_policy": "سياسة الخصوصية",
"rate": "تقييم",
+ "realtime_geolocation": "تحديد الموقع في الوقت الفعلي",
+ "realtime_geolocation_warning": "ترسل هذه الميزة موقع هذه الوحدة إلى إدارتك أثناء فتح التطبيق، وتتصل بمركز الموقع في الوقت الفعلي لاستقبال مواقع الوحدات الأخرى. وهي تتطلب اتصال شبكة نشطًا.",
"select_unit": "اختر الوحدة",
"server": "الخادم",
"server_url": "عنوان URL للخادم",
diff --git a/src/translations/de.json b/src/translations/de.json
index a6e7b2f7..a0998b11 100644
--- a/src/translations/de.json
+++ b/src/translations/de.json
@@ -674,6 +674,13 @@
"pleaseWait": "Bitte warten",
"processingRequest": "Ihre Anfrage wird verarbeitet..."
},
+ "location": {
+ "fix_permission_denied": "Standortberechtigung erforderlich. Aktivieren Sie den Standortzugriff in den Geräteeinstellungen und versuchen Sie es erneut.",
+ "fix_services_disabled": "Standortdienste sind deaktiviert. Aktivieren Sie sie in den Geräteeinstellungen und versuchen Sie es erneut.",
+ "fix_unavailable": "Standort konnte nicht ermittelt werden. Begeben Sie sich an einen Ort mit freier Sicht zum Himmel und versuchen Sie es erneut.",
+ "tracking_notification_body": "Der Standort dieser Einheit wird mit Ihrer Abteilung geteilt.",
+ "tracking_notification_title": "Standortverfolgung"
+ },
"login": {
"errorModal": {
"confirmButton": "OK",
@@ -1033,6 +1040,8 @@
"french": "Französisch",
"general": "Allgemein",
"generale": "Allgemein",
+ "geolocation_hub_connected": "Mit Hub verbunden.",
+ "geolocation_hub_connecting": "Verbindung zum Hub wird hergestellt...",
"german": "Deutsch",
"github": "Github",
"greek": "Griechisch",
@@ -1062,6 +1071,8 @@
"privacy": "Datenschutzrichtlinie",
"privacy_policy": "Datenschutzrichtlinie",
"rate": "Bewerten",
+ "realtime_geolocation": "Echtzeit-Standort",
+ "realtime_geolocation_warning": "Diese Funktion sendet den Standort dieser Einheit an Ihre Abteilung, während die App geöffnet ist, und verbindet sich mit dem Echtzeit-Standort-Hub, um die Positionen anderer Einheiten zu empfangen. Eine aktive Netzwerkverbindung ist erforderlich.",
"select_unit": "Einheit auswählen",
"server": "Server",
"server_url": "Server-URL",
diff --git a/src/translations/el.json b/src/translations/el.json
index 5fd8959a..b0b3d3cb 100644
--- a/src/translations/el.json
+++ b/src/translations/el.json
@@ -674,6 +674,13 @@
"pleaseWait": "Παρακαλώ περιμένετε",
"processingRequest": "Επεξεργασία του αιτήματός σας..."
},
+ "location": {
+ "fix_permission_denied": "Απαιτείται άδεια τοποθεσίας. Ενεργοποιήστε την πρόσβαση στην τοποθεσία στις ρυθμίσεις της συσκευής σας και δοκιμάστε ξανά.",
+ "fix_services_disabled": "Οι υπηρεσίες τοποθεσίας είναι απενεργοποιημένες. Ενεργοποιήστε τες στις ρυθμίσεις της συσκευής σας και δοκιμάστε ξανά.",
+ "fix_unavailable": "Δεν ήταν δυνατός ο εντοπισμός της τοποθεσίας. Μετακινηθείτε σε περιοχή με καθαρή θέα του ουρανού και δοκιμάστε ξανά.",
+ "tracking_notification_body": "Κοινοποίηση της τοποθεσίας αυτής της μονάδας στο τμήμα σας.",
+ "tracking_notification_title": "Παρακολούθηση τοποθεσίας"
+ },
"login": {
"errorModal": {
"confirmButton": "Εντάξει",
@@ -1033,6 +1040,8 @@
"french": "Γαλλικά",
"general": "Γενικά",
"generale": "Γενικά",
+ "geolocation_hub_connected": "Συνδέθηκε στον κόμβο.",
+ "geolocation_hub_connecting": "Σύνδεση στον κόμβο...",
"german": "Γερμανικά",
"github": "Github",
"greek": "Ελληνικά",
@@ -1062,6 +1071,8 @@
"privacy": "Πολιτική Απορρήτου",
"privacy_policy": "Πολιτική Απορρήτου",
"rate": "Αξιολόγηση",
+ "realtime_geolocation": "Γεωεντοπισμός σε πραγματικό χρόνο",
+ "realtime_geolocation_warning": "Αυτή η λειτουργία στέλνει την τοποθεσία αυτής της μονάδας στο τμήμα σας όσο η εφαρμογή είναι ανοικτή και συνδέεται στον κόμβο τοποθεσίας πραγματικού χρόνου για να λαμβάνει τις θέσεις άλλων μονάδων. Απαιτείται ενεργή σύνδεση δικτύου.",
"select_unit": "Επιλογή Μονάδας",
"server": "Διακομιστής",
"server_url": "Διεύθυνση URL Διακομιστή",
diff --git a/src/translations/en.json b/src/translations/en.json
index bc0f2746..a3fcbeea 100644
--- a/src/translations/en.json
+++ b/src/translations/en.json
@@ -674,6 +674,13 @@
"pleaseWait": "Please wait",
"processingRequest": "Processing your request..."
},
+ "location": {
+ "fix_permission_denied": "Location permission is required. Enable location access in your device settings and try again.",
+ "fix_services_disabled": "Location services are turned off. Turn them on in your device settings and try again.",
+ "fix_unavailable": "Could not get a location fix. Move to an area with a clearer view of the sky and try again.",
+ "tracking_notification_body": "Sharing this unit's location with your department.",
+ "tracking_notification_title": "Location Tracking"
+ },
"login": {
"errorModal": {
"confirmButton": "OK",
@@ -1033,6 +1040,8 @@
"french": "French",
"general": "General",
"generale": "General",
+ "geolocation_hub_connected": "Connected to hub.",
+ "geolocation_hub_connecting": "Connecting to hub...",
"german": "German",
"github": "Github",
"greek": "Greek",
@@ -1062,6 +1071,8 @@
"privacy": "Privacy Policy",
"privacy_policy": "Privacy Policy",
"rate": "Rate",
+ "realtime_geolocation": "Realtime Geolocation",
+ "realtime_geolocation_warning": "This feature sends this unit's location to your department while the app is open, and connects to the real-time location hub to receive other units' positions. It requires an active network connection.",
"select_unit": "Select Unit",
"server": "Server",
"server_url": "Server URL",
diff --git a/src/translations/es.json b/src/translations/es.json
index 909ff5c2..21713c3d 100644
--- a/src/translations/es.json
+++ b/src/translations/es.json
@@ -674,6 +674,13 @@
"pleaseWait": "Por favor, espere",
"processingRequest": "Procesando su solicitud..."
},
+ "location": {
+ "fix_permission_denied": "Se requiere permiso de ubicación. Active el acceso a la ubicación en los ajustes del dispositivo e inténtelo de nuevo.",
+ "fix_services_disabled": "Los servicios de ubicación están desactivados. Actívelos en los ajustes del dispositivo e inténtelo de nuevo.",
+ "fix_unavailable": "No se pudo obtener la ubicación. Diríjase a una zona con mejor visibilidad del cielo e inténtelo de nuevo.",
+ "tracking_notification_body": "Compartiendo la ubicación de esta unidad con su departamento.",
+ "tracking_notification_title": "Seguimiento de ubicación"
+ },
"login": {
"errorModal": {
"confirmButton": "Aceptar",
@@ -1033,6 +1040,8 @@
"french": "Francés",
"general": "General",
"generale": "General",
+ "geolocation_hub_connected": "Conectado al centro.",
+ "geolocation_hub_connecting": "Conectando al centro...",
"german": "Alemán",
"github": "Github",
"greek": "Griego",
@@ -1062,6 +1071,8 @@
"privacy": "Política de privacidad",
"privacy_policy": "Política de privacidad",
"rate": "Calificar",
+ "realtime_geolocation": "Geolocalización en tiempo real",
+ "realtime_geolocation_warning": "Esta función envía la ubicación de esta unidad a su departamento mientras la aplicación está abierta y se conecta al centro de ubicación en tiempo real para recibir las posiciones de otras unidades. Requiere una conexión de red activa.",
"select_unit": "Seleccionar unidad",
"server": "Servidor",
"server_url": "URL del servidor",
diff --git a/src/translations/fr.json b/src/translations/fr.json
index a5e493d7..843f0282 100644
--- a/src/translations/fr.json
+++ b/src/translations/fr.json
@@ -674,6 +674,13 @@
"pleaseWait": "Veuillez patienter",
"processingRequest": "Traitement de votre demande..."
},
+ "location": {
+ "fix_permission_denied": "L'autorisation de localisation est requise. Activez l'accès à la localisation dans les réglages de votre appareil, puis réessayez.",
+ "fix_services_disabled": "Les services de localisation sont désactivés. Activez-les dans les réglages de votre appareil, puis réessayez.",
+ "fix_unavailable": "Impossible d'obtenir une position. Placez-vous dans une zone dégagée, puis réessayez.",
+ "tracking_notification_body": "Partage de la position de cette unité avec votre département.",
+ "tracking_notification_title": "Suivi de la position"
+ },
"login": {
"errorModal": {
"confirmButton": "OK",
@@ -1033,6 +1040,8 @@
"french": "Français",
"general": "Général",
"generale": "Général",
+ "geolocation_hub_connected": "Connecté au hub.",
+ "geolocation_hub_connecting": "Connexion au hub...",
"german": "Allemand",
"github": "Github",
"greek": "Grec",
@@ -1062,6 +1071,8 @@
"privacy": "Politique de confidentialité",
"privacy_policy": "Politique de confidentialité",
"rate": "Évaluer",
+ "realtime_geolocation": "Géolocalisation en temps réel",
+ "realtime_geolocation_warning": "Cette fonction envoie la position de cette unité à votre département lorsque l'application est ouverte et se connecte au hub de localisation en temps réel pour recevoir la position des autres unités. Une connexion réseau active est requise.",
"select_unit": "Sélectionner l'unité",
"server": "Serveur",
"server_url": "URL du serveur",
diff --git a/src/translations/it.json b/src/translations/it.json
index bfd140ae..3abafd64 100644
--- a/src/translations/it.json
+++ b/src/translations/it.json
@@ -674,6 +674,13 @@
"pleaseWait": "Attendere prego",
"processingRequest": "Elaborazione richiesta in corso..."
},
+ "location": {
+ "fix_permission_denied": "È necessaria l'autorizzazione alla posizione. Attiva l'accesso alla posizione nelle impostazioni del dispositivo e riprova.",
+ "fix_services_disabled": "I servizi di localizzazione sono disattivati. Attivali nelle impostazioni del dispositivo e riprova.",
+ "fix_unavailable": "Impossibile ottenere la posizione. Spostati in un'area con una visuale del cielo più libera e riprova.",
+ "tracking_notification_body": "Condivisione della posizione di questa unità con il tuo dipartimento.",
+ "tracking_notification_title": "Tracciamento della posizione"
+ },
"login": {
"errorModal": {
"confirmButton": "OK",
@@ -1033,6 +1040,8 @@
"french": "Francese",
"general": "Generale",
"generale": "Generale",
+ "geolocation_hub_connected": "Connesso all'hub.",
+ "geolocation_hub_connecting": "Connessione all'hub...",
"german": "Tedesco",
"github": "Github",
"greek": "Greco",
@@ -1062,6 +1071,8 @@
"privacy": "Informativa sulla privacy",
"privacy_policy": "Informativa sulla privacy",
"rate": "Valuta",
+ "realtime_geolocation": "Geolocalizzazione in tempo reale",
+ "realtime_geolocation_warning": "Questa funzione invia la posizione di questa unità al tuo dipartimento mentre l'app è aperta e si collega all'hub di localizzazione in tempo reale per ricevere le posizioni delle altre unità. Richiede una connessione di rete attiva.",
"select_unit": "Seleziona unità",
"server": "Server",
"server_url": "URL server",
diff --git a/src/translations/pl.json b/src/translations/pl.json
index 16203e90..36ddf151 100644
--- a/src/translations/pl.json
+++ b/src/translations/pl.json
@@ -674,6 +674,13 @@
"pleaseWait": "Proszę czekać",
"processingRequest": "Przetwarzanie żądania..."
},
+ "location": {
+ "fix_permission_denied": "Wymagane jest uprawnienie do lokalizacji. Włącz dostęp do lokalizacji w ustawieniach urządzenia i spróbuj ponownie.",
+ "fix_services_disabled": "Usługi lokalizacji są wyłączone. Włącz je w ustawieniach urządzenia i spróbuj ponownie.",
+ "fix_unavailable": "Nie udało się ustalić lokalizacji. Przejdź w miejsce z lepszą widocznością nieba i spróbuj ponownie.",
+ "tracking_notification_body": "Udostępnianie lokalizacji tej jednostki jednostce organizacyjnej.",
+ "tracking_notification_title": "Śledzenie lokalizacji"
+ },
"login": {
"errorModal": {
"confirmButton": "OK",
@@ -1033,6 +1040,8 @@
"french": "Francuski",
"general": "Ogólne",
"generale": "Ogólne",
+ "geolocation_hub_connected": "Połączono z centrum.",
+ "geolocation_hub_connecting": "Łączenie z centrum...",
"german": "Niemiecki",
"github": "Github",
"greek": "Grecki",
@@ -1062,6 +1071,8 @@
"privacy": "Polityka prywatności",
"privacy_policy": "Polityka prywatności",
"rate": "Oceń",
+ "realtime_geolocation": "Geolokalizacja w czasie rzeczywistym",
+ "realtime_geolocation_warning": "Ta funkcja wysyła lokalizację tej jednostki do Twojej jednostki organizacyjnej, gdy aplikacja jest otwarta, i łączy się z centrum lokalizacji w czasie rzeczywistym, aby odbierać pozycje innych jednostek. Wymaga aktywnego połączenia sieciowego.",
"select_unit": "Wybierz jednostkę",
"server": "Serwer",
"server_url": "URL serwera",
diff --git a/src/translations/sv.json b/src/translations/sv.json
index a33cb8be..58f66ecf 100644
--- a/src/translations/sv.json
+++ b/src/translations/sv.json
@@ -674,6 +674,13 @@
"pleaseWait": "Vänta",
"processingRequest": "Behandlar din begäran..."
},
+ "location": {
+ "fix_permission_denied": "Platsbehörighet krävs. Aktivera platsåtkomst i enhetens inställningar och försök igen.",
+ "fix_services_disabled": "Platstjänster är avstängda. Slå på dem i enhetens inställningar och försök igen.",
+ "fix_unavailable": "Kunde inte fastställa din position. Flytta till en plats med fri sikt mot himlen och försök igen.",
+ "tracking_notification_body": "Delar den här enhetens position med din avdelning.",
+ "tracking_notification_title": "Platsspårning"
+ },
"login": {
"errorModal": {
"confirmButton": "OK",
@@ -1033,6 +1040,8 @@
"french": "Franska",
"general": "Allmänt",
"generale": "Allmänt",
+ "geolocation_hub_connected": "Ansluten till navet.",
+ "geolocation_hub_connecting": "Ansluter till navet...",
"german": "Tyska",
"github": "Github",
"greek": "Grekiska",
@@ -1062,6 +1071,8 @@
"privacy": "Integritetspolicy",
"privacy_policy": "Integritetspolicy",
"rate": "Betygsätt",
+ "realtime_geolocation": "Positionering i realtid",
+ "realtime_geolocation_warning": "Den här funktionen skickar den här enhetens position till din avdelning medan appen är öppen och ansluter till realtidsnavet för att ta emot andra enheters positioner. Det kräver en aktiv nätverksanslutning.",
"select_unit": "Välj enhet",
"server": "Server",
"server_url": "Server-URL",
diff --git a/src/translations/uk.json b/src/translations/uk.json
index 10412853..1052df6d 100644
--- a/src/translations/uk.json
+++ b/src/translations/uk.json
@@ -674,6 +674,13 @@
"pleaseWait": "Будь ласка, зачекайте",
"processingRequest": "Обробка запиту..."
},
+ "location": {
+ "fix_permission_denied": "Потрібен дозвіл на доступ до місцезнаходження. Увімкніть доступ до місцезнаходження в налаштуваннях пристрою та повторіть спробу.",
+ "fix_services_disabled": "Служби визначення місцезнаходження вимкнено. Увімкніть їх у налаштуваннях пристрою та повторіть спробу.",
+ "fix_unavailable": "Не вдалося визначити місцезнаходження. Перейдіть у місце з кращим оглядом неба та повторіть спробу.",
+ "tracking_notification_body": "Місцезнаходження цього підрозділу надається вашому підрозділу.",
+ "tracking_notification_title": "Відстеження місцезнаходження"
+ },
"login": {
"errorModal": {
"confirmButton": "OK",
@@ -1033,6 +1040,8 @@
"french": "Французька",
"general": "Загальне",
"generale": "Загальне",
+ "geolocation_hub_connected": "Підключено до концентратора.",
+ "geolocation_hub_connecting": "Підключення до концентратора...",
"german": "Німецька",
"github": "Github",
"greek": "Грецька",
@@ -1062,6 +1071,8 @@
"privacy": "Політика конфіденційності",
"privacy_policy": "Політика конфіденційності",
"rate": "Оцінити",
+ "realtime_geolocation": "Геолокація в реальному часі",
+ "realtime_geolocation_warning": "Ця функція надсилає місцезнаходження цього підрозділу вашому підрозділу, доки застосунок відкрито, і підключається до концентратора геолокації в реальному часі для отримання позицій інших підрозділів. Потрібне активне мережеве з’єднання.",
"select_unit": "Вибрати підрозділ",
"server": "Сервер",
"server_url": "URL сервера",