diff --git a/assets/mapping/direction_arrow.png b/assets/mapping/direction_arrow.png
new file mode 100644
index 00000000..3bc42109
Binary files /dev/null and b/assets/mapping/direction_arrow.png differ
diff --git a/jest-setup.ts b/jest-setup.ts
index 5b46e1e8..82915641 100644
--- a/jest-setup.ts
+++ b/jest-setup.ts
@@ -268,21 +268,10 @@ jest.mock('expo-video', () => ({
})),
}));
-// Mock zod globally to avoid validation schema issues in tests
-jest.mock('zod', () => ({
- z: {
- object: jest.fn(() => ({
- parse: jest.fn((data) => data),
- safeParse: jest.fn((data) => ({ success: true, data })),
- })),
- string: jest.fn(() => ({
- min: jest.fn(() => ({
- parse: jest.fn((data) => data),
- safeParse: jest.fn((data) => ({ success: true, data })),
- })),
- parse: jest.fn((data) => data),
- safeParse: jest.fn((data) => ({ success: true, data })),
- })),
- },
- __esModule: true,
-}));
+// zod is deliberately NOT mocked. A global stub used to live here, returning a
+// schema whose safeParse accepted everything and which had no parseAsync at all
+// — the method zodResolver actually calls. Any form suite that loaded it either
+// crashed on `import * as z` or silently validated nothing. Every schema in this
+// app guards a form a responder depends on (login, SSO, call create, call edit),
+// so those schemas must run for real in tests. zod is plain CJS and resolves
+// under jest with no configuration.
diff --git a/src/__tests__/no-self-mocking-suites.test.ts b/src/__tests__/no-self-mocking-suites.test.ts
new file mode 100644
index 00000000..98cc8077
--- /dev/null
+++ b/src/__tests__/no-self-mocking-suites.test.ts
@@ -0,0 +1,93 @@
+import fs from 'fs';
+import path from 'path';
+
+/**
+ * Guards against test suites that mock away the very module they claim to cover.
+ *
+ * A file at `
/__tests__/.test.tsx` whose subject is `/.tsx`
+ * must not call `jest.mock('../')`. Doing so replaces the subject with a
+ * hand-written stand-in, so the suite asserts against the stand-in and executes
+ * none of the production code — it passes forever, including when the real
+ * component is broken or deleted.
+ *
+ * Mocking a subject's *dependencies* is normal and correct; only the subject
+ * itself is off limits. That is why the check is narrow: it fires solely when a
+ * test mocks the sibling module it is named after.
+ *
+ * A sweep on 2026-08-21 found eight such suites. Four covered production code
+ * that nothing else touched (login-form, call-images-modal,
+ * full-screen-image-modal, full-screen-location-picker) and were rewritten
+ * against the real components — which immediately surfaced six real bugs. The
+ * four below still have a sibling suite exercising the real code, so they are
+ * misleading rather than dangerous. They are listed here as known debt: the list
+ * may shrink, never grow.
+ */
+const KNOWN_SELF_MOCKING_SUITES = [
+ 'src/components/notifications/__tests__/NotificationInbox.test.tsx',
+ 'src/components/maps/__tests__/pin-detail-modal.test.tsx',
+ 'src/components/calls/__tests__/call-notes-modal.test.tsx',
+ 'src/components/calls/__tests__/call-detail-menu.test.tsx',
+];
+
+const SRC = path.join(__dirname, '..');
+const SUBJECT_EXTENSIONS = ['.ts', '.tsx'];
+
+const collectTestFiles = (dir: string, found: string[] = []): string[] => {
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
+ const full = path.join(dir, entry.name);
+ if (entry.isDirectory()) {
+ collectTestFiles(full, found);
+ } else if (/\.(test|spec)\.tsx?$/.test(entry.name)) {
+ found.push(full);
+ }
+ }
+ return found;
+};
+
+/**
+ * Strip comments so a suite that merely *describes* the anti-pattern in prose
+ * (as the rewritten login-form suite does) is not mistaken for one committing it.
+ */
+const stripComments = (source: string): string => source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1');
+
+/** Returns the repo-relative path of a suite that mocks its own subject, or null. */
+const findSelfMock = (testFile: string): string | null => {
+ const dir = path.dirname(testFile);
+ if (path.basename(dir) !== '__tests__') {
+ return null;
+ }
+
+ const subjectName = path.basename(testFile).replace(/\.(test|spec)\.tsx?$/, '');
+ const subjectDir = path.dirname(dir);
+ const subjectExists = SUBJECT_EXTENSIONS.some((ext) => fs.existsSync(path.join(subjectDir, `${subjectName}${ext}`)));
+ if (!subjectExists) {
+ return null;
+ }
+
+ const source = stripComments(fs.readFileSync(testFile, 'utf8'));
+ // Match jest.mock('../') / jest.doMock("../"), with or without a factory.
+ const selfMock = new RegExp(String.raw`jest\.(?:do)?[Mm]ock\(\s*['"\`]\.\./${subjectName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}['"\`]`);
+ return selfMock.test(source) ? path.relative(path.join(SRC, '..'), testFile) : null;
+};
+
+describe('test suites cover their real subject', () => {
+ const testFiles = collectTestFiles(SRC);
+
+ it('finds test files to check', () => {
+ expect(testFiles.length).toBeGreaterThan(100);
+ });
+
+ it('has no self-mocking suite outside the known-debt list', () => {
+ const offenders = testFiles.map(findSelfMock).filter((entry): entry is string => entry !== null);
+ const unexpected = offenders.filter((entry) => !KNOWN_SELF_MOCKING_SUITES.includes(entry));
+
+ expect(unexpected).toEqual([]);
+ });
+
+ it('keeps the known-debt list honest — entries that no longer self-mock must be removed', () => {
+ const offenders = new Set(testFiles.map(findSelfMock).filter((entry): entry is string => entry !== null));
+ const staleEntries = KNOWN_SELF_MOCKING_SUITES.filter((entry) => !offenders.has(entry));
+
+ expect(staleEntries).toEqual([]);
+ });
+});
diff --git a/src/api/calls/callFiles.ts b/src/api/calls/callFiles.ts
index 8eb0d801..1a8ea0ce 100644
--- a/src/api/calls/callFiles.ts
+++ b/src/api/calls/callFiles.ts
@@ -2,6 +2,7 @@ import axios, { type AxiosProgressEvent, type AxiosRequestConfig, type AxiosResp
import { Platform } from 'react-native';
import { createApiEndpoint } from '@/api/common/client';
+import { logger } from '@/lib/logging';
import { type CallFilesResult } from '@/models/v4/callFiles/callFilesResult';
import { type SaveCallFileResult } from '@/models/v4/callFiles/saveCallFileResult';
@@ -83,7 +84,7 @@ export const getCallAttachmentFile = async (url: string, options: DownloadOption
// Callers should check the return value and fall back to expo-file-system / expo-sharing on native.
export const saveBlobAsFile = (blob: Blob, fileName: string): boolean => {
if (Platform.OS !== 'web') {
- console.warn('saveBlobAsFile is not supported on native platforms. Use expo-file-system and expo-sharing instead.');
+ logger.warn({ message: 'saveBlobAsFile is not supported on native platforms. Use expo-file-system and expo-sharing instead.' });
return false;
}
diff --git a/src/api/calls/calls.ts b/src/api/calls/calls.ts
index 10da8082..23f611eb 100644
--- a/src/api/calls/calls.ts
+++ b/src/api/calls/calls.ts
@@ -1,4 +1,5 @@
import { cacheManager } from '@/lib/cache/cache-manager';
+import { logger } from '@/lib/logging';
import { type ActiveCallsResult } from '@/models/v4/calls/activeCallsResult';
import { type CallExtraDataResult } from '@/models/v4/calls/callExtraDataResult';
import { type CallResult } from '@/models/v4/calls/callResult';
@@ -17,8 +18,8 @@ const createCallApi = createApiEndpoint('/Calls/SaveCall');
const updateCallApi = createApiEndpoint('/Calls/EditCall');
const closeCallApi = createApiEndpoint('/Calls/CloseCall');
-export const getCalls = async () => {
- const response = await callsApi.get();
+export const getCalls = async (forceRefresh = false) => {
+ const response = await callsApi.get(undefined, { forceRefresh });
return response.data;
};
@@ -136,8 +137,8 @@ export const createCall = async (callData: CreateCallRequest) => {
try {
cacheManager.remove('/Calls/GetActiveCalls');
} catch (error) {
- // Silently handle cache removal errors
- console.warn('Failed to invalidate calls cache:', error);
+ // Cache removal failures are non-fatal
+ logger.warn({ message: 'Failed to invalidate calls cache', context: { error } });
}
return response.data;
@@ -169,8 +170,8 @@ export const updateCall = async (callData: UpdateCallRequest) => {
try {
cacheManager.remove('/Calls/GetActiveCalls');
} catch (error) {
- // Silently handle cache removal errors
- console.warn('Failed to invalidate calls cache:', error);
+ // Cache removal failures are non-fatal
+ logger.warn({ message: 'Failed to invalidate calls cache', context: { error } });
}
return response.data;
@@ -189,8 +190,8 @@ export const closeCall = async (callData: CloseCallRequest) => {
try {
cacheManager.remove('/Calls/GetActiveCalls');
} catch (error) {
- // Silently handle cache removal errors
- console.warn('Failed to invalidate calls cache:', error);
+ // Cache removal failures are non-fatal
+ logger.warn({ message: 'Failed to invalidate calls cache', context: { error } });
}
return response.data;
diff --git a/src/api/common/__tests__/api-provider.test.ts b/src/api/common/__tests__/api-provider.test.ts
new file mode 100644
index 00000000..5eeac7de
--- /dev/null
+++ b/src/api/common/__tests__/api-provider.test.ts
@@ -0,0 +1,30 @@
+jest.mock('@dev-plugins/react-query', () => ({
+ useReactQueryDevTools: jest.fn(),
+}));
+
+import { queryClient } from '../api-provider';
+
+describe('shared QueryClient defaults', () => {
+ const defaults = queryClient.getDefaultOptions().queries;
+
+ it('applies a modest stale time instead of refetching on every mount', () => {
+ expect(defaults?.staleTime).toBe(30 * 1000);
+ });
+
+ it('retries failed queries a bounded number of times', () => {
+ expect(defaults?.retry).toBe(2);
+ });
+
+ it('does not refetch on window focus', () => {
+ // On web/Electron every tab focus would otherwise re-run every mounted query.
+ expect(defaults?.refetchOnWindowFocus).toBe(false);
+ });
+
+ it('leaves per-query options free to override the defaults', () => {
+ // Consumers pass their own enabled/queryFn/staleTime; defaults must not be
+ // frozen into the client in a way that blocks that.
+ const observer = queryClient.defaultQueryOptions({ queryKey: ['x'], staleTime: 0, retry: false });
+ expect(observer.staleTime).toBe(0);
+ expect(observer.retry).toBe(false);
+ });
+});
diff --git a/src/api/common/__tests__/client.test.ts b/src/api/common/__tests__/client.test.ts
index 236910ae..c573176b 100644
--- a/src/api/common/__tests__/client.test.ts
+++ b/src/api/common/__tests__/client.test.ts
@@ -18,10 +18,17 @@ const mockAxiosInstance = Object.assign(jest.fn(), {
},
});
+// Records the config the client passes to axios.create, so the assertions below
+// survive both jest.isolateModules and the beforeEach clearAllMocks().
+const mockCreateConfigs: Record[] = [];
+
jest.mock('axios', () => ({
__esModule: true,
default: {
- create: jest.fn(() => mockAxiosInstance),
+ create: jest.fn((config: Record) => {
+ mockCreateConfigs.push(config);
+ return mockAxiosInstance;
+ }),
},
}));
@@ -87,4 +94,10 @@ describe('API client token refresh logging', () => {
});
expect(getHeader).toHaveBeenCalledWith('x-trace-id');
});
+
+ it('configures a request timeout so a hung request cannot stall the refresh queue', () => {
+ // Axios defaults to no timeout: a hung request would hold the single-flight
+ // refresh promise and every 401-queued request behind it, indefinitely.
+ expect(mockCreateConfigs[0]).toMatchObject({ timeout: 30000 });
+ });
});
diff --git a/src/api/common/api-provider.tsx b/src/api/common/api-provider.tsx
index ff1e48dd..2ba842ab 100644
--- a/src/api/common/api-provider.tsx
+++ b/src/api/common/api-provider.tsx
@@ -2,7 +2,15 @@ import { useReactQueryDevTools } from '@dev-plugins/react-query';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import * as React from 'react';
-export const queryClient = new QueryClient();
+export const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ staleTime: 30 * 1000,
+ retry: 2,
+ refetchOnWindowFocus: false,
+ },
+ },
+});
export function APIProvider({ children }: { children: React.ReactNode }) {
useReactQueryDevTools(queryClient);
diff --git a/src/api/common/client.tsx b/src/api/common/client.tsx
index ae98e69e..5cc8ba85 100644
--- a/src/api/common/client.tsx
+++ b/src/api/common/client.tsx
@@ -7,6 +7,9 @@ import useAuthStore from '@/stores/auth/store';
// Create axios instance with default config
const axiosInstance: AxiosInstance = axios.create({
baseURL: getBaseApiUrl(),
+ // Axios defaults to no timeout — a hung request would otherwise hold the
+ // single-flight refresh (and every 401-queued request behind it) forever.
+ timeout: 30000,
headers: {
'Content-Type': 'application/json',
},
diff --git a/src/api/notes/notes.ts b/src/api/notes/notes.ts
index 14175fb4..a7faee30 100644
--- a/src/api/notes/notes.ts
+++ b/src/api/notes/notes.ts
@@ -63,8 +63,8 @@ export const saveNote = async (data: SaveNoteInput) => {
const response = await saveNoteApi.post({
...data,
});
- // The notes list is cached for 2 days — invalidate so the new/updated note
- // is visible immediately instead of after cache expiry.
+ // The notes list is cached for 15 minutes — invalidate so the new/updated
+ // note is visible immediately instead of after cache expiry.
cacheManager.remove('/Notes/GetAllNotes');
return response.data;
};
diff --git a/src/app/(app)/__tests__/calls.test.tsx b/src/app/(app)/__tests__/calls.test.tsx
index da6b63f9..75798c1f 100644
--- a/src/app/(app)/__tests__/calls.test.tsx
+++ b/src/app/(app)/__tests__/calls.test.tsx
@@ -1,4 +1,4 @@
-import { fireEvent, render, screen, waitFor } from '@testing-library/react-native';
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react-native';
import { router } from 'expo-router';
import React from 'react';
@@ -8,9 +8,11 @@ jest.mock('react-native', () => ({
OS: 'ios',
},
Pressable: ({ children, onPress, testID, ...props }: any) => (
- {children}
+
+ {children}
+
),
- RefreshControl: () => null,
+ RefreshControl: ({ refreshing, onRefresh }: any) =>
,
View: ({ children, ...props }: any) => {children}
,
StatusBar: {
setBackgroundColor: jest.fn(),
@@ -72,7 +74,7 @@ const mockAnalytics = {
// Mock the stores with proper getState method
jest.mock('@/stores/calls/store', () => {
- const useCallsStore = jest.fn((selector: any) => typeof selector === 'function' ? selector(mockCallsStore) : mockCallsStore);
+ const useCallsStore = jest.fn((selector: any) => (typeof selector === 'function' ? selector(mockCallsStore) : mockCallsStore));
(useCallsStore as any).getState = jest.fn(() => mockCallsStore);
return {
@@ -82,7 +84,7 @@ jest.mock('@/stores/calls/store', () => {
jest.mock('@/stores/security/store', () => ({
securityStore: jest.fn(),
- useSecurityStore: jest.fn((selector: any) => typeof selector === 'function' ? selector(mockSecurityStore) : mockSecurityStore),
+ useSecurityStore: jest.fn((selector: any) => (typeof selector === 'function' ? selector(mockSecurityStore) : mockSecurityStore)),
}));
jest.mock('@/hooks/use-analytics', () => ({
@@ -98,11 +100,7 @@ jest.mock('react-i18next', () => ({
// Mock components
jest.mock('@/components/calls/call-card', () => ({
- CallCard: ({ call }: any) => (
-
- {call.Nature}
-
- ),
+ CallCard: ({ call }: any) => {call.Nature}
,
}));
jest.mock('@/components/common/loading', () => ({
@@ -112,7 +110,7 @@ jest.mock('@/components/common/loading', () => ({
jest.mock('@/components/common/zero-state', () => ({
__esModule: true,
default: ({ heading, description, isError }: any) => (
-
+
{heading} - {description}
),
@@ -121,7 +119,9 @@ jest.mock('@/components/common/zero-state', () => ({
// Mock UI components
jest.mock('@/components/ui/box', () => ({
Box: ({ children, className, ...props }: any) => (
-
{children}
+
+ {children}
+
),
}));
@@ -129,13 +129,7 @@ jest.mock('@/components/ui/fab', () => ({
Fab: ({ children, onPress, testID = 'fab', ...props }: any) => {
const handleClick = onPress;
return (
-
+
{children}
);
@@ -144,35 +138,22 @@ jest.mock('@/components/ui/fab', () => ({
}));
jest.mock('@/components/ui/flat-list', () => ({
- FlatList: ({ data, renderItem, keyExtractor, ListEmptyComponent, ...props }: any) => (
+ FlatList: ({ data, renderItem, keyExtractor, ListEmptyComponent, refreshControl, ...props }: any) => (
- {data.length === 0 && ListEmptyComponent ? (
-
{ListEmptyComponent}
- ) : (
- data.map((item: any, index: number) => (
-
- {renderItem({ item, index })}
-
- ))
- )}
+ {refreshControl}
+ {data.length === 0 && ListEmptyComponent ?
{ListEmptyComponent}
: data.map((item: any, index: number) =>
{renderItem({ item, index })}
)}
),
}));
jest.mock('@/components/ui/input', () => ({
Input: ({ children, ...props }: any) => {children}
,
- InputField: ({ value, onChangeText, placeholder, ...props }: any) => (
- onChangeText(e.target.value)}
- placeholder={placeholder}
- role="textbox"
- {...props}
- />
- ),
+ InputField: ({ value, onChangeText, placeholder, ...props }: any) => onChangeText(e.target.value)} placeholder={placeholder} role="textbox" {...props} />,
InputIcon: ({ as: IconComponent, ...props }: any) => ,
InputSlot: ({ children, onPress, ...props }: any) => (
- {children}
+
+ {children}
+
),
}));
@@ -218,8 +199,8 @@ describe('CallsScreen', () => {
jest.clearAllMocks();
// Reset mock returns to defaults
- useCallsStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector(mockCallsStore) : mockCallsStore);
- useSecurityStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector(mockSecurityStore) : mockSecurityStore);
+ useCallsStore.mockImplementation((selector: any) => (typeof selector === 'function' ? selector(mockCallsStore) : mockCallsStore));
+ useSecurityStore.mockImplementation((selector: any) => (typeof selector === 'function' ? selector(mockSecurityStore) : mockSecurityStore));
useAnalytics.mockReturnValue(mockAnalytics);
// Setup securityStore as a selector-based store
@@ -247,7 +228,7 @@ describe('CallsScreen', () => {
describe('when user has create calls permission', () => {
beforeEach(() => {
mockSecurityStore.canUserCreateCalls = true;
- useSecurityStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector(mockSecurityStore) : mockSecurityStore);
+ useSecurityStore.mockImplementation((selector: any) => (typeof selector === 'function' ? selector(mockSecurityStore) : mockSecurityStore));
});
it('renders the new call FAB button', () => {
@@ -277,7 +258,7 @@ describe('CallsScreen', () => {
describe('when user does not have create calls permission', () => {
beforeEach(() => {
mockSecurityStore.canUserCreateCalls = false;
- useSecurityStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector(mockSecurityStore) : mockSecurityStore);
+ useSecurityStore.mockImplementation((selector: any) => (typeof selector === 'function' ? selector(mockSecurityStore) : mockSecurityStore));
});
it('does not render the new call FAB button', () => {
@@ -305,7 +286,7 @@ describe('CallsScreen', () => {
beforeEach(() => {
mockCallsStore.calls = mockCalls;
- useCallsStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector(mockCallsStore) : mockCallsStore);
+ useCallsStore.mockImplementation((selector: any) => (typeof selector === 'function' ? selector(mockCallsStore) : mockCallsStore));
});
it('renders call cards for each call', () => {
@@ -347,7 +328,7 @@ describe('CallsScreen', () => {
describe('loading and error states', () => {
it('shows loading state when isLoading is true', () => {
mockCallsStore.isLoading = true;
- useCallsStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector(mockCallsStore) : mockCallsStore);
+ useCallsStore.mockImplementation((selector: any) => (typeof selector === 'function' ? selector(mockCallsStore) : mockCallsStore));
render( );
@@ -360,7 +341,7 @@ describe('CallsScreen', () => {
it('shows error state when there is an error', () => {
mockCallsStore.error = 'Network error';
- useCallsStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector(mockCallsStore) : mockCallsStore);
+ useCallsStore.mockImplementation((selector: any) => (typeof selector === 'function' ? selector(mockCallsStore) : mockCallsStore));
render( );
@@ -373,7 +354,7 @@ describe('CallsScreen', () => {
it('shows zero state when there are no calls', () => {
mockCallsStore.calls = [];
- useCallsStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector(mockCallsStore) : mockCallsStore);
+ useCallsStore.mockImplementation((selector: any) => (typeof selector === 'function' ? selector(mockCallsStore) : mockCallsStore));
render( );
@@ -397,14 +378,120 @@ describe('CallsScreen', () => {
it('tracks view rendered event with correct parameters', () => {
const mockCalls = [{ CallId: 'call-1', Nature: 'Test' }];
mockCallsStore.calls = mockCalls;
- useCallsStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector(mockCallsStore) : mockCallsStore);
+ useCallsStore.mockImplementation((selector: any) => (typeof selector === 'function' ? selector(mockCallsStore) : mockCallsStore));
- render( );
+ const { unmount } = render( );
expect(mockAnalytics.trackEvent).toHaveBeenCalledWith('calls_view_rendered', {
callsCount: 1,
hasSearchQuery: false,
});
+
+ unmount();
+ });
+
+ it('does not fire a tracking event per search keystroke', () => {
+ mockCallsStore.calls = [{ CallId: 'call-1', Nature: 'Test' }];
+ useCallsStore.mockImplementation((selector: any) => (typeof selector === 'function' ? selector(mockCallsStore) : mockCallsStore));
+
+ const view = render( );
+
+ const countRendered = () => mockAnalytics.trackEvent.mock.calls.filter((call: any[]) => call[0] === 'calls_view_rendered').length;
+
+ const callsAfterMount = countRendered();
+
+ // This suite mocks react-native with HTML elements, so the search field is
+ // reached by prop rather than by role/placeholder query.
+ const searchInput = view.UNSAFE_getByProps({ role: 'textbox' });
+ fireEvent(searchInput, 'change', { target: { value: 'f' } });
+ fireEvent(searchInput, 'change', { target: { value: 'fi' } });
+ fireEvent(searchInput, 'change', { target: { value: 'fir' } });
+
+ expect(countRendered()).toBe(callsAfterMount);
+
+ view.unmount();
+ });
+ });
+
+ describe('stale-while-revalidate and pull-to-refresh', () => {
+ const treeText = (view: any) => JSON.stringify(view.toJSON());
+
+ it('shows the full-screen loader only when loading with no data yet', () => {
+ mockCallsStore.isLoading = true;
+ mockCallsStore.calls = [];
+ useCallsStore.mockImplementation((selector: any) => (typeof selector === 'function' ? selector(mockCallsStore) : mockCallsStore));
+
+ const view = render( );
+
+ expect(treeText(view)).toContain('calls.loading');
+
+ view.unmount();
+ });
+
+ it('keeps the stale list on screen while refreshing with data present', () => {
+ mockCallsStore.isLoading = true;
+ mockCallsStore.calls = [{ CallId: 'call-1', Nature: 'Existing' }];
+ useCallsStore.mockImplementation((selector: any) => (typeof selector === 'function' ? selector(mockCallsStore) : mockCallsStore));
+
+ const view = render( );
+ const html = treeText(view);
+
+ expect(html).not.toContain('calls.loading');
+ expect(html).toContain('calls-list');
+
+ view.unmount();
+ });
+
+ it('does not replace the list with an error state while stale data is showing', () => {
+ mockCallsStore.error = 'Failed to fetch calls';
+ mockCallsStore.calls = [{ CallId: 'call-1', Nature: 'Existing' }];
+ useCallsStore.mockImplementation((selector: any) => (typeof selector === 'function' ? selector(mockCallsStore) : mockCallsStore));
+
+ const view = render( );
+ const html = treeText(view);
+
+ expect(html).not.toContain('error-state');
+ expect(html).toContain('calls-list');
+
+ view.unmount();
+ });
+
+ it('shows a translated error state when the load failed and there is no data', () => {
+ mockCallsStore.error = 'Failed to fetch calls';
+ mockCallsStore.calls = [];
+ useCallsStore.mockImplementation((selector: any) => (typeof selector === 'function' ? selector(mockCallsStore) : mockCallsStore));
+
+ const view = render( );
+ const html = treeText(view);
+
+ expect(html).toContain('error-state');
+ expect(html).toContain('calls.errors.load_failed');
+ // The raw English store sentinel must not reach the user
+ expect(html).not.toContain('Failed to fetch calls');
+
+ view.unmount();
+ });
+
+ it('force-refreshes on pull-to-refresh so the cached endpoint is bypassed', async () => {
+ mockCallsStore.calls = [{ CallId: 'call-1', Nature: 'Existing' }];
+ mockCallsStore.fetchCalls.mockResolvedValue(undefined);
+ mockCallsStore.fetchCallPriorities.mockResolvedValue(undefined);
+ useCallsStore.mockImplementation((selector: any) => (typeof selector === 'function' ? selector(mockCallsStore) : mockCallsStore));
+
+ const view = render( );
+
+ mockCallsStore.fetchCalls.mockClear();
+
+ const refreshControl = view.UNSAFE_getByProps({ testID: 'refresh-control' });
+ expect(refreshControl.props.refreshing).toBe(false);
+
+ await act(async () => {
+ await refreshControl.props.onRefresh();
+ });
+
+ expect(mockCallsStore.fetchCalls).toHaveBeenCalledWith(true);
+
+ view.unmount();
});
});
});
diff --git a/src/app/(app)/__tests__/index.test.tsx b/src/app/(app)/__tests__/index.test.tsx
index bd45d781..e53a9e87 100644
--- a/src/app/(app)/__tests__/index.test.tsx
+++ b/src/app/(app)/__tests__/index.test.tsx
@@ -33,24 +33,55 @@ jest.mock('@/hooks/use-map-signalr-updates', () => ({
}));
jest.mock('@/api/mapping/mapping', () => ({
getMapDataAndMarkers: jest.fn().mockResolvedValue({
- Data: { MapMakerInfos: [] }
+ Data: { MapMakerInfos: [] },
}),
}));
-jest.mock('@rnmapbox/maps', () => ({
- setAccessToken: jest.fn(),
- MapView: 'MapView',
- Camera: 'Camera',
- PointAnnotation: 'PointAnnotation',
- StyleURL: {
- Street: 'mapbox://styles/mapbox/streets-v11',
- Dark: 'mapbox://styles/mapbox/dark-v10',
- Light: 'mapbox://styles/mapbox/light-v10',
- },
- UserTrackingMode: {
- Follow: 'follow',
- FollowWithHeading: 'followWithHeading',
- },
-}));
+// Camera commands issued by the screen. The MapView mock reports the map ready
+// on mount and the Camera mock exposes a real imperative handle, so the
+// follow-camera effects actually run in tests.
+const mockSetCamera = jest.fn();
+
+jest.mock('@rnmapbox/maps', () => {
+ const ReactActual = jest.requireActual('react');
+ const { View } = jest.requireActual('react-native');
+
+ const MapView = ({ children, onDidFinishLoadingMap, ...props }: any) => {
+ ReactActual.useEffect(() => {
+ onDidFinishLoadingMap?.();
+ // Fire once — mirrors the native "map finished loading" callback.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+ return ReactActual.createElement(View, props, children);
+ };
+
+ const Camera = ReactActual.forwardRef((_props: any, ref: any) => {
+ ReactActual.useImperativeHandle(ref, () => ({ setCamera: mockSetCamera }), []);
+ return null;
+ });
+
+ return {
+ setAccessToken: jest.fn(),
+ MapView,
+ Camera,
+ PointAnnotation: 'PointAnnotation',
+ MarkerView: 'MarkerView',
+ ShapeSource: 'ShapeSource',
+ SymbolLayer: 'SymbolLayer',
+ CircleLayer: 'CircleLayer',
+ LineLayer: 'LineLayer',
+ FillLayer: 'FillLayer',
+ Images: 'Images',
+ StyleURL: {
+ Street: 'mapbox://styles/mapbox/streets-v11',
+ Dark: 'mapbox://styles/mapbox/dark-v10',
+ Light: 'mapbox://styles/mapbox/light-v10',
+ },
+ UserTrackingMode: {
+ Follow: 'follow',
+ FollowWithHeading: 'followWithHeading',
+ },
+ };
+});
jest.mock('expo-router', () => ({
useIsFocused: jest.fn(() => true),
useNavigation: jest.fn(() => ({
@@ -67,9 +98,14 @@ jest.mock('expo-router', () => ({
replace: jest.fn(),
back: jest.fn(),
}),
- useFocusEffect: jest.fn(() => {
- // Don't call the callback to prevent infinite loops in tests
- }),
+ // Mirror the real hook: run the effect, re-running only when the callback
+ // identity changes. The screen's focus callback is dependency-stable, so this
+ // fires once per mount — a callback that churned per lock toggle (the bug the
+ // consolidated camera effect fixes) would show up here as extra setCamera calls.
+ useFocusEffect: (callback: any) => {
+ const ReactActual = jest.requireActual('react');
+ ReactActual.useEffect(() => callback(), [callback]);
+ },
}));
jest.mock('react-i18next', () => ({
useTranslation: () => ({
@@ -77,17 +113,20 @@ jest.mock('react-i18next', () => ({
}),
}));
jest.mock('@/stores/toast/store', () => ({
- useToastStore: (selector: any) => typeof selector === 'function' ? selector({
- showToast: jest.fn(),
- getState: () => ({
- showToast: jest.fn(),
- }),
- }) : {
- showToast: jest.fn(),
- getState: () => ({
- showToast: jest.fn(),
- }),
- },
+ useToastStore: (selector: any) =>
+ typeof selector === 'function'
+ ? selector({
+ showToast: jest.fn(),
+ getState: () => ({
+ showToast: jest.fn(),
+ }),
+ })
+ : {
+ showToast: jest.fn(),
+ getState: () => ({
+ showToast: jest.fn(),
+ }),
+ },
}));
jest.mock('@/stores/app/core-store', () => {
const storeState = {
@@ -98,7 +137,7 @@ jest.mock('@/stores/app/core-store', () => {
activeUnit: null,
activeUnitStatus: null,
};
- const mockFn = jest.fn((selector) => typeof selector === 'function' ? selector(storeState) : storeState) as jest.Mock & { getState: () => typeof storeState };
+ const mockFn = jest.fn((selector) => (typeof selector === 'function' ? selector(storeState) : storeState)) as jest.Mock & { getState: () => typeof storeState };
mockFn.getState = () => storeState;
return { useCoreStore: mockFn };
});
@@ -117,9 +156,7 @@ jest.mock('@/hooks/use-analytics', () => ({
}));
// Test wrapper component
-const TestWrapper = ({ children }: { children: React.ReactNode }) => (
- {children}
-);
+const TestWrapper = ({ children }: { children: React.ReactNode }) => {children} ;
jest.mock('@/components/ui/focus-aware-status-bar', () => ({
FocusAwareStatusBar: () => null,
}));
@@ -132,11 +169,21 @@ const mockUseColorScheme = useColorScheme as jest.MockedFunction = { ...defaultLocationState };
+
+const setLocationState = (next: Record) => {
+ currentLocationState = { ...currentLocationState, ...next };
+ mockUseLocationStore.mockImplementation((selector: any) => (typeof selector === 'function' ? selector(currentLocationState) : currentLocationState));
+ (useLocationStore as any).getState = () => currentLocationState;
+};
+
const defaultAppLifecycleState = {
isActive: true,
appState: 'active' as const,
@@ -149,8 +196,12 @@ describe('Map Component - App Lifecycle', () => {
jest.clearAllMocks();
jest.useFakeTimers();
+ mockSetCamera.mockClear();
+
// Setup default mocks with stable objects
- mockUseLocationStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector(defaultLocationState) : defaultLocationState);
+ currentLocationState = { ...defaultLocationState, speed: 0, accuracy: 10 };
+ (useLocationStore as any).getState = () => currentLocationState;
+ mockUseLocationStore.mockImplementation((selector: any) => (typeof selector === 'function' ? selector(defaultLocationState) : defaultLocationState));
mockUseAppLifecycle.mockReturnValue(defaultAppLifecycleState);
mockUseColorScheme.mockReturnValue({
colorScheme: 'light',
@@ -225,24 +276,32 @@ describe('Map Component - App Lifecycle', () => {
it('should handle map lock state changes', async () => {
// Start with unlocked map
- mockUseLocationStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
- ...defaultLocationState,
- isMapLocked: false,
- }) : {
- ...defaultLocationState,
- isMapLocked: false,
- });
+ mockUseLocationStore.mockImplementation((selector: any) =>
+ typeof selector === 'function'
+ ? selector({
+ ...defaultLocationState,
+ isMapLocked: false,
+ })
+ : {
+ ...defaultLocationState,
+ isMapLocked: false,
+ }
+ );
const { rerender, unmount } = render( , { wrapper: TestWrapper });
// Change to locked map
- mockUseLocationStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
- ...defaultLocationState,
- isMapLocked: true,
- }) : {
- ...defaultLocationState,
- isMapLocked: true,
- });
+ mockUseLocationStore.mockImplementation((selector: any) =>
+ typeof selector === 'function'
+ ? selector({
+ ...defaultLocationState,
+ isMapLocked: true,
+ })
+ : {
+ ...defaultLocationState,
+ isMapLocked: true,
+ }
+ );
rerender( );
@@ -255,15 +314,19 @@ describe('Map Component - App Lifecycle', () => {
it('should handle navigation mode with heading', async () => {
// Mock locked map with heading
- mockUseLocationStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
- ...defaultLocationState,
- heading: 90,
- isMapLocked: true,
- }) : {
- ...defaultLocationState,
- heading: 90,
- isMapLocked: true,
- });
+ mockUseLocationStore.mockImplementation((selector: any) =>
+ typeof selector === 'function'
+ ? selector({
+ ...defaultLocationState,
+ heading: 90,
+ isMapLocked: true,
+ })
+ : {
+ ...defaultLocationState,
+ heading: 90,
+ isMapLocked: true,
+ }
+ );
const { unmount } = render( , { wrapper: TestWrapper });
@@ -363,4 +426,179 @@ describe('Map Component - App Lifecycle', () => {
// Note: The analytics tracking is tested indirectly since we can't easily mock it in this setup
unmount();
});
-});
\ No newline at end of file
+});
+describe('Map Component - follow camera', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ jest.useFakeTimers();
+ mockSetCamera.mockClear();
+
+ mockUseAppLifecycle.mockReturnValue(defaultAppLifecycleState);
+ mockUseColorScheme.mockReturnValue({
+ colorScheme: 'light',
+ setColorScheme: jest.fn(),
+ toggleColorScheme: jest.fn(),
+ });
+
+ mockLocationService.startLocationUpdates = jest.fn().mockResolvedValue(undefined);
+ mockLocationService.stopLocationUpdates = jest.fn().mockResolvedValue(undefined);
+
+ currentLocationState = { ...defaultLocationState, speed: 0, accuracy: 10 };
+ setLocationState({});
+ });
+
+ afterEach(() => {
+ jest.runOnlyPendingTimers();
+ jest.useRealTimers();
+ });
+
+ const lastCameraConfig = () => mockSetCamera.mock.calls[mockSetCamera.mock.calls.length - 1][0];
+
+ it('issues exactly one camera command when the map becomes ready', () => {
+ const { unmount } = render( , { wrapper: TestWrapper });
+
+ // The focus effect and the camera effect must not both fire here.
+ expect(mockSetCamera).toHaveBeenCalledTimes(1);
+ unmount();
+ });
+
+ it('issues exactly one camera command per lock toggle', () => {
+ const { rerender, unmount } = render( , { wrapper: TestWrapper });
+ expect(mockSetCamera).toHaveBeenCalledTimes(1);
+ mockSetCamera.mockClear();
+
+ // Lock the map.
+ setLocationState({ isMapLocked: true });
+ rerender( );
+ expect(mockSetCamera).toHaveBeenCalledTimes(1);
+
+ // ...and unlock it again.
+ mockSetCamera.mockClear();
+ setLocationState({ isMapLocked: false });
+ rerender( );
+ expect(mockSetCamera).toHaveBeenCalledTimes(1);
+
+ unmount();
+ });
+
+ it('delivers a throttled location update on the trailing edge', () => {
+ const { rerender, unmount } = render( , { wrapper: TestWrapper });
+ expect(mockSetCamera).toHaveBeenCalledTimes(1);
+ mockSetCamera.mockClear();
+
+ // A fix arriving inside the 5s throttle window is dropped...
+ jest.advanceTimersByTime(1000);
+ setLocationState({ latitude: 41.0, longitude: -75.0 });
+ rerender( );
+ expect(mockSetCamera).not.toHaveBeenCalled();
+
+ // ...but replayed once the window expires, so the camera can't park behind
+ // a unit whose last movement landed inside the window.
+ jest.advanceTimersByTime(4000);
+ expect(mockSetCamera).toHaveBeenCalledTimes(1);
+ expect(lastCameraConfig().centerCoordinate).toEqual([-75.0, 41.0]);
+
+ unmount();
+ });
+
+ it('replays only the newest dropped update on the trailing edge', () => {
+ const { rerender, unmount } = render( , { wrapper: TestWrapper });
+ mockSetCamera.mockClear();
+
+ jest.advanceTimersByTime(500);
+ setLocationState({ latitude: 41.0, longitude: -75.0 });
+ rerender( );
+
+ jest.advanceTimersByTime(500);
+ setLocationState({ latitude: 42.0, longitude: -76.0 });
+ rerender( );
+
+ expect(mockSetCamera).not.toHaveBeenCalled();
+
+ jest.advanceTimersByTime(5000);
+ // Superseded, not queued: one command carrying the latest fix.
+ expect(mockSetCamera).toHaveBeenCalledTimes(1);
+ expect(lastCameraConfig().centerCoordinate).toEqual([-76.0, 42.0]);
+
+ unmount();
+ });
+
+ it('does not fire a trailing update after unmount', () => {
+ const { rerender, unmount } = render( , { wrapper: TestWrapper });
+ mockSetCamera.mockClear();
+
+ jest.advanceTimersByTime(1000);
+ setLocationState({ latitude: 41.0, longitude: -75.0 });
+ rerender( );
+
+ unmount();
+ jest.advanceTimersByTime(10000);
+ expect(mockSetCamera).not.toHaveBeenCalled();
+ });
+
+ // The camera pitch is driven by the *smoothed* speed, so these steps are
+ // chosen to land it inside the 0.7–1.5 m/s dead band, where the old
+ // hard "> 1 m/s" rule and the hysteresis rule disagree.
+ it('holds top-down while smoothed speed is still inside the dead band', () => {
+ setLocationState({ heading: 90, speed: 0 });
+ const { rerender, unmount } = render( , { wrapper: TestWrapper });
+ expect(lastCameraConfig().pitch).toBe(0);
+
+ // One 3 m/s fix from a standstill smooths to 1.2 m/s. The old rule tilted
+ // here; hysteresis holds top-down until 1.5 m/s is cleared.
+ jest.advanceTimersByTime(6000);
+ setLocationState({ speed: 3, latitude: 40.72 });
+ rerender( );
+ expect(lastCameraConfig().pitch).toBe(0);
+
+ unmount();
+ });
+
+ it('holds the tilt while slowing through the dead band', () => {
+ setLocationState({ heading: 90, speed: 2 });
+ const { rerender, unmount } = render( , { wrapper: TestWrapper });
+
+ // Settle the smoothed speed at ~2 m/s so the camera is tilted.
+ for (let i = 0; i < 8; i++) {
+ jest.advanceTimersByTime(6000);
+ setLocationState({ speed: 2, latitude: 40.72 + i * 0.01 });
+ rerender( );
+ }
+ expect(lastCameraConfig().pitch).toBe(45);
+
+ // Stopping smooths 2 → 1.2 (still tilted under either rule)...
+ jest.advanceTimersByTime(6000);
+ setLocationState({ speed: 0, latitude: 40.85 });
+ rerender( );
+ expect(lastCameraConfig().pitch).toBe(45);
+
+ // ...then 1.2 → 0.72, which the old rule flattened. Hysteresis keeps the
+ // tilt until the unit is properly stopped.
+ jest.advanceTimersByTime(6000);
+ setLocationState({ speed: 0, latitude: 40.86 });
+ rerender( );
+ expect(lastCameraConfig().pitch).toBe(45);
+
+ // Below 0.7 m/s the camera finally returns to top-down.
+ jest.advanceTimersByTime(6000);
+ setLocationState({ speed: 0, latitude: 40.87 });
+ rerender( );
+ expect(lastCameraConfig().pitch).toBe(0);
+
+ unmount();
+ });
+
+ it('tilts once the unit is clearly moving', () => {
+ setLocationState({ heading: 90, speed: 0 });
+ const { rerender, unmount } = render( , { wrapper: TestWrapper });
+
+ for (let i = 0; i < 6; i++) {
+ jest.advanceTimersByTime(6000);
+ setLocationState({ speed: 12, latitude: 40.73 + i * 0.01 });
+ rerender( );
+ }
+ expect(lastCameraConfig().pitch).toBe(45);
+
+ unmount();
+ });
+});
diff --git a/src/app/(app)/__tests__/init-retry-backoff.test.tsx b/src/app/(app)/__tests__/init-retry-backoff.test.tsx
new file mode 100644
index 00000000..d5451e81
--- /dev/null
+++ b/src/app/(app)/__tests__/init-retry-backoff.test.tsx
@@ -0,0 +1,154 @@
+/**
+ * A failed app initialization used to re-fire immediately, three times: three full
+ * multi-request bursts back-to-back against a backend that had just failed, then
+ * silence — the user was left on a spinner with no idea anything had gone wrong.
+ *
+ * The layout itself pulls in Mapbox, Novu, push notifications and the whole store
+ * graph, so — as with init-session-generation.test.tsx — the retry protocol is
+ * exercised through the same effect shape the layout uses rather than by rendering it.
+ */
+import { act, renderHook } from '@testing-library/react-native';
+import React from 'react';
+
+const MAX_INIT_RETRIES = 3;
+
+/** Mirrors the layout's initialization effect: first attempt immediate, retries backed off, toast once at the cap. */
+function useInitRetry(effects: { initializeApp: jest.Mock; showToast: jest.Mock }) {
+ const [initRetryCount, setInitRetryCount] = React.useState(0);
+ const hasInitialized = React.useRef(false);
+ const isInitializing = React.useRef(false);
+ const hasShownInitFailureToast = React.useRef(false);
+
+ React.useEffect(() => {
+ const shouldInitialize = !hasInitialized.current && !isInitializing.current && initRetryCount < MAX_INIT_RETRIES;
+
+ if (!shouldInitialize) {
+ if (!hasInitialized.current && initRetryCount >= MAX_INIT_RETRIES && !hasShownInitFailureToast.current) {
+ hasShownInitFailureToast.current = true;
+ effects.showToast('error', 'app.initialization_failed');
+ }
+ return;
+ }
+
+ if (initRetryCount === 0) {
+ effects.initializeApp();
+ return;
+ }
+
+ const backoffMs = 1000 * Math.pow(3, initRetryCount - 1);
+ const retryTimer = setTimeout(() => {
+ effects.initializeApp();
+ }, backoffMs);
+ return () => clearTimeout(retryTimer);
+ }, [initRetryCount, effects]);
+
+ return { fail: () => setInitRetryCount((c) => c + 1), initRetryCount };
+}
+
+describe('app initialization retry backoff', () => {
+ const effects = { initializeApp: jest.fn(), showToast: jest.fn() };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ jest.useFakeTimers();
+ });
+
+ afterEach(() => {
+ jest.clearAllTimers();
+ jest.useRealTimers();
+ });
+
+ it('runs the first initialization attempt immediately', () => {
+ renderHook(() => useInitRetry(effects));
+
+ expect(effects.initializeApp).toHaveBeenCalledTimes(1);
+ });
+
+ it('backs off 1s, then 3s, then 9s between retries', () => {
+ const { result } = renderHook(() => useInitRetry(effects));
+ expect(effects.initializeApp).toHaveBeenCalledTimes(1);
+
+ // First failure -> retry after 1s, not immediately.
+ act(() => result.current.fail());
+ expect(effects.initializeApp).toHaveBeenCalledTimes(1);
+ act(() => {
+ jest.advanceTimersByTime(999);
+ });
+ expect(effects.initializeApp).toHaveBeenCalledTimes(1);
+ act(() => {
+ jest.advanceTimersByTime(1);
+ });
+ expect(effects.initializeApp).toHaveBeenCalledTimes(2);
+
+ // Second failure -> 3s.
+ act(() => result.current.fail());
+ act(() => {
+ jest.advanceTimersByTime(2999);
+ });
+ expect(effects.initializeApp).toHaveBeenCalledTimes(2);
+ act(() => {
+ jest.advanceTimersByTime(1);
+ });
+ expect(effects.initializeApp).toHaveBeenCalledTimes(3);
+
+ // Third failure -> retry budget exhausted, no fourth attempt.
+ act(() => result.current.fail());
+ act(() => {
+ jest.advanceTimersByTime(60000);
+ });
+ expect(effects.initializeApp).toHaveBeenCalledTimes(3);
+ });
+
+ it('surfaces a toast once the retry budget is exhausted', () => {
+ const { result } = renderHook(() => useInitRetry(effects));
+
+ act(() => result.current.fail());
+ act(() => {
+ jest.advanceTimersByTime(1000);
+ });
+ act(() => result.current.fail());
+ act(() => {
+ jest.advanceTimersByTime(3000);
+ });
+ expect(effects.showToast).not.toHaveBeenCalled();
+
+ act(() => result.current.fail());
+
+ expect(effects.showToast).toHaveBeenCalledTimes(1);
+ expect(effects.showToast).toHaveBeenCalledWith('error', 'app.initialization_failed');
+ });
+
+ it('does not repeat the toast on subsequent re-renders', () => {
+ const { result, rerender } = renderHook(() => useInitRetry(effects));
+
+ act(() => result.current.fail());
+ act(() => {
+ jest.advanceTimersByTime(1000);
+ });
+ act(() => result.current.fail());
+ act(() => {
+ jest.advanceTimersByTime(3000);
+ });
+ act(() => result.current.fail());
+ expect(effects.showToast).toHaveBeenCalledTimes(1);
+
+ rerender({});
+ rerender({});
+
+ expect(effects.showToast).toHaveBeenCalledTimes(1);
+ });
+
+ it('cancels a pending retry when the effect is torn down', () => {
+ const { result, unmount } = renderHook(() => useInitRetry(effects));
+
+ act(() => result.current.fail());
+ unmount();
+
+ act(() => {
+ jest.advanceTimersByTime(60000);
+ });
+
+ // Only the initial attempt ran; the scheduled retry was cleaned up.
+ expect(effects.initializeApp).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/app/(app)/_layout.tsx b/src/app/(app)/_layout.tsx
index 7ca442ef..937c203d 100644
--- a/src/app/(app)/_layout.tsx
+++ b/src/app/(app)/_layout.tsx
@@ -37,6 +37,7 @@ import { FeatureFlagKeys, featureFlagsStore } from '@/stores/feature-flags/store
import { useRolesStore } from '@/stores/roles/store';
import { securityStore } from '@/stores/security/store';
import { useSignalRStore } from '@/stores/signalr/signalr-store';
+import { useToastStore } from '@/stores/toast/store';
import { useWeatherAlertsStore } from '@/stores/weather-alerts/store';
import { isNetworkError } from '@/utils/network';
@@ -89,6 +90,8 @@ export default function TabLayout() {
// over can no longer connect hubs or mark the app initialized.
const initGeneration = useRef(0);
const hasHiddenSplash = useRef(false);
+ // Guards the "initialization failed" toast so a failure streak surfaces it once.
+ const hasShownInitFailureToast = useRef(false);
const parentRef = useRef(null);
// Render counting for diagnostics (web only)
@@ -298,6 +301,7 @@ export default function TabLayout() {
// initializing".
initGeneration.current += 1;
isInitializing.current = false;
+ hasShownInitFailureToast.current = false;
if (initRetryCount > 0) {
setInitRetryCount(0);
@@ -313,7 +317,17 @@ export default function TabLayout() {
const isAppInBackground = Platform.OS !== 'web' && appState === 'background';
const shouldInitialize = status === 'signedIn' && !isAppInBackground && !hasInitialized.current && !isInitializing.current && initRetryCount < MAX_INIT_RETRIES;
- if (shouldInitialize) {
+ if (!shouldInitialize) {
+ // Retry budget exhausted: surface the failure instead of giving up silently.
+ // Foregrounding the app still retries via the app-resume effect below.
+ if (status === 'signedIn' && !isAppInBackground && !hasInitialized.current && initRetryCount >= MAX_INIT_RETRIES && !hasShownInitFailureToast.current) {
+ hasShownInitFailureToast.current = true;
+ useToastStore.getState().showToast('error', t('app.initialization_failed'));
+ }
+ return;
+ }
+
+ if (initRetryCount === 0) {
logger.info({
message: 'Triggering app initialization',
context: {
@@ -322,8 +336,24 @@ export default function TabLayout() {
},
});
initializeApp();
+ return;
}
- }, [status, initializeApp, initRetryCount, appState]);
+
+ // Exponential backoff between retries (1s, 3s, 9s) — without it a failed
+ // initialization re-fires its full multi-request burst back-to-back.
+ const backoffMs = 1000 * Math.pow(3, initRetryCount - 1);
+ logger.info({
+ message: 'Scheduling app initialization retry',
+ context: {
+ initRetryCount,
+ backoffMs,
+ },
+ });
+ const retryTimer = setTimeout(() => {
+ initializeApp();
+ }, backoffMs);
+ return () => clearTimeout(retryTimer);
+ }, [status, initializeApp, initRetryCount, appState, t]);
// Handle app resuming from background - separate from initialization
useEffect(() => {
@@ -595,7 +625,7 @@ export default function TabLayout() {
{/* NotificationInbox positioned within the tab content area — only after init and Novu is ready */}
- {isInitComplete && novuReady && }
+ {isInitComplete && novuReady ? : null}
diff --git a/src/app/(app)/calls.tsx b/src/app/(app)/calls.tsx
index 9cfcf31b..b6146295 100644
--- a/src/app/(app)/calls.tsx
+++ b/src/app/(app)/calls.tsx
@@ -1,7 +1,7 @@
import { useFocusEffect } from 'expo-router';
import { router } from 'expo-router';
import { PlusIcon, RefreshCcwDotIcon, Search, X } from 'lucide-react-native';
-import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Pressable, RefreshControl, View } from 'react-native';
@@ -51,6 +51,7 @@ export default function Calls() {
const { t } = useTranslation();
const { trackEvent } = useAnalytics();
const [searchQuery, setSearchQuery] = useState('');
+ const [refreshing, setRefreshing] = useState(false);
// Fetch data when screen comes into focus
useFocusEffect(
@@ -72,19 +73,30 @@ export default function Calls() {
}
}, [calls, fetchCallDispatches]);
+ // Search query is read through a ref so typing does not fire an analytics
+ // event per keystroke — the effect below must not depend on searchQuery.
+ const searchQueryRef = useRef(searchQuery);
+ useEffect(() => {
+ searchQueryRef.current = searchQuery;
+ }, [searchQuery]);
+
// Track when calls view is rendered
useEffect(() => {
trackEvent('calls_view_rendered', {
callsCount: calls.length,
- hasSearchQuery: searchQuery.length > 0,
+ hasSearchQuery: searchQueryRef.current.length > 0,
});
- }, [trackEvent, calls.length, searchQuery]);
-
- const handleRefresh = () => {
- fetchCalls();
- fetchCallPriorities();
- // Dispatches will auto-fetch via useEffect when calls update
- };
+ }, [trackEvent, calls.length]);
+
+ const handleRefresh = useCallback(async () => {
+ setRefreshing(true);
+ try {
+ // Force refresh to bypass the cached endpoint; dispatches auto-fetch via useEffect when calls update
+ await Promise.all([fetchCalls(true), fetchCallPriorities()]);
+ } finally {
+ setRefreshing(false);
+ }
+ }, [fetchCalls, fetchCallPriorities]);
const handleNewCall = () => {
router.push('/call/new/');
@@ -108,12 +120,14 @@ export default function Calls() {
// Render content based on loading, error, and data states
const renderContent = () => {
- if (isLoading) {
+ // Full-screen loader only when there is nothing to show yet; background
+ // refreshes keep the stale list on screen (stale-while-revalidate).
+ if (isLoading && calls.length === 0) {
return ;
}
- if (error) {
- return ;
+ if (error && calls.length === 0) {
+ return ;
}
return (
@@ -122,7 +136,7 @@ export default function Calls() {
data={filteredCalls}
renderItem={renderItem}
keyExtractor={keyExtractor}
- refreshControl={ }
+ refreshControl={ }
ListEmptyComponent={ }
contentContainerStyle={{ paddingBottom: 20 }}
/>
@@ -140,7 +154,7 @@ export default function Calls() {
{searchQuery ? (
- setSearchQuery('')}>
+ setSearchQuery('')} accessibilityRole="button" accessibilityLabel={t('common.clear_search')}>
) : null}
diff --git a/src/app/(app)/chat.tsx b/src/app/(app)/chat.tsx
index a0292c94..71f3d154 100644
--- a/src/app/(app)/chat.tsx
+++ b/src/app/(app)/chat.tsx
@@ -1,6 +1,6 @@
import { type Href, Redirect, useFocusEffect, useRouter } from 'expo-router';
import { Bot, MessageCircle, MessagesSquare, Network, Plus, Sparkles, Users } from 'lucide-react-native';
-import React, { useCallback, useState } from 'react';
+import React, { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { RefreshControl, ScrollView } from 'react-native';
@@ -22,40 +22,42 @@ import { type ChatChannelResultData, ChatChannelType } from '@/models/v4/chat';
import { useChatStore } from '@/stores/chat/store';
import { useChatSystemStatus } from '@/stores/feature-flags/store';
+// Module scope: declaring this inside ChannelRow's render would give it a new
+// component type each render, remounting the avatar/icon subtree every time.
+function ChannelLeading({ channel, displayName }: { channel: ChatChannelResultData; displayName: string }) {
+ if (channel.ChannelType === ChatChannelType.DirectMessage) {
+ return (
+
+ {displayName}
+
+ );
+ }
+ const isIncident =
+ channel.ChannelType === ChatChannelType.Incident ||
+ channel.ChannelType === ChatChannelType.IncidentLane ||
+ channel.ChannelType === ChatChannelType.IncidentCommand ||
+ channel.ChannelType === ChatChannelType.IncidentLeads ||
+ channel.ChannelType === ChatChannelType.IncidentDispatch;
+ const Icon = channel.ChannelType === ChatChannelType.Chatbot ? Sparkles : isIncident ? Network : Users;
+ return (
+
+
+
+ );
+}
+
function ChannelRow({ channel, onPress }: { channel: ChatChannelResultData; onPress: () => void }) {
const { t } = useTranslation();
const unread = channel.UnreadCount > 0;
- const isDm = channel.ChannelType === ChatChannelType.DirectMessage;
-
- const Leading = () => {
- if (isDm) {
- return (
-
- {getChannelDisplayName(channel, t)}
-
- );
- }
- const isIncident =
- channel.ChannelType === ChatChannelType.Incident ||
- channel.ChannelType === ChatChannelType.IncidentLane ||
- channel.ChannelType === ChatChannelType.IncidentCommand ||
- channel.ChannelType === ChatChannelType.IncidentLeads ||
- channel.ChannelType === ChatChannelType.IncidentDispatch;
- const Icon = channel.ChannelType === ChatChannelType.Chatbot ? Sparkles : isIncident ? Network : Users;
- return (
-
-
-
- );
- };
+ const displayName = getChannelDisplayName(channel, t);
return (
-
+
- {getChannelDisplayName(channel, t)}
+ {displayName}
{channel.Topic ? (
@@ -104,7 +106,7 @@ export default function ChatScreen() {
}, [isChatEnabled])
);
- const grouped = groupChannels(channels);
+ const grouped = useMemo(() => groupChannels(channels), [channels]);
const openChannel = useCallback(
(channelId: string) => {
diff --git a/src/app/(app)/contacts.tsx b/src/app/(app)/contacts.tsx
index 99169fab..9639163f 100644
--- a/src/app/(app)/contacts.tsx
+++ b/src/app/(app)/contacts.tsx
@@ -30,13 +30,20 @@ export default function Contacts() {
fetchContacts();
}, [fetchContacts]);
+ // Search query is read through a ref so typing does not fire an analytics
+ // event per keystroke — the effect below must not depend on searchQuery.
+ const searchQueryRef = React.useRef(searchQuery);
+ React.useEffect(() => {
+ searchQueryRef.current = searchQuery;
+ }, [searchQuery]);
+
// Track when contacts view is rendered
React.useEffect(() => {
trackEvent('contacts_view_rendered', {
contactsCount: contacts.length,
- hasSearchQuery: searchQuery.length > 0,
+ hasSearchQuery: searchQueryRef.current.length > 0,
});
- }, [trackEvent, contacts.length, searchQuery]);
+ }, [trackEvent, contacts.length]);
const handleRefresh = React.useCallback(async () => {
setRefreshing(true);
@@ -83,7 +90,7 @@ export default function Contacts() {
{searchQuery ? (
- setSearchQuery('')} testID="clear-search-button">
+ setSearchQuery('')} testID="clear-search-button" accessibilityRole="button" accessibilityLabel={t('common.clear_search')}>
) : null}
diff --git a/src/app/(app)/index.tsx b/src/app/(app)/index.tsx
index 6b9d0dd6..e9c0332e 100644
--- a/src/app/(app)/index.tsx
+++ b/src/app/(app)/index.tsx
@@ -3,7 +3,7 @@ import { NavigationIcon } from 'lucide-react-native';
import { useColorScheme } from 'nativewind';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
-import { Animated, Platform, StyleSheet, TouchableOpacity, View } from 'react-native';
+import { StyleSheet, TouchableOpacity, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { getMapDataAndMarkers } from '@/api/mapping/mapping';
@@ -11,6 +11,7 @@ import { Loading } from '@/components/common/loading';
import MapPins from '@/components/maps/map-pins';
import Mapbox from '@/components/maps/mapbox';
import PinDetailModal from '@/components/maps/pin-detail-modal';
+import UnitLocationMarker from '@/components/maps/unit-location-marker';
import { StopMarker } from '@/components/routes/stop-marker';
import { FocusAwareStatusBar } from '@/components/ui/focus-aware-status-bar';
import { WeatherAlertBanner } from '@/components/weather-alerts/weather-alert-banner';
@@ -20,6 +21,7 @@ import { useMapSignalRUpdates } from '@/hooks/use-map-signalr-updates';
import { useWeatherAlertBanner } from '@/hooks/use-weather-alert-banner';
import { Env } from '@/lib/env';
import { logger } from '@/lib/logging';
+import { applyPitchHysteresis, applyZoomHysteresis, createCirclePolygon, normalizeHeading, normalizeSpeed, smoothSpeed, zoomForSpeed } from '@/lib/map-camera';
import { getDepartmentMapCenter } from '@/lib/map-center';
import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData';
import { locationService } from '@/services/location';
@@ -57,12 +59,23 @@ function MapContent() {
const mapRef = useRef>(null);
const cameraRef = useRef(null); // Using any due to imperative handle
const [isMapReady, setIsMapReady] = useState(false);
+ // Ref mirror so the dependency-stable focus effect and the trailing follow
+ // timer can read readiness without re-running/capturing a stale value.
+ const isMapReadyRef = useRef(false);
+ isMapReadyRef.current = isMapReady;
// Track screen focus so camera follow/animations can be stopped on blur. A camera
// event delivered while the native map view is tearing down crashes in @rnmapbox/maps
// (onCameraChanged use-after-free), so we quiet the camera when the user navigates
// away to shrink that window.
const [isScreenFocused, setIsScreenFocused] = useState(true);
+ const isScreenFocusedRef = useRef(true);
+ isScreenFocusedRef.current = isScreenFocused;
const [hasUserMovedMap, setHasUserMovedMap] = useState(false);
+ // Ref mirror for the trailing follow-camera timer: hasUserMovedMap is
+ // intentionally excluded from the follow effect's deps, so the timer checks
+ // this at fire time to avoid recentering over a fresh user pan.
+ const hasUserMovedMapRef = useRef(false);
+ hasUserMovedMapRef.current = hasUserMovedMap;
const [mapPins, setMapPins] = useState([]);
const [selectedPin, setSelectedPin] = useState(null);
const [isPinDetailModalOpen, setIsPinDetailModalOpen] = useState(false);
@@ -70,6 +83,8 @@ function MapContent() {
const locationLatitude = useLocationStore((state) => state.latitude);
const locationLongitude = useLocationStore((state) => state.longitude);
const locationHeading = useLocationStore((state) => state.heading);
+ const locationAccuracy = useLocationStore((state) => state.accuracy);
+ const locationSpeed = useLocationStore((state) => state.speed);
const isMapLocked = useLocationStore((state) => state.isMapLocked);
// Weather alert banner state
@@ -85,6 +100,7 @@ function MapContent() {
// Route overlay state
const activeUnitId = useCoreStore((state) => state.activeUnitId);
+ const activeCallId = useCoreStore((state) => state.activeCallId);
const activeInstance = useRoutesStore((state) => state.activeInstance);
const instanceStops = useRoutesStore((state) => state.instanceStops);
const fetchActiveRoute = useRoutesStore((state) => state.fetchActiveRoute);
@@ -105,11 +121,81 @@ function MapContent() {
const [styleURL, setStyleURL] = useState({ styleURL: getMapStyle() });
- const pulseAnim = useRef(new Animated.Value(1)).current;
useMapSignalRUpdates(setMapPins);
// Throttle state for programmatic camera follow (see effect below)
const lastCameraFollowRef = useRef(0);
+ // Trailing-edge timer for the follow throttle: the location store dedupes
+ // identical fixes, so an update dropped inside the throttle window may be the
+ // last one before the unit stops — deliver it when the window expires.
+ const trailingFollowTimeoutRef = useRef | null>(null);
+
+ // Follow-camera state: smoothed ground speed drives the zoom level (walking
+ // pace zooms in tight, highway speed pulls out for lookahead), and the last
+ // known heading keeps the camera pointed "behind" the unit while stationary
+ // fixes report no heading.
+ const smoothedSpeedRef = useRef(null);
+ const followZoomRef = useRef(null);
+ const followPitchRef = useRef(0);
+ const lastBearingRef = useRef(null);
+
+ // Previous lock/ready state, so the single camera effect below can tell a lock
+ // toggle (or the map first becoming ready) from an ordinary location update.
+ const prevIsMapLockedRef = useRef(isMapLocked);
+ const prevIsMapReadyRef = useRef(isMapReady);
+
+ const clearTrailingFollow = useCallback(() => {
+ if (trailingFollowTimeoutRef.current) {
+ clearTimeout(trailingFollowTimeoutRef.current);
+ trailingFollowTimeoutRef.current = null;
+ }
+ }, []);
+
+ /**
+ * Camera config that frames the unit like a navigation app: centered on the
+ * unit, rotated to its heading, zoomed by its speed, tilted while moving.
+ * Reads the location store directly so callers always frame the freshest fix.
+ */
+ const buildFollowCamera = useCallback((animationDuration: number) => {
+ const { latitude, longitude, heading, speed } = useLocationStore.getState();
+ if (latitude == null || longitude == null) return null;
+
+ smoothedSpeedRef.current = smoothSpeed(smoothedSpeedRef.current, normalizeSpeed(speed));
+ const zoomLevel = applyZoomHysteresis(followZoomRef.current, zoomForSpeed(smoothedSpeedRef.current));
+ followZoomRef.current = zoomLevel;
+
+ const currentHeading = normalizeHeading(heading);
+ if (currentHeading != null) {
+ lastBearingRef.current = currentHeading;
+ }
+ const bearing = lastBearingRef.current;
+
+ // Tilt behind the unit only while it's actually moving with a known
+ // heading; a stationary crew on foot gets a top-down view. Hysteresis
+ // keeps the pitch from flipping when speed hovers around the boundary.
+ const pitch = bearing != null ? applyPitchHysteresis(followPitchRef.current, smoothedSpeedRef.current) : 0;
+ followPitchRef.current = pitch;
+
+ return {
+ centerCoordinate: [longitude, latitude] as [number, number],
+ zoomLevel,
+ heading: bearing ?? 0,
+ pitch,
+ animationDuration,
+ };
+ }, []);
+
+ /** Issue exactly one camera command and (re)arm the follow throttle window. */
+ const applyFollowCamera = useCallback(
+ (animationDuration: number) => {
+ const cameraConfig = buildFollowCamera(animationDuration);
+ if (!cameraConfig) return null;
+ lastCameraFollowRef.current = Date.now();
+ cameraRef.current?.setCamera(cameraConfig);
+ return cameraConfig;
+ },
+ [buildFollowCamera]
+ );
// Stable initial camera settings so the native Camera renders at the
// correct position from the very first frame (fixes Android/iOS centering).
@@ -117,8 +203,8 @@ function MapContent() {
if (locationLatitude != null && locationLongitude != null) {
return {
centerCoordinate: [locationLongitude, locationLatitude] as [number, number],
- zoomLevel: isMapLocked ? 16 : 12,
- heading: 0,
+ zoomLevel: zoomForSpeed(normalizeSpeed(locationSpeed)),
+ heading: normalizeHeading(locationHeading) ?? 0,
pitch: 0,
};
}
@@ -130,7 +216,9 @@ function MapContent() {
heading: 0,
pitch: 0,
};
- }, [locationLatitude, locationLongitude, isMapLocked]);
+ // Initial settings only matter for the first frame — don't churn them on every fix.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
// Fetch active route overlay data
useEffect(() => {
@@ -190,18 +278,7 @@ function MapContent() {
// Geofence circle GeoJSON
const geofenceGeoJSON = useMemo((): GeoJSON.Feature | null => {
if (!nextStop || !nextStop.GeofenceRadiusMeters) return null;
- const points = 64;
- const coords: number[][] = [];
- const radiusDeg = nextStop.GeofenceRadiusMeters / 111320;
- for (let i = 0; i <= points; i++) {
- const angle = (i / points) * 2 * Math.PI;
- coords.push([nextStop.Longitude + radiusDeg * Math.cos(angle), nextStop.Latitude + radiusDeg * Math.sin(angle)]);
- }
- return {
- type: 'Feature',
- properties: {},
- geometry: { type: 'Polygon', coordinates: [coords] },
- };
+ return createCirclePolygon(nextStop.Longitude, nextStop.Latitude, nextStop.GeofenceRadiusMeters);
}, [nextStop]);
// Update map style when theme changes
@@ -210,7 +287,12 @@ function MapContent() {
setStyleURL({ styleURL: newStyle });
}, [getMapStyle]);
- // Handle navigation focus - reset map state when user navigates back to map page
+ // Handle navigation focus - reset map state when user navigates back to map page.
+ // The callback is intentionally dependency-stable (state is read through refs /
+ // the store) so it only re-runs on genuine focus/blur transitions — with
+ // isMapReady/isMapLocked in the deps it also re-ran on every lock toggle and
+ // map-ready flip, issuing a second camera command on top of the lock effect
+ // below and double-advancing the speed EMA.
useFocusEffect(
useCallback(() => {
// Mark the screen focused again so camera follow/animations are allowed
@@ -219,40 +301,28 @@ function MapContent() {
// Reset hasUserMovedMap when navigating back to map
setHasUserMovedMap(false);
- // Reset camera to current location when navigating back to map
- if (isMapReady && locationLatitude && locationLongitude) {
- const cameraConfig: any = {
- centerCoordinate: [locationLongitude, locationLatitude],
- zoomLevel: isMapLocked ? 16 : 12,
- animationDuration: 1000,
- heading: 0,
- pitch: 0,
- };
-
- // Add heading and pitch for navigation mode when locked
- if (isMapLocked && locationHeading !== null && locationHeading !== undefined) {
- cameraConfig.heading = locationHeading;
- cameraConfig.pitch = 45;
+ // Reset camera to follow the unit when navigating back to map
+ if (isMapReadyRef.current) {
+ const cameraConfig = applyFollowCamera(1000);
+ if (cameraConfig) {
+ logger.info({
+ message: 'Map focused, resetting camera to current location',
+ context: {
+ latitude: cameraConfig.centerCoordinate[1],
+ longitude: cameraConfig.centerCoordinate[0],
+ isMapLocked: useLocationStore.getState().isMapLocked,
+ },
+ });
}
-
- cameraRef.current?.setCamera(cameraConfig);
-
- logger.info({
- message: 'Map focused, resetting camera to current location',
- context: {
- latitude: locationLatitude,
- longitude: locationLongitude,
- isMapLocked: isMapLocked,
- },
- });
}
// On blur (cleanup), stop the camera following/animating before the native
// map view is detached, so a camera event can't fire into a torn-down view.
return () => {
setIsScreenFocused(false);
+ clearTrailingFollow();
};
- }, [isMapReady, locationLatitude, locationLongitude, isMapLocked, locationHeading])
+ }, [applyFollowCamera, clearTrailingFollow])
);
useEffect(() => {
@@ -295,63 +365,72 @@ function MapContent() {
};
}, []);
+ // Single driver for programmatic camera moves. Lock toggles, the map first
+ // becoming ready and ordinary location updates all funnel through here so each
+ // trigger issues exactly one setCamera — two effects firing back-to-back
+ // double-advanced the speed EMA and the zoom hysteresis on every lock toggle.
useEffect(() => {
+ const lockChanged = prevIsMapLockedRef.current !== isMapLocked;
+ const becameReady = isMapReady && !prevIsMapReadyRef.current;
+ prevIsMapLockedRef.current = isMapLocked;
+ prevIsMapReadyRef.current = isMapReady;
+
+ // Toggling the lock (either direction) returns the camera to the default
+ // follow-the-unit behavior and clears any manual pan/zoom the user made.
+ if (lockChanged) {
+ setHasUserMovedMap(false);
+ }
+
// Skip camera animations while the screen is unfocused so a location update
// arriving during/after navigation away doesn't drive the (possibly tearing
// down) native map view.
- if (isScreenFocused && isMapReady && locationLatitude && locationLongitude) {
- // When map is locked, always follow the location
- // When map is unlocked, only follow if user hasn't moved the map
- if (isMapLocked || !hasUserMovedMap) {
- // Throttle programmatic camera moves — GPS fixes arrive every ~15s and
- // each setCamera triggers a native camera animation + re-render.
- const now = Date.now();
- if (now - lastCameraFollowRef.current < CAMERA_FOLLOW_THROTTLE_MS) {
- return;
- }
- lastCameraFollowRef.current = now;
-
- const cameraConfig: any = {
- centerCoordinate: [locationLongitude, locationLatitude],
- zoomLevel: isMapLocked ? 16 : 12,
- animationDuration: isMapLocked ? 500 : 1000,
- };
-
- // Add heading and pitch for navigation mode when locked
- if (isMapLocked && locationHeading !== null && locationHeading !== undefined) {
- cameraConfig.heading = locationHeading;
- cameraConfig.pitch = 45;
- }
+ if (!isScreenFocused || !isMapReady || locationLatitude == null || locationLongitude == null) {
+ return;
+ }
- cameraRef.current?.setCamera(cameraConfig);
- }
+ // A lock toggle (or the map becoming ready) recenters right away, bypassing
+ // and re-arming the follow throttle.
+ if (lockChanged || becameReady) {
+ clearTrailingFollow();
+ applyFollowCamera(800);
+ return;
}
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [isScreenFocused, isMapReady, locationLatitude, locationLongitude, locationHeading, isMapLocked]);
- // NOTE: hasUserMovedMap intentionally excluded from deps to avoid toggle loop
- // on web where programmatic easeTo → moveend → setHasUserMovedMap(true) → re-trigger.
- // Reset hasUserMovedMap when map gets locked and reset camera when unlocked
- useEffect(() => {
- if (isMapLocked) {
- setHasUserMovedMap(false);
- } else {
- // When exiting locked mode, reset camera to normal view and reset user interaction state
- setHasUserMovedMap(false);
+ // When map is locked, always follow the location.
+ // When map is unlocked, follow by default — but stop the moment the user
+ // pans/zooms/rotates the map, until they recenter or toggle the lock.
+ if (!isMapLocked && hasUserMovedMap) {
+ return;
+ }
- if (isMapReady && locationLatitude && locationLongitude) {
- cameraRef.current?.setCamera({
- centerCoordinate: [locationLongitude, locationLatitude],
- zoomLevel: 12,
- heading: 0,
- pitch: 0,
- animationDuration: 1000,
- });
- }
+ // Throttle programmatic camera moves — GPS fixes arrive every ~15s and
+ // each setCamera triggers a native camera animation + re-render.
+ const elapsed = Date.now() - lastCameraFollowRef.current;
+ clearTrailingFollow();
+
+ if (elapsed < CAMERA_FOLLOW_THROTTLE_MS) {
+ // The location store dedupes identical fixes, so a dropped update can be
+ // the last position change before the unit stops. Replay it on the
+ // trailing edge instead of parking the camera behind the stopped unit.
+ trailingFollowTimeoutRef.current = setTimeout(() => {
+ trailingFollowTimeoutRef.current = null;
+ if (!isScreenFocusedRef.current || !isMapReadyRef.current) return;
+ if (!useLocationStore.getState().isMapLocked && hasUserMovedMapRef.current) return;
+ applyFollowCamera(4000);
+ }, CAMERA_FOLLOW_THROTTLE_MS - elapsed);
+ return;
}
- // Only react to lock mode changes — NOT location changes (those are handled above)
+
+ // Long animation glides the camera between sparse fixes instead of
+ // hopping, approximating continuous navigation-style tracking.
+ applyFollowCamera(4000);
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [isMapReady, isMapLocked]);
+ }, [isScreenFocused, isMapReady, locationLatitude, locationLongitude, locationHeading, locationSpeed, isMapLocked, applyFollowCamera, clearTrailingFollow]);
+ // NOTE: hasUserMovedMap intentionally excluded from deps to avoid toggle loop
+ // on web where programmatic easeTo → moveend → setHasUserMovedMap(true) → re-trigger.
+
+ // Drop any pending trailing follow when the map screen goes away.
+ useEffect(() => clearTrailingFollow, [clearTrailingFollow]);
useEffect(() => {
const abortController = new AbortController();
@@ -387,32 +466,6 @@ function MapContent() {
};
}, []);
- // Only run Animated.loop on native — on web, useNativeDriver falls back to JS driver
- // which creates continuous requestAnimationFrame overhead. Web uses CSS animation instead.
- useEffect(() => {
- if (Platform.OS !== 'web') {
- const loopAnim = Animated.loop(
- Animated.sequence([
- Animated.timing(pulseAnim, {
- toValue: 1.2,
- duration: 1000,
- useNativeDriver: true,
- }),
- Animated.timing(pulseAnim, {
- toValue: 1,
- duration: 1000,
- useNativeDriver: true,
- }),
- ])
- );
- loopAnim.start();
-
- return () => {
- loopAnim.stop();
- };
- }
- }, [pulseAnim]);
-
// Track when map view is rendered
useEffect(() => {
trackEvent('map_view_rendered', {
@@ -434,20 +487,8 @@ function MapContent() {
);
const handleRecenterMap = () => {
- if (locationLatitude && locationLongitude) {
- const cameraConfig: any = {
- centerCoordinate: [locationLongitude, locationLatitude],
- zoomLevel: isMapLocked ? 16 : 12,
- animationDuration: 1000,
- };
-
- // Add heading and pitch for navigation mode when locked
- if (isMapLocked && locationHeading !== null && locationHeading !== undefined) {
- cameraConfig.heading = locationHeading;
- cameraConfig.pitch = 45;
- }
-
- cameraRef.current?.setCamera(cameraConfig);
+ clearTrailingFollow();
+ if (applyFollowCamera(1000)) {
setHasUserMovedMap(false);
}
};
@@ -489,30 +530,12 @@ function MapContent() {
};
// Show recenter button only when map is not locked and user has moved the map
- const showRecenterButton = !isMapLocked && hasUserMovedMap && locationLatitude && locationLongitude;
+ const showRecenterButton = !isMapLocked && hasUserMovedMap && locationLatitude != null && locationLongitude != null;
// Create dynamic styles based on theme - useMemo to avoid new objects every render
const themedStyles = useMemo(() => {
const isDark = colorScheme === 'dark';
return {
- markerInnerContainer: {
- width: 24,
- height: 24,
- alignItems: 'center' as const,
- justifyContent: 'center' as const,
- backgroundColor: '#3b82f6',
- borderRadius: 12,
- borderWidth: 3,
- borderColor: isDark ? '#1f2937' : '#ffffff',
- elevation: 5,
- shadowColor: isDark ? '#ffffff' : '#000000',
- shadowOffset: {
- width: 0,
- height: 2,
- },
- shadowOpacity: isDark ? 0.1 : 0.25,
- shadowRadius: 3.84,
- },
recenterButton: {
position: 'absolute' as const,
bottom: 20 + insets.bottom,
@@ -559,36 +582,11 @@ function MapContent() {
rotateEnabled={!isMapLocked}
pitchEnabled={!isMapLocked}
>
-
-
- {locationLatitude != null && locationLongitude != null ? (
-
-
-
-
-
- {locationHeading != null ? (
-
- ) : null}
-
-
-
- ) : null}
-
+ {/* Camera is driven imperatively (buildFollowCamera) so locked and
+ unlocked modes share the same speed-adaptive follow behavior. */}
+
+
+
{/* Active route polyline overlay */}
{routeOverlayGeoJSON ? (
@@ -658,6 +656,10 @@ function MapContent() {
) : null
)}
+
+ {/* Unit location indicator: accuracy circle, heading arrow, dot.
+ Rendered last so its layers draw above the overlay fills. */}
+ {locationLatitude != null && locationLongitude != null ? : null}
{/* Weather Alert Banner */}
@@ -688,52 +690,6 @@ const styles = StyleSheet.create({
map: {
flex: 1,
},
- markerContainer: {
- alignItems: 'center',
- justifyContent: 'center',
- width: 60,
- height: 60,
- position: 'relative',
- },
- markerOuterRing: {
- position: 'absolute',
- width: 60,
- height: 60,
- borderRadius: 30,
- backgroundColor: 'rgba(59, 130, 246, 0.15)',
- borderWidth: 2,
- borderColor: 'rgba(59, 130, 246, 0.3)',
- },
- markerInnerContainer: {
- width: 24,
- height: 24,
- alignItems: 'center',
- justifyContent: 'center',
- backgroundColor: '#3b82f6',
- borderRadius: 12,
- borderWidth: 3,
- // borderColor and shadow properties are handled by themedStyles
- },
- markerDot: {
- width: 8,
- height: 8,
- borderRadius: 4,
- backgroundColor: '#ffffff',
- },
- directionIndicator: {
- position: 'absolute',
- width: 0,
- height: 0,
- backgroundColor: 'transparent',
- borderStyle: 'solid',
- borderLeftWidth: 8,
- borderRightWidth: 8,
- borderBottomWidth: 24,
- borderLeftColor: 'transparent',
- borderRightColor: 'transparent',
- borderBottomColor: '#3b82f6',
- top: -36,
- },
recenterButton: {
position: 'absolute',
bottom: 20,
@@ -746,10 +702,4 @@ const styles = StyleSheet.create({
alignItems: 'center',
// elevation and shadow properties are handled by themedStyles
},
- // Web-only CSS pulse animation (replaces Animated.loop which falls back to JS driver on web).
- // Applied via the global `.pulse-ring` CSS class — react-native-web rejects
- // `animationName` as an inline style property.
- markerPulseWeb: {
- // No JS-driven transform on web — the outer ring animates via CSS instead
- } as any,
});
diff --git a/src/app/(app)/notes.tsx b/src/app/(app)/notes.tsx
index bef15bc0..eb3c14c4 100644
--- a/src/app/(app)/notes.tsx
+++ b/src/app/(app)/notes.tsx
@@ -30,13 +30,20 @@ export default function Notes() {
fetchNotes();
}, [fetchNotes]);
+ // Search query is read through a ref so typing does not fire an analytics
+ // event per keystroke — the effect below must not depend on searchQuery.
+ const searchQueryRef = React.useRef(searchQuery);
+ React.useEffect(() => {
+ searchQueryRef.current = searchQuery;
+ }, [searchQuery]);
+
// Track when notes view is rendered
React.useEffect(() => {
trackEvent('notes_view_rendered', {
notesCount: notes.length,
- hasSearchQuery: searchQuery.length > 0,
+ hasSearchQuery: searchQueryRef.current.length > 0,
});
- }, [trackEvent, notes.length, searchQuery]);
+ }, [trackEvent, notes.length]);
const handleRefresh = React.useCallback(async () => {
setRefreshing(true);
@@ -65,7 +72,7 @@ export default function Notes() {
{searchQuery ? (
- setSearchQuery('')}>
+ setSearchQuery('')} testID="clear-search-button" accessibilityRole="button" accessibilityLabel={t('common.clear_search')}>
) : null}
diff --git a/src/app/(app)/protocols.tsx b/src/app/(app)/protocols.tsx
index e57408fe..a7149b1b 100644
--- a/src/app/(app)/protocols.tsx
+++ b/src/app/(app)/protocols.tsx
@@ -31,13 +31,20 @@ export default function Protocols() {
fetchProtocols();
}, [fetchProtocols]);
+ // Search query is read through a ref so typing does not fire an analytics
+ // event per keystroke — the effect below must not depend on searchQuery.
+ const searchQueryRef = React.useRef(searchQuery);
+ React.useEffect(() => {
+ searchQueryRef.current = searchQuery;
+ }, [searchQuery]);
+
// Track when protocols view is rendered
React.useEffect(() => {
trackEvent('protocols_view_rendered', {
protocolsCount: protocols.length,
- hasSearchQuery: searchQuery.length > 0,
+ hasSearchQuery: searchQueryRef.current.length > 0,
});
- }, [trackEvent, protocols.length, searchQuery.length]);
+ }, [trackEvent, protocols.length]);
const handleRefresh = React.useCallback(async () => {
setRefreshing(true);
@@ -72,7 +79,7 @@ export default function Protocols() {
{searchQuery ? (
- setSearchQuery('')}>
+ setSearchQuery('')} testID="clear-search-button" accessibilityRole="button" accessibilityLabel={t('common.clear_search')}>
) : null}
diff --git a/src/app/(app)/weather-alerts.tsx b/src/app/(app)/weather-alerts.tsx
index 4f5ab76c..fc198ac9 100644
--- a/src/app/(app)/weather-alerts.tsx
+++ b/src/app/(app)/weather-alerts.tsx
@@ -106,7 +106,7 @@ export default function WeatherAlerts() {
{searchQuery ? (
- setSearchQuery('')}>
+ setSearchQuery('')} testID="clear-search-button" accessibilityRole="button" accessibilityLabel={t('common.clear_search')}>
) : null}
diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx
index 8b9faa54..f95e782d 100644
--- a/src/app/_layout.tsx
+++ b/src/app/_layout.tsx
@@ -103,7 +103,6 @@ if (Platform.OS !== 'web') {
// Load the selected theme from storage and apply it
loadSelectedTheme();
-//useAuth().hydrate();
// Prevent the splash screen from auto-hiding before asset loading is complete.
//SplashScreen.preventAutoHideAsync();
// Set the animation options. This is optional.
diff --git a/src/app/call/[id].tsx b/src/app/call/[id].tsx
index 0bcc6b2f..4285338f 100644
--- a/src/app/call/[id].tsx
+++ b/src/app/call/[id].tsx
@@ -82,9 +82,9 @@ export default function CallDetail() {
const stopPolling = useCheckInTimerStore((state) => state.stopPolling);
const resetTimers = useCheckInTimerStore((state) => state.reset);
- // Get current user location from the location store
- const userLatitude = useLocationStore((state) => state.latitude);
- const userLongitude = useLocationStore((state) => state.longitude);
+ // NOTE: the user's location is read via useLocationStore.getState() inside the
+ // route handlers instead of subscribing — subscribing re-rendered this whole
+ // screen (tab tree + WebViews) on every GPS fix.
const handleBack = () => {
router.back();
@@ -237,6 +237,7 @@ export default function CallDetail() {
try {
const destinationName = call?.Address || t('call_detail.call_location');
+ const { latitude: userLatitude, longitude: userLongitude } = useLocationStore.getState();
const success = await openMapsWithDirections(coordinates.latitude, coordinates.longitude, destinationName, userLatitude || undefined, userLongitude || undefined);
if (!success) {
@@ -265,6 +266,7 @@ export default function CallDetail() {
try {
const destinationName = call?.DestinationName || call?.DestinationAddress || t('call_detail.destination');
+ const { latitude: userLatitude, longitude: userLongitude } = useLocationStore.getState();
const success = await openMapsWithDirections(latitude, longitude, destinationName, userLatitude || undefined, userLongitude || undefined);
if (!success) {
@@ -565,12 +567,12 @@ export default function CallDetail() {
{call.Name} ({call.Number})
{/* Show "Set Active" button if this call is not the active call and there is an active unit */}
- {activeUnit && activeCall?.CallId !== call.CallId && (
+ {activeUnit && activeCall?.CallId !== call.CallId ? (
- {isSettingActive && }
+ {isSettingActive ? : null}
{isSettingActive ? t('call_detail.setting_active') : t('call_detail.set_active')}
- )}
+ ) : null}
diff --git a/src/app/call/[id]/edit.tsx b/src/app/call/[id]/edit.tsx
index 81fcc84d..61f5fb5f 100644
--- a/src/app/call/[id]/edit.tsx
+++ b/src/app/call/[id]/edit.tsx
@@ -7,6 +7,7 @@ import React, { useEffect, useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { ScrollView, View } from 'react-native';
+import { KeyboardAvoidingView } from 'react-native-keyboard-controller';
import * as z from 'zod';
import { DestinationPoiSelector } from '@/components/calls/destination-poi-selector';
@@ -17,7 +18,7 @@ import FullScreenLocationPicker from '@/components/maps/full-screen-location-pic
import LocationPicker from '@/components/maps/location-picker';
import { CustomBottomSheet } from '@/components/ui/bottom-sheet';
import { Box } from '@/components/ui/box';
-import { Button, ButtonText } from '@/components/ui/button';
+import { Button, ButtonSpinner, ButtonText } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { FormControl, FormControlError, FormControlLabel, FormControlLabelText } from '@/components/ui/form-control';
import { Input, InputField } from '@/components/ui/input';
@@ -26,6 +27,7 @@ import { Text } from '@/components/ui/text';
import { Textarea, TextareaInput } from '@/components/ui/textarea';
import { useToast } from '@/components/ui/toast';
import { useAnalytics } from '@/hooks/use-analytics';
+import { logger } from '@/lib/logging';
import { useCoreStore } from '@/stores/app/core-store';
import { useCallDetailStore } from '@/stores/calls/detail-store';
import { useCallsStore } from '@/stores/calls/store';
@@ -118,7 +120,7 @@ export default function EditCall() {
const {
control,
handleSubmit,
- formState: { errors },
+ formState: { errors, isSubmitting },
setValue,
reset,
} = useForm({
@@ -282,7 +284,7 @@ export default function EditCall() {
// Navigate back to call detail
router.back();
} catch (error) {
- console.error('Error updating call:', error);
+ logger.error({ message: 'Error updating call', context: { error } });
// Show error toast
toast.show({
@@ -394,7 +396,7 @@ export default function EditCall() {
});
}
} catch (error) {
- console.error('Error geocoding address:', error);
+ logger.error({ message: 'Error geocoding address', context: { error } });
toast.show({
placement: 'top',
@@ -462,7 +464,7 @@ export default function EditCall() {
/>
- {callDetailError || callDataError || 'Call not found'}
+ {callDetailError || callDataError || t('call_detail.not_found')}
>
@@ -481,243 +483,246 @@ export default function EditCall() {
/>
-
- {t('calls.edit_call_description')}
-
-
-
-
- {t('calls.name')}
-
- (
-
-
-
- )}
- />
- {errors.name && (
-
- {errors.name.message}
-
- )}
-
-
-
-
-
-
- {t('calls.nature')}
-
- (
-
- )}
- />
- {errors.nature && (
-
- {errors.nature.message}
-
- )}
-
-
-
-
-
-
- {t('calls.priority')}
-
- (
-
-
-
-
-
-
-
-
- {callPriorities.map((priority) => (
-
- ))}
-
-
-
- )}
- />
- {errors.priority && (
-
- {errors.priority.message}
-
- )}
-
-
-
-
-
-
- {t('calls.type')}
-
- (
-
-
-
-
-
-
-
-
- {callTypes.map((type) => (
-
- ))}
-
-
-
- )}
- />
- {errors.type && (
-
- {errors.type.message}
-
- )}
-
-
-
-
-
-
- {t('calls.note')}
-
- (
-
- )}
- />
-
-
-
-
- {t('calls.call_location')}
-
- {/* Address Field */}
-
-
- {t('calls.address')}
-
- (
-
-
-
-
-
+
+
+ {t('calls.edit_call_description')}
+
+
+
+
+ {t('calls.name')}
+
+ (
+
+
+
+ )}
+ />
+ {errors.name ? (
+
+ {errors.name.message}
+
+ ) : null}
+
+
+
+
+
+
+ {t('calls.nature')}
+
+ (
+
+ )}
+ />
+ {errors.nature ? (
+
+ {errors.nature.message}
+
+ ) : null}
+
+
+
+
+
+
+ {t('calls.priority')}
+
+ (
+
+
+
+
+
+
+
+
+ {callPriorities.map((priority) => (
+
+ ))}
+
+
+
+ )}
+ />
+ {errors.priority ? (
+
+ {errors.priority.message}
+
+ ) : null}
+
+
+
+
+
+
+ {t('calls.type')}
+
+ (
+
+
+
+
+
+
+
+
+ {callTypes.map((type) => (
+
+ ))}
+
+
+
+ )}
+ />
+ {errors.type ? (
+
+ {errors.type.message}
+
+ ) : null}
+
+
+
+
+
+
+ {t('calls.note')}
+
+ (
+
+ )}
+ />
+
+
+
+
+ {t('calls.call_location')}
+
+ {/* Address Field */}
+
+
+ {t('calls.address')}
+
+ (
+
+
+
+
+
+
+ handleAddressSearch(value || '')} disabled={isGeocodingAddress || !value?.trim()}>
+ {isGeocodingAddress ? ... : }
+
- handleAddressSearch(value || '')} disabled={isGeocodingAddress || !value?.trim()}>
- {isGeocodingAddress ? ... : }
-
-
- )}
- />
-
-
- {/* Map Preview */}
-
- {selectedLocation ? (
-
- ) : (
- setShowLocationPicker(true)} className="w-full">
- {t('calls.select_location')}
-
- )}
-
-
- (
- onChange(poiId != null ? poiId.toString() : '')}
+ )}
/>
- )}
- />
-
-
-
-
-
- {t('calls.contact_name')}
-
- (
-
-
-
+
+
+ {/* Map Preview */}
+
+ {selectedLocation ? (
+
+ ) : (
+ setShowLocationPicker(true)} className="w-full">
+ {t('calls.select_location')}
+
)}
- />
-
-
-
-
-
-
- {t('calls.contact_info')}
-
+
+
(
-
-
-
+ name="destinationPoiId"
+ render={({ field: { onChange, value } }) => (
+ onChange(poiId != null ? poiId.toString() : '')}
+ />
)}
/>
-
-
-
-
- {t('calls.dispatch_to')}
- setShowDispatchModal(true)} className="w-full">
- {getDispatchSummary()}
-
-
-
-
- router.back()}>
- {t('common.cancel')}
-
-
- {t('common.save')}
-
-
-
+
+
+
+
+
+ {t('calls.contact_name')}
+
+ (
+
+
+
+ )}
+ />
+
+
+
+
+
+
+ {t('calls.contact_info')}
+
+ (
+
+
+
+ )}
+ />
+
+
+
+
+ {t('calls.dispatch_to')}
+ setShowDispatchModal(true)} className="w-full">
+ {getDispatchSummary()}
+
+
+
+
+ router.back()}>
+ {t('common.cancel')}
+
+
+ {isSubmitting ? : null}
+ {isSubmitting ? t('common.submitting') : t('common.save')}
+
+
+
+
{/* Full-screen location picker overlay */}
- {showLocationPicker && (
+ {showLocationPicker ? (
setShowLocationPicker(false)}
/>
- )}
+ ) : null}
{/* Dispatch selection modal */}
setShowDispatchModal(false)} onConfirm={handleDispatchSelection} initialSelection={dispatchSelection} />
diff --git a/src/app/call/__tests__/[id].test.tsx b/src/app/call/__tests__/[id].test.tsx
index eb18bbdf..e2ca35ab 100644
--- a/src/app/call/__tests__/[id].test.tsx
+++ b/src/app/call/__tests__/[id].test.tsx
@@ -15,8 +15,6 @@ import { openMapsWithDirections } from '@/lib/navigation';
import CallDetail from '../[id]';
-
-
// Mock UI components that might use NativeWind
jest.mock('@/components/ui', () => ({
FocusAwareStatusBar: jest.fn().mockImplementation(() => null),
@@ -31,21 +29,25 @@ jest.mock('@/components/ui/button', () => ({
Button: jest.fn().mockImplementation(({ children, onPress, disabled, ...props }) => {
const React = require('react');
- return React.createElement('button', {
- onPress,
- onClick: onPress, // For web compatibility
- disabled,
- accessibilityRole: 'button',
- accessibilityLabel: React.Children.toArray(children).map((child: any) =>
- typeof child === 'string' ? child :
- child?.props?.children || ''
- ).join(' '),
- testID: `button-${React.Children.toArray(children).map((child: any) =>
- typeof child === 'string' ? child :
- child?.props?.children || ''
- ).join(' ').toLowerCase().replace(/\s+/g, '-')}`,
- ...props
- }, children);
+ return React.createElement(
+ 'button',
+ {
+ onPress,
+ onClick: onPress, // For web compatibility
+ disabled,
+ accessibilityRole: 'button',
+ accessibilityLabel: React.Children.toArray(children)
+ .map((child: any) => (typeof child === 'string' ? child : child?.props?.children || ''))
+ .join(' '),
+ testID: `button-${React.Children.toArray(children)
+ .map((child: any) => (typeof child === 'string' ? child : child?.props?.children || ''))
+ .join(' ')
+ .toLowerCase()
+ .replace(/\s+/g, '-')}`,
+ ...props,
+ },
+ children
+ );
}),
ButtonIcon: jest.fn().mockImplementation(() => null),
ButtonText: jest.fn().mockImplementation(({ children }) => children),
@@ -89,13 +91,13 @@ jest.mock('expo-clipboard', () => ({
jest.mock('expo-constants', () => ({
expoConfig: {
extra: {
- IS_MOBILE_APP: "true",
+ IS_MOBILE_APP: 'true',
},
},
default: {
expoConfig: {
extra: {
- IS_MOBILE_APP: "true",
+ IS_MOBILE_APP: 'true',
},
},
},
@@ -104,7 +106,7 @@ jest.mock('expo-constants', () => ({
// Mock @env to prevent expo-constants issues
jest.mock('@env', () => ({
Env: {
- IS_MOBILE_APP: "true",
+ IS_MOBILE_APP: 'true',
},
}));
@@ -131,7 +133,11 @@ jest.mock('axios', () => {
// Mock query-string
jest.mock('query-string', () => ({
- stringify: jest.fn((obj) => Object.keys(obj).map(key => `${key}=${obj[key]}`).join('&')),
+ stringify: jest.fn((obj) =>
+ Object.keys(obj)
+ .map((key) => `${key}=${obj[key]}`)
+ .join('&')
+ ),
}));
// Mock auth store
@@ -355,19 +361,19 @@ jest.mock('react-native', () => ({
},
Platform: {
OS: 'ios',
- select: jest.fn(options => options.ios),
+ select: jest.fn((options) => options.ios),
},
StyleSheet: {
- create: jest.fn(styles => styles),
- flatten: jest.fn(style => style),
+ create: jest.fn((styles) => styles),
+ flatten: jest.fn((style) => style),
},
Appearance: {
getColorScheme: jest.fn(() => 'light'),
addEventListener: jest.fn((eventType, callback) => ({
- remove: jest.fn()
+ remove: jest.fn(),
})),
addChangeListener: jest.fn((callback) => ({
- remove: jest.fn()
+ remove: jest.fn(),
})),
removeChangeListener: jest.fn(),
isReduceMotionEnabled: jest.fn(() => false),
@@ -375,7 +381,7 @@ jest.mock('react-native', () => ({
AccessibilityInfo: {
isReduceMotionEnabled: jest.fn(() => Promise.resolve(false)),
addEventListener: jest.fn((eventType, callback) => ({
- remove: jest.fn()
+ remove: jest.fn(),
})),
removeEventListener: jest.fn(),
},
@@ -484,7 +490,7 @@ describe('CallDetail', () => {
const defaultLocationStore = {
latitude: 40.7128,
- longitude: -74.0060,
+ longitude: -74.006,
};
const defaultStatusBottomSheetStore = {
@@ -515,6 +521,9 @@ describe('CallDetail', () => {
}
return defaultLocationStore;
});
+ // The screen reads the user's location via getState() inside the route handlers
+ // rather than subscribing, so every GPS fix no longer re-renders the tab tree.
+ (mockUseLocationStore as unknown as { getState: jest.Mock }).getState = jest.fn(() => defaultLocationStore);
mockUseToastStore.mockImplementation((selector: any) => {
const store = { showToast: jest.fn() };
diff --git a/src/app/call/new/index.tsx b/src/app/call/new/index.tsx
index ad280a97..13322d2d 100644
--- a/src/app/call/new/index.tsx
+++ b/src/app/call/new/index.tsx
@@ -1,5 +1,4 @@
import { zodResolver } from '@hookform/resolvers/zod';
-import { render } from '@testing-library/react-native';
import axios from 'axios';
import * as Location from 'expo-location';
import { router, Stack } from 'expo-router';
@@ -9,6 +8,7 @@ import React, { useEffect, useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { ScrollView, View } from 'react-native';
+import { KeyboardAvoidingView } from 'react-native-keyboard-controller';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import * as z from 'zod';
@@ -21,7 +21,7 @@ import FullScreenLocationPicker from '@/components/maps/full-screen-location-pic
import LocationPicker from '@/components/maps/location-picker';
import { CustomBottomSheet } from '@/components/ui/bottom-sheet';
import { Box } from '@/components/ui/box';
-import { Button, ButtonText } from '@/components/ui/button';
+import { Button, ButtonSpinner, ButtonText } from '@/components/ui/button';
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';
@@ -32,6 +32,7 @@ import { Textarea, TextareaInput } from '@/components/ui/textarea';
import { useAnalytics } from '@/hooks/use-analytics';
import { useNewCallFieldPolicy } from '@/hooks/use-new-call-field-policy';
import { useToast } from '@/hooks/use-toast';
+import { logger } from '@/lib/logging';
import { type NewCallFieldKey, NewCallFieldKeys } from '@/models/v4/calls/newCallFieldPolicyResultData';
import { useCoreStore } from '@/stores/app/core-store';
import { useCallsStore } from '@/stores/calls/store';
@@ -172,7 +173,7 @@ export default function NewCall() {
const {
control,
handleSubmit,
- formState: { errors },
+ formState: { errors, isSubmitting },
setValue,
} = useForm({
resolver: zodResolver(formSchema),
@@ -302,7 +303,7 @@ export default function NewCall() {
// Navigate back to calls list
router.push('/calls');
} catch (error) {
- console.error('Error creating call:', error);
+ logger.error({ message: 'Error creating call', context: { error } });
// Show error toast
toast.error(t('calls.create_error'));
@@ -406,7 +407,7 @@ export default function NewCall() {
toast.error(t('calls.address_not_found'));
}
} catch (error) {
- console.error('Error geocoding address:', error);
+ logger.error({ message: 'Error geocoding address', context: { error } });
// Show error toast
toast.error(t('calls.geocoding_error'));
@@ -486,7 +487,7 @@ export default function NewCall() {
toast.error(t('calls.what3words_not_found'));
}
} catch (error) {
- console.error('Error geocoding what3words:', error);
+ logger.error({ message: 'Error geocoding what3words', context: { error } });
// Show error toast
toast.error(t('calls.what3words_geocoding_error'));
@@ -544,7 +545,7 @@ export default function NewCall() {
toast.error(t('calls.plus_code_not_found'));
}
} catch (error) {
- console.error('Error geocoding plus code:', error);
+ logger.error({ message: 'Error geocoding plus code', context: { error } });
// Show error toast
toast.error(t('calls.plus_code_geocoding_error'));
@@ -629,7 +630,7 @@ export default function NewCall() {
toast.info(t('calls.coordinates_no_address'));
}
} catch (error) {
- console.error('Error reverse geocoding coordinates:', error);
+ logger.error({ message: 'Error reverse geocoding coordinates', context: { error } });
// Even if geocoding fails, still set the location on the map
const newLocation = {
@@ -684,335 +685,344 @@ export default function NewCall() {
/>
-
- {t('calls.create_new_call')}
-
-
-
-
- {t('calls.name')}
-
- (
-
-
-
- )}
- />
- {errors.name && (
-
- {errors.name.message}
-
- )}
-
-
-
-
-
-
- {t('calls.nature')}
-
- (
-
- )}
- />
- {errors.nature && (
-
- {errors.nature.message}
-
- )}
-
-
-
-
-
-
- {t('calls.priority')}
-
- (
-
-
-
-
-
-
-
-
- {callPriorities.map((priority) => (
-
- ))}
-
-
-
- )}
- />
- {errors.priority && (
-
- {errors.priority.message}
-
- )}
-
-
-
-
-
-
- {t('calls.type')}
-
- (
-
-
-
-
-
-
-
-
- {callTypes.map((type) => (
-
- ))}
-
-
-
- )}
- />
- {errors.type && (
-
- {errors.type.message}
-
- )}
-
-
-
- {fieldPolicy.isVisible(NewCallFieldKeys.Note) ? (
+
+
+ {t('calls.create_new_call')}
+
+
+
+
+ {t('calls.name')}
+
+ (
+
+
+
+ )}
+ />
+ {errors.name ? (
+
+ {errors.name.message}
+
+ ) : null}
+
+
+
-
+
- {t('calls.note')}
+ {t('calls.nature')}
(
)}
/>
+ {errors.nature ? (
+
+ {errors.nature.message}
+
+ ) : null}
- ) : null}
- {showLocationCard ? (
- {t('calls.call_location')}
+
+
+ {t('calls.priority')}
+
+ (
+
+
+
+
+
+
+
+
+ {callPriorities.map((priority) => (
+
+ ))}
+
+
+
+ )}
+ />
+ {errors.priority ? (
+
+ {errors.priority.message}
+
+ ) : null}
+
+
- {/* Address Field */}
- {showAddress ? (
-
+
+
+
+ {t('calls.type')}
+
+ (
+
+
+
+
+
+
+
+
+ {callTypes.map((type) => (
+
+ ))}
+
+
+
+ )}
+ />
+ {errors.type ? (
+
+ {errors.type.message}
+
+ ) : null}
+
+
+
+ {fieldPolicy.isVisible(NewCallFieldKeys.Note) ? (
+
+
- {t('calls.address')}
+ {t('calls.note')}
(
-
-
-
-
-
-
- handleAddressSearch(value || '')} disabled={isGeocodingAddress || !value?.trim()}>
- {isGeocodingAddress ? ... : }
-
-
+
)}
/>
- ) : null}
+
+ ) : null}
+
+ {showLocationCard ? (
+
+ {t('calls.call_location')}
+
+ {/* Address Field */}
+ {showAddress ? (
+
+
+ {t('calls.address')}
+
+ (
+
+
+
+
+
+
+ handleAddressSearch(value || '')} disabled={isGeocodingAddress || !value?.trim()}>
+ {isGeocodingAddress ? ... : }
+
+
+ )}
+ />
+
+ ) : null}
+
+ {/* GPS Coordinates Field */}
+ {showGeolocation ? (
+
+
+ {t('calls.coordinates')}
+
+ (
+
+
+
+
+
+
+ handleCoordinatesSearch(value || '')}
+ disabled={isGeocodingCoordinates || !value?.trim()}
+ >
+ {isGeocodingCoordinates ? ... : }
+
+
+ )}
+ />
+
+ ) : null}
+
+ {/* what3words Field */}
+ {showWhat3Words ? (
+
+
+ {t('calls.what3words')}
+
+ (
+
+
+
+
+
+
+ handleWhat3WordsSearch(value || '')} disabled={isGeocodingWhat3Words || !value?.trim()}>
+ {isGeocodingWhat3Words ? ... : }
+
+
+ )}
+ />
+
+ ) : null}
+
+ {/* Plus Code Field */}
+ {showPlusCode ? (
+
+
+ {t('calls.plus_code')}
+
+ (
+
+
+
+
+
+
+ handlePlusCodeSearch(value || '')} disabled={isGeocodingPlusCode || !value?.trim()}>
+ {isGeocodingPlusCode ? ... : }
+
+
+ )}
+ />
+
+ ) : null}
+
+ {/* Map Preview — the map is how a geolocation gets picked, so it follows that field. */}
+ {showGeolocation ? (
+
+ {selectedLocation ? (
+
+ ) : (
+ setShowLocationPicker(true)} className="w-full">
+ {t('calls.select_location')}
+
+ )}
+
+ ) : null}
- {/* GPS Coordinates Field */}
- {showGeolocation ? (
-
-
- {t('calls.coordinates')}
-
+ {showDestinationPoi ? (
(
-
-
-
-
-
-
- handleCoordinatesSearch(value || '')} disabled={isGeocodingCoordinates || !value?.trim()}>
- {isGeocodingCoordinates ? ... : }
-
-
+ name="destinationPoiId"
+ render={({ field: { onChange, value } }) => (
+ onChange(poiId != null ? poiId.toString() : '')}
+ />
)}
/>
-
- ) : null}
+ ) : null}
+
+ ) : null}
- {/* what3words Field */}
- {showWhat3Words ? (
-
+ {fieldPolicy.isVisible(NewCallFieldKeys.ContactName) ? (
+
+
- {t('calls.what3words')}
+ {t('calls.contact_name')}
(
-
-
-
-
-
-
- handleWhat3WordsSearch(value || '')} disabled={isGeocodingWhat3Words || !value?.trim()}>
- {isGeocodingWhat3Words ? ... : }
-
-
+
+
+
)}
/>
- ) : null}
+
+ ) : null}
- {/* Plus Code Field */}
- {showPlusCode ? (
-
+ {fieldPolicy.isVisible(NewCallFieldKeys.ContactInfo) ? (
+
+
- {t('calls.plus_code')}
+ {t('calls.contact_info')}
(
-
-
-
-
-
-
- handlePlusCodeSearch(value || '')} disabled={isGeocodingPlusCode || !value?.trim()}>
- {isGeocodingPlusCode ? ... : }
-
-
+
+
+
)}
/>
- ) : null}
-
- {/* Map Preview — the map is how a geolocation gets picked, so it follows that field. */}
- {showGeolocation ? (
-
- {selectedLocation ? (
-
- ) : (
- setShowLocationPicker(true)} className="w-full">
- {t('calls.select_location')}
-
- )}
-
- ) : null}
-
- {showDestinationPoi ? (
- (
- onChange(poiId != null ? poiId.toString() : '')}
- />
- )}
- />
- ) : null}
-
- ) : null}
-
- {fieldPolicy.isVisible(NewCallFieldKeys.ContactName) ? (
-
-
-
- {t('calls.contact_name')}
-
- (
-
-
-
- )}
- />
-
-
- ) : null}
-
- {fieldPolicy.isVisible(NewCallFieldKeys.ContactInfo) ? (
-
-
-
- {t('calls.contact_info')}
-
- (
-
-
-
- )}
- />
-
-
- ) : null}
-
- {showDispatchList ? (
-
- {t('calls.dispatch_to')}
- setShowDispatchModal(true)} className="w-full">
- {getDispatchSummary()}
+
+ ) : null}
+
+ {showDispatchList ? (
+
+ {t('calls.dispatch_to')}
+ setShowDispatchModal(true)} className="w-full">
+ {getDispatchSummary()}
+
+
+ ) : null}
+
+
+ router.back()}>
+ {t('common.cancel')}
-
- ) : null}
-
-
- router.back()}>
- {t('common.cancel')}
-
-
-
- {t('calls.create')}
-
-
-
+
+ {isSubmitting ? : }
+ {isSubmitting ? t('common.submitting') : t('calls.create')}
+
+
+
+
{/* Full-screen location picker overlay */}
- {showLocationPicker && (
+ {showLocationPicker ? (
setShowLocationPicker(false)}
/>
- )}
+ ) : null}
{/* Dispatch selection modal */}
setShowDispatchModal(false)} onConfirm={handleDispatchSelection} initialSelection={dispatchSelection} />
diff --git a/src/app/chat/[channelId].tsx b/src/app/chat/[channelId].tsx
index d2146651..5297feb2 100644
--- a/src/app/chat/[channelId].tsx
+++ b/src/app/chat/[channelId].tsx
@@ -233,6 +233,11 @@ export default function ChannelConversationScreen() {
[router, channelId]
);
+ // Stable identity so the memoized MessageBubble isn't re-rendered by a fresh inline handler per item.
+ const handleRetry = useCallback((m: ChatMessageResultData) => {
+ if (m.ClientMessageId) void useChatStore.getState().retryOutboxItem(m.ClientMessageId);
+ }, []);
+
const renderItem = useCallback(
({ item }: { item: ChatMessageResultData }) => (
m.ClientMessageId && useChatStore.getState().retryOutboxItem(m.ClientMessageId)}
+ onRetry={handleRetry}
onPressImage={setImageUri}
/>
),
- [currentUserId, showSender, handleToggleReaction, openThread]
+ [currentUserId, showSender, handleToggleReaction, openThread, handleRetry]
);
const keyExtractor = useCallback((item: ChatMessageResultData) => item.ChatMessageId, []);
diff --git a/src/app/login/__tests__/login-form.test.tsx b/src/app/login/__tests__/login-form.test.tsx
index 9c754788..4ce4e6ee 100644
--- a/src/app/login/__tests__/login-form.test.tsx
+++ b/src/app/login/__tests__/login-form.test.tsx
@@ -1,353 +1,537 @@
+/**
+ * Renders the REAL LoginForm.
+ *
+ * The previous suite did `jest.mock('../login-form')` and asserted against a hand-written
+ * stand-in, so the app's authentication entry point had zero coverage. Only the form's
+ * dependencies are mocked here (i18n, the language hook, the icon set and the keyboard
+ * module); the react-hook-form + zod wiring under test is the real thing.
+ */
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react-native';
import React from 'react';
-import { render, screen, fireEvent, waitFor } from '@testing-library/react-native';
-import { View, Text, TouchableOpacity, TextInput } from 'react-native';
+import { Image, Keyboard } from 'react-native';
-// Mock the entire login-form module to replace the schema creation
-jest.mock('../login-form', () => {
- const React = require('react');
- const { View, Text, TouchableOpacity, TextInput } = require('react-native');
-
- const MockLoginForm = ({ onSubmit = () => { }, isLoading = false, error = undefined, onServerUrlPress }: any) => {
- const [username, setUsername] = React.useState('');
- const [password, setPassword] = React.useState('');
- const [showPassword, setShowPassword] = React.useState(false);
-
- const handleSubmit = () => {
- onSubmit({ username, password });
- };
-
- return (
-
- Username
-
- Password
-
-
- setShowPassword(!showPassword)}
- >
- {showPassword ? 'Hide' : 'Show'}
-
-
-
- {isLoading && }
- {isLoading ? 'Signing in...' : 'Log in'}
-
- {onServerUrlPress && (
-
- Server URL
-
- )}
- {error && {error} }
-
- );
- };
+// ── Dependency mocks (must precede the subject import) ───────────────────────
- return {
- LoginForm: MockLoginForm,
- };
-});
+// The validation assertions below run the real zod schema through the real
+// zodResolver. jest-setup.ts used to stub zod globally, which made that
+// impossible; that stub has been removed, so no opt-out is needed here.
-import { LoginForm } from '../login-form';
-
-// Mock react-i18next
jest.mock('react-i18next', () => ({
useTranslation: () => ({
- t: (key: string) => {
- const translations: Record = {
- 'login.username': 'Username',
- 'login.password': 'Password',
- 'login.username_placeholder': 'Enter username',
- 'login.password_placeholder': 'Enter password',
- 'login.login_button_loading': 'Signing in...',
- 'login.password_incorrect': 'Incorrect password',
- 'settings.server_url': 'Server URL',
- 'form.required': 'This field is required',
- };
- return translations[key] || key;
- },
+ t: (key: string) => key,
+ i18n: { language: 'en' },
}),
}));
-// Mock nativewind
+// Mirrors the global nativewind stub in jest-setup.ts (gluestack needs styled/cssInterop
+// to stay pass-through) but lets the colour scheme be driven per test.
+let mockColorScheme = 'light';
jest.mock('nativewind', () => ({
- styled: jest.fn((Component: any) => Component),
- useColorScheme: () => ({
- colorScheme: 'light',
- }),
+ __esModule: true,
+ styled: jest.fn((Component: unknown) => Component),
+ vars: jest.fn((v: unknown) => v),
+ cssInterop: jest.fn((Component: unknown) => Component),
+ useColorScheme: () => ({ colorScheme: mockColorScheme, get: () => mockColorScheme }),
}));
-// Mock react-native-keyboard-controller
-jest.mock('react-native-keyboard-controller', () => ({
- KeyboardAvoidingView: ({ children }: any) => children,
+const mockSetLanguage = jest.fn();
+jest.mock('@/lib', () => ({
+ translate: (key: string) => key,
+ useSelectedLanguage: () => ({ language: 'en', setLanguage: mockSetLanguage }),
}));
-// Mock react-hook-form
-jest.mock('react-hook-form', () => ({
- useForm: () => ({
- control: {},
- handleSubmit: (fn: any) => fn,
- formState: { errors: {} },
- }),
- Controller: ({ render }: any) => {
- const fieldProps = {
- field: {
- onChange: jest.fn(),
- onBlur: jest.fn(),
- value: '',
- },
- };
- return render(fieldProps);
- },
-}));
+jest.mock('lucide-react-native', () => {
+ const React = require('react');
+ const { View } = require('react-native');
+ const makeIcon = (testID: string) => {
+ const Icon = React.forwardRef((props: Record, ref: unknown) => React.createElement(View, { testID, ...props, ref }));
+ Icon.displayName = testID;
+ return Icon;
+ };
+ return {
+ AlertTriangle: makeIcon('alert-triangle-icon'),
+ ChevronDownIcon: makeIcon('chevron-down-icon'),
+ EyeIcon: makeIcon('eye-icon'),
+ EyeOffIcon: makeIcon('eye-off-icon'),
+ Globe: makeIcon('globe-icon'),
+ ShieldCheck: makeIcon('shield-check-icon'),
+ };
+});
-// Mock @hookform/resolvers/zod
-jest.mock('@hookform/resolvers/zod', () => ({
- zodResolver: () => ({}),
-}));
+import { GluestackUIProvider } from '@/components/ui/gluestack-ui-provider';
-// Mock React Native modules to avoid native module issues
-jest.mock('react-native', () => {
- const RN = jest.requireActual('react-native');
+import { LoginForm } from '../login-form';
- // Create a safe mock for Settings that won't try to access native modules
- const mockSettings = {
- get: jest.fn(),
- set: jest.fn(),
- watchKeys: jest.fn(() => ({ remove: jest.fn() })),
- };
+const USERNAME_PLACEHOLDER = 'login.username_placeholder';
+const PASSWORD_PLACEHOLDER = 'login.password_placeholder';
+const SUBMIT_LABEL = 'login.login_button';
+/** Accessible names for the icon-only reveal toggle (the i18n mock echoes the key). */
+const SHOW_PASSWORD_LABEL = 'login.show_password';
+const HIDE_PASSWORD_LABEL = 'login.hide_password';
- const mockKeyboard = {
- dismiss: jest.fn(),
- addListener: jest.fn(() => ({ remove: jest.fn() })),
- removeAllListeners: jest.fn(),
- removeListener: jest.fn(),
- };
+/** Fills both fields and presses Sign In, then waits for the async resolver to settle. */
+const submitWith = async (username: string, password: string) => {
+ fireEvent.changeText(screen.getByPlaceholderText(USERNAME_PLACEHOLDER), username);
+ fireEvent.changeText(screen.getByPlaceholderText(PASSWORD_PLACEHOLDER), password);
+ fireEvent.press(screen.getByLabelText(SUBMIT_LABEL));
+};
- // Don't spread the entire RN object to avoid including problematic native modules
- return {
- View: RN.View,
- Text: RN.Text,
- TextInput: RN.TextInput,
- TouchableOpacity: RN.TouchableOpacity,
- Image: RN.Image,
- ActivityIndicator: RN.ActivityIndicator,
- ScrollView: RN.ScrollView,
- Platform: RN.Platform,
- Dimensions: RN.Dimensions,
- StyleSheet: RN.StyleSheet,
- Alert: RN.Alert,
- Keyboard: mockKeyboard,
- Settings: mockSettings,
- // Mock TurboModuleRegistry to prevent any native module access
- TurboModuleRegistry: {
- getEnforcing: jest.fn(() => ({})),
- get: jest.fn(() => ({})),
- },
- };
-});
+describe('LoginForm', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockColorScheme = 'light';
+ });
-// Mock UI components
-jest.mock('@/components/ui', () => {
- const React = require('react');
- const { View } = require('react-native');
+ describe('rendering', () => {
+ it('renders the heading, both credential fields and the submit control', () => {
+ const { unmount } = render( );
- return {
- View: ({ children, className }: any) => React.createElement(View, { className }, children),
- };
-});
+ expect(screen.getByText('login.title')).toBeTruthy();
+ expect(screen.getByText('login.subtitle')).toBeTruthy();
+ expect(screen.getByText('login.username')).toBeTruthy();
+ expect(screen.getByText('login.password')).toBeTruthy();
+ expect(screen.getByPlaceholderText(USERNAME_PLACEHOLDER)).toBeTruthy();
+ expect(screen.getByPlaceholderText(PASSWORD_PLACEHOLDER)).toBeTruthy();
+ expect(screen.getByLabelText(SUBMIT_LABEL)).toBeTruthy();
-jest.mock('@/components/ui/button', () => {
- const React = require('react');
- const { TouchableOpacity, Text, ActivityIndicator } = require('react-native');
+ unmount();
+ });
- return {
- Button: ({ children, onPress, className, variant, action }: any) =>
- React.createElement(TouchableOpacity, { onPress, testID: 'button', className }, children),
- ButtonText: ({ children }: any) => React.createElement(Text, {}, children),
- ButtonSpinner: ({ color }: any) => React.createElement(ActivityIndicator, { color, testID: 'button-spinner' }),
- };
-});
+ it('keeps credential fields free of autocapitalisation and autocomplete', () => {
+ // Capitalising the first letter of a username is a classic "my password stopped
+ // working" support ticket.
+ const { unmount } = render( );
-jest.mock('@/components/ui/form-control', () => {
- const React = require('react');
- const { View, Text } = require('react-native');
+ for (const placeholder of [USERNAME_PLACEHOLDER, PASSWORD_PLACEHOLDER]) {
+ const field = screen.getByPlaceholderText(placeholder);
+ expect(field.props.autoCapitalize).toBe('none');
+ expect(field.props.autoComplete).toBe('off');
+ }
- return {
- FormControl: ({ children, isInvalid, className }: any) =>
- React.createElement(View, { className, testID: isInvalid ? 'form-control-invalid' : 'form-control' }, children),
- FormControlLabel: ({ children }: any) => React.createElement(View, {}, children),
- FormControlLabelText: ({ children }: any) => React.createElement(Text, {}, children),
- FormControlError: ({ children }: any) => React.createElement(View, { testID: 'form-control-error' }, children),
- FormControlErrorIcon: ({ as: IconComponent, className }: any) =>
- React.createElement(View, { testID: 'form-control-error-icon', className }),
- FormControlErrorText: ({ children, className }: any) =>
- React.createElement(Text, { className, testID: 'form-control-error-text' }, children),
- };
-});
+ unmount();
+ });
-jest.mock('@/components/ui/input', () => {
- const React = require('react');
- const { View, TextInput, TouchableOpacity } = require('react-native');
+ it('swaps the wordmark for the light-on-dark asset in dark mode', () => {
+ const { unmount } = render( );
+ const lightLogo = screen.UNSAFE_getByType(Image).props.source;
+ unmount();
- return {
- Input: ({ children }: any) => React.createElement(View, { testID: 'input' }, children),
- InputField: ({ value, onChangeText, onBlur, onSubmitEditing, placeholder, type, ...props }: any) =>
- React.createElement(TextInput, {
- value,
- onChangeText,
- onBlur,
- onSubmitEditing,
- placeholder,
- secureTextEntry: type === 'password',
- testID: 'input-field',
- ...props
- }),
- InputSlot: ({ children, onPress }: any) =>
- React.createElement(TouchableOpacity, { onPress, testID: 'input-slot' }, children),
- InputIcon: ({ as: IconComponent }: any) =>
- React.createElement(View, { testID: 'input-icon' }),
- };
-});
+ mockColorScheme = 'dark';
+ const dark = render( );
+ const darkLogo = screen.UNSAFE_getByType(Image).props.source;
-jest.mock('@/components/ui/text', () => {
- const React = require('react');
- const { Text } = require('react-native');
+ // A dark-mode login screen showing the dark wordmark is an invisible logo.
+ expect(darkLogo).not.toEqual(lightLogo);
- return {
- Text: ({ children, className }: any) => React.createElement(Text, { className }, children),
- };
-});
+ dark.unmount();
+ });
-// Mock lucide icons
-jest.mock('lucide-react-native', () => ({
- AlertTriangle: () => null,
- EyeIcon: () => null,
- EyeOffIcon: () => null,
-}));
+ it('masks the password field by default', () => {
+ const { unmount } = render( );
-// Mock colors
-jest.mock('@/constants/colors', () => ({
- light: {
- neutral: {
- 400: '#9CA3AF',
- },
- },
-}));
+ expect(screen.getByPlaceholderText(PASSWORD_PLACEHOLDER).props.secureTextEntry).toBe(true);
+ expect(screen.getByPlaceholderText(USERNAME_PLACEHOLDER).props.secureTextEntry).toBeFalsy();
-describe('LoginForm', () => {
- const defaultProps = {
- onSubmit: jest.fn(),
- isLoading: false,
- error: undefined,
- };
+ unmount();
+ });
- beforeEach(() => {
- jest.clearAllMocks();
- });
+ it('reveals and re-masks the password when the reveal toggle is pressed', () => {
+ // Driven by accessible name rather than icon testID: that is how a screen-reader user
+ // reaches this control, so the test fails if the label regresses.
+ const { unmount } = render( );
- it('renders all form fields', () => {
- render( );
+ expect(screen.getByTestId('eye-off-icon', { includeHiddenElements: true })).toBeTruthy();
- expect(screen.getByText('Username')).toBeTruthy();
- expect(screen.getByText('Password')).toBeTruthy();
- expect(screen.getByPlaceholderText('Enter username')).toBeTruthy();
- expect(screen.getByPlaceholderText('Enter password')).toBeTruthy();
- expect(screen.getByText('Log in')).toBeTruthy();
- });
+ fireEvent.press(screen.getByLabelText(SHOW_PASSWORD_LABEL));
+
+ expect(screen.getByPlaceholderText(PASSWORD_PLACEHOLDER).props.secureTextEntry).toBe(false);
+ expect(screen.getByTestId('eye-icon', { includeHiddenElements: true })).toBeTruthy();
+ expect(screen.queryByTestId('eye-off-icon', { includeHiddenElements: true })).toBeNull();
+
+ fireEvent.press(screen.getByLabelText(HIDE_PASSWORD_LABEL));
+
+ expect(screen.getByPlaceholderText(PASSWORD_PLACEHOLDER).props.secureTextEntry).toBe(true);
+
+ unmount();
+ });
+
+ it('names the reveal toggle for assistive tech and flips the name with its state', () => {
+ // An unlabelled icon-only toggle announces as a bare "button"; the name must also
+ // describe what the press will do, not what the field currently is.
+ const { unmount } = render( );
+
+ const toggle = screen.getByLabelText(SHOW_PASSWORD_LABEL);
+ expect(toggle.props.accessibilityRole).toBe('button');
+ expect(screen.queryByLabelText(HIDE_PASSWORD_LABEL)).toBeNull();
+
+ fireEvent.press(toggle);
- it('renders server URL button when onServerUrlPress prop is provided', () => {
- const onServerUrlPress = jest.fn();
- render( );
+ expect(screen.getByLabelText(HIDE_PASSWORD_LABEL)).toBeTruthy();
+ expect(screen.queryByLabelText(SHOW_PASSWORD_LABEL)).toBeNull();
- expect(screen.getByText('Server URL')).toBeTruthy();
+ unmount();
+ });
});
- it('does not render server URL button when onServerUrlPress prop is not provided', () => {
- render( );
+ describe('validation', () => {
+ it('blocks submission and reports both fields when nothing is entered', async () => {
+ const onSubmit = jest.fn();
+ const { unmount } = render( );
+
+ fireEvent.press(screen.getByLabelText(SUBMIT_LABEL));
+
+ // These strings come from the real zod schema in login-form.tsx.
+ expect(await screen.findByText('Username must be at least 3 characters')).toBeTruthy();
+ expect(screen.getByText('Password is required')).toBeTruthy();
+ expect(onSubmit).not.toHaveBeenCalled();
+
+ unmount();
+ });
+
+ it('rejects a username shorter than three characters', async () => {
+ const onSubmit = jest.fn();
+ const { unmount } = render( );
+
+ await submitWith('ab', 'correct-horse');
+
+ expect(await screen.findByText('Username must be at least 3 characters')).toBeTruthy();
+ expect(screen.queryByText('Password is required')).toBeNull();
+ expect(onSubmit).not.toHaveBeenCalled();
+
+ unmount();
+ });
+
+ it('accepts a three-character username — the boundary is inclusive', async () => {
+ const onSubmit = jest.fn();
+ const { unmount } = render( );
+
+ await submitWith('abc', 'correct-horse');
- expect(screen.queryByText('Server URL')).toBeNull();
+ await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
+ expect(screen.queryByText('Username must be at least 3 characters')).toBeNull();
+
+ unmount();
+ });
+
+ it('rejects an empty password even when the username is valid', async () => {
+ const onSubmit = jest.fn();
+ const { unmount } = render( );
+
+ await submitWith('responder', '');
+
+ expect(await screen.findByText('Password is required')).toBeTruthy();
+ expect(screen.queryByText('Username must be at least 3 characters')).toBeNull();
+ expect(onSubmit).not.toHaveBeenCalled();
+
+ unmount();
+ });
+
+ it('accepts a single-character password — the schema sets no minimum length', async () => {
+ // Documents the schema as written: only emptiness is rejected.
+ const onSubmit = jest.fn();
+ const { unmount } = render( );
+
+ await submitWith('responder', 'x');
+
+ await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
+ expect(onSubmit.mock.calls[0][0]).toEqual({ username: 'responder', password: 'x' });
+
+ unmount();
+ });
+
+ it('reports the schema message verbatim, never a serialised ZodError', async () => {
+ // The controlled fields used to carry their own `rules` whose validate() returned a
+ // caught ZodError's `.message` — a JSON blob of issue objects. react-hook-form ignores
+ // field-level rules when a resolver is supplied, so they were dead weight that would
+ // have gone live (and user-visible) the moment anyone dropped zodResolver. The zod
+ // schema is now the single source of truth; this pins the message a user actually sees.
+ const onSubmit = jest.fn();
+ const { unmount } = render( );
+
+ await submitWith('ab', '');
+
+ expect(await screen.findByText('Username must be at least 3 characters')).toBeTruthy();
+ expect(screen.getByText('Password is required')).toBeTruthy();
+ // A ZodError message serialises its issues, e.g. {"code": "too_small", ...}.
+ expect(screen.queryByText(/"code"|too_small|invalid_type/)).toBeNull();
+ expect(onSubmit).not.toHaveBeenCalled();
+
+ unmount();
+ });
+
+ it('validates the username on its own, not against a placeholder password', async () => {
+ // The removed username rule parsed {username: value, password: 'placeholder'}, so it
+ // could never fail on the password and would have masked an empty one. Both fields
+ // must be reported independently.
+ const onSubmit = jest.fn();
+ const { unmount } = render( );
+
+ await submitWith('responder', '');
+
+ expect(await screen.findByText('Password is required')).toBeTruthy();
+ expect(screen.queryByText('Username must be at least 3 characters')).toBeNull();
+ expect(onSubmit).not.toHaveBeenCalled();
+
+ unmount();
+ });
+
+ it('clears the error once the offending field is corrected and resubmitted', async () => {
+ const onSubmit = jest.fn();
+ const { unmount } = render( );
+
+ await submitWith('ab', 'correct-horse');
+ expect(await screen.findByText('Username must be at least 3 characters')).toBeTruthy();
+
+ fireEvent.changeText(screen.getByPlaceholderText(USERNAME_PLACEHOLDER), 'responder');
+ fireEvent.press(screen.getByLabelText(SUBMIT_LABEL));
+
+ await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
+ expect(screen.queryByText('Username must be at least 3 characters')).toBeNull();
+
+ unmount();
+ });
});
- it('calls onServerUrlPress when server URL button is pressed', () => {
- const onServerUrlPress = jest.fn();
- render( );
+ describe('submission', () => {
+ it('hands the entered credentials to onSubmit', async () => {
+ const onSubmit = jest.fn();
+ const { unmount } = render( );
- const serverUrlButton = screen.getByText('Server URL').parent;
- if (serverUrlButton) {
- fireEvent.press(serverUrlButton);
- expect(onServerUrlPress).toHaveBeenCalledTimes(1);
- }
+ await submitWith('responder', 'correct-horse');
+
+ await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
+ expect(onSubmit.mock.calls[0][0]).toEqual({ username: 'responder', password: 'correct-horse' });
+
+ unmount();
+ });
+
+ it('submits from the keyboard return key and dismisses the keyboard first', async () => {
+ const dismissSpy = jest.spyOn(Keyboard, 'dismiss').mockImplementation(() => {});
+ const onSubmit = jest.fn();
+ const { unmount } = render( );
+
+ fireEvent.changeText(screen.getByPlaceholderText(USERNAME_PLACEHOLDER), 'responder');
+ fireEvent.changeText(screen.getByPlaceholderText(PASSWORD_PLACEHOLDER), 'correct-horse');
+ fireEvent(screen.getByPlaceholderText(PASSWORD_PLACEHOLDER), 'submitEditing');
+
+ await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
+ expect(onSubmit.mock.calls[0][0]).toEqual({ username: 'responder', password: 'correct-horse' });
+ expect(dismissSpy).toHaveBeenCalled();
+
+ dismissSpy.mockRestore();
+ unmount();
+ });
+
+ it('does not submit from the keyboard when the entry is invalid', async () => {
+ const onSubmit = jest.fn();
+ const { unmount } = render( );
+
+ fireEvent.changeText(screen.getByPlaceholderText(USERNAME_PLACEHOLDER), 'ab');
+ fireEvent(screen.getByPlaceholderText(USERNAME_PLACEHOLDER), 'submitEditing');
+
+ expect(await screen.findByText('Username must be at least 3 characters')).toBeTruthy();
+ expect(onSubmit).not.toHaveBeenCalled();
+
+ unmount();
+ });
+
+ it('renders without crashing when no onSubmit handler is supplied', async () => {
+ // The prop is optional and defaults to a no-op; a valid submit must not throw.
+ const { unmount } = render( );
+
+ await submitWith('responder', 'correct-horse');
+ await waitFor(() => expect(screen.queryByText('Username must be at least 3 characters')).toBeNull());
+
+ expect(screen.getByLabelText(SUBMIT_LABEL)).toBeTruthy();
+
+ unmount();
+ });
});
- it('shows loading state when isLoading is true', () => {
- render( );
+ describe('in-flight state', () => {
+ it('swaps the submit control for a spinner and blocks further submits while loading', async () => {
+ const onSubmit = jest.fn();
+ const { unmount } = render( );
+
+ expect(screen.getByText('login.login_button_loading')).toBeTruthy();
+ // The pressable submit control is gone entirely while a login is in flight.
+ expect(screen.queryByLabelText(SUBMIT_LABEL)).toBeNull();
+
+ fireEvent.changeText(screen.getByPlaceholderText(USERNAME_PLACEHOLDER), 'responder');
+ fireEvent.changeText(screen.getByPlaceholderText(PASSWORD_PLACEHOLDER), 'correct-horse');
+ fireEvent.press(screen.getByText('login.login_button_loading'));
+
+ await act(async () => {});
+ expect(onSubmit).not.toHaveBeenCalled();
+
+ unmount();
+ });
+
+ it('restores the submit control when loading finishes', () => {
+ const { unmount } = render( );
+
+ expect(screen.queryByLabelText(SUBMIT_LABEL)).toBeNull();
+
+ screen.rerender( );
+
+ expect(screen.getByLabelText(SUBMIT_LABEL)).toBeTruthy();
+ expect(screen.queryByText('login.login_button_loading')).toBeNull();
- expect(screen.getByTestId('button-spinner')).toBeTruthy();
- expect(screen.getByText('Signing in...')).toBeTruthy();
+ unmount();
+ });
});
- it('allows user to toggle password visibility', () => {
- render( );
+ describe('error surfacing', () => {
+ it('shows the failure message handed down from the login attempt', () => {
+ const { unmount } = render( );
+
+ expect(screen.getByText('Invalid username or password')).toBeTruthy();
+
+ unmount();
+ });
+
+ it('renders no error text when there is no error', () => {
+ const { unmount } = render( );
+
+ expect(screen.queryByText('Invalid username or password')).toBeNull();
+
+ unmount();
+ });
+
+ it('clears the message once the error prop goes away', () => {
+ const { unmount } = render( );
+
+ screen.rerender( );
+
+ expect(screen.queryByText('Invalid username or password')).toBeNull();
+
+ unmount();
+ });
- const passwordField = screen.getByPlaceholderText('Enter password');
- const toggleButton = screen.getByTestId('input-slot');
+ it('surfaces a rejected login through the banner alone, with no field-level duplicate', () => {
+ // The form has exactly one error surface for a rejected login: the banner fed by the
+ // `error` prop, which app/login/index.tsx wires to the auth store's failure string.
+ // A second, field-level "password was incorrect" message used to be rendered behind a
+ // `validated` flag that had no setter and so could never turn false — dead code that
+ // read as a live feature. Nothing upstream attributes a failure to a specific field,
+ // so the banner stays the single honest path.
+ const { unmount } = render( );
- // Initially should be secured
- expect(passwordField.props.secureTextEntry).toBe(true);
+ expect(screen.getByText('Invalid username or password')).toBeTruthy();
+ expect(screen.queryByText('login.password_incorrect')).toBeNull();
- // Toggle visibility
- fireEvent.press(toggleButton);
+ unmount();
+ });
- // Re-query the password field and verify it's now visible
- const updatedPasswordField = screen.getByPlaceholderText('Enter password');
- expect(updatedPasswordField.props.secureTextEntry).toBe(false);
+ it('keeps the banner and the field validation as separate, non-overlapping surfaces', async () => {
+ // A server rejection and a client-side schema failure can be on screen at once; neither
+ // may leak into the other's slot.
+ const onSubmit = jest.fn();
+ const { unmount } = render( );
+
+ await submitWith('ab', '');
+
+ expect(await screen.findByText('Username must be at least 3 characters')).toBeTruthy();
+ expect(screen.getByText('Invalid username or password')).toBeTruthy();
+ expect(screen.queryByText('login.password_incorrect')).toBeNull();
+
+ unmount();
+ });
});
- it('calls onSubmit with form data when form is submitted', async () => {
- const onSubmit = jest.fn();
- render( );
+ describe('secondary affordances', () => {
+ it('renders and wires the server URL button only when a handler is supplied', () => {
+ const { unmount } = render( );
+ expect(screen.queryByText('settings.server_url')).toBeNull();
+ unmount();
- const usernameField = screen.getByPlaceholderText('Enter username');
- const passwordField = screen.getByPlaceholderText('Enter password');
- const submitButton = screen.getByText('Log in').parent;
+ const onServerUrlPress = jest.fn();
+ const second = render( );
- fireEvent.changeText(usernameField, 'testuser');
- fireEvent.changeText(passwordField, 'testpass');
+ fireEvent.press(screen.getByText('settings.server_url'));
+ expect(onServerUrlPress).toHaveBeenCalledTimes(1);
- if (submitButton) {
- fireEvent.press(submitButton);
+ second.unmount();
+ });
- await waitFor(() => {
- expect(onSubmit).toHaveBeenCalledTimes(1);
- });
+ it('renders and wires the SSO button only when a handler is supplied', () => {
+ const { unmount } = render( );
+ expect(screen.queryByText('login.sso_button')).toBeNull();
+ expect(screen.queryByTestId('shield-check-icon', { includeHiddenElements: true })).toBeNull();
+ unmount();
- expect(onSubmit).toHaveBeenCalledWith(
- expect.objectContaining({
- username: 'testuser',
- password: 'testpass'
- })
- );
- }
+ const onSsoPress = jest.fn();
+ const second = render( );
+
+ expect(screen.getByTestId('shield-check-icon', { includeHiddenElements: true })).toBeTruthy();
+ fireEvent.press(screen.getByText('login.sso_button'));
+ expect(onSsoPress).toHaveBeenCalledTimes(1);
+
+ second.unmount();
+ });
+
+ it('does not submit the form when a secondary button is pressed', async () => {
+ const onSubmit = jest.fn();
+ const { unmount } = render( );
+
+ // Fill in valid credentials first: with an empty form, validation alone would stop a
+ // stray submit and the assertion below would pass for the wrong reason.
+ fireEvent.changeText(screen.getByPlaceholderText(USERNAME_PLACEHOLDER), 'responder');
+ fireEvent.changeText(screen.getByPlaceholderText(PASSWORD_PLACEHOLDER), 'correct-horse');
+
+ fireEvent.press(screen.getByText('settings.server_url'));
+ fireEvent.press(screen.getByText('login.sso_button'));
+
+ // handleSubmit resolves asynchronously, so flush before concluding nothing submitted.
+ await act(async () => {});
+ expect(onSubmit).not.toHaveBeenCalled();
+
+ unmount();
+ });
});
- it('disables form when loading', () => {
- render( );
+ describe('language selector', () => {
+ // The trigger's TextInput is aria-hidden by gluestack, so it needs the hidden-element opt-in.
+ const getLanguageTrigger = () => screen.getByPlaceholderText('settings.language', { includeHiddenElements: true });
+
+ it('reflects the currently selected language', () => {
+ const { unmount } = render( );
+
+ expect(getLanguageTrigger().props.value).toBe('en');
+
+ unmount();
+ });
+
+ it('offers every supported language and reports the choice back to the language hook', async () => {
+ const { unmount } = render(
+
+
+
+ );
- // When loading, the submit button should show loading state
- expect(screen.getByTestId('button-spinner')).toBeTruthy();
- expect(screen.queryByText('Signing in...')).toBeTruthy();
+ fireEvent.press(getLanguageTrigger());
+
+ // All ten options the form declares must be reachable.
+ for (const key of [
+ 'settings.english',
+ 'settings.spanish',
+ 'settings.swedish',
+ 'settings.german',
+ 'settings.greek',
+ 'settings.french',
+ 'settings.italian',
+ 'settings.polish',
+ 'settings.ukrainian',
+ 'settings.arabic',
+ ]) {
+ expect(await screen.findByText(key)).toBeTruthy();
+ }
+
+ fireEvent.press(screen.getByText('settings.spanish'));
+
+ await waitFor(() => expect(mockSetLanguage).toHaveBeenCalledWith('es'));
+
+ unmount();
+ });
});
});
diff --git a/src/app/login/login-form.tsx b/src/app/login/login-form.tsx
index cbd14a35..1b9c4cf1 100644
--- a/src/app/login/login-form.tsx
+++ b/src/app/login/login-form.tsx
@@ -1,5 +1,5 @@
import { zodResolver } from '@hookform/resolvers/zod';
-import { AlertTriangle, EyeIcon, EyeOffIcon, Globe, LogIn, ShieldCheck } from 'lucide-react-native';
+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';
@@ -72,15 +72,10 @@ export const LoginForm = ({ onSubmit = () => {}, isLoading = false, error = unde
const {
control,
handleSubmit,
- getValues,
formState: { errors },
} = useForm({
resolver: zodResolver(loginFormSchema),
});
- const [validated] = useState({
- usernameValid: true,
- passwordValid: true,
- });
const [showPassword, setShowPassword] = useState(false);
@@ -102,7 +97,7 @@ export const LoginForm = ({ onSubmit = () => {}, isLoading = false, error = unde
{/* Username */}
-
+
{t('login.username')}
@@ -110,16 +105,6 @@ export const LoginForm = ({ onSubmit = () => {}, isLoading = false, error = unde
defaultValue=""
name="username"
control={control}
- rules={{
- validate: async (value) => {
- try {
- await loginFormSchema.parseAsync({ username: value, password: 'placeholder' });
- return true;
- } catch (err: any) {
- return err.message;
- }
- },
- }}
render={({ field: { onChange, onBlur, value } }) => (
{}, isLoading = false, error = unde
{/* Password form */}
-
+
{t('login.password')}
@@ -150,16 +135,6 @@ export const LoginForm = ({ onSubmit = () => {}, isLoading = false, error = unde
defaultValue=""
name="password"
control={control}
- rules={{
- validate: async (value) => {
- try {
- await loginFormSchema.parseAsync({ username: getValues('username'), password: value });
- return true;
- } catch (err: any) {
- return err.message;
- }
- },
- }}
render={({ field: { onChange, onBlur, value } }) => (
{}, isLoading = false, error = unde
autoCapitalize="none"
autoComplete="off"
/>
-
+ {/* gluestack's Slot defaults to accessibilityElementsHidden, which would hide this
+ control from VoiceOver even once labelled — an icon-only toggle with no other
+ way to reach it. Re-expose it explicitly. */}
+
@@ -181,7 +166,7 @@ export const LoginForm = ({ onSubmit = () => {}, isLoading = false, error = unde
/>
- {errors?.password?.message || (!validated.passwordValid && t('login.password_incorrect'))}
+ {errors?.password?.message}
diff --git a/src/app/maps/indoor/[id].tsx b/src/app/maps/indoor/[id].tsx
index be338243..116382a1 100644
--- a/src/app/maps/indoor/[id].tsx
+++ b/src/app/maps/indoor/[id].tsx
@@ -153,63 +153,66 @@ export default function IndoorMapViewer() {
{/* Map */}
+ {/* The map stays mounted across floor switches — unmounting it tore down
+ and rebuilt the whole GL context on every tab tap. The loader is
+ overlaid instead. */}
+
+
+
+ {/* Floor plan image overlay */}
+ {floorImageUrl && currentFloor && currentIndoorMap.BoundsNELatitude ? (
+
+
+
+ ) : null}
+
+ {/* Zone polygons */}
+ {filteredZonesGeoJSON ? (
+
+
+
+
+
+ ) : null}
+
+
{isLoadingGeoJSON ? (
-
+
- ) : (
-
-
-
- {/* Floor plan image overlay */}
- {floorImageUrl && currentFloor && currentIndoorMap.BoundsNELatitude ? (
-
-
-
- ) : null}
-
- {/* Zone polygons */}
- {filteredZonesGeoJSON ? (
-
-
-
-
-
- ) : null}
-
- )}
+ ) : null}
{/* Zone detail popover */}
{selectedZone ? (
diff --git a/src/app/routes/directions.tsx b/src/app/routes/directions.tsx
index 74d8413b..b9600320 100644
--- a/src/app/routes/directions.tsx
+++ b/src/app/routes/directions.tsx
@@ -107,10 +107,10 @@ const congestionColor = (level: string): string => {
}
};
-/** Derive a human-readable driving condition summary from congestion data */
-const deriveDrivingCondition = (congestion: CongestionSegment[]): { label: string; color: string; icon: typeof TrafficConeIcon } => {
+/** Derive a driving condition summary from congestion data; labelKey is an i18n key resolved at render time */
+export const deriveDrivingCondition = (congestion: CongestionSegment[]): { labelKey: string; color: string; icon: typeof TrafficConeIcon } => {
if (congestion.length === 0) {
- return { label: 'No traffic data', color: '#9ca3af', icon: TrafficConeIcon };
+ return { labelKey: 'routes.traffic_no_data', color: '#9ca3af', icon: TrafficConeIcon };
}
const counts = { low: 0, moderate: 0, heavy: 0, severe: 0 };
@@ -120,15 +120,15 @@ const deriveDrivingCondition = (congestion: CongestionSegment[]): { label: strin
const total = congestion.length;
if (counts.severe / total > 0.15) {
- return { label: 'Severe traffic', color: '#ef4444', icon: AlertTriangleIcon };
+ return { labelKey: 'routes.traffic_severe', color: '#ef4444', icon: AlertTriangleIcon };
}
if (counts.heavy / total > 0.2) {
- return { label: 'Heavy traffic', color: '#f97316', icon: AlertTriangleIcon };
+ return { labelKey: 'routes.traffic_heavy', color: '#f97316', icon: AlertTriangleIcon };
}
if (counts.moderate / total > 0.3) {
- return { label: 'Moderate traffic', color: '#eab308', icon: TrafficConeIcon };
+ return { labelKey: 'routes.traffic_moderate', color: '#eab308', icon: TrafficConeIcon };
}
- return { label: 'Light traffic', color: '#22c55e', icon: TrafficConeIcon };
+ return { labelKey: 'routes.traffic_light', color: '#22c55e', icon: TrafficConeIcon };
};
// ---------------------------------------------------------------------------
@@ -143,7 +143,7 @@ const deriveDrivingCondition = (congestion: CongestionSegment[]): { label: strin
* Returns parsed route geometry, durations, and congestion data, or null
* on failure (e.g., missing API key, network error).
*/
-async function fetchMapboxDirections(waypoints: [number, number][]): Promise {
+export async function fetchMapboxDirections(waypoints: [number, number][], language: string = 'en'): Promise {
if (waypoints.length < 2) return null;
const token = Env.UNIT_MAPBOX_PUBKEY;
@@ -154,7 +154,14 @@ async function fetchMapboxDirections(waypoints: [number, number][]): Promise `${lng},${lat}`).join(';');
const url =
- `${MAPBOX_DIRECTIONS_API}/${coords}` + `?access_token=${token}` + `&geometries=geojson` + `&overview=full` + `&annotations=congestion,duration,distance` + `&steps=true` + `&continue_straight=true` + `&language=en`;
+ `${MAPBOX_DIRECTIONS_API}/${coords}` +
+ `?access_token=${token}` +
+ `&geometries=geojson` +
+ `&overview=full` +
+ `&annotations=congestion,duration,distance` +
+ `&steps=true` +
+ `&continue_straight=true` +
+ `&language=${encodeURIComponent(language)}`;
try {
const response = await fetch(url);
@@ -371,7 +378,7 @@ const markerStyles = StyleSheet.create({
// ---------------------------------------------------------------------------
export default function RouteDirectionsScreen() {
- const { t } = useTranslation();
+ const { t, i18n } = useTranslation();
const { instanceId } = useLocalSearchParams<{ instanceId: string }>();
const { colorScheme } = useColorScheme();
const cameraRef = useRef(null);
@@ -416,6 +423,9 @@ export default function RouteDirectionsScreen() {
return validStops.slice(1, -1);
}, [validStops]);
+ // Directions in the user's locale (Mapbox expects a primary language subtag, e.g. "en" from "en-US")
+ const directionsLanguage = i18n.language?.split('-')[0] || 'en';
+
// Fetch real driving directions from Mapbox API
useEffect(() => {
if (validStops.length < 2) return;
@@ -426,7 +436,7 @@ export default function RouteDirectionsScreen() {
setIsFetchingDirections(true);
const waypoints: [number, number][] = validStops.map((s) => [s.Longitude, s.Latitude]);
- const result = await fetchMapboxDirections(waypoints);
+ const result = await fetchMapboxDirections(waypoints, directionsLanguage);
if (!cancelled) {
setMapboxDirections(result);
@@ -439,7 +449,7 @@ export default function RouteDirectionsScreen() {
return () => {
cancelled = true;
};
- }, [validStops]);
+ }, [validStops, directionsLanguage]);
// Build the route GeoJSON: prefer Mapbox directions, fallback to backend, then straight-line
const routeGeoJson = useMemo((): GeoJSON.Feature | null => {
@@ -743,7 +753,7 @@ export default function RouteDirectionsScreen() {
- {drivingCondition.label}
+ {t(drivingCondition.labelKey)}
{trafficDelaySeconds != null && trafficDelaySeconds > 0 ? (
diff --git a/src/components/calls/__tests__/call-card.test.tsx b/src/components/calls/__tests__/call-card.test.tsx
new file mode 100644
index 00000000..046a0dfe
--- /dev/null
+++ b/src/components/calls/__tests__/call-card.test.tsx
@@ -0,0 +1,77 @@
+import { render } from '@testing-library/react-native';
+import React from 'react';
+
+import { CallCard } from '../call-card';
+
+jest.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string) => key,
+ }),
+}));
+
+// If the card ever reintroduces the WebView-backed renderer, this mock makes it
+// visible in the tree so the assertions below fail loudly.
+jest.mock('@/components/ui/html-renderer', () => {
+ const { View } = require('react-native');
+ return {
+ HtmlRenderer: () => ,
+ };
+});
+
+const baseCall = {
+ CallId: '42',
+ Number: '2024-042',
+ Name: 'Structure Fire',
+ Address: '1 Main St',
+ Nature: 'Heavy smoke & flames showing
',
+ Priority: 1,
+ LoggedOnUtc: new Date().toISOString(),
+} as never;
+
+const priority = { Id: 1, Name: 'High', Color: '#ff0000' } as never;
+
+describe('CallCard', () => {
+ it('renders the call nature as plain text, not a WebView', () => {
+ const { queryByTestId, getByText, unmount } = render( );
+
+ // A WebView per FlashList row is far too heavy — the card must render text.
+ expect(queryByTestId('html-renderer')).toBeNull();
+ expect(getByText('Heavy smoke & flames showing')).toBeTruthy();
+
+ unmount();
+ });
+
+ it('limits the nature preview to a few lines', () => {
+ const { getByText, unmount } = render( );
+
+ expect(getByText('Heavy smoke & flames showing').props.numberOfLines).toBe(4);
+
+ unmount();
+ });
+
+ it('renders no nature block when the call has no nature', () => {
+ const { queryByTestId, unmount } = render( );
+
+ expect(queryByTestId('html-renderer')).toBeNull();
+
+ unmount();
+ });
+
+ it('renders a nature that is only markup as no nature block', () => {
+ const { queryByText, unmount } = render(' } as never} priority={priority} />);
+
+ expect(queryByText('
')).toBeNull();
+
+ unmount();
+ });
+
+ it('still renders the core call fields', () => {
+ const { getByText, unmount } = render( );
+
+ expect(getByText('#2024-042')).toBeTruthy();
+ expect(getByText('Structure Fire')).toBeTruthy();
+ expect(getByText('1 Main St')).toBeTruthy();
+
+ unmount();
+ });
+});
diff --git a/src/components/calls/__tests__/call-images-modal.test.tsx b/src/components/calls/__tests__/call-images-modal.test.tsx
index d1cc8edc..343fda1a 100644
--- a/src/components/calls/__tests__/call-images-modal.test.tsx
+++ b/src/components/calls/__tests__/call-images-modal.test.tsx
@@ -1,26 +1,37 @@
-import React, { useEffect, useMemo, useState } from 'react';
-import { render, fireEvent, waitFor } from '@testing-library/react-native';
-import { useAuthStore } from '@/lib';
-import { useCallDetailStore } from '@/stores/calls/detail-store';
-import { useLocationStore } from '@/stores/app/location-store';
-import { useAnalytics } from '@/hooks/use-analytics';
-
-// Mock dependencies
+/**
+ * Exercises the real CallImagesModal. Everything mocked here is a dependency of the
+ * component (stores, expo modules, the child full-screen modal, native plumbing); the
+ * component under test is imported and rendered for real.
+ */
+import { fireEvent, render, waitFor } from '@testing-library/react-native';
+import React from 'react';
+
+jest.mock('react-i18next', () => ({
+ useTranslation: () => ({ t: (key: string) => key }),
+}));
+
+jest.mock('@/stores/calls/detail-store', () => ({
+ useCallDetailStore: (selector: any) => (selector ? selector(mockDetailState) : mockDetailState),
+}));
+
+jest.mock('@/stores/app/location-store', () => ({
+ useLocationStore: (selector: any) => (selector ? selector(mockLocationState) : mockLocationState),
+}));
+
jest.mock('@/lib', () => ({
- useAuthStore: {
- getState: jest.fn(),
- },
+ useAuthStore: { getState: () => ({ userId: mockUserId }) },
}));
-jest.mock('@/stores/calls/detail-store');
-jest.mock('@/stores/app/location-store');
+jest.mock('@/stores/toast/store', () => ({
+ useToastStore: (selector: any) => (selector ? selector(mockToastState) : mockToastState),
+}));
-jest.mock('@/hooks/use-analytics');
+jest.mock('@/lib/logging', () => ({
+ logger: { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() },
+}));
-jest.mock('react-i18next', () => ({
- useTranslation: () => ({
- t: (key: string) => key,
- }),
+jest.mock('@/hooks/use-analytics', () => ({
+ useAnalytics: () => ({ trackEvent: mockTrackEvent }),
}));
jest.mock('expo-image-picker', () => ({
@@ -28,1187 +39,789 @@ jest.mock('expo-image-picker', () => ({
requestCameraPermissionsAsync: jest.fn(),
launchImageLibraryAsync: jest.fn(),
launchCameraAsync: jest.fn(),
- MediaTypeOptions: {
- Images: 'Images',
- },
-}));
-
-jest.mock('expo-file-system/legacy', () => ({
- readAsStringAsync: jest.fn(),
- EncodingType: {
- Base64: 'base64',
- },
+ CameraType: { back: 'back', front: 'front' },
}));
jest.mock('expo-image-manipulator', () => ({
manipulateAsync: jest.fn(),
- SaveFormat: {
- PNG: 'png',
- },
+ SaveFormat: { PNG: 'png', JPEG: 'jpeg' },
}));
-// Create MockCallImagesModal to avoid CSS interop issues
-interface CallImagesModalProps {
- isOpen: boolean;
- onClose: () => void;
- callId: string;
-}
-
-const MockCallImagesModal: React.FC = ({ isOpen, onClose, callId }) => {
- const [activeIndex, setActiveIndex] = useState(0);
- const [imageErrors, setImageErrors] = useState>(new Set());
- const [fullScreenImage, setFullScreenImage] = useState<{ uri: string; name?: string } | null>(null);
-
- const { callImages, isLoadingImages, errorImages, fetchCallImages, uploadCallImage } = useCallDetailStore();
- const { trackEvent } = useAnalytics();
- const { latitude, longitude } = useLocationStore();
-
- // Filter valid images and memoize to prevent re-filtering on every render
- const validImages = useMemo(() => {
- if (!callImages) return [];
- return callImages.filter((item) => item && (item.Data?.trim() || item.Url?.trim()));
- }, [callImages]);
-
- useEffect(() => {
- if (isOpen && callId) {
- fetchCallImages(callId);
- setActiveIndex(0);
- setImageErrors(new Set());
- }
- }, [isOpen, callId, fetchCallImages]);
-
- // Track when call images modal is opened/rendered
- useEffect(() => {
- if (isOpen) {
- trackEvent('call_images_modal_opened', {
- callId: callId,
- hasExistingImages: validImages.length > 0,
- imagesCount: validImages.length,
- isLoadingImages: isLoadingImages,
- hasError: !!errorImages,
- });
- }
- }, [isOpen, trackEvent, callId, validImages.length, isLoadingImages, errorImages]);
-
- // Reset active index when valid images change
- useEffect(() => {
- if (activeIndex >= validImages.length && validImages.length > 0) {
- setActiveIndex(0);
- }
- }, [validImages.length, activeIndex]);
-
- const handleNext = () => {
- setActiveIndex(Math.min(validImages.length - 1, activeIndex + 1));
+jest.mock('expo-file-system/legacy', () => ({
+ readAsStringAsync: jest.fn(),
+ EncodingType: { Base64: 'base64' },
+}));
+
+// expo-image sets no testID of its own; key the stub off the production `recyclingKey`
+// (the gallery sets it per item) so a specific rendered image can be addressed.
+jest.mock('expo-image', () => {
+ const React = require('react');
+ const { View } = require('react-native');
+ return {
+ Image: (props: any) => React.createElement(View, { testID: props.recyclingKey ? `expo-image-${props.recyclingKey}` : 'expo-image', ...props }),
};
+});
+
+jest.mock('react-native-keyboard-controller', () => {
+ const React = require('react');
+ const { View } = require('react-native');
+ return { KeyboardStickyView: ({ children, ...props }: any) => React.createElement(View, props, children) };
+});
- const handlePrevious = () => {
- setActiveIndex(Math.max(0, activeIndex - 1));
+jest.mock('lucide-react-native', () => {
+ const React = require('react');
+ const { View } = require('react-native');
+ const icon = React.forwardRef((props: Record, ref: unknown) => React.createElement(View, { ...props, ref }));
+ return new Proxy({}, { get: () => icon });
+});
+
+// Stub for the child modal (covered by full-screen-image-modal.test.tsx) that surfaces
+// what this component hands it.
+jest.mock('../full-screen-image-modal', () => {
+ const React = require('react');
+ const { Pressable, View } = require('react-native');
+ return {
+ __esModule: true,
+ default: ({ isOpen, onClose, imageSource, imageName }: any) =>
+ isOpen ? React.createElement(View, { testID: 'full-screen-modal', imageSource, imageName }, React.createElement(Pressable, { testID: 'full-screen-close', onPress: onClose })) : null,
};
+});
- if (!isOpen) return null;
-
- // Mock React Native components for testing
- const View = (props: any) => React.createElement('div', { testID: props.testID, ...props });
- const Text = (props: any) => React.createElement('span', { testID: props.testID, ...props });
- const TouchableOpacity = (props: any) => React.createElement('button', {
- testID: props.testID,
- onPress: props.onPress,
- onClick: props.onPress,
- disabled: props.disabled,
- ...props
- });
- const Image = (props: any) => React.createElement('img', {
- testID: props.testID,
- src: props.source?.uri,
- alt: props.alt,
- onError: props.onError,
- onLoad: props.onLoad,
- ...props
- });
+import { readAsStringAsync } from 'expo-file-system/legacy';
+import * as ImageManipulator from 'expo-image-manipulator';
+import * as ImagePicker from 'expo-image-picker';
+
+import { logger } from '@/lib/logging';
+import { type CallFileResultData } from '@/models/v4/callFiles/callFileResultData';
+
+import CallImagesModal from '../call-images-modal';
- if (isLoadingImages) {
- return React.createElement(View, { testID: 'actionsheet' },
- React.createElement(View, { testID: 'loading' }, 'Loading...')
- );
- }
-
- if (errorImages) {
- return React.createElement(View, { testID: 'actionsheet' },
- React.createElement(View, { testID: 'error-state' }, [
- React.createElement(View, { testID: 'heading', key: 'heading' }, 'Error'),
- React.createElement(View, { testID: 'description', key: 'description' }, errorImages)
- ])
- );
- }
-
- if (validImages.length === 0) {
- return React.createElement(View, { testID: 'actionsheet' },
- React.createElement(View, { testID: 'zero-state' }, [
- React.createElement(View, { testID: 'heading', key: 'heading' }, 'No images'),
- React.createElement(View, { testID: 'description', key: 'description' }, 'No images available')
- ])
- );
- }
-
- return React.createElement(View, { testID: 'actionsheet' },
- React.createElement(View, { testID: 'actionsheet-backdrop' },
- React.createElement(View, { testID: 'actionsheet-content' }, [
- React.createElement(View, { testID: 'drag-indicator-wrapper', key: 'drag-wrapper' },
- React.createElement(View, { testID: 'drag-indicator' })
- ),
- React.createElement(View, { testID: 'pagination', key: 'pagination' },
- validImages.length > 0 ? `${activeIndex + 1} / ${validImages.length}` : ''
- ),
- React.createElement(View, { testID: 'flatlist', key: 'flatlist' },
- validImages.map((item, index) => {
- const hasError = imageErrors.has(item.Id);
- let imageSource: { uri: string } | null = null;
-
- if (item.Data && item.Data.trim() !== '') {
- const mimeType = item.Mime || 'image/png';
- imageSource = { uri: `data:${mimeType};base64,${item.Data}` };
- } else if (item.Url && item.Url.trim() !== '') {
- imageSource = { uri: item.Url };
- }
-
- if (!imageSource || hasError) {
- return React.createElement(View, {
- testID: `image-error-${item.Id}`,
- key: item.Id
- }, [
- React.createElement(Text, { key: 'error-text' }, 'callImages.failed_to_load'),
- React.createElement(View, { key: 'name' }, item.Name || ''),
- React.createElement(View, { key: 'timestamp' }, item.Timestamp || '')
- ]);
- }
-
- return React.createElement(View, {
- testID: `image-${item.Id}`,
- key: item.Id
- }, [
- React.createElement(TouchableOpacity, {
- testID: `image-${item.Id}-touchable`,
- key: 'touchable',
- onPress: () => setFullScreenImage({ uri: imageSource!.uri, name: item.Name })
- },
- React.createElement(Image, {
- key: 'image',
- source: imageSource,
- alt: item.Name,
- onError: () => {
- setImageErrors((prev) => new Set([...prev, item.Id]));
- },
- onLoad: () => {
- setImageErrors((prev) => {
- const newSet = new Set(prev);
- newSet.delete(item.Id);
- return newSet;
- });
- }
- })
- ),
- React.createElement(View, { key: 'name' }, item.Name || ''),
- React.createElement(View, { key: 'timestamp' }, item.Timestamp || '')
- ]);
- })
- ),
- React.createElement(View, { testID: 'navigation', key: 'navigation' }, [
- React.createElement(TouchableOpacity, {
- testID: 'previous-button',
- key: 'previous',
- onPress: handlePrevious,
- disabled: activeIndex === 0
- }, 'Previous'),
- React.createElement(TouchableOpacity, {
- testID: 'next-button',
- key: 'next',
- onPress: handleNext,
- disabled: activeIndex === validImages.length - 1
- }, 'Next')
- ]),
- React.createElement(TouchableOpacity, {
- testID: 'close-button',
- key: 'close',
- onPress: onClose
- }, 'Close'),
- fullScreenImage && React.createElement(View, {
- testID: 'full-screen-modal',
- key: 'full-screen-modal'
- }, [
- React.createElement(TouchableOpacity, {
- testID: 'full-screen-close-button',
- key: 'full-screen-close',
- onPress: () => setFullScreenImage(null)
- }, 'Close Full Screen'),
- React.createElement(Image, {
- testID: 'full-screen-image',
- key: 'full-screen-image',
- source: { uri: fullScreenImage.uri },
- alt: fullScreenImage.name || 'Full screen image'
- })
- ])
- ])
- )
- );
+const mockFetchCallImages = jest.fn();
+const mockUploadCallImage = jest.fn();
+const mockClearImages = jest.fn();
+const mockTrackEvent = jest.fn();
+let mockUserId: string | null = 'user-42';
+
+const buildImage = (overrides: Partial): CallFileResultData =>
+ ({
+ Id: 'img-1',
+ CallId: 'call-1',
+ Type: 2,
+ Name: 'Image One',
+ Size: 100,
+ Url: '',
+ Data: '',
+ UserId: 'user-42',
+ Timestamp: '2024-05-01 08:00',
+ Mime: 'image/png',
+ FileName: 'one.png',
+ ...overrides,
+ }) as CallFileResultData;
+
+const base64Image = buildImage({ Id: 'img-1', Name: 'Front of structure', Data: 'AAAABBBB', Mime: 'image/jpeg', Timestamp: '2024-05-01 08:00' });
+const urlImage = buildImage({ Id: 'img-2', Name: 'Side alpha', Url: ' https://example.com/side-a.png ', Timestamp: '2024-05-01 08:05' });
+const blankImage = buildImage({ Id: 'img-3', Name: 'Nothing at all', Data: ' ', Url: ' ' });
+
+// One object that is mutated in place — never reassigned — so the selector mock stays live.
+const mockDetailState = {
+ callImages: [base64Image, urlImage] as CallFileResultData[] | null,
+ isLoadingImages: false,
+ errorImages: null as string | null,
+ fetchCallImages: mockFetchCallImages,
+ uploadCallImage: mockUploadCallImage,
+ clearImages: mockClearImages,
};
-// Mock the actual component
-jest.mock('../call-images-modal', () => ({
- __esModule: true,
- default: MockCallImagesModal,
-}));
+const mockLocationState = {
+ latitude: 40.1 as number | null,
+ longitude: -75.2 as number | null,
+};
-const mockUseCallDetailStore = useCallDetailStore as jest.MockedFunction;
-const mockUseLocationStore = useLocationStore as jest.MockedFunction;
-const mockUseAuthStore = useAuthStore as jest.MockedObject;
-const mockUseAnalytics = useAnalytics as jest.MockedFunction;
+const mockShowToast = jest.fn();
+const mockToastState = { showToast: mockShowToast };
-const mockTrackEvent = jest.fn();
+const setDetailState = (updates: Partial) => Object.assign(mockDetailState, updates);
-// Mock expo modules
-const mockReadAsStringAsync = jest.fn();
-const mockManipulateAsync = jest.fn();
+const defaultProps = { isOpen: true, onClose: jest.fn(), callId: 'call-1' };
-jest.mock('expo-file-system/legacy', () => ({
- readAsStringAsync: mockReadAsStringAsync,
- EncodingType: {
- Base64: 'base64',
- },
-}));
+const mockedPicker = ImagePicker as jest.Mocked;
+const mockManipulateAsync = ImageManipulator.manipulateAsync as jest.Mock;
+const mockReadAsStringAsync = readAsStringAsync as jest.Mock;
+const mockAlert = jest.fn();
-jest.mock('expo-image-manipulator', () => ({
- manipulateAsync: mockManipulateAsync,
- SaveFormat: {
- PNG: 'png',
- },
-}));
+/** Walks from the "Add" chooser to a picked-image preview ready to upload. */
+const pickImageFromLibrary = async (screen: ReturnType, asset: { uri: string; fileName?: string } = { uri: 'file:///tmp/original.jpg', fileName: 'original.jpg' }) => {
+ mockedPicker.requestMediaLibraryPermissionsAsync.mockResolvedValue({ status: 'granted' } as never);
+ mockedPicker.launchImageLibraryAsync.mockResolvedValue({ canceled: false, assets: [asset] } as never);
-describe('CallImagesModal', () => {
- const defaultProps = {
- isOpen: true,
- onClose: jest.fn(),
- callId: 'test-call-id',
- };
+ fireEvent.press(screen.getByText('callImages.add'));
+ fireEvent.press(screen.getByText('callImages.select_from_gallery'));
- const mockCallImages = [
- {
- Id: '1',
- Name: 'Image 1',
- Data: 'base64data1',
- Url: '',
- Mime: 'image/png',
- Timestamp: '2023-01-01',
- },
- {
- Id: '2',
- Name: 'Image 2',
- Data: '',
- Url: 'https://example.com/image2.jpg',
- Mime: 'image/jpeg',
- Timestamp: '2023-01-02',
- },
- {
- Id: '3',
- Name: 'Invalid Image',
- Data: '',
- Url: '',
- Mime: 'image/png',
- Timestamp: '2023-01-03',
- },
- {
- Id: '4',
- Name: 'Image 4',
- Data: 'base64data4',
- Url: '',
- Mime: 'image/png',
- Timestamp: '2023-01-04',
- },
- {
- Id: '5',
- Name: 'Image 5',
- Data: 'base64data5',
- Url: '',
- Mime: 'image/png',
- Timestamp: '2023-01-05',
- },
- ];
-
- const mockStore = {
- callImages: mockCallImages,
- isLoadingImages: false,
- errorImages: null,
- fetchCallImages: jest.fn(),
- uploadCallImage: jest.fn(),
- };
+ await waitFor(() => expect(screen.getByTestId('image-note-input')).toBeTruthy());
+};
+describe('CallImagesModal', () => {
beforeEach(() => {
jest.clearAllMocks();
- mockReadAsStringAsync.mockClear();
- mockManipulateAsync.mockClear();
- mockUseCallDetailStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector(mockStore as any) : mockStore as any);
- mockUseLocationStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
- latitude: 40.7128,
- longitude: -74.0060,
- heading: null,
- accuracy: null,
- speed: null,
- altitude: null,
- timestamp: null,
- isBackgroundEnabled: false,
- isMapLocked: false,
- setLocation: jest.fn(),
- setBackgroundEnabled: jest.fn(),
- setMapLocked: jest.fn(),
- }) : {
- latitude: 40.7128,
- longitude: -74.0060,
- heading: null,
- accuracy: null,
- speed: null,
- altitude: null,
- timestamp: null,
- isBackgroundEnabled: false,
- isMapLocked: false,
- setLocation: jest.fn(),
- setBackgroundEnabled: jest.fn(),
- setMapLocked: jest.fn(),
- });
- mockUseAnalytics.mockReturnValue({
- trackEvent: mockTrackEvent,
- });
- mockUseAuthStore.getState.mockReturnValue({
- userId: 'test-user-id',
- accessToken: 'test-token',
- refreshToken: 'test-refresh-token',
- refreshTokenExpiresOn: new Date(),
- status: 'authenticated',
- user: null,
- departmentId: 'test-dept-id',
- groupIds: [],
- userName: 'test-user',
- signIn: jest.fn(),
- signOut: jest.fn(),
- setUser: jest.fn(),
- setDepartmentId: jest.fn(),
- setGroupIds: jest.fn(),
- } as any);
+ setDetailState({
+ callImages: [base64Image, urlImage],
+ isLoadingImages: false,
+ errorImages: null,
+ });
+ mockLocationState.latitude = 40.1;
+ mockLocationState.longitude = -75.2;
+ mockUserId = 'user-42';
+ mockUploadCallImage.mockResolvedValue(undefined);
+ mockManipulateAsync.mockResolvedValue({ uri: 'file:///tmp/manipulated.png', width: 1024, height: 768 });
+ mockReadAsStringAsync.mockResolvedValue('BASE64PAYLOAD');
+ (global as unknown as { alert: jest.Mock }).alert = mockAlert;
});
- describe('CSS Interop Fix - Basic Functionality', () => {
- it('renders correctly when open', () => {
- const { getByTestId } = render( );
- expect(getByTestId('actionsheet')).toBeTruthy();
- });
+ describe('opening and closing', () => {
+ it('renders nothing and fetches nothing while closed', () => {
+ const { queryByText, unmount } = render( );
- it('does not render when closed', () => {
- const { queryByTestId } = render( );
- expect(queryByTestId('actionsheet')).toBeFalsy();
- });
+ expect(queryByText('callImages.title')).toBeNull();
+ expect(mockFetchCallImages).not.toHaveBeenCalled();
- it('fetches images when opened', () => {
- render( );
- expect(mockStore.fetchCallImages).toHaveBeenCalledWith('test-call-id');
+ unmount();
});
- it('shows loading state', () => {
- mockUseCallDetailStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
- ...mockStore,
- isLoadingImages: true,
- } as any) : {
- ...mockStore,
- isLoadingImages: true,
- } as any);
-
- const { getByTestId } = render( );
- expect(getByTestId('loading')).toBeTruthy();
+ it('fetches the images for the call it was opened with', () => {
+ const { getByText, unmount } = render( );
+
+ expect(mockFetchCallImages).toHaveBeenCalledWith('call-1');
+ expect(getByText('callImages.title')).toBeTruthy();
+
+ unmount();
});
- it('shows error state', () => {
- mockUseCallDetailStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
- ...mockStore,
- errorImages: 'Failed to load images',
- } as any) : {
- ...mockStore,
- errorImages: 'Failed to load images',
- } as any);
-
- const { getByTestId } = render( );
- expect(getByTestId('error-state')).toBeTruthy();
+ it('refetches when it is pointed at a different call', () => {
+ const { rerender, unmount } = render( );
+
+ rerender( );
+
+ expect(mockFetchCallImages).toHaveBeenCalledWith('call-9');
+
+ unmount();
});
- it('shows zero state when no images', () => {
- mockUseCallDetailStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
- ...mockStore,
- callImages: [],
- } as any) : {
- ...mockStore,
- callImages: [],
- } as any);
-
- const { getByTestId } = render( );
- expect(getByTestId('zero-state')).toBeTruthy();
+ it('drops the loaded images from the store when it closes', () => {
+ const { rerender, unmount } = render( );
+
+ mockClearImages.mockClear();
+ rerender( );
+
+ expect(mockClearImages).toHaveBeenCalled();
+
+ unmount();
});
- it('filters out invalid images from pagination', () => {
- const { getByTestId } = render( );
- const pagination = getByTestId('pagination');
- expect(pagination).toHaveTextContent('1 / 4'); // 4 valid images (filtering out the one with no data or URL)
+ it('closes when the close button is pressed', () => {
+ const onClose = jest.fn();
+ const { getByTestId, unmount } = render( );
+
+ fireEvent.press(getByTestId('close-button'));
+
+ expect(onClose).toHaveBeenCalledTimes(1);
+
+ unmount();
});
});
- describe('Component Behavior', () => {
- it('handles pagination correctly', () => {
- const { getByTestId } = render( );
+ describe('gallery', () => {
+ it('shows the first image with its name and timestamp', () => {
+ const { getByText, unmount } = render( );
- // Should start at first image
- expect(getByTestId('pagination')).toHaveTextContent('1 / 4');
+ expect(getByText('Front of structure')).toBeTruthy();
+ expect(getByText('2024-05-01 08:00')).toBeTruthy();
- // Click next button
- const nextButton = getByTestId('next-button');
- fireEvent.press(nextButton);
-
- // Should move to second image - need to re-render to see state change
- expect(getByTestId('pagination')).toHaveTextContent('2 / 4');
+ unmount();
});
- it('handles image loading errors gracefully', () => {
- // Test that images with invalid data show error state
- const invalidImagesStore = {
- ...mockStore,
- callImages: [
- {
- Id: '1',
- Name: 'Valid Image',
- Data: 'base64data1',
- Url: '',
- Mime: 'image/png',
- Timestamp: '2023-01-01',
- },
- {
- Id: '2',
- Name: 'Invalid Image',
- Data: '',
- Url: '',
- Mime: 'image/png',
- Timestamp: '2023-01-02',
- }
- ]
- };
-
- mockUseCallDetailStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector(invalidImagesStore as any) : invalidImagesStore as any);
-
- const { getByTestId, queryByTestId } = render( );
-
- // Should have valid image
- expect(getByTestId('image-1')).toBeTruthy();
- // Should not have invalid image in gallery (filtered out)
- expect(queryByTestId('image-2')).toBeFalsy();
+ it('builds a data URI from the stored payload using the image mime type', () => {
+ const { getByTestId, unmount } = render( );
+
+ expect(getByTestId('expo-image-img-1').props.source).toEqual({ uri: 'data:image/jpeg;base64,AAAABBBB' });
+
+ unmount();
});
- it('calls onClose when close button clicked', () => {
- const mockOnClose = jest.fn();
- const { getByTestId } = render( );
+ it('falls back to the remote URL, trimmed, when there is no payload', () => {
+ const { getByTestId, unmount } = render( );
- const closeButton = getByTestId('close-button');
- fireEvent.press(closeButton);
+ fireEvent.press(getByTestId('next-button'));
- expect(mockOnClose).toHaveBeenCalled();
+ expect(getByTestId('expo-image-img-2').props.source).toEqual({ uri: 'https://example.com/side-a.png' });
+
+ unmount();
});
- });
- describe('Logic Tests', () => {
- it('should filter valid images correctly', () => {
- const mockImages = [
- { Id: '1', Data: 'base64data', Url: '', Name: 'Valid Image 1' },
- { Id: '2', Data: '', Url: 'https://example.com/image.jpg', Name: 'Valid Image 2' },
- { Id: '3', Data: '', Url: '', Name: 'Invalid Image' },
- { Id: '4', Data: 'base64data2', Url: '', Name: 'Valid Image 3' },
- ];
+ it('ignores images that carry neither a payload nor a URL', () => {
+ setDetailState({ callImages: [base64Image, blankImage, urlImage] });
+ const { getByText, queryByText, unmount } = render( );
- const validImages = mockImages.filter((item) => item && (item.Data?.trim() || item.Url?.trim()));
+ expect(getByText('1 / 2')).toBeTruthy();
+ expect(queryByText('Nothing at all')).toBeNull();
- expect(validImages).toHaveLength(3);
- expect(validImages.map(img => img.Id)).toEqual(['1', '2', '4']);
+ unmount();
});
- it('should prefer Data over Url when both are available', () => {
- const mockImage = {
- Id: '1',
- Data: 'base64data',
- Url: 'https://example.com/fallback.jpg',
- Mime: 'image/png',
- Name: 'Test Image'
- };
-
- let imageSource = null;
- if (mockImage.Data && mockImage.Data.trim() !== '') {
- const mimeType = mockImage.Mime || 'image/png';
- imageSource = { uri: `data:${mimeType};base64,${mockImage.Data}` };
- } else if (mockImage.Url && mockImage.Url.trim() !== '') {
- imageSource = { uri: mockImage.Url };
- }
-
- expect(imageSource).toEqual({
- uri: 'data:image/png;base64,base64data'
- });
+ it('pages forward and back through the images', () => {
+ const { getByTestId, getByText, unmount } = render( );
+
+ expect(getByText('1 / 2')).toBeTruthy();
+
+ fireEvent.press(getByTestId('next-button'));
+ expect(getByText('Side alpha')).toBeTruthy();
+ expect(getByText('2 / 2')).toBeTruthy();
+
+ fireEvent.press(getByTestId('previous-button'));
+ expect(getByText('Front of structure')).toBeTruthy();
+ expect(getByText('1 / 2')).toBeTruthy();
+
+ unmount();
});
- it('should fall back to URL when Data is empty', () => {
- const mockImage = {
- Id: '2',
- Data: '',
- Url: 'https://example.com/image.jpg',
- Mime: 'image/jpeg',
- Name: 'Test Image'
- };
-
- let imageSource = null;
- if (mockImage.Data && mockImage.Data.trim() !== '') {
- const mimeType = mockImage.Mime || 'image/png';
- imageSource = { uri: `data:${mimeType};base64,${mockImage.Data}` };
- } else if (mockImage.Url && mockImage.Url.trim() !== '') {
- imageSource = { uri: mockImage.Url };
- }
-
- expect(imageSource).toEqual({
- uri: 'https://example.com/image.jpg'
- });
+ it('stops at the last image instead of paging off the end', () => {
+ const { getByTestId, getByText, unmount } = render( );
+
+ fireEvent.press(getByTestId('next-button'));
+ fireEvent.press(getByTestId('next-button'));
+
+ expect(getByText('2 / 2')).toBeTruthy();
+ expect(getByText('Side alpha')).toBeTruthy();
+
+ unmount();
});
- it('should return null when both Data and Url are empty', () => {
- const mockImage = {
- Id: '3',
- Data: '',
- Url: '',
- Mime: 'image/png',
- Name: 'Invalid Image'
- };
-
- let imageSource = null;
- if (mockImage.Data && mockImage.Data.trim() !== '') {
- const mimeType = mockImage.Mime || 'image/png';
- imageSource = { uri: `data:${mimeType};base64,${mockImage.Data}` };
- } else if (mockImage.Url && mockImage.Url.trim() !== '') {
- imageSource = { uri: mockImage.Url };
- }
-
- expect(imageSource).toBeNull();
+ it('stops at the first image instead of paging before the start', () => {
+ const { getByTestId, getByText, unmount } = render( );
+
+ fireEvent.press(getByTestId('previous-button'));
+
+ expect(getByText('1 / 2')).toBeTruthy();
+
+ unmount();
});
- it('should handle pagination bounds correctly', () => {
- const validImagesLength = 5;
- let activeIndex = 0;
+ it('hides the pager when there is only one image', () => {
+ setDetailState({ callImages: [base64Image] });
+ const { queryByTestId, unmount } = render( );
+
+ expect(queryByTestId('next-button')).toBeNull();
+ expect(queryByTestId('previous-button')).toBeNull();
- const handleNext = () => {
- return Math.min(validImagesLength - 1, activeIndex + 1);
- };
+ unmount();
+ });
- const handlePrevious = () => {
- return Math.max(0, activeIndex - 1);
- };
+ it('swaps in a placeholder when an image fails to decode', () => {
+ const { getByTestId, getByText, queryByTestId, unmount } = render( );
- // Test at start
- expect(handlePrevious()).toBe(0);
- expect(handleNext()).toBe(1);
+ fireEvent(getByTestId('expo-image-img-1'), 'error');
- // Test in middle
- activeIndex = 2;
- expect(handlePrevious()).toBe(1);
- expect(handleNext()).toBe(3);
+ expect(getByText('callImages.failed_to_load')).toBeTruthy();
+ expect(queryByTestId('expo-image-img-1')).toBeNull();
+ // The placeholder is not tappable, so a broken image cannot be opened full screen.
+ expect(queryByTestId('image-img-1-touchable')).toBeNull();
+ expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: 'Call image failed to load' }));
- // Test at end
- activeIndex = 4;
- expect(handlePrevious()).toBe(3);
- expect(handleNext()).toBe(4); // Should not exceed bounds
+ unmount();
});
});
- describe('Full Screen Image Modal', () => {
- it('should open full screen modal when image is tapped', () => {
- const { getByTestId, queryByTestId } = render( );
+ describe('empty, loading and error states', () => {
+ it('shows the zero state when the call has no images', () => {
+ setDetailState({ callImages: [] });
+ const { getByText, queryByTestId, unmount } = render( );
+
+ expect(getByText('callImages.no_images')).toBeTruthy();
+ expect(getByText('callImages.no_images_description')).toBeTruthy();
+ expect(queryByTestId('next-button')).toBeNull();
+
+ unmount();
+ });
- // Initially, full screen modal should not be visible
- expect(queryByTestId('full-screen-modal')).toBeFalsy();
+ it('shows the zero state when every image is unusable', () => {
+ setDetailState({ callImages: [blankImage] });
+ const { getByText, unmount } = render( );
- // Tap on an image
- const imageTouchable = getByTestId('image-1-touchable');
- fireEvent.press(imageTouchable);
+ expect(getByText('callImages.no_images')).toBeTruthy();
- // Full screen modal should now be visible
- expect(getByTestId('full-screen-modal')).toBeTruthy();
+ unmount();
});
- it('should close full screen modal when close button is pressed', () => {
- const { getByTestId, queryByTestId } = render( );
+ it('shows a loading state instead of the gallery while images are being fetched', () => {
+ setDetailState({ callImages: null, isLoadingImages: true });
+ const { getByText, queryByText, unmount } = render( );
- // Open full screen modal
- const imageTouchable = getByTestId('image-1-touchable');
- fireEvent.press(imageTouchable);
+ expect(getByText('callImages.loading')).toBeTruthy();
+ expect(queryByText('callImages.add')).toBeNull();
- expect(getByTestId('full-screen-modal')).toBeTruthy();
+ unmount();
+ });
+
+ it('surfaces the store error message', () => {
+ setDetailState({ callImages: [], errorImages: 'Network unreachable' });
+ const { getByText, unmount } = render( );
- // Close full screen modal
- const fullScreenCloseButton = getByTestId('full-screen-close-button');
- fireEvent.press(fullScreenCloseButton);
+ expect(getByText('callImages.error')).toBeTruthy();
+ expect(getByText('Network unreachable')).toBeTruthy();
- // Full screen modal should be closed
- expect(queryByTestId('full-screen-modal')).toBeFalsy();
+ unmount();
});
+ });
- it('should display correct image in full screen modal', () => {
- const { getByTestId } = render( );
+ describe('full screen viewer', () => {
+ it('opens the viewer with the resolved source and name of the tapped image', () => {
+ const { getByTestId, unmount } = render( );
- // Tap on first image
- const imageTouchable = getByTestId('image-1-touchable');
- fireEvent.press(imageTouchable);
+ fireEvent.press(getByTestId('image-img-1-touchable'));
- // Check that the correct image is displayed in full screen
- const fullScreenImage = getByTestId('full-screen-image');
- expect(fullScreenImage.props.source.uri).toBe('data:image/png;base64,base64data1');
- expect(fullScreenImage.props.alt).toBe('Image 1');
+ const viewer = getByTestId('full-screen-modal');
+ expect(viewer.props.imageSource).toEqual({ uri: 'data:image/jpeg;base64,AAAABBBB' });
+ expect(viewer.props.imageName).toBe('Front of structure');
+
+ unmount();
});
- it('should handle full screen modal for URL-based images', () => {
- const { getByTestId } = render( );
+ it('keeps the viewer closed until an image is tapped', () => {
+ const { queryByTestId, unmount } = render( );
- // Tap on second image (URL-based)
- const imageTouchable = getByTestId('image-2-touchable');
- fireEvent.press(imageTouchable);
+ expect(queryByTestId('full-screen-modal')).toBeNull();
- // Check that the correct image is displayed in full screen
- const fullScreenImage = getByTestId('full-screen-image');
- expect(fullScreenImage.props.source.uri).toBe('https://example.com/image2.jpg');
- expect(fullScreenImage.props.alt).toBe('Image 2');
+ unmount();
});
- it('should not open full screen modal for images with errors', () => {
- const invalidImagesStore = {
- ...mockStore,
- callImages: [
- {
- Id: '1',
- Name: 'Valid Image',
- Data: 'base64data1',
- Url: '',
- Mime: 'image/png',
- Timestamp: '2023-01-01',
- },
- {
- Id: '2',
- Name: 'Invalid Image',
- Data: '',
- Url: '',
- Mime: 'image/png',
- Timestamp: '2023-01-02',
- }
- ]
- };
-
- mockUseCallDetailStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector(invalidImagesStore as any) : invalidImagesStore as any);
-
- const { getByTestId, queryByTestId } = render( );
-
- // Should have valid image touchable
- expect(getByTestId('image-1-touchable')).toBeTruthy();
- // Should not have invalid image in gallery (filtered out)
- expect(queryByTestId('image-2-touchable')).toBeFalsy();
+ it('closes the viewer again', () => {
+ const { getByTestId, queryByTestId, unmount } = render( );
+
+ fireEvent.press(getByTestId('image-img-1-touchable'));
+ fireEvent.press(getByTestId('full-screen-close'));
+
+ expect(queryByTestId('full-screen-modal')).toBeNull();
+
+ unmount();
});
});
- describe('Image Slider Improvements', () => {
- it('should have proper width styling for image containers', () => {
- const { getByTestId } = render( );
+ describe('choosing an image', () => {
+ it('offers the gallery and camera options behind the add button', () => {
+ const { getByText, unmount } = render( );
- // Check that images are properly contained
- expect(getByTestId('image-1')).toBeTruthy();
- expect(getByTestId('image-2')).toBeTruthy();
- expect(getByTestId('image-4')).toBeTruthy();
- expect(getByTestId('image-5')).toBeTruthy();
- });
+ fireEvent.press(getByText('callImages.add'));
- it('should center images properly', () => {
- // This test verifies that images use contentFit="contain" for proper centering
- // In the actual implementation, this is handled by the Image component props
- const { getByTestId } = render( );
+ expect(getByText('callImages.add_new')).toBeTruthy();
+ expect(getByText('callImages.select_from_gallery')).toBeTruthy();
+ expect(getByText('callImages.take_photo')).toBeTruthy();
- const imageContainer = getByTestId('image-1');
- expect(imageContainer).toBeTruthy();
+ unmount();
});
- it('should handle touch interactions correctly', () => {
- const { getByTestId } = render( );
+ it('offers no note or upload until an image has been chosen', () => {
+ const { getByText, getByTestId, queryByTestId, unmount } = render( );
+
+ fireEvent.press(getByText('callImages.add'));
- // Should be able to interact with image touchables
- const imageTouchable = getByTestId('image-1-touchable');
- expect(imageTouchable).toBeTruthy();
+ expect(getByTestId('cancel-add-button')).toBeTruthy();
+ expect(queryByTestId('image-note-input')).toBeNull();
+ expect(queryByTestId('upload-button')).toBeNull();
- // Touching should not throw an error
- expect(() => fireEvent.press(imageTouchable)).not.toThrow();
+ unmount();
});
- });
- describe('Analytics', () => {
- it('should track analytics event when modal is opened', () => {
- render( );
+ it('asks for media library permission on iOS and shows the preview once granted', async () => {
+ const screen = render( );
- expect(mockTrackEvent).toHaveBeenCalledWith('call_images_modal_opened', {
- callId: 'test-call-id',
- hasExistingImages: true,
- imagesCount: 4,
- isLoadingImages: false,
- hasError: false,
- });
+ await pickImageFromLibrary(screen);
+
+ expect(mockedPicker.requestMediaLibraryPermissionsAsync).toHaveBeenCalled();
+ expect(mockedPicker.launchImageLibraryAsync).toHaveBeenCalledWith(expect.objectContaining({ mediaTypes: ['images'], allowsEditing: true, quality: 0.8 }));
+ expect(screen.getByTestId('expo-image').props.source).toEqual({ uri: 'file:///tmp/original.jpg' });
+
+ screen.unmount();
});
- it('should not track analytics event when modal is closed', () => {
- render( );
+ it('tells the user and opens no picker when library permission is denied', async () => {
+ mockedPicker.requestMediaLibraryPermissionsAsync.mockResolvedValue({ status: 'denied' } as never);
+ const { getByText, queryByTestId, unmount } = render( );
- expect(mockTrackEvent).not.toHaveBeenCalled();
+ fireEvent.press(getByText('callImages.add'));
+ fireEvent.press(getByText('callImages.select_from_gallery'));
+
+ await waitFor(() => expect(mockShowToast).toHaveBeenCalledWith('error', 'common.permission_denied'));
+ // Feedback goes through the app's toast, never a bare alert().
+ expect(mockAlert).not.toHaveBeenCalled();
+ expect(mockedPicker.launchImageLibraryAsync).not.toHaveBeenCalled();
+ expect(queryByTestId('image-note-input')).toBeNull();
+
+ unmount();
});
- it('should track analytics event with existing images', () => {
- const mockImagesStore = {
- ...mockStore,
- callImages: [
- { Id: '1', Name: 'Image 1', Data: 'base64data', Url: '', Timestamp: '2024-01-01', Mime: 'image/png' },
- { Id: '2', Name: 'Image 2', Data: 'base64data2', Url: '', Timestamp: '2024-01-02', Mime: 'image/jpeg' },
- ],
- };
+ it('stays on the chooser when the user cancels the picker', async () => {
+ mockedPicker.requestMediaLibraryPermissionsAsync.mockResolvedValue({ status: 'granted' } as never);
+ mockedPicker.launchImageLibraryAsync.mockResolvedValue({ canceled: true, assets: null } as never);
+ const { getByText, queryByTestId, unmount } = render( );
- mockUseCallDetailStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector(mockImagesStore as any) : mockImagesStore as any);
+ fireEvent.press(getByText('callImages.add'));
+ fireEvent.press(getByText('callImages.select_from_gallery'));
- render( );
+ await waitFor(() => expect(mockedPicker.launchImageLibraryAsync).toHaveBeenCalled());
+ expect(queryByTestId('image-note-input')).toBeNull();
+ expect(getByText('callImages.select_from_gallery')).toBeTruthy();
- expect(mockTrackEvent).toHaveBeenCalledWith('call_images_modal_opened', {
- callId: 'test-call-456',
- hasExistingImages: true,
- imagesCount: 2,
- isLoadingImages: false,
- hasError: false,
- });
+ unmount();
});
- it('should track analytics event with loading state', () => {
- mockUseCallDetailStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
- ...mockStore,
- isLoadingImages: true,
- } as any) : {
- ...mockStore,
- isLoadingImages: true,
- } as any);
+ it('reports a failing picker to the user and to the log', async () => {
+ mockedPicker.requestMediaLibraryPermissionsAsync.mockResolvedValue({ status: 'granted' } as never);
+ mockedPicker.launchImageLibraryAsync.mockRejectedValue(new Error('picker exploded'));
+ const { getByText, unmount } = render( );
- render( );
+ fireEvent.press(getByText('callImages.add'));
+ fireEvent.press(getByText('callImages.select_from_gallery'));
- expect(mockTrackEvent).toHaveBeenCalledWith('call_images_modal_opened', {
- callId: 'test-call-789',
- hasExistingImages: true,
- imagesCount: 4,
- isLoadingImages: true,
- hasError: false,
- });
+ await waitFor(() => expect(mockShowToast).toHaveBeenCalledWith('error', 'callImages.error_selecting_image'));
+ expect(mockAlert).not.toHaveBeenCalled();
+ expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: 'Error selecting image from library' }));
+
+ unmount();
});
- it('should track analytics event with error state', () => {
- mockUseCallDetailStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
- ...mockStore,
- errorImages: 'Failed to load images',
- } as any) : {
- ...mockStore,
- errorImages: 'Failed to load images',
- } as any);
+ it('asks for camera permission and opens the back camera', async () => {
+ mockedPicker.requestCameraPermissionsAsync.mockResolvedValue({ status: 'granted' } as never);
+ mockedPicker.launchCameraAsync.mockResolvedValue({ canceled: false, assets: [{ uri: 'file:///tmp/shot.jpg' }] } as never);
+ const { getByText, getByTestId, unmount } = render( );
- render( );
+ fireEvent.press(getByText('callImages.add'));
+ fireEvent.press(getByText('callImages.take_photo'));
- expect(mockTrackEvent).toHaveBeenCalledWith('call_images_modal_opened', {
- callId: 'test-call-error',
- hasExistingImages: true,
- imagesCount: 4,
- isLoadingImages: false,
- hasError: true,
- });
+ await waitFor(() => expect(getByTestId('image-note-input')).toBeTruthy());
+ expect(mockedPicker.requestCameraPermissionsAsync).toHaveBeenCalled();
+ expect(mockedPicker.launchCameraAsync).toHaveBeenCalledWith(expect.objectContaining({ mediaTypes: ['images'], quality: 0.8, cameraType: 'back' }));
+
+ unmount();
});
- it('should track analytics event only once when isOpen changes from false to true', () => {
- const { rerender } = render( );
+ it('tells the user and opens no camera when camera permission is denied', async () => {
+ mockedPicker.requestCameraPermissionsAsync.mockResolvedValue({ status: 'denied' } as never);
+ const { getByText, unmount } = render( );
- // Should not track when initially closed
- expect(mockTrackEvent).not.toHaveBeenCalled();
+ fireEvent.press(getByText('callImages.add'));
+ fireEvent.press(getByText('callImages.take_photo'));
- // Should track when opened
- rerender( );
+ await waitFor(() => expect(mockShowToast).toHaveBeenCalledWith('error', 'common.permission_denied'));
+ expect(mockAlert).not.toHaveBeenCalled();
+ expect(mockedPicker.launchCameraAsync).not.toHaveBeenCalled();
- expect(mockTrackEvent).toHaveBeenCalledTimes(1);
- expect(mockTrackEvent).toHaveBeenCalledWith('call_images_modal_opened', {
- callId: 'test-call-id',
- hasExistingImages: true,
- imagesCount: 4,
- isLoadingImages: false,
- hasError: false,
- });
+ unmount();
+ });
- // Should not track again when staying open
- rerender( );
+ it('reports a failing camera to the user and to the log', async () => {
+ mockedPicker.requestCameraPermissionsAsync.mockResolvedValue({ status: 'granted' } as never);
+ mockedPicker.launchCameraAsync.mockRejectedValue(new Error('camera exploded'));
+ const { getByText, unmount } = render( );
- expect(mockTrackEvent).toHaveBeenCalledTimes(1);
+ fireEvent.press(getByText('callImages.add'));
+ fireEvent.press(getByText('callImages.take_photo'));
+
+ await waitFor(() => expect(mockShowToast).toHaveBeenCalledWith('error', 'callImages.error_capturing_image'));
+ expect(mockAlert).not.toHaveBeenCalled();
+ expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: 'Error capturing image from camera' }));
+
+ unmount();
});
- it('should track analytics event with no images', () => {
- mockUseCallDetailStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
- ...mockStore,
- callImages: [],
- } as any) : {
- ...mockStore,
- callImages: [],
- } as any);
+ it('abandons the picked image when the user cancels', async () => {
+ const screen = render( );
- render( );
+ await pickImageFromLibrary(screen);
+ fireEvent.press(screen.getByTestId('cancel-add-button'));
- expect(mockTrackEvent).toHaveBeenCalledWith('call_images_modal_opened', {
- callId: 'test-call-no-images',
- hasExistingImages: false,
- imagesCount: 0,
- isLoadingImages: false,
- hasError: false,
- });
+ expect(screen.queryByTestId('image-note-input')).toBeNull();
+ expect(screen.getByText('Front of structure')).toBeTruthy();
+
+ screen.unmount();
});
});
- describe('Image Upload and PNG Conversion', () => {
- beforeEach(() => {
- mockManipulateAsync.mockClear();
- mockReadAsStringAsync.mockClear();
+ describe('uploading', () => {
+ it('resizes to 1024px wide and compresses to PNG before uploading', async () => {
+ const screen = render( );
+
+ await pickImageFromLibrary(screen);
+ fireEvent.press(screen.getByTestId('upload-button'));
+
+ await waitFor(() => expect(mockManipulateAsync).toHaveBeenCalled());
+ expect(mockManipulateAsync).toHaveBeenCalledWith('file:///tmp/original.jpg', [{ resize: { width: 1024 } }], { compress: 0.8, format: 'png' });
+
+ screen.unmount();
});
- it('should convert images to PNG format before upload', async () => {
- const mockManipulatedUri = 'file://path/to/manipulated.png';
- const mockBase64Data = 'base64EncodedPNGData';
+ it('uploads the base64 of the resized file, not of the original', async () => {
+ const screen = render( );
- mockManipulateAsync.mockResolvedValue({
- uri: mockManipulatedUri,
- width: 1024,
- height: 768,
- });
+ await pickImageFromLibrary(screen);
+ fireEvent.press(screen.getByTestId('upload-button'));
- mockReadAsStringAsync.mockResolvedValue(mockBase64Data);
-
- // Test the PNG conversion logic
- const selectedImageUri = 'file://path/to/original.jpg';
-
- // Simulate the logic from handleUploadImage
- const manipulatedImage = await mockManipulateAsync(
- selectedImageUri,
- [{ resize: { width: 1024 } }],
- {
- compress: 0.8,
- format: 'png', // PNG format
- }
- );
-
- expect(mockManipulateAsync).toHaveBeenCalledWith(
- selectedImageUri,
- [{ resize: { width: 1024 } }],
- {
- compress: 0.8,
- format: 'png',
- }
- );
-
- expect(manipulatedImage.uri).toBe(mockManipulatedUri);
-
- // Read the manipulated image as base64
- const base64Image = await mockReadAsStringAsync(manipulatedImage.uri, {
- encoding: 'base64',
- });
+ await waitFor(() => expect(mockReadAsStringAsync).toHaveBeenCalled());
+ expect(mockReadAsStringAsync).toHaveBeenCalledWith('file:///tmp/manipulated.png', { encoding: 'base64' });
+
+ screen.unmount();
+ });
+
+ it('sends the note, the file name, the user and the current position', async () => {
+ const screen = render( );
- expect(mockReadAsStringAsync).toHaveBeenCalledWith(
- mockManipulatedUri,
- { encoding: 'base64' }
- );
+ await pickImageFromLibrary(screen);
+ fireEvent.changeText(screen.getByTestId('image-note-input'), 'Rear entry blocked');
+ fireEvent.press(screen.getByTestId('upload-button'));
- expect(base64Image).toBe(mockBase64Data);
+ await waitFor(() => expect(mockUploadCallImage).toHaveBeenCalledWith('call-1', 'user-42', 'Rear entry blocked', 'original.jpg', 40.1, -75.2, 'BASE64PAYLOAD'));
+
+ screen.unmount();
});
- it('should handle image manipulation errors gracefully', async () => {
- const error = new Error('Image manipulation failed');
- mockManipulateAsync.mockRejectedValue(error);
-
- const selectedImageUri = 'file://path/to/original.jpg';
-
- try {
- await mockManipulateAsync(
- selectedImageUri,
- [{ resize: { width: 1024 } }],
- {
- compress: 0.8,
- format: 'png',
- }
- );
- } catch (caughtError) {
- expect(caughtError).toBe(error);
- }
-
- expect(mockManipulateAsync).toHaveBeenCalled();
+ it('uploads with an empty note when the user typed none', async () => {
+ const screen = render( );
+
+ await pickImageFromLibrary(screen);
+ fireEvent.press(screen.getByTestId('upload-button'));
+
+ await waitFor(() => expect(mockUploadCallImage).toHaveBeenCalledWith('call-1', 'user-42', '', 'original.jpg', 40.1, -75.2, 'BASE64PAYLOAD'));
+
+ screen.unmount();
});
- it('should resize images to max width of 1024px while maintaining aspect ratio', async () => {
- const selectedImageUri = 'file://path/to/large-image.jpg';
+ it('generates a name for a library image that has none', async () => {
+ const screen = render( );
- mockManipulateAsync.mockResolvedValue({
- uri: 'file://path/to/resized.png',
- width: 1024,
- height: 768,
- });
+ await pickImageFromLibrary(screen, { uri: 'file:///tmp/no-name.jpg' });
+ fireEvent.press(screen.getByTestId('upload-button'));
+
+ await waitFor(() => expect(mockUploadCallImage).toHaveBeenCalled());
+ expect(mockUploadCallImage.mock.calls[0][3]).toMatch(/^image_\d+\.png$/);
- await mockManipulateAsync(
- selectedImageUri,
- [{ resize: { width: 1024 } }],
- {
- compress: 0.8,
- format: 'png',
- }
- );
-
- expect(mockManipulateAsync).toHaveBeenCalledWith(
- selectedImageUri,
- [{ resize: { width: 1024 } }],
- expect.objectContaining({
- compress: 0.8,
- format: 'png',
- })
- );
+ screen.unmount();
});
- it('should apply 0.8 compression to the converted PNG', async () => {
- const selectedImageUri = 'file://path/to/original.jpg';
+ it('names camera captures after the moment they were taken', async () => {
+ mockedPicker.requestCameraPermissionsAsync.mockResolvedValue({ status: 'granted' } as never);
+ mockedPicker.launchCameraAsync.mockResolvedValue({ canceled: false, assets: [{ uri: 'file:///tmp/shot.jpg' }] } as never);
+ const screen = render( );
- mockManipulateAsync.mockResolvedValue({
- uri: 'file://path/to/compressed.png',
- width: 800,
- height: 600,
- });
+ fireEvent.press(screen.getByText('callImages.add'));
+ fireEvent.press(screen.getByText('callImages.take_photo'));
+ await waitFor(() => expect(screen.getByTestId('upload-button')).toBeTruthy());
+ fireEvent.press(screen.getByTestId('upload-button'));
+
+ await waitFor(() => expect(mockUploadCallImage).toHaveBeenCalled());
+ expect(mockUploadCallImage.mock.calls[0][3]).toMatch(/^camera_\d+\.png$/);
- await mockManipulateAsync(
- selectedImageUri,
- [{ resize: { width: 1024 } }],
- {
- compress: 0.8,
- format: 'png',
- }
- );
-
- expect(mockManipulateAsync).toHaveBeenCalledWith(
- selectedImageUri,
- expect.any(Array),
- expect.objectContaining({
- compress: 0.8,
- })
- );
+ screen.unmount();
});
- });
- describe('Image Note and Filename Handling', () => {
- it('should use note input for the note field and filename for the name field', () => {
- // Test the logic that separates note and filename
- const mockImageInfo = {
- uri: 'file://path/to/image.jpg',
- filename: 'my_photo.jpg'
- };
- const noteText = 'This is a test note';
-
- // Simulate upload call parameters
- const uploadParams = {
- note: noteText,
- name: mockImageInfo.filename,
- };
-
- expect(uploadParams.note).toBe('This is a test note');
- expect(uploadParams.name).toBe('my_photo.jpg');
+ it('uploads without coordinates when the device has no fix', async () => {
+ mockLocationState.latitude = null;
+ mockLocationState.longitude = null;
+ const screen = render( );
+
+ await pickImageFromLibrary(screen);
+ fireEvent.press(screen.getByTestId('upload-button'));
+
+ await waitFor(() => expect(mockUploadCallImage).toHaveBeenCalledWith('call-1', 'user-42', '', 'original.jpg', null, null, 'BASE64PAYLOAD'));
+
+ screen.unmount();
});
- it('should generate filename for camera images', () => {
- const timestamp = Date.now();
- const generatedFilename = `camera_${timestamp}.png`;
+ it('returns to the gallery with a clean form after a successful upload', async () => {
+ const screen = render( );
- expect(generatedFilename).toMatch(/^camera_\d+\.png$/);
+ await pickImageFromLibrary(screen);
+ fireEvent.changeText(screen.getByTestId('image-note-input'), 'Rear entry blocked');
+ fireEvent.press(screen.getByTestId('upload-button'));
+
+ await waitFor(() => expect(screen.queryByTestId('image-note-input')).toBeNull());
+ expect(screen.getByText('Front of structure')).toBeTruthy();
+
+ // The next add starts from the chooser, not from the previous selection.
+ fireEvent.press(screen.getByText('callImages.add'));
+ expect(screen.getByText('callImages.select_from_gallery')).toBeTruthy();
+
+ screen.unmount();
});
- it('should use original filename from gallery images or generate one', () => {
- // Test with filename from asset
- const assetWithFilename = {
- fileName: 'vacation_photo.jpg',
- uri: 'file://path/to/image.jpg'
- };
-
- const filename1 = assetWithFilename.fileName || `image_${Date.now()}.png`;
- expect(filename1).toBe('vacation_photo.jpg');
-
- // Test without filename (generate one)
- const assetWithoutFilename = {
- fileName: null,
- uri: 'file://path/to/image.jpg'
- };
-
- const timestamp = Date.now();
- const filename2 = assetWithoutFilename.fileName || `image_${timestamp}.png`;
- expect(filename2).toMatch(/^image_\d+\.png$/);
+ it('tells the user when the upload fails, and keeps the form and the typed note', async () => {
+ mockUploadCallImage.mockRejectedValue(new Error('upload rejected'));
+ const screen = render( );
+
+ await pickImageFromLibrary(screen);
+ fireEvent.changeText(screen.getByTestId('image-note-input'), 'Rear entry blocked');
+ fireEvent.press(screen.getByTestId('upload-button'));
+
+ await waitFor(() => expect(mockShowToast).toHaveBeenCalledWith('error', 'callImages.upload_error'));
+ expect(mockAlert).not.toHaveBeenCalled();
+ // The form is still there once the failed attempt settles, note and all.
+ expect(screen.getByText('callImages.upload')).toBeTruthy();
+ expect(screen.getByTestId('image-note-input').props.value).toBe('Rear entry blocked');
+ expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: 'Error uploading call image', context: expect.objectContaining({ callId: 'call-1' }) }));
+
+ screen.unmount();
});
- });
- describe('Geolocation Integration', () => {
- it('should include current location when uploading images', () => {
- const mockLocationStore = {
- latitude: 40.7128,
- longitude: -74.0060,
- heading: null,
- accuracy: 10,
- speed: null,
- altitude: null,
- timestamp: Date.now(),
- isBackgroundEnabled: false,
- isMapLocked: false,
- setLocation: jest.fn(),
- setBackgroundEnabled: jest.fn(),
- setMapLocked: jest.fn(),
- };
-
- mockUseLocationStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector(mockLocationStore) : mockLocationStore);
-
- // Simulate the upload logic
- const uploadParams = {
- latitude: mockLocationStore.latitude,
- longitude: mockLocationStore.longitude,
- };
-
- expect(uploadParams.latitude).toBe(40.7128);
- expect(uploadParams.longitude).toBe(-74.0060);
+ it('refuses to upload with no signed in user, and says why', async () => {
+ mockUserId = null;
+ const screen = render( );
+
+ await pickImageFromLibrary(screen);
+ fireEvent.press(screen.getByTestId('upload-button'));
+
+ await waitFor(() => expect(mockShowToast).toHaveBeenCalledWith('error', 'callImages.not_signed_in'));
+ expect(mockUploadCallImage).not.toHaveBeenCalled();
+ expect(mockManipulateAsync).not.toHaveBeenCalled();
+ expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: 'Cannot upload call image without a signed in user' }));
+ // The picked image is kept so the user can retry after signing in.
+ expect(screen.getByTestId('image-note-input')).toBeTruthy();
+
+ screen.unmount();
});
- it('should handle null location gracefully', () => {
- const mockLocationStoreNoLocation = {
- latitude: null,
- longitude: null,
- heading: null,
- accuracy: null,
- speed: null,
- altitude: null,
- timestamp: null,
- isBackgroundEnabled: false,
- isMapLocked: false,
- setLocation: jest.fn(),
- setBackgroundEnabled: jest.fn(),
- setMapLocked: jest.fn(),
- };
-
- mockUseLocationStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector(mockLocationStoreNoLocation) : mockLocationStoreNoLocation);
-
- // Simulate the upload logic
- const uploadParams = {
- latitude: mockLocationStoreNoLocation.latitude,
- longitude: mockLocationStoreNoLocation.longitude,
- };
-
- expect(uploadParams.latitude).toBeNull();
- expect(uploadParams.longitude).toBeNull();
+ it('re-enables the upload button after a failure so the user can retry', async () => {
+ mockUploadCallImage.mockRejectedValueOnce(new Error('upload rejected'));
+ const screen = render( );
+
+ await pickImageFromLibrary(screen);
+ fireEvent.press(screen.getByTestId('upload-button'));
+
+ await waitFor(() => expect(logger.error).toHaveBeenCalled());
+ expect(screen.getByText('callImages.upload')).toBeTruthy();
+
+ fireEvent.press(screen.getByTestId('upload-button'));
+
+ await waitFor(() => expect(mockUploadCallImage).toHaveBeenCalledTimes(2));
+
+ screen.unmount();
+ });
+
+ it('never uploads when the resize step fails', async () => {
+ mockManipulateAsync.mockRejectedValue(new Error('manipulation failed'));
+ const screen = render( );
+
+ await pickImageFromLibrary(screen);
+ fireEvent.press(screen.getByTestId('upload-button'));
+
+ await waitFor(() => expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: 'Error uploading call image' })));
+ expect(mockUploadCallImage).not.toHaveBeenCalled();
+ expect(mockShowToast).toHaveBeenCalledWith('error', 'callImages.upload_error');
+
+ screen.unmount();
});
- it('should only include location when both latitude and longitude are available', () => {
- const mockLocationStorePartial = {
- latitude: 40.7128,
- longitude: null, // Missing longitude
- heading: null,
- accuracy: null,
- speed: null,
- altitude: null,
- timestamp: null,
- isBackgroundEnabled: false,
- isMapLocked: false,
- setLocation: jest.fn(),
- setBackgroundEnabled: jest.fn(),
- setMapLocked: jest.fn(),
- };
-
- mockUseLocationStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector(mockLocationStorePartial) : mockLocationStorePartial);
-
- // In the actual implementation, this would be handled by the API
- // which checks if both latitude and longitude are provided
- const hasCompleteLocation = mockLocationStorePartial.latitude !== null &&
- mockLocationStorePartial.longitude !== null;
-
- expect(hasCompleteLocation).toBe(false);
+ it('shows the uploading label while the upload is in flight', async () => {
+ let resolveUpload: () => void = () => undefined;
+ mockUploadCallImage.mockImplementation(() => new Promise((resolve) => (resolveUpload = resolve)));
+ const screen = render( );
+
+ await pickImageFromLibrary(screen);
+ fireEvent.press(screen.getByTestId('upload-button'));
+
+ await waitFor(() => expect(screen.getByText('common.uploading')).toBeTruthy());
+
+ resolveUpload();
+ await waitFor(() => expect(screen.queryByTestId('image-note-input')).toBeNull());
+
+ screen.unmount();
});
});
- describe('UI Updates for Note Input', () => {
- it('should have testID for image note input', () => {
- const noteInputTestId = 'image-note-input';
- expect(noteInputTestId).toBe('image-note-input');
+ describe('analytics', () => {
+ it('reports the modal opening with what it is showing', () => {
+ const { unmount } = render( );
+
+ expect(mockTrackEvent).toHaveBeenCalledWith('call_images_modal_opened', {
+ callId: 'call-1',
+ hasExistingImages: true,
+ imagesCount: 2,
+ isLoadingImages: false,
+ hasError: false,
+ });
+
+ unmount();
});
- it('should use image_note translation key for placeholder', () => {
- const placeholderKey = 'callImages.image_note';
- expect(placeholderKey).toBe('callImages.image_note');
+ it('reports nothing while the modal is closed', () => {
+ const { unmount } = render( );
+
+ expect(mockTrackEvent).not.toHaveBeenCalled();
+
+ unmount();
});
- it('should maintain the same upload button testID for consistency', () => {
- const uploadButtonTestId = 'upload-button';
- expect(uploadButtonTestId).toBe('upload-button');
+ it('reports an empty gallery as such', () => {
+ setDetailState({ callImages: [] });
+ const { unmount } = render( );
+
+ expect(mockTrackEvent).toHaveBeenCalledWith('call_images_modal_opened', expect.objectContaining({ hasExistingImages: false, imagesCount: 0 }));
+
+ unmount();
});
- });
- describe('UI Layout and Accessibility', () => {
- it('should have full-width input and save button in add image mode', () => {
- // This test verifies the layout structure through the class names
- // In a real test environment, you would use actual rendering
- const inputClassName = 'w-full';
- const buttonClassName = 'w-full';
+ it('reports the error state', () => {
+ setDetailState({ callImages: [], errorImages: 'Network unreachable' });
+ const { unmount } = render( );
- expect(inputClassName).toBe('w-full');
- expect(buttonClassName).toBe('w-full');
+ expect(mockTrackEvent).toHaveBeenCalledWith('call_images_modal_opened', expect.objectContaining({ hasError: true }));
+
+ unmount();
});
- it('should have testIDs for input and button elements', () => {
- const inputTestId = 'image-note-input';
- const buttonTestId = 'upload-button';
+ it('reports once per opening, not once per render', () => {
+ const { rerender, unmount } = render( );
+
+ rerender( );
+ rerender( );
+
+ expect(mockTrackEvent).toHaveBeenCalledTimes(1);
- expect(inputTestId).toBe('image-note-input');
- expect(buttonTestId).toBe('upload-button');
+ unmount();
});
- it('should have fixed bottom section for input and save button', () => {
- // This test verifies the layout structure
- // The fixed bottom section should have border and background styling
- const bottomSectionClasses = 'max-h-20 space-y-2 border-t border-gray-200 bg-white px-4 py-2 dark:border-gray-700 dark:bg-gray-800';
-
- expect(bottomSectionClasses).toContain('max-h-20');
- expect(bottomSectionClasses).toContain('border-t');
- expect(bottomSectionClasses).toContain('bg-white');
- expect(bottomSectionClasses).toContain('dark:bg-gray-800');
- expect(bottomSectionClasses).toContain('px-4');
- expect(bottomSectionClasses).toContain('py-2');
+ it('does not report again as the images finish loading', () => {
+ setDetailState({ callImages: null, isLoadingImages: true });
+ const { rerender, unmount } = render( );
+
+ expect(mockTrackEvent).toHaveBeenCalledTimes(1);
+
+ // Images land: the store data changes under a re-rendering parent. A new onClose
+ // identity stands in for that parent render, since the component is memoized.
+ setDetailState({ callImages: [base64Image, urlImage], isLoadingImages: false });
+ rerender( );
+
+ expect(mockTrackEvent).toHaveBeenCalledTimes(1);
+
+ unmount();
});
- it('should only show fixed bottom section when image is selected', () => {
- // Logic test: bottom section should only render when selectedImage is truthy
- const selectedImage = 'file://path/to/image.jpg';
- const shouldShowBottomSection = Boolean(selectedImage);
+ it('reports again once the modal is closed and reopened', () => {
+ const { rerender, unmount } = render( );
- expect(shouldShowBottomSection).toBe(true);
+ rerender( );
+ rerender( );
- const noSelectedImage = null;
- const shouldNotShowBottomSection = Boolean(noSelectedImage);
+ expect(mockTrackEvent).toHaveBeenCalledTimes(2);
- expect(shouldNotShowBottomSection).toBe(false);
+ unmount();
});
- it('should use flexbox layout for proper spacing', () => {
- // Verify that the main container uses flex-1 for proper layout
- const mainContainerClass = 'flex-1';
- const scrollViewStyle = { flex: 1 };
- const contentContainerStyle = { flexGrow: 1 };
+ it('reports again when it is pointed at a different call', () => {
+ const { rerender, unmount } = render( );
+
+ rerender( );
+
+ expect(mockTrackEvent).toHaveBeenCalledTimes(2);
+ expect(mockTrackEvent).toHaveBeenLastCalledWith('call_images_modal_opened', expect.objectContaining({ callId: 'call-9' }));
- expect(mainContainerClass).toBe('flex-1');
- expect(scrollViewStyle.flex).toBe(1);
- expect(contentContainerStyle.flexGrow).toBe(1);
+ unmount();
});
});
});
diff --git a/src/components/calls/__tests__/dispatch-selection-basic.test.tsx b/src/components/calls/__tests__/dispatch-selection-basic.test.tsx
index 88125d7a..93bd27f8 100644
--- a/src/components/calls/__tests__/dispatch-selection-basic.test.tsx
+++ b/src/components/calls/__tests__/dispatch-selection-basic.test.tsx
@@ -6,71 +6,74 @@ import { DispatchSelectionModal } from '../dispatch-selection-modal';
// Mock dependencies
jest.mock('@/stores/dispatch/store', () => ({
- useDispatchStore: (selector: any) => typeof selector === 'function' ? selector({
- data: {
- users: [],
- groups: [],
- roles: [],
- units: [],
- },
- selection: {
- everyone: false,
- users: [],
- groups: [],
- roles: [],
- units: [],
- },
- isLoading: false,
- error: null,
- searchQuery: '',
- fetchDispatchData: jest.fn(),
- setSelection: jest.fn(),
- toggleEveryone: jest.fn(),
- toggleUser: jest.fn(),
- toggleGroup: jest.fn(),
- toggleRole: jest.fn(),
- toggleUnit: jest.fn(),
- setSearchQuery: jest.fn(),
- clearSelection: jest.fn(),
- getFilteredData: () => ({
- users: [],
- groups: [],
- roles: [],
- units: [],
- }),
- }) : {
- data: {
- users: [],
- groups: [],
- roles: [],
- units: [],
- },
- selection: {
- everyone: false,
- users: [],
- groups: [],
- roles: [],
- units: [],
- },
- isLoading: false,
- error: null,
- searchQuery: '',
- fetchDispatchData: jest.fn(),
- setSelection: jest.fn(),
- toggleEveryone: jest.fn(),
- toggleUser: jest.fn(),
- toggleGroup: jest.fn(),
- toggleRole: jest.fn(),
- toggleUnit: jest.fn(),
- setSearchQuery: jest.fn(),
- clearSelection: jest.fn(),
- getFilteredData: () => ({
- users: [],
- groups: [],
- roles: [],
- units: [],
- }),
- },
+ useDispatchStore: (selector: any) =>
+ typeof selector === 'function'
+ ? selector({
+ data: {
+ users: [],
+ groups: [],
+ roles: [],
+ units: [],
+ },
+ selection: {
+ everyone: false,
+ users: [],
+ groups: [],
+ roles: [],
+ units: [],
+ },
+ isLoading: false,
+ error: null,
+ searchQuery: '',
+ fetchDispatchData: jest.fn(),
+ setSelection: jest.fn(),
+ toggleEveryone: jest.fn(),
+ toggleUser: jest.fn(),
+ toggleGroup: jest.fn(),
+ toggleRole: jest.fn(),
+ toggleUnit: jest.fn(),
+ setSearchQuery: jest.fn(),
+ clearSelection: jest.fn(),
+ getFilteredData: () => ({
+ users: [],
+ groups: [],
+ roles: [],
+ units: [],
+ }),
+ })
+ : {
+ data: {
+ users: [],
+ groups: [],
+ roles: [],
+ units: [],
+ },
+ selection: {
+ everyone: false,
+ users: [],
+ groups: [],
+ roles: [],
+ units: [],
+ },
+ isLoading: false,
+ error: null,
+ searchQuery: '',
+ fetchDispatchData: jest.fn(),
+ setSelection: jest.fn(),
+ toggleEveryone: jest.fn(),
+ toggleUser: jest.fn(),
+ toggleGroup: jest.fn(),
+ toggleRole: jest.fn(),
+ toggleUnit: jest.fn(),
+ setSearchQuery: jest.fn(),
+ clearSelection: jest.fn(),
+ getFilteredData: () => ({
+ users: [],
+ groups: [],
+ roles: [],
+ units: [],
+ }),
+ },
}));
jest.mock('nativewind', () => ({
@@ -96,6 +99,15 @@ jest.mock('@/components/ui/actionsheet', () => {
),
ActionsheetDragIndicator: () => ,
ActionsheetDragIndicatorWrapper: ({ children }: any) => {children} ,
+ // The recipient list is virtualized (FlashList); render every row so the
+ // sheet's content stays reachable in the test tree.
+ ActionsheetFlatList: ({ data, renderItem, keyExtractor, testID }: any) => (
+
+ {(data ?? []).map((item: any, index: number) => (
+ {renderItem({ item, index })}
+ ))}
+
+ ),
};
});
@@ -120,9 +132,7 @@ describe('DispatchSelectionModal', () => {
});
it('should not render when not visible', () => {
- const { queryByText } = render(
-
- );
+ const { queryByText } = render( );
expect(queryByText('calls.select_dispatch_recipients')).toBeNull();
});
@@ -139,4 +149,4 @@ describe('DispatchSelectionModal', () => {
expect(screen.getByText('common.confirm')).toBeTruthy();
expect(screen.getByText('common.cancel')).toBeTruthy();
});
-});
\ No newline at end of file
+});
diff --git a/src/components/calls/__tests__/dispatch-selection-modal.test.tsx b/src/components/calls/__tests__/dispatch-selection-modal.test.tsx
index 19cb8286..88ce741c 100644
--- a/src/components/calls/__tests__/dispatch-selection-modal.test.tsx
+++ b/src/components/calls/__tests__/dispatch-selection-modal.test.tsx
@@ -33,12 +33,8 @@ const mockDispatchStore = {
Roles: [],
},
],
- groups: [
- { GroupId: '1', Name: 'Fire Department', TypeId: 1, Address: '', GroupType: 'Fire' },
- ],
- roles: [
- { UnitRoleId: '1', Name: 'Captain', UnitId: '1' },
- ],
+ groups: [{ GroupId: '1', Name: 'Fire Department', TypeId: 1, Address: '', GroupType: 'Fire' }],
+ roles: [{ UnitRoleId: '1', Name: 'Captain', UnitId: '1' }],
units: [
{
UnitId: '1',
@@ -108,12 +104,8 @@ const mockDispatchStore = {
Roles: [],
},
],
- groups: [
- { GroupId: '1', Name: 'Fire Department', TypeId: 1, Address: '', GroupType: 'Fire' },
- ],
- roles: [
- { UnitRoleId: '1', Name: 'Captain', UnitId: '1' },
- ],
+ groups: [{ GroupId: '1', Name: 'Fire Department', TypeId: 1, Address: '', GroupType: 'Fire' }],
+ roles: [{ UnitRoleId: '1', Name: 'Captain', UnitId: '1' }],
units: [
{
UnitId: '1',
@@ -140,7 +132,7 @@ const mockDispatchStore = {
};
jest.mock('@/stores/dispatch/store', () => ({
- useDispatchStore: jest.fn((selector: any) => typeof selector === 'function' ? selector(mockDispatchStore) : mockDispatchStore),
+ useDispatchStore: jest.fn((selector: any) => (typeof selector === 'function' ? selector(mockDispatchStore) : mockDispatchStore)),
}));
// Mock the color scheme and cssInterop
@@ -164,6 +156,15 @@ jest.mock('@/components/ui/actionsheet', () => {
),
ActionsheetDragIndicator: () => ,
ActionsheetDragIndicatorWrapper: ({ children }: any) => {children} ,
+ // The recipient list is virtualized (FlashList); render every row so the
+ // sheet's content stays reachable in the test tree.
+ ActionsheetFlatList: ({ data, renderItem, keyExtractor, testID }: any) => (
+
+ {(data ?? []).map((item: any, index: number) => (
+ {renderItem({ item, index })}
+ ))}
+
+ ),
};
});
@@ -206,9 +207,7 @@ describe('DispatchSelectionModal', () => {
});
it('should not render when not visible', () => {
- const { queryByText } = render(
-
- );
+ const { queryByText } = render( );
expect(queryByText('calls.select_dispatch_recipients')).toBeNull();
});
diff --git a/src/components/calls/__tests__/full-screen-image-modal.test.tsx b/src/components/calls/__tests__/full-screen-image-modal.test.tsx
index 931b662a..eec98887 100644
--- a/src/components/calls/__tests__/full-screen-image-modal.test.tsx
+++ b/src/components/calls/__tests__/full-screen-image-modal.test.tsx
@@ -1,325 +1,479 @@
+/**
+ * Exercises the real FullScreenImageModal.
+ *
+ * Only dependencies are mocked, and the two that carry behaviour (reanimated shared
+ * values and the gesture builders) are mocked as thin, faithful plumbing: shared values
+ * are real mutable objects and the gesture callbacks registered by the component are
+ * captured so the component's own clamping/reset logic runs for real.
+ */
+import { act, fireEvent, render } from '@testing-library/react-native';
import React from 'react';
-import { render, fireEvent } from '@testing-library/react-native';
-import { useTranslation } from 'react-i18next';
+import { Dimensions, StyleSheet } from 'react-native';
-// Mock dependencies
jest.mock('react-i18next', () => ({
- useTranslation: () => ({
- t: (key: string) => key,
- }),
+ useTranslation: () => ({ t: (key: string) => key }),
}));
+jest.mock('lucide-react-native', () => {
+ const React = require('react');
+ const { View } = require('react-native');
+ const icon = React.forwardRef((props: Record, ref: unknown) => React.createElement(View, { ...props, ref }));
+ return new Proxy({}, { get: () => icon });
+});
+
+// gluestack's Modal only mounts its children while isOpen; the backdrop is pressable.
+jest.mock('@/components/ui/modal', () => {
+ const React = require('react');
+ const { Pressable, View } = require('react-native');
+ return {
+ Modal: ({ children, isOpen }: any) => (isOpen ? React.createElement(View, { testID: 'modal' }, children) : null),
+ ModalBackdrop: ({ onPress, ...props }: any) => React.createElement(Pressable, { testID: 'modal-backdrop', onPress, ...props }),
+ ModalContent: ({ children, ...props }: any) => React.createElement(View, { testID: 'modal-content', ...props }, children),
+ };
+});
+
+jest.mock('@/components/ui/image', () => {
+ const React = require('react');
+ const { Image: RNImage } = require('react-native');
+ return { Image: (props: Record) => React.createElement(RNImage, props) };
+});
+
jest.mock('react-native-reanimated', () => {
- const View = require('react-native/Libraries/Components/View/View');
+ const React = require('react');
+ const { View } = require('react-native');
+
+ const clampedLerp = (value: number, input: number[], output: number[]) => {
+ const [inMin, inMax] = input;
+ const [outMin, outMax] = output;
+ if (value <= inMin) return outMin;
+ if (value >= inMax) return outMax;
+ return outMin + ((value - inMin) / (inMax - inMin)) * (outMax - outMin);
+ };
+
+ // Every function handed to runOnJS, so tests can check it is a stable JS-thread
+ // reference rather than a closure minted inside the worklet.
+ const runOnJSTargets: unknown[] = [];
+
return {
+ __esModule: true,
+ __runOnJSTargets: runOnJSTargets,
default: {
- View,
+ View: ({ children, ...props }: any) => React.createElement(View, props, children),
+ },
+ // Real mutable boxes so the component's reads/writes behave like shared values.
+ useSharedValue: (initial: number) => {
+ const ref = React.useRef({ value: initial });
+ return ref.current;
+ },
+ useAnimatedStyle: (factory: () => Record) => factory(),
+ withTiming: (value: number) => value,
+ interpolate: clampedLerp,
+ runOnJS: (fn: (...args: unknown[]) => unknown) => {
+ runOnJSTargets.push(fn);
+ return fn;
},
- useSharedValue: jest.fn(() => ({ value: 0 })),
- useAnimatedStyle: jest.fn(() => ({})),
- withTiming: jest.fn((value) => value),
- interpolate: jest.fn(),
- runOnJS: jest.fn((fn) => fn),
};
});
-jest.mock('react-native-gesture-handler', () => ({
- Gesture: {
- Pinch: jest.fn(() => ({
- onUpdate: jest.fn().mockReturnThis(),
- onEnd: jest.fn().mockReturnThis(),
- })),
- Pan: jest.fn(() => ({
- onUpdate: jest.fn().mockReturnThis(),
- onEnd: jest.fn().mockReturnThis(),
- })),
- Tap: jest.fn(() => ({
- numberOfTaps: jest.fn().mockReturnThis(),
- onEnd: jest.fn().mockReturnThis(),
- })),
- Simultaneous: jest.fn(),
- },
- GestureDetector: ({ children }: any) => children,
-}));
+// Captures the gesture callbacks the component registers so tests can drive them.
+jest.mock('react-native-gesture-handler', () => {
+ const captured: Record void; end?: () => void }> = {
+ pinch: {},
+ pan: {},
+ tap: {},
+ };
-jest.mock('react-native-safe-area-context', () => ({
- useSafeAreaInsets: () => ({ top: 44, bottom: 34, left: 0, right: 0 }),
-}));
+ const build = (kind: string) => {
+ const gesture: any = {
+ onUpdate: (fn: (e: any) => void) => {
+ captured[kind].update = fn;
+ return gesture;
+ },
+ onEnd: (fn: () => void) => {
+ captured[kind].end = fn;
+ return gesture;
+ },
+ numberOfTaps: () => gesture,
+ };
+ return gesture;
+ };
-// Create MockFullScreenImageModal to avoid CSS interop issues
-interface FullScreenImageModalProps {
- isOpen: boolean;
- onClose: () => void;
- imageSource: { uri: string };
- imageName?: string;
-}
-
-const MockFullScreenImageModal: React.FC = ({
- isOpen,
- onClose,
- imageSource,
- imageName,
-}) => {
- const { t } = useTranslation();
-
- if (!isOpen) return null;
-
- // Mock React Native components for testing
- const View = (props: any) => React.createElement('div', { testID: props.testID, ...props });
- const TouchableOpacity = (props: any) => React.createElement('button', {
- testID: props.testID,
- onPress: props.onPress,
- onClick: props.onPress,
- ...props
- });
- const Image = (props: any) => React.createElement('img', {
- testID: props.testID,
- src: props.source?.uri,
- alt: props.alt,
- ...props
- });
+ return {
+ __captured: captured,
+ Gesture: {
+ Pinch: () => build('pinch'),
+ Pan: () => build('pan'),
+ Tap: () => build('tap'),
+ Simultaneous: (...gestures: unknown[]) => gestures[0],
+ },
+ GestureDetector: ({ children }: any) => children,
+ };
+});
+
+import FullScreenImageModal from '../full-screen-image-modal';
- return React.createElement(View, { testID: 'full-screen-modal' }, [
- React.createElement(View, { testID: 'modal-backdrop', key: 'backdrop' }),
- React.createElement(View, { testID: 'modal-content', key: 'content' }, [
- React.createElement(View, { testID: 'close-button-container', key: 'close-container' },
- React.createElement(TouchableOpacity, {
- testID: 'close-button',
- key: 'close-button',
- onPress: onClose
- }, 'Close')
- ),
- React.createElement(View, { testID: 'image-container', key: 'image-container' },
- React.createElement(Image, {
- testID: 'full-screen-image',
- key: 'image',
- source: imageSource,
- alt: imageName || t('callImages.image_alt')
- })
- )
- ])
- ]);
+const { __captured: gestures } = require('react-native-gesture-handler') as {
+ __captured: Record void; end?: () => void }>;
};
-// Mock the actual component
-jest.mock('../full-screen-image-modal', () => ({
- __esModule: true,
- default: MockFullScreenImageModal,
-}));
+const { __runOnJSTargets: runOnJSTargets } = require('react-native-reanimated') as { __runOnJSTargets: unknown[] };
-describe('FullScreenImageModal', () => {
- const defaultProps = {
- isOpen: true,
- onClose: jest.fn(),
- imageSource: { uri: 'https://example.com/image.jpg' },
- imageName: 'Test Image',
+const { width: screenWidth, height: screenHeight } = Dimensions.get('window');
+
+const defaultProps = {
+ isOpen: true,
+ onClose: jest.fn(),
+ imageSource: { uri: 'https://example.com/image.jpg' },
+ imageName: 'Test Image',
+};
+
+type RenderedNode = { type: string; props: Record; children: (RenderedNode | string)[] | null };
+
+/** The rendered wrapper around the element carrying `testID` — i.e. the animated container. */
+const wrapperOf = (tree: any, testID: string): RenderedNode => {
+ const roots: RenderedNode[] = Array.isArray(tree) ? tree : [tree];
+ let found: RenderedNode | undefined;
+
+ const walk = (node: RenderedNode, parent?: RenderedNode) => {
+ if (found || typeof node !== 'object' || node === null) return;
+ if (node.props?.testID === testID && parent) {
+ found = parent;
+ return;
+ }
+ (node.children ?? []).forEach((child) => (typeof child === 'object' ? walk(child, node) : undefined));
};
+ roots.forEach((root) => walk(root));
+ if (!found) throw new Error(`No rendered wrapper found around testID "${testID}"`);
+ return found;
+};
+
+/** The transform handed to the animated image container: [{scale},{translateX},{translateY}]. */
+const readTransform = (tree: any) => {
+ const style = StyleSheet.flatten(wrapperOf(tree, 'full-screen-image').props.style) as { transform: Record[] };
+ return Object.assign({}, ...style.transform) as { scale: number; translateX: number; translateY: number };
+};
+
+const readCloseButtonOpacity = (tree: any) => (StyleSheet.flatten(wrapperOf(tree, 'close-button').props.style) as { opacity: number }).opacity;
+
+describe('FullScreenImageModal', () => {
beforeEach(() => {
jest.clearAllMocks();
+ runOnJSTargets.length = 0;
});
- describe('Basic functionality', () => {
- it('renders correctly when open', () => {
- const { getByTestId } = render( );
- expect(getByTestId('full-screen-modal')).toBeTruthy();
+ describe('image source resolution', () => {
+ it('hands the remote URL it was given straight to the image', () => {
+ const { getByTestId, unmount } = render( );
+
+ expect(getByTestId('full-screen-image').props.source).toEqual({ uri: 'https://example.com/image.jpg' });
+
+ unmount();
});
- it('does not render when closed', () => {
- const { queryByTestId } = render( );
- expect(queryByTestId('full-screen-modal')).toBeFalsy();
+ it('passes a base64 data payload through untouched', () => {
+ const dataUri = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA';
+ const { getByTestId, unmount } = render( );
+
+ expect(getByTestId('full-screen-image').props.source).toEqual({ uri: dataUri });
+
+ unmount();
});
- it('displays the image correctly', () => {
- const { getByTestId } = render( );
+ it('renders the image at the full screen width and contains it', () => {
+ const { getByTestId, unmount } = render( );
+
const image = getByTestId('full-screen-image');
- expect(image).toBeTruthy();
- expect(image.props.src).toBe('https://example.com/image.jpg');
- expect(image.props.alt).toBe('Test Image');
+ expect(StyleSheet.flatten(image.props.style)).toMatchObject({ width: screenWidth, maxWidth: screenWidth });
+ expect(image.props.contentFit).toBe('contain');
+
+ unmount();
});
- it('calls onClose when close button is pressed', () => {
- const mockOnClose = jest.fn();
- const { getByTestId } = render( );
+ it('labels the image with the supplied name', () => {
+ const { getByTestId, unmount } = render( );
- const closeButton = getByTestId('close-button');
- fireEvent.press(closeButton);
+ expect(getByTestId('full-screen-image').props.alt).toBe('Test Image');
- expect(mockOnClose).toHaveBeenCalledTimes(1);
+ unmount();
});
- it('uses fallback alt text when no image name provided', () => {
- const propsWithoutName = { ...defaultProps, imageName: undefined };
- const { getByTestId } = render( );
+ it('falls back to the translated alt text when no name is supplied', () => {
+ const { getByTestId, unmount } = render( );
- const image = getByTestId('full-screen-image');
- expect(image.props.alt).toBe('callImages.image_alt');
+ expect(getByTestId('full-screen-image').props.alt).toBe('callImages.image_alt');
+
+ unmount();
+ });
+
+ it('falls back to the translated alt text for an empty name', () => {
+ const { getByTestId, unmount } = render( );
+
+ expect(getByTestId('full-screen-image').props.alt).toBe('callImages.image_alt');
+
+ unmount();
});
});
- describe('Image source handling', () => {
- it('handles base64 image sources', () => {
- const base64Props = {
- ...defaultProps,
- imageSource: { uri: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA...' },
- };
+ describe('visibility and closing', () => {
+ it('mounts nothing while closed', () => {
+ const { queryByTestId, unmount } = render( );
- const { getByTestId } = render( );
- const image = getByTestId('full-screen-image');
- expect(image.props.src).toBe('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA...');
+ expect(queryByTestId('modal')).toBeNull();
+ expect(queryByTestId('full-screen-image')).toBeNull();
+
+ unmount();
});
- it('handles URL image sources', () => {
- const urlProps = {
- ...defaultProps,
- imageSource: { uri: 'https://example.com/test-image.jpg' },
- };
+ it('closes when the close button is pressed', () => {
+ const onClose = jest.fn();
+ const { getByTestId, unmount } = render( );
- const { getByTestId } = render( );
- const image = getByTestId('full-screen-image');
- expect(image.props.src).toBe('https://example.com/test-image.jpg');
+ fireEvent.press(getByTestId('close-button'));
+
+ expect(onClose).toHaveBeenCalledTimes(1);
+
+ unmount();
});
- it('handles empty image source gracefully', () => {
- const emptyProps = {
- ...defaultProps,
- imageSource: { uri: '' },
- };
+ it('closes when the backdrop is pressed', () => {
+ const onClose = jest.fn();
+ const { getByTestId, unmount } = render( );
- const { getByTestId } = render( );
- const image = getByTestId('full-screen-image');
- expect(image.props.src).toBe('');
+ fireEvent.press(getByTestId('modal-backdrop'));
+
+ expect(onClose).toHaveBeenCalledTimes(1);
+
+ unmount();
+ });
+
+ it('does not close on its own while simply open', () => {
+ const onClose = jest.fn();
+ const { unmount } = render( );
+
+ expect(onClose).not.toHaveBeenCalled();
+
+ unmount();
});
});
- describe('Gesture handling capabilities', () => {
- it('should have gesture detector in component structure', () => {
- const { GestureDetector } = require('react-native-gesture-handler');
+ describe('pinch to zoom', () => {
+ it('starts unzoomed and unpanned', () => {
+ const { toJSON, unmount } = render( );
- render( );
+ expect(readTransform(toJSON())).toEqual({ scale: 1, translateX: 0, translateY: 0 });
- // Verify that GestureDetector is available for use
- expect(typeof GestureDetector).toBe('function');
+ unmount();
});
- it('should have access to gesture creation functions', () => {
- const { Gesture } = require('react-native-gesture-handler');
+ it('applies the pinch factor to the current scale', () => {
+ const { rerender, toJSON, unmount } = render( );
+
+ gestures.pinch.update!({ scale: 2 });
+ rerender( );
- // Verify that Gesture creation functions are available
- expect(typeof Gesture.Pinch).toBe('function');
- expect(typeof Gesture.Pan).toBe('function');
- expect(typeof Gesture.Tap).toBe('function');
- expect(typeof Gesture.Simultaneous).toBe('function');
+ expect(readTransform(toJSON()).scale).toBe(2);
+
+ unmount();
});
- });
- describe('Animation capabilities', () => {
- it('should have access to reanimated hooks', () => {
- const { useSharedValue, useAnimatedStyle } = require('react-native-reanimated');
+ it('never zooms past 5x however hard the user pinches', () => {
+ const { rerender, toJSON, unmount } = render( );
- // Verify that animation hooks are available for use
- expect(typeof useSharedValue).toBe('function');
- expect(typeof useAnimatedStyle).toBe('function');
+ gestures.pinch.update!({ scale: 50 });
+ rerender( );
+
+ expect(readTransform(toJSON()).scale).toBe(5);
+
+ unmount();
});
- it('should have access to animation utilities', () => {
- const { withTiming, interpolate } = require('react-native-reanimated');
+ it('never shrinks the image below its natural size', () => {
+ const { rerender, toJSON, unmount } = render( );
+
+ gestures.pinch.update!({ scale: 0.1 });
+ rerender( );
- // Verify that animation utilities are available
- expect(typeof withTiming).toBe('function');
- expect(typeof interpolate).toBe('function');
+ expect(readTransform(toJSON()).scale).toBe(1);
+
+ unmount();
+ });
+
+ it('accumulates across successive pinches instead of restarting from 1', () => {
+ const { rerender, toJSON, unmount } = render( );
+
+ gestures.pinch.update!({ scale: 2 });
+ gestures.pinch.end!();
+ gestures.pinch.update!({ scale: 2 });
+ rerender( );
+
+ expect(readTransform(toJSON()).scale).toBe(4);
+
+ unmount();
});
});
- describe('Modal state management', () => {
- it('should reset animation values when modal opens', () => {
- const { rerender } = render( );
+ describe('pan', () => {
+ it('refuses to move an image that is not zoomed in', () => {
+ const { rerender, toJSON, unmount } = render( );
+
+ gestures.pan.update!({ translationX: 400, translationY: 400 });
+ rerender( );
- // Open the modal
- rerender( );
+ expect(readTransform(toJSON())).toMatchObject({ translateX: 0, translateY: 0 });
- // Verify the modal is rendered
- const { getByTestId } = render( );
- expect(getByTestId('full-screen-modal')).toBeTruthy();
+ unmount();
});
- it('should handle modal close correctly', () => {
- const mockOnClose = jest.fn();
- const { getByTestId } = render( );
+ it('moves a zoomed image by the drag distance', () => {
+ const { rerender, toJSON, unmount } = render( );
- const closeButton = getByTestId('close-button');
- fireEvent.press(closeButton);
+ gestures.pinch.update!({ scale: 2 });
+ gestures.pinch.end!();
+ gestures.pan.update!({ translationX: 40, translationY: 60 });
+ rerender( );
- expect(mockOnClose).toHaveBeenCalledTimes(1);
+ expect(readTransform(toJSON())).toMatchObject({ translateX: 40, translateY: 60 });
+
+ unmount();
});
- });
- describe('Accessibility', () => {
- it('should have testID for close button', () => {
- const { getByTestId } = render( );
- expect(getByTestId('close-button')).toBeTruthy();
+ it('keeps the image edges from being dragged inside the viewport', () => {
+ const { rerender, toJSON, unmount } = render( );
+
+ gestures.pinch.update!({ scale: 2 });
+ gestures.pinch.end!();
+ gestures.pan.update!({ translationX: 999999, translationY: 999999 });
+ rerender( );
+
+ // At 2x the image overflows by one screen, so it may travel half a screen each way.
+ expect(readTransform(toJSON())).toMatchObject({ translateX: screenWidth / 2, translateY: screenHeight / 2 });
+
+ unmount();
});
- it('should have testID for full screen image', () => {
- const { getByTestId } = render( );
- expect(getByTestId('full-screen-image')).toBeTruthy();
+ it('clamps against the window it is currently displayed in, not the one it started in', () => {
+ const original = Dimensions.get('window');
+ const { rerender, toJSON, unmount } = render( );
+
+ // Rotate: the window swaps its width and height.
+ act(() => {
+ Dimensions.set({ window: { ...original, width: original.height, height: original.width } });
+ });
+
+ gestures.pinch.update!({ scale: 2 });
+ gestures.pinch.end!();
+ gestures.pan.update!({ translationX: 999999, translationY: 999999 });
+ rerender( );
+
+ expect(readTransform(toJSON())).toMatchObject({ translateX: original.height / 2, translateY: original.width / 2 });
+
+ act(() => {
+ Dimensions.set({ window: original });
+ });
+ unmount();
});
- it('should provide alt text for the image', () => {
- const { getByTestId } = render( );
- const image = getByTestId('full-screen-image');
- expect(image.props.alt).toBe('Test Image');
+ it('pulls an off-centre image back into bounds when the user zooms back out', () => {
+ const { rerender, toJSON, unmount } = render( );
+
+ gestures.pinch.update!({ scale: 3 });
+ gestures.pinch.end!();
+ gestures.pan.update!({ translationX: 999999, translationY: 999999 });
+ gestures.pan.end!();
+ gestures.pinch.update!({ scale: 1 / 3 });
+ gestures.pinch.end!();
+ rerender( );
+
+ expect(readTransform(toJSON())).toEqual({ scale: 1, translateX: 0, translateY: 0 });
+
+ unmount();
});
});
- describe('Edge cases', () => {
- it('should handle undefined image name gracefully', () => {
- const propsWithUndefinedName = { ...defaultProps, imageName: undefined };
- const { getByTestId } = render( );
+ describe('double tap', () => {
+ it('zooms to 2x', () => {
+ const { rerender, toJSON, unmount } = render( );
- const image = getByTestId('full-screen-image');
- expect(image.props.alt).toBe('callImages.image_alt');
+ gestures.tap.end!();
+ rerender( );
+
+ expect(readTransform(toJSON()).scale).toBe(2);
+
+ unmount();
});
- it('should handle empty string image name', () => {
- const propsWithEmptyName = { ...defaultProps, imageName: '' };
- const { getByTestId } = render( );
+ it('returns to fit, and to centre, on the next double tap', () => {
+ const { rerender, toJSON, unmount } = render( );
- const image = getByTestId('full-screen-image');
- expect(image.props.alt).toBe('callImages.image_alt');
+ gestures.tap.end!();
+ gestures.pan.update!({ translationX: 999999, translationY: 999999 });
+ gestures.pan.end!();
+ gestures.tap.end!();
+ rerender( );
+
+ expect(readTransform(toJSON())).toEqual({ scale: 1, translateX: 0, translateY: 0 });
+
+ unmount();
});
- it('should handle invalid image URI gracefully', () => {
- const propsWithInvalidUri = {
- ...defaultProps,
- imageSource: { uri: 'invalid-uri' },
- };
+ it('hands runOnJS one stable function instead of a fresh worklet-local closure each tap', () => {
+ const { rerender, unmount } = render( );
- const { getByTestId } = render( );
- const image = getByTestId('full-screen-image');
- expect(image.props.src).toBe('invalid-uri');
+ gestures.tap.end!();
+ rerender( );
+ gestures.tap.end!();
+
+ // Reanimated cannot transfer a closure created inside the worklet to the JS
+ // thread; the same JS-thread reference must be scheduled every time.
+ expect(runOnJSTargets.length).toBeGreaterThanOrEqual(2);
+ expect(runOnJSTargets[runOnJSTargets.length - 1]).toBe(runOnJSTargets[runOnJSTargets.length - 2]);
+
+ unmount();
});
});
- describe('Component structure', () => {
- it('should render modal backdrop', () => {
- const { getByTestId } = render( );
- expect(getByTestId('modal-backdrop')).toBeTruthy();
- });
+ describe('reopening', () => {
+ it('discards the previous zoom and pan when the modal is reopened', () => {
+ const { rerender, toJSON, unmount } = render( );
+
+ gestures.pinch.update!({ scale: 3 });
+ gestures.pinch.end!();
+ gestures.pan.update!({ translationX: 120, translationY: 90 });
+ gestures.pan.end!();
+ rerender( );
+ expect(readTransform(toJSON()).scale).toBe(3);
+
+ rerender( );
+ rerender( );
+ // On device the reset effect drives the shared values straight into the native
+ // animation; here one more render is needed to observe the values it wrote.
+ rerender( );
+
+ expect(readTransform(toJSON())).toEqual({ scale: 1, translateX: 0, translateY: 0 });
- it('should render modal content', () => {
- const { getByTestId } = render( );
- expect(getByTestId('modal-content')).toBeTruthy();
+ unmount();
});
+ });
+
+ describe('close button visibility', () => {
+ it('shows the close button at full opacity when the image is not zoomed', () => {
+ const { toJSON, unmount } = render( );
+
+ expect(readCloseButtonOpacity(toJSON())).toBe(1);
- it('should render close button container', () => {
- const { getByTestId } = render( );
- expect(getByTestId('close-button-container')).toBeTruthy();
+ unmount();
});
- it('should render image container', () => {
- const { getByTestId } = render( );
- expect(getByTestId('image-container')).toBeTruthy();
+ it('fades the close button out of the way once the image is zoomed', () => {
+ const { rerender, toJSON, unmount } = render( );
+
+ gestures.pinch.update!({ scale: 4 });
+ rerender( );
+
+ expect(readCloseButtonOpacity(toJSON())).toBeLessThan(1);
+
+ unmount();
});
});
});
diff --git a/src/components/calls/call-card.tsx b/src/components/calls/call-card.tsx
index db9b1edc..e6c74e52 100644
--- a/src/components/calls/call-card.tsx
+++ b/src/components/calls/call-card.tsx
@@ -1,11 +1,10 @@
import { AlertTriangle, MapPin, Phone, Timer } from 'lucide-react-native';
-import React, { useEffect, useRef } from 'react';
+import React, { useEffect, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
-import { Animated, Platform, ScrollView, StyleSheet } from 'react-native';
+import { Animated, ScrollView } from 'react-native';
import { Box } from '@/components/ui/box';
import { HStack } from '@/components/ui/hstack';
-import { HtmlRenderer } from '@/components/ui/html-renderer';
import { Icon } from '@/components/ui/icon';
import { Text } from '@/components/ui/text';
import { VStack } from '@/components/ui/vstack';
@@ -13,6 +12,7 @@ import { getTimeAgoUtc, invertColor } from '@/lib/utils';
import { type CallPriorityResultData } from '@/models/v4/callPriorities/callPriorityResultData';
import type { CallResultData } from '@/models/v4/calls/callResultData';
import type { DispatchedEventResultData } from '@/models/v4/calls/dispatchedEventResultData';
+import { stripHtml } from '@/utils/strip-html';
function getColor(call: CallResultData, priority: CallPriorityResultData | undefined) {
if (!call) {
@@ -39,6 +39,10 @@ export const CallCard: React.FC = React.memo(({ call, priority, s
const textColor = invertColor(getColor(call, priority), true);
const pulseAnim = useRef(new Animated.Value(1)).current;
const destinationLabel = call.DestinationName || call.DestinationAddress || '';
+ // Nature is server-authored HTML; render it as stripped plain text here — a
+ // WebView-backed HtmlRenderer per list row is far too heavy. The call detail
+ // screen keeps the full HTML rendering.
+ const natureText = useMemo(() => stripHtml(call.Nature), [call.Nature]);
useEffect(() => {
if (isTimerOverdue) {
@@ -174,20 +178,13 @@ export const CallCard: React.FC = React.memo(({ call, priority, s
{/* Nature of Call */}
- {call.Nature ? (
- // Android's WebView claims the touch stream (requestDisallowInterceptTouchEvent),
- // so a drag starting on it never reaches the surrounding list — kill its pointer
- // events there and let the list scroll. iOS nests scrolling fine, leave it alone.
-
-
+ {natureText ? (
+
+
+ {natureText}
+
) : null}
);
});
-const styles = StyleSheet.create({
- container: {
- width: '100%',
- backgroundColor: 'transparent',
- },
-});
diff --git a/src/components/calls/call-files-modal.tsx b/src/components/calls/call-files-modal.tsx
index 35c150f2..b6d885f4 100644
--- a/src/components/calls/call-files-modal.tsx
+++ b/src/components/calls/call-files-modal.tsx
@@ -17,6 +17,7 @@ import { Spinner } from '@/components/ui/spinner';
import { Text } from '@/components/ui/text';
import { VStack } from '@/components/ui/vstack';
import { useAnalytics } from '@/hooks/use-analytics';
+import { logger } from '@/lib/logging';
import { type CallFileResultData } from '@/models/v4/callFiles/callFileResultData';
import { useCallDetailStore } from '@/stores/calls/detail-store';
@@ -153,7 +154,7 @@ export const CallFilesModal: React.FC = ({ isOpen, onClose,
dialogTitle: file.Name || file.FileName,
});
} else {
- Alert.alert(t('calls.files.share_error'), 'Sharing is not available on this device');
+ Alert.alert(t('calls.files.share_error'), t('calls.files.sharing_unavailable'));
}
setDownloadingFiles((prev) => {
@@ -162,8 +163,8 @@ export const CallFilesModal: React.FC = ({ isOpen, onClose,
return newState;
});
} catch (error) {
- console.error('Error downloading file:', error);
- Alert.alert(t('calls.files.open_error'), error instanceof Error ? error.message : 'Unknown error occurred');
+ logger.error({ message: 'Error downloading call file', context: { error, callId, fileId: file.Id } });
+ Alert.alert(t('calls.files.open_error'), error instanceof Error ? error.message : t('common.unknown_error'));
setDownloadingFiles((prev) => {
const newState = { ...prev };
delete newState[file.Id];
@@ -193,7 +194,7 @@ export const CallFilesModal: React.FC = ({ isOpen, onClose,
{formatFileSize(file.Size)}
- {file.Timestamp && {formatDate(file.Timestamp)} }
+ {file.Timestamp ? {formatDate(file.Timestamp)} : null}
@@ -254,7 +255,7 @@ export const CallFilesModal: React.FC = ({ isOpen, onClose,
return (
<>
- {isOpen && (
+ {isOpen ? (
= ({ isOpen, onClose,
- )}
+ ) : null}
>
);
};
diff --git a/src/components/calls/call-images-modal.tsx b/src/components/calls/call-images-modal.tsx
index e4998453..8a13fe40 100644
--- a/src/components/calls/call-images-modal.tsx
+++ b/src/components/calls/call-images-modal.tsx
@@ -4,19 +4,22 @@ import * as ImageManipulator from 'expo-image-manipulator';
import * as ImagePicker from 'expo-image-picker';
import { CameraIcon, ChevronLeftIcon, ChevronRightIcon, ImageIcon, PlusIcon, X } from 'lucide-react-native';
import { useColorScheme } from 'nativewind';
-import React, { memo, useCallback, useEffect, useMemo, useState } from 'react';
+import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
-import { Keyboard, Modal, SafeAreaView, StyleSheet, TouchableOpacity, View } from 'react-native';
+import { Keyboard, Modal, StyleSheet, TouchableOpacity, View } from 'react-native';
import { KeyboardStickyView } from 'react-native-keyboard-controller';
+import { SafeAreaView } from 'react-native-safe-area-context';
import { Loading } from '@/components/common/loading';
import ZeroState from '@/components/common/zero-state';
import { useAnalytics } from '@/hooks/use-analytics';
import { useAuthStore } from '@/lib';
+import { logger } from '@/lib/logging';
import { isIOS } from '@/lib/platform';
import { type CallFileResultData } from '@/models/v4/callFiles/callFileResultData';
import { useLocationStore } from '@/stores/app/location-store';
import { useCallDetailStore } from '@/stores/calls/detail-store';
+import { useToastStore } from '@/stores/toast/store';
import { Box } from '../ui/box';
import { Button, ButtonIcon, ButtonText } from '../ui/button';
@@ -39,6 +42,7 @@ const CallImagesModal: React.FC = ({ isOpen, onClose, call
const { colorScheme } = useColorScheme();
const latitude = useLocationStore((state) => state.latitude);
const longitude = useLocationStore((state) => state.longitude);
+ const showToast = useToastStore((state) => state.showToast);
const isDark = colorScheme === 'dark';
@@ -90,17 +94,27 @@ const CallImagesModal: React.FC = ({ isOpen, onClose, call
};
}, [isOpen, callId, fetchCallImages, clearImages]);
- // Track when call images modal is opened/rendered
+ // Track when the call images modal is opened — once per open, not again as the
+ // loading flag flips or the images arrive.
+ const trackedOpenForCall = useRef(null);
useEffect(() => {
- if (isOpen) {
- trackEvent('call_images_modal_opened', {
- callId: callId,
- hasExistingImages: validImages.length > 0,
- imagesCount: validImages.length,
- isLoadingImages: isLoadingImages,
- hasError: !!errorImages,
- });
+ if (!isOpen) {
+ trackedOpenForCall.current = null;
+ return;
+ }
+
+ if (trackedOpenForCall.current === callId) {
+ return;
}
+
+ trackedOpenForCall.current = callId;
+ trackEvent('call_images_modal_opened', {
+ callId: callId,
+ hasExistingImages: validImages.length > 0,
+ imagesCount: validImages.length,
+ isLoadingImages: isLoadingImages,
+ hasError: !!errorImages,
+ });
}, [isOpen, trackEvent, callId, validImages.length, isLoadingImages, errorImages]);
// Reset active index when valid images change
@@ -117,7 +131,7 @@ const CallImagesModal: React.FC = ({ isOpen, onClose, call
if (isIOS) {
const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (permissionResult.status !== 'granted') {
- alert(t('common.permission_denied'));
+ showToast('error', t('common.permission_denied'));
return;
}
}
@@ -134,8 +148,8 @@ const CallImagesModal: React.FC = ({ isOpen, onClose, call
setSelectedImageInfo({ uri: asset.uri, filename });
}
} catch (error) {
- console.error('Error selecting image from library:', error);
- alert(t('callImages.error_selecting_image'));
+ logger.error({ message: 'Error selecting image from library', context: { error } });
+ showToast('error', t('callImages.error_selecting_image'));
}
};
@@ -143,7 +157,7 @@ const CallImagesModal: React.FC = ({ isOpen, onClose, call
try {
const permissionResult = await ImagePicker.requestCameraPermissionsAsync();
if (permissionResult.status !== 'granted') {
- alert(t('common.permission_denied'));
+ showToast('error', t('common.permission_denied'));
return;
}
const result = await ImagePicker.launchCameraAsync({
@@ -158,14 +172,21 @@ const CallImagesModal: React.FC = ({ isOpen, onClose, call
setSelectedImageInfo({ uri: asset.uri, filename });
}
} catch (error) {
- console.error('Error capturing image from camera:', error);
- alert(t('callImages.error_capturing_image'));
+ logger.error({ message: 'Error capturing image from camera', context: { error } });
+ showToast('error', t('callImages.error_capturing_image'));
}
};
const handleUploadImage = async () => {
if (!selectedImageInfo) return;
+ const userId = useAuthStore.getState().userId;
+ if (!userId) {
+ logger.error({ message: 'Cannot upload call image without a signed in user', context: { callId } });
+ showToast('error', t('callImages.not_signed_in'));
+ return;
+ }
+
setIsUploading(true);
try {
// Manipulate image to ensure PNG format and proper compression
@@ -189,7 +210,7 @@ const CallImagesModal: React.FC = ({ isOpen, onClose, call
await uploadCallImage(
callId,
- useAuthStore.getState().userId!,
+ userId,
newImageNote || '', // Use note for the note field
selectedImageInfo.filename, // Use filename for the name field
currentLatitude, // Current latitude
@@ -201,7 +222,8 @@ const CallImagesModal: React.FC = ({ isOpen, onClose, call
setIsAddingImage(false);
Keyboard.dismiss();
} catch (error) {
- console.error('Error uploading image:', error);
+ logger.error({ message: 'Error uploading call image', context: { error, callId } });
+ showToast('error', t('callImages.upload_error'));
} finally {
setIsUploading(false);
}
@@ -223,7 +245,7 @@ const CallImagesModal: React.FC = ({ isOpen, onClose, call
}, [onClose]);
const handleImageError = (itemId: string, error: any) => {
- console.error(`Image failed to load for ${itemId}:`, error);
+ logger.warn({ message: 'Call image failed to load', context: { itemId, error } });
setImageErrors((prev) => new Set([...prev, itemId]));
};
@@ -264,11 +286,11 @@ const CallImagesModal: React.FC = ({ isOpen, onClose, call
{t('callImages.failed_to_load')}
- {item.Url && (
+ {item.Url ? (
URL: {item.Url}
- )}
+ ) : null}
{item.Name || ''}
@@ -436,7 +458,7 @@ const CallImagesModal: React.FC = ({ isOpen, onClose, call
{t('callImages.add')}
) : null}
-
+
diff --git a/src/components/calls/call-notes-modal.tsx b/src/components/calls/call-notes-modal.tsx
index 478d1bc2..17cc4f09 100644
--- a/src/components/calls/call-notes-modal.tsx
+++ b/src/components/calls/call-notes-modal.tsx
@@ -7,6 +7,7 @@ import { KeyboardAvoidingView } from 'react-native-keyboard-controller';
import { useAnalytics } from '@/hooks/use-analytics';
import { useAuthStore } from '@/lib/auth';
+import { logger } from '@/lib/logging';
import { useCallDetailStore } from '@/stores/calls/detail-store';
import { Loading } from '../common/loading';
@@ -77,7 +78,7 @@ const CallNotesModal = ({ isOpen, onClose, callId }: CallNotesModalProps) => {
setNewNote('');
Keyboard.dismiss();
} catch (error) {
- console.error('Failed to add note:', error);
+ logger.error({ message: 'Failed to add call note', context: { error, callId } });
}
}
}, [newNote, callId, currentUser, addNote]);
diff --git a/src/components/calls/close-call-bottom-sheet.tsx b/src/components/calls/close-call-bottom-sheet.tsx
index 9d5f1be2..c72dff2b 100644
--- a/src/components/calls/close-call-bottom-sheet.tsx
+++ b/src/components/calls/close-call-bottom-sheet.tsx
@@ -14,6 +14,7 @@ import { Textarea, TextareaInput } from '@/components/ui/textarea';
import { VStack } from '@/components/ui/vstack';
import { useAnalytics } from '@/hooks/use-analytics';
import { useKeyboardHeight } from '@/hooks/use-keyboard-height';
+import { logger } from '@/lib/logging';
import { useCallDetailStore } from '@/stores/calls/detail-store';
import { useCallsStore } from '@/stores/calls/store';
import { useToastStore } from '@/stores/toast/store';
@@ -78,7 +79,7 @@ export const CloseCallBottomSheet: React.FC = ({ isOp
router.replace('/(app)/calls');
await fetchCalls();
} catch (error) {
- console.error('Error closing call:', error);
+ logger.error({ message: 'Error closing call', context: { error, callId } });
// Show error toast
showToast('error', t('call_detail.close_call_error'));
} finally {
diff --git a/src/components/calls/dispatch-selection-modal.tsx b/src/components/calls/dispatch-selection-modal.tsx
index e1c34f22..cdbe51d5 100644
--- a/src/components/calls/dispatch-selection-modal.tsx
+++ b/src/components/calls/dispatch-selection-modal.tsx
@@ -1,11 +1,11 @@
import { CheckIcon, SearchIcon, UsersIcon, X } from 'lucide-react-native';
import { useColorScheme } from 'nativewind';
-import React, { useEffect } from 'react';
+import React, { useCallback, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
-import { ScrollView, TouchableOpacity } from 'react-native';
+import { TouchableOpacity } from 'react-native';
import { Loading } from '@/components/common/loading';
-import { Actionsheet, ActionsheetBackdrop, ActionsheetContent, ActionsheetDragIndicator, ActionsheetDragIndicatorWrapper } from '@/components/ui/actionsheet';
+import { Actionsheet, ActionsheetBackdrop, ActionsheetContent, ActionsheetDragIndicator, ActionsheetDragIndicatorWrapper, ActionsheetFlatList } from '@/components/ui/actionsheet';
import { Box } from '@/components/ui/box';
import { Button, ButtonText } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
@@ -24,6 +24,45 @@ interface DispatchSelectionModalProps {
const nameIncludesQuery = (name: string | null | undefined, query: string): boolean => name?.toLowerCase().includes(query) ?? false;
+type RecipientKind = 'user' | 'group' | 'role' | 'unit';
+
+/**
+ * One flat, virtualized row stream instead of four mapped card lists inside a
+ * ScrollView — a department with a few hundred recipients previously mounted
+ * every card up front.
+ */
+type DispatchRow = { type: 'everyone'; key: string } | { type: 'header'; key: string; label: string } | { type: 'recipient'; key: string; kind: RecipientKind; id: string; name: string } | { type: 'empty'; key: string };
+
+const rowKeyExtractor = (row: DispatchRow) => row.key;
+
+interface RecipientRowProps {
+ id: string;
+ name: string;
+ isSelected: boolean;
+ onToggle: (id: string) => void;
+}
+
+const RecipientRow: React.FC = React.memo(({ id, name, isSelected, onToggle }) => {
+ const handlePress = useCallback(() => onToggle(id), [onToggle, id]);
+
+ return (
+
+
+
+
+ {isSelected ? : null}
+
+
+ {name}
+
+
+
+
+ );
+});
+
+RecipientRow.displayName = 'RecipientRow';
+
export const DispatchSelectionModal: React.FC = ({ isVisible, onClose, onConfirm, initialSelection }) => {
const { t } = useTranslation();
const { colorScheme } = useColorScheme();
@@ -45,7 +84,7 @@ export const DispatchSelectionModal: React.FC = ({
const clearSelection = useDispatchStore((state) => state.clearSelection);
// Memoized filtering instead of recomputing getFilteredData() every render
- const filteredData = React.useMemo(() => {
+ const filteredData = useMemo(() => {
if (!searchQuery.trim()) {
return data;
}
@@ -58,6 +97,40 @@ export const DispatchSelectionModal: React.FC = ({
};
}, [data, searchQuery]);
+ // Set-based membership: `selection.users.includes(id)` per card made selection
+ // checks O(n²) across the list.
+ const selectedUsers = useMemo(() => new Set(selection.users), [selection.users]);
+ const selectedGroups = useMemo(() => new Set(selection.groups), [selection.groups]);
+ const selectedRoles = useMemo(() => new Set(selection.roles), [selection.roles]);
+ const selectedUnits = useMemo(() => new Set(selection.units), [selection.units]);
+
+ const rows = useMemo(() => {
+ const built: DispatchRow[] = [{ type: 'everyone', key: 'everyone' }];
+
+ const sections: { kind: RecipientKind; labelKey: string; items: typeof filteredData.users }[] = [
+ { kind: 'user', labelKey: 'calls.users', items: filteredData.users },
+ { kind: 'group', labelKey: 'calls.groups', items: filteredData.groups },
+ { kind: 'role', labelKey: 'calls.roles', items: filteredData.roles },
+ { kind: 'unit', labelKey: 'calls.units', items: filteredData.units },
+ ];
+
+ for (const section of sections) {
+ if (section.items.length === 0) {
+ continue;
+ }
+ built.push({ type: 'header', key: `header-${section.kind}`, label: `${t(section.labelKey)} (${section.items.length})` });
+ for (const item of section.items) {
+ built.push({ type: 'recipient', key: `${section.kind}-${item.Id}`, kind: section.kind, id: item.Id, name: item.Name });
+ }
+ }
+
+ if (searchQuery && built.length === 1) {
+ built.push({ type: 'empty', key: 'empty' });
+ }
+
+ return built;
+ }, [filteredData, searchQuery, t]);
+
useEffect(() => {
if (isVisible) {
fetchDispatchData();
@@ -67,20 +140,90 @@ export const DispatchSelectionModal: React.FC = ({
}
}, [isVisible, initialSelection, fetchDispatchData, setSelection]);
- const handleConfirm = () => {
+ const handleConfirm = useCallback(() => {
onConfirm(selection);
onClose();
- };
+ }, [onConfirm, onClose, selection]);
- const handleCancel = () => {
+ const handleCancel = useCallback(() => {
clearSelection();
onClose();
- };
+ }, [clearSelection, onClose]);
- const getSelectionCount = () => {
+ const selectionCount = useMemo(() => {
if (selection.everyone) return 1;
return selection.users.length + selection.groups.length + selection.roles.length + selection.units.length;
- };
+ }, [selection]);
+
+ const isRecipientSelected = useCallback(
+ (kind: RecipientKind, id: string): boolean => {
+ switch (kind) {
+ case 'user':
+ return selectedUsers.has(id);
+ case 'group':
+ return selectedGroups.has(id);
+ case 'role':
+ return selectedRoles.has(id);
+ case 'unit':
+ return selectedUnits.has(id);
+ }
+ },
+ [selectedUsers, selectedGroups, selectedRoles, selectedUnits]
+ );
+
+ // Store actions are stable identities, so each row keeps a stable onToggle.
+ const toggleForKind = useCallback(
+ (kind: RecipientKind) => {
+ switch (kind) {
+ case 'user':
+ return toggleUser;
+ case 'group':
+ return toggleGroup;
+ case 'role':
+ return toggleRole;
+ case 'unit':
+ return toggleUnit;
+ }
+ },
+ [toggleUser, toggleGroup, toggleRole, toggleUnit]
+ );
+
+ const renderRow = useCallback(
+ ({ item }: { item: DispatchRow }) => {
+ if (item.type === 'everyone') {
+ return (
+
+
+
+
+ {selection.everyone ? : null}
+
+
+ {t('calls.everyone')}
+ {t('calls.dispatch_to_everyone')}
+
+
+
+
+ );
+ }
+
+ if (item.type === 'header') {
+ return {item.label} ;
+ }
+
+ if (item.type === 'empty') {
+ return (
+
+ {t('common.no_results_found')}
+
+ );
+ }
+
+ return ;
+ },
+ [toggleEveryone, selection.everyone, t, isRecipientSelected, toggleForKind]
+ );
return (
@@ -98,7 +241,7 @@ export const DispatchSelectionModal: React.FC = ({
{t('calls.select_dispatch_recipients')}
-
+
@@ -121,133 +264,19 @@ export const DispatchSelectionModal: React.FC = ({
{error}
) : (
-
- {/* Everyone Option */}
-
-
-
-
- {selection.everyone ? : null}
-
-
- {t('calls.everyone')}
- {t('calls.dispatch_to_everyone')}
-
-
-
-
-
- {/* Users Section */}
- {filteredData.users.length > 0 ? (
-
-
- {t('calls.users')} ({filteredData.users.length})
-
- {filteredData.users.map((user) => (
-
- toggleUser(user.Id)}>
-
-
- {selection.users.includes(user.Id) ? : null}
-
-
- {user.Name}
-
-
-
-
- ))}
-
- ) : null}
-
- {/* Groups Section */}
- {filteredData.groups.length > 0 ? (
-
-
- {t('calls.groups')} ({filteredData.groups.length})
-
- {filteredData.groups.map((group) => (
-
- toggleGroup(group.Id)}>
-
-
- {selection.groups.includes(group.Id) ? : null}
-
-
- {group.Name}
-
-
-
-
- ))}
-
- ) : null}
-
- {/* Roles Section */}
- {filteredData.roles.length > 0 ? (
-
-
- {t('calls.roles')} ({filteredData.roles.length})
-
- {filteredData.roles.map((role) => (
-
- toggleRole(role.Id)}>
-
-
- {selection.roles.includes(role.Id) ? : null}
-
-
- {role.Name}
-
-
-
-
- ))}
-
- ) : null}
-
- {/* Units Section */}
- {filteredData.units.length > 0 ? (
-
-
- {t('calls.units')} ({filteredData.units.length})
-
- {filteredData.units.map((unit) => (
-
- toggleUnit(unit.Id)}>
-
-
- {selection.units.includes(unit.Id) ? : null}
-
-
- {unit.Name}
-
-
-
-
- ))}
-
- ) : null}
-
- {/* No Results */}
- {searchQuery && filteredData.users.length === 0 && filteredData.groups.length === 0 && filteredData.roles.length === 0 && filteredData.units.length === 0 ? (
-
- {t('common.no_results_found')}
-
- ) : null}
-
+
)}
{/* Footer */}
- {getSelectionCount()} {t('calls.selected')}
+ {selectionCount} {t('calls.selected')}
{t('common.cancel')}
-
+
{t('common.confirm')}
diff --git a/src/components/calls/full-screen-image-modal.tsx b/src/components/calls/full-screen-image-modal.tsx
index b1e955a5..dcbead95 100644
--- a/src/components/calls/full-screen-image-modal.tsx
+++ b/src/components/calls/full-screen-image-modal.tsx
@@ -1,7 +1,7 @@
import { XIcon } from 'lucide-react-native';
import React from 'react';
import { useTranslation } from 'react-i18next';
-import { Dimensions, StatusBar, TouchableOpacity } from 'react-native';
+import { StatusBar, TouchableOpacity, useWindowDimensions } from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, { interpolate, runOnJS, useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
@@ -16,11 +16,12 @@ interface FullScreenImageModalProps {
imageName?: string;
}
-const { width: screenWidth, height: screenHeight } = Dimensions.get('window');
-
const FullScreenImageModal: React.FC = ({ isOpen, onClose, imageSource, imageName }) => {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
+ // Read live: a module-level Dimensions.get('window') keeps the pre-rotation size
+ // forever, which leaves the pan clamping bounded to the wrong screen.
+ const { width: screenWidth, height: screenHeight } = useWindowDimensions();
// Animation values
const scale = useSharedValue(1);
@@ -80,24 +81,32 @@ const FullScreenImageModal: React.FC = ({ isOpen, onC
savedTranslateY.value = translateY.value;
});
+ // runOnJS can only schedule a stable JS-thread function; a closure created inside the
+ // worklet cannot be transferred, so the toggle lives out here and takes the scale it
+ // needs as an argument.
+ const handleDoubleTap = React.useCallback(
+ (currentScale: number) => {
+ if (currentScale > 1) {
+ // Reset to original size
+ scale.value = withTiming(1);
+ translateX.value = withTiming(0);
+ translateY.value = withTiming(0);
+ savedScale.value = 1;
+ savedTranslateX.value = 0;
+ savedTranslateY.value = 0;
+ } else {
+ // Zoom in to 2x
+ scale.value = withTiming(2);
+ savedScale.value = 2;
+ }
+ },
+ [scale, translateX, translateY, savedScale, savedTranslateX, savedTranslateY]
+ );
+
const doubleTapGesture = Gesture.Tap()
.numberOfTaps(2)
.onEnd(() => {
- runOnJS(() => {
- if (scale.value > 1) {
- // Reset to original size
- scale.value = withTiming(1);
- translateX.value = withTiming(0);
- translateY.value = withTiming(0);
- savedScale.value = 1;
- savedTranslateX.value = 0;
- savedTranslateY.value = 0;
- } else {
- // Zoom in to 2x
- scale.value = withTiming(2);
- savedScale.value = 2;
- }
- })();
+ runOnJS(handleDoubleTap)(scale.value);
});
const composedGesture = Gesture.Simultaneous(Gesture.Simultaneous(pinchGesture, panGesture), doubleTapGesture);
diff --git a/src/components/chat/__tests__/message-bubble.test.tsx b/src/components/chat/__tests__/message-bubble.test.tsx
new file mode 100644
index 00000000..628904a4
--- /dev/null
+++ b/src/components/chat/__tests__/message-bubble.test.tsx
@@ -0,0 +1,138 @@
+/**
+ * The conversation list re-renders on every chat-store update (typing, presence, a
+ * sibling message arriving). Before MessageBubble was memoized, each of those repainted
+ * every visible bubble and re-ran linkifySegments per bubble. The memo only holds if
+ * callers pass stable handlers, which the channel screen now does via useCallback.
+ */
+import { render, screen } from '@testing-library/react-native';
+import React from 'react';
+
+jest.mock('react-i18next', () => ({
+ useTranslation: () => ({ t: (key: string) => key }),
+}));
+
+jest.mock('@/api/chat/chat', () => ({
+ getChatAttachmentImageSource: (id: string) => ({ uri: `https://example.test/${id}` }),
+}));
+
+jest.mock('expo-image', () => {
+ const React = require('react');
+ const { View } = require('react-native');
+ return { Image: (props: Record) => React.createElement(View, props) };
+});
+
+jest.mock('lucide-react-native', () => {
+ const React = require('react');
+ const { View } = require('react-native');
+ const icon = React.forwardRef((props: Record, ref: unknown) => React.createElement(View, { ...props, ref }));
+ return new Proxy({}, { get: () => icon });
+});
+
+// formatShortTime runs once per bubble render, so its call count is a render counter.
+const mockFormatShortTime = jest.fn((_iso?: string | null) => '10:00');
+const mockLinkifySegments = jest.fn((body: string) => jest.requireActual('../chat-utils').linkifySegments(body));
+
+jest.mock('../chat-utils', () => ({
+ ...jest.requireActual('../chat-utils'),
+ formatShortTime: (iso?: string | null) => mockFormatShortTime(iso),
+ linkifySegments: (body: string) => mockLinkifySegments(body),
+}));
+
+import { ChatMessagePriority, type ChatMessageResultData, ChatMessageType } from '@/models/v4/chat';
+
+import { MessageBubble } from '../message-bubble';
+
+const buildMessage = (overrides: Partial = {}): ChatMessageResultData =>
+ ({
+ ChatMessageId: 'm1',
+ ChatChannelId: 'c1',
+ SenderUserId: 'user-2',
+ SenderDisplayName: 'Engine 6',
+ Body: 'Arriving on scene',
+ MessageType: ChatMessageType.Text,
+ Priority: ChatMessagePriority.Normal,
+ SentOn: '2024-01-01T10:00:00Z',
+ ThreadReplyCount: 0,
+ Reactions: [],
+ Attachments: [],
+ ...overrides,
+ }) as ChatMessageResultData;
+
+const stableHandlers = {
+ onLongPress: jest.fn(),
+ onToggleReaction: jest.fn(),
+ onRetry: jest.fn(),
+};
+
+describe('MessageBubble', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders the message body', () => {
+ const { unmount } = render( );
+
+ expect(screen.getByText('Arriving on scene')).toBeTruthy();
+
+ unmount();
+ });
+
+ it('does not re-render when the parent repaints with identical props', () => {
+ const message = buildMessage();
+ const element = ;
+
+ const { rerender, unmount } = render(element);
+ expect(mockFormatShortTime).toHaveBeenCalledTimes(1);
+
+ // Same message object and same handler identities: the memo must short-circuit.
+ rerender(element);
+ rerender(element);
+
+ expect(mockFormatShortTime).toHaveBeenCalledTimes(1);
+
+ unmount();
+ });
+
+ it('re-renders when its own message changes', () => {
+ const { rerender, unmount } = render( );
+ expect(mockFormatShortTime).toHaveBeenCalledTimes(1);
+
+ rerender( );
+
+ expect(mockFormatShortTime).toHaveBeenCalledTimes(2);
+ expect(screen.getByText('Clearing scene')).toBeTruthy();
+
+ unmount();
+ });
+
+ it('re-runs linkify only when the body text changes', () => {
+ const message = buildMessage({ Body: 'See https://resgrid.com for details' });
+ const element = ;
+
+ const { rerender, unmount } = render(element);
+ const afterFirstRender = mockLinkifySegments.mock.calls.length;
+ expect(afterFirstRender).toBeGreaterThan(0);
+
+ // A reaction change re-renders the bubble but must not re-scan the unchanged body.
+ rerender( );
+
+ expect(mockLinkifySegments.mock.calls.length).toBe(afterFirstRender);
+
+ unmount();
+ });
+
+ it('renders a fresh inline handler as a prop change, confirming the memo compares identities', () => {
+ const message = buildMessage();
+
+ const { rerender, unmount } = render( undefined} />);
+ expect(mockFormatShortTime).toHaveBeenCalledTimes(1);
+
+ // This is exactly what the channel screen used to do per item; it defeats the memo,
+ // which is why onRetry is now a useCallback there.
+ rerender( undefined} />);
+
+ expect(mockFormatShortTime).toHaveBeenCalledTimes(2);
+
+ unmount();
+ });
+});
diff --git a/src/components/chat/message-bubble.tsx b/src/components/chat/message-bubble.tsx
index 1bdf0569..d23f8a65 100644
--- a/src/components/chat/message-bubble.tsx
+++ b/src/components/chat/message-bubble.tsx
@@ -27,7 +27,7 @@ interface MessageBubbleProps {
onPressImage?: (uri: string) => void;
}
-export function MessageBubble({ message, isOwn, showSender, currentUserId, onLongPress, onToggleReaction, onOpenThread, onRetry, onPressImage }: MessageBubbleProps) {
+function MessageBubbleComponent({ message, isOwn, showSender, currentUserId, onLongPress, onToggleReaction, onOpenThread, onRetry, onPressImage }: MessageBubbleProps) {
const { t } = useTranslation();
// Realtime payloads omit empty collections; the store normalizes them, but messages
@@ -43,6 +43,10 @@ export function MessageBubble({ message, isOwn, showSender, currentUserId, onLon
return Array.from(map.entries());
}, [message.Reactions, currentUserId]);
+ // Linkify is regex work over the body; cache it so re-renders (reactions, status
+ // changes elsewhere in the list) don't re-scan every visible bubble.
+ const linkSegments = useMemo(() => linkifySegments(message.Body ?? ''), [message.Body]);
+
// System message: centered, subtle.
if (message.MessageType === ChatMessageType.System) {
return (
@@ -102,7 +106,7 @@ export function MessageBubble({ message, isOwn, showSender, currentUserId, onLon
}
// Text (with inline links).
- const segments = linkifySegments(message.Body ?? '');
+ const segments = linkSegments;
if (segments.length === 0) return {message.Body} ;
return (
@@ -183,3 +187,8 @@ export function MessageBubble({ message, isOwn, showSender, currentUserId, onLon
);
}
+
+// Memoized: the conversation list re-renders on every store update (typing, presence,
+// reactions); unchanged bubbles must not re-render. Callers must pass stable handlers.
+export const MessageBubble = React.memo(MessageBubbleComponent);
+MessageBubble.displayName = 'MessageBubble';
diff --git a/src/components/contacts/contact-details-sheet.tsx b/src/components/contacts/contact-details-sheet.tsx
index 3bda5d92..b7ce7ee4 100644
--- a/src/components/contacts/contact-details-sheet.tsx
+++ b/src/components/contacts/contact-details-sheet.tsx
@@ -220,9 +220,9 @@ export const ContactDetailsSheet: React.FC = () => {
const middleName = selectedContact.MiddleName?.trim() || '';
const lastName = selectedContact.LastName?.trim() || '';
const fullName = [firstName, middleName, lastName].filter(Boolean).join(' ');
- return fullName || selectedContact.Name || 'Unknown Person';
+ return fullName || selectedContact.Name || t('contacts.unknown_person');
} else {
- return selectedContact.CompanyName?.trim() || selectedContact.Name || 'Unknown Company';
+ return selectedContact.CompanyName?.trim() || selectedContact.Name || t('contacts.unknown_company');
}
};
diff --git a/src/components/maps/__tests__/full-screen-location-picker.test.tsx b/src/components/maps/__tests__/full-screen-location-picker.test.tsx
index 73c9d9b6..56cfcbd7 100644
--- a/src/components/maps/__tests__/full-screen-location-picker.test.tsx
+++ b/src/components/maps/__tests__/full-screen-location-picker.test.tsx
@@ -1,15 +1,597 @@
-import { describe, expect, it } from '@jest/globals';
+/**
+ * Renders the REAL FullScreenLocationPicker.
+ *
+ * The previous suite replaced the module with `() => null` and then asserted that the
+ * replacement was defined, so none of the 284 lines under test ever executed. Only the
+ * component's dependencies are mocked here (Mapbox, expo-location, the department map
+ * centre, i18n and the icon set) — the picker itself is the real thing.
+ */
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react-native';
+import React from 'react';
+import { ActivityIndicator } from 'react-native';
-// Mock the component since it uses Mapbox which may not be available in tests
-jest.mock('../full-screen-location-picker', () => ({
- __esModule: true,
- default: () => null,
+// ── Native / module mocks (must precede the subject import) ──────────────────
+
+jest.mock('@/lib/env', () => ({
+ Env: {
+ UNIT_MAPBOX_PUBKEY: 'test-mapbox-key',
+ },
+}));
+
+const mockDepartmentCenter = { latitude: 39.14086268299356, longitude: -119.7583809782715, zoomLevel: 9 };
+jest.mock('@/lib/map-center', () => ({
+ getDepartmentMapCenter: jest.fn(() => mockDepartmentCenter),
}));
+jest.mock('expo-location', () => ({
+ Accuracy: { Lowest: 1, Low: 2, Balanced: 3, High: 4, Highest: 5, BestForNavigation: 6 },
+ requestForegroundPermissionsAsync: jest.fn(),
+ getCurrentPositionAsync: jest.fn(),
+ reverseGeocodeAsync: jest.fn(),
+}));
+
+interface MockMapProps {
+ children?: React.ReactNode;
+ [key: string]: unknown;
+}
+
+// The camera is driven imperatively by the component, so the mock has to expose a ref
+// with setCamera — that is the only way the picker can move the map.
+const mockSetCamera = jest.fn();
+
+jest.mock('@/components/maps/mapbox', () => {
+ const React = require('react');
+ const { View } = require('react-native');
+
+ const MapView = React.forwardRef(({ children, ...props }: MockMapProps, ref: unknown) => (
+
+ {children}
+
+ ));
+ MapView.displayName = 'MockMapView';
+
+ const Camera = React.forwardRef((props: MockMapProps, ref: any) => {
+ React.useImperativeHandle(ref, () => ({ setCamera: mockSetCamera }));
+ return ;
+ });
+ Camera.displayName = 'MockCamera';
+
+ const PointAnnotation = ({ children, ...props }: MockMapProps) => (
+
+ {children}
+
+ );
+
+ return {
+ __esModule: true,
+ default: {
+ MapView,
+ Camera,
+ PointAnnotation,
+ setAccessToken: jest.fn(),
+ StyleURL: { Dark: 'dark', Street: 'street' },
+ },
+ };
+});
+
+jest.mock('react-i18next', () => ({
+ useTranslation: () => ({ t: (key: string) => key }),
+}));
+
+jest.mock('@/lib/logging', () => ({
+ logger: {
+ info: jest.fn(),
+ debug: jest.fn(),
+ warn: jest.fn(),
+ error: jest.fn(),
+ },
+}));
+
+jest.mock('lucide-react-native', () => {
+ const { View } = require('react-native');
+ return {
+ LocateIcon: (props: Record) => ,
+ MapPinIcon: (props: Record) => ,
+ XIcon: (props: Record) => ,
+ };
+});
+
+import * as Location from 'expo-location';
+
+import { logger } from '@/lib/logging';
+
+import FullScreenLocationPicker from '../full-screen-location-picker';
+
+const mockRequestPermissions = Location.requestForegroundPermissionsAsync as unknown as jest.Mock;
+const mockGetCurrentPosition = Location.getCurrentPositionAsync as unknown as jest.Mock;
+const mockReverseGeocode = Location.reverseGeocodeAsync as unknown as jest.Mock;
+const mockLoggerWarn = logger.warn as unknown as jest.Mock;
+const mockLoggerError = logger.error as unknown as jest.Mock;
+
+/** Accessible names for the icon-only controls (the i18n mock echoes the key). */
+const CLOSE_LABEL = 'common.close';
+const MY_LOCATION_LABEL = 'common.get_my_location';
+
+/** Mirrors LOCATION_TIMEOUT in the component. */
+const LOCATION_TIMEOUT_MS = 10000;
+
+let setTimeoutSpy: jest.SpyInstance;
+let clearTimeoutSpy: jest.SpyInstance;
+
+/**
+ * Counts the component's own still-armed LOCATION_TIMEOUT timers.
+ *
+ * `jest.getTimerCount()` is unusable here: it also counts React Native internals, so it
+ * reports a non-zero baseline that has nothing to do with this component. Matching on the
+ * 10s delay isolates the timer under test and keeps the assertion honest.
+ */
+const armedLocationTimers = (): unknown[] => {
+ const armed = setTimeoutSpy.mock.calls.map((call, index) => ({ delay: call[1], id: setTimeoutSpy.mock.results[index]?.value })).filter((entry) => entry.delay === LOCATION_TIMEOUT_MS);
+ const cleared = new Set(clearTimeoutSpy.mock.calls.map((call) => call[0]));
+ return armed.filter((entry) => !cleared.has(entry.id)).map((entry) => entry.id);
+};
+
+/** A GeoJSON Point feature shaped the way Mapbox hands one to onPress. */
+const pointFeature = (longitude: number, latitude: number) =>
+ ({
+ type: 'Feature',
+ properties: {},
+ geometry: { type: 'Point', coordinates: [longitude, latitude] },
+ }) as unknown as GeoJSON.Feature;
+
+const INITIAL_LOCATION = { latitude: 40.7128, longitude: -74.006 };
+
describe('FullScreenLocationPicker', () => {
- it('should be importable', () => {
- // This is a basic test to ensure the module can be imported
- const FullScreenLocationPicker = require('../full-screen-location-picker').default;
- expect(FullScreenLocationPicker).toBeDefined();
+ beforeEach(() => {
+ jest.clearAllMocks();
+ // Fake timers let the 10s LOCATION_TIMEOUT be exercised directly, and make the pending
+ // timer set an observable — which is how the leak tests below assert cleanup.
+ jest.useFakeTimers();
+ // Installed after useFakeTimers so the spies wrap the fake implementations.
+ setTimeoutSpy = jest.spyOn(global, 'setTimeout');
+ clearTimeoutSpy = jest.spyOn(global, 'clearTimeout');
+
+ mockRequestPermissions.mockResolvedValue({ status: 'granted' });
+ mockGetCurrentPosition.mockResolvedValue({ coords: { latitude: 51.5074, longitude: -0.1278 } });
+ mockReverseGeocode.mockResolvedValue([{ street: '123 Main St', name: '123 Main St', city: 'Springfield', region: 'IL', country: 'USA', postalCode: '62704' }]);
+ });
+
+ afterEach(() => {
+ setTimeoutSpy.mockRestore();
+ clearTimeoutSpy.mockRestore();
+ jest.useRealTimers();
+ });
+
+ describe('initial location handling', () => {
+ it('opens on the supplied initial location at street zoom and skips the device fix', async () => {
+ const { unmount } = render( );
+
+ await waitFor(() => expect(mockReverseGeocode).toHaveBeenCalledWith({ latitude: 40.7128, longitude: -74.006 }));
+
+ expect(screen.getByTestId('map-camera').props.defaultSettings).toEqual({
+ centerCoordinate: [-74.006, 40.7128],
+ zoomLevel: 15,
+ });
+ expect(screen.getByTestId('map-point-annotation').props.coordinate).toEqual([-74.006, 40.7128]);
+ expect(screen.getByText('40.712800, -74.006000')).toBeTruthy();
+ // A known starting point means no device lookup and no "tap the map" nag.
+ expect(mockRequestPermissions).not.toHaveBeenCalled();
+ expect(screen.queryByText('common.tap_map_to_select')).toBeNull();
+
+ unmount();
+ });
+
+ it('flies the camera to the initial location imperatively', async () => {
+ const { unmount } = render( );
+
+ await waitFor(() =>
+ expect(mockSetCamera).toHaveBeenCalledWith({
+ centerCoordinate: [-74.006, 40.7128],
+ zoomLevel: 15,
+ animationDuration: 1000,
+ })
+ );
+
+ unmount();
+ });
+
+ it('never passes centerCoordinate as a Camera prop', async () => {
+ // Regression guard: driving the camera by prop as well as imperatively made every
+ // map tap re-fly the camera to the tapped point, fighting the pan gesture.
+ const { unmount } = render( );
+
+ await waitFor(() => expect(mockReverseGeocode).toHaveBeenCalled());
+
+ expect(screen.getByTestId('map-camera').props.centerCoordinate).toBeUndefined();
+
+ unmount();
+ });
+
+ it('falls back to the department map centre at overview zoom when no initial location is given', async () => {
+ // Never resolving: keeps the picker in its pre-fix state so the fallback is observable.
+ mockGetCurrentPosition.mockImplementation(() => new Promise(() => {}));
+
+ const { unmount } = render( );
+
+ await waitFor(() => expect(mockRequestPermissions).toHaveBeenCalled());
+
+ expect(screen.getByTestId('map-camera').props.defaultSettings).toEqual({
+ centerCoordinate: [mockDepartmentCenter.longitude, mockDepartmentCenter.latitude],
+ zoomLevel: 4,
+ });
+ expect(screen.getByText('39.140863, -119.758381')).toBeTruthy();
+ expect(screen.getByText('common.tap_map_to_select')).toBeTruthy();
+
+ unmount();
+ });
+
+ it('adopts the device fix when no initial location is given', async () => {
+ const { unmount } = render( );
+
+ expect(await screen.findByText('51.507400, -0.127800')).toBeTruthy();
+ expect(mockGetCurrentPosition).toHaveBeenCalledWith({ accuracy: Location.Accuracy.Balanced });
+ expect(mockReverseGeocode).toHaveBeenCalledWith({ latitude: 51.5074, longitude: -0.1278 });
+ expect(mockSetCamera).toHaveBeenCalledWith({ centerCoordinate: [-0.1278, 51.5074], zoomLevel: 15, animationDuration: 1000 });
+ // Hint clears once a real position is known.
+ expect(screen.queryByText('common.tap_map_to_select')).toBeNull();
+
+ unmount();
+ });
+
+ it('keeps the department centre when the location permission is denied', async () => {
+ mockRequestPermissions.mockResolvedValue({ status: 'denied' });
+
+ const { unmount } = render( );
+
+ // Wait for isLocating to fall back to false: that only happens once getUserLocation
+ // has run to completion, so a bare "not called" check cannot pass by racing ahead.
+ await waitFor(() => expect(screen.getByTestId('locate-icon')).toBeTruthy());
+ expect(mockRequestPermissions).toHaveBeenCalledTimes(1);
+ expect(mockGetCurrentPosition).not.toHaveBeenCalled();
+ expect(screen.getByText('39.140863, -119.758381')).toBeTruthy();
+ expect(screen.getByText('common.tap_map_to_select')).toBeTruthy();
+
+ unmount();
+ });
+
+ it('logs a denied permission at warn, not error', async () => {
+ // A user declining the prompt is an expected outcome, not a fault to page Sentry with.
+ mockRequestPermissions.mockResolvedValue({ status: 'denied' });
+
+ const { unmount } = render( );
+
+ await waitFor(() => expect(mockLoggerWarn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining('Location permission not granted') })));
+ expect(mockLoggerError).not.toHaveBeenCalled();
+
+ unmount();
+ });
+
+ it('gives up on a device fix that never returns once the 10s timeout elapses', async () => {
+ mockGetCurrentPosition.mockImplementation(() => new Promise(() => {}));
+
+ const { unmount } = render( );
+
+ await waitFor(() => expect(mockGetCurrentPosition).toHaveBeenCalled());
+ // Spinner is up while the fix is outstanding.
+ expect(screen.queryByTestId('locate-icon')).toBeNull();
+
+ await act(async () => {
+ jest.advanceTimersByTime(10000);
+ });
+
+ // A slow fix is transient, so it logs at warn rather than as a Sentry error.
+ expect(mockLoggerWarn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining('Timed out getting the device location') }));
+ expect(mockLoggerError).not.toHaveBeenCalled();
+ // Button becomes usable again and the map stays on the department centre.
+ expect(screen.getByTestId('locate-icon')).toBeTruthy();
+ expect(screen.getByText('39.140863, -119.758381')).toBeTruthy();
+
+ unmount();
+ });
+
+ it('keeps the department centre when the device fix fails', async () => {
+ mockGetCurrentPosition.mockRejectedValue(new Error('no gps'));
+
+ const { unmount } = render( );
+
+ // An unexpected platform failure is a genuine error and must reach Sentry.
+ await waitFor(() => expect(mockLoggerError).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining('Failed to get the device location') })));
+
+ expect(screen.getByText('39.140863, -119.758381')).toBeTruthy();
+ // The spinner must stop even on the failure path, or the button stays dead.
+ expect(screen.getByTestId('locate-icon')).toBeTruthy();
+
+ unmount();
+ });
+ });
+
+ describe('location timeout cleanup', () => {
+ // The 10s timer holds a closure over the component. Every path that leaves
+ // getUserLocation must disarm it, or failed attempts pile up live timers.
+
+ it('leaves no pending timer when the device fix rejects', async () => {
+ mockGetCurrentPosition.mockRejectedValue(new Error('no gps'));
+
+ const { unmount } = render( );
+
+ await waitFor(() => expect(mockLoggerError).toHaveBeenCalled());
+
+ // The timer was armed before getCurrentPositionAsync rejected, so this is not vacuous.
+ expect(setTimeoutSpy.mock.calls.some((call) => call[1] === LOCATION_TIMEOUT_MS)).toBe(true);
+ expect(armedLocationTimers()).toEqual([]);
+
+ unmount();
+ });
+
+ it('does not stack timers across repeated failed attempts', async () => {
+ mockGetCurrentPosition.mockRejectedValue(new Error('no gps'));
+
+ const { unmount } = render( );
+
+ await waitFor(() => expect(mockReverseGeocode).toHaveBeenCalled());
+
+ for (let attempt = 1; attempt <= 3; attempt += 1) {
+ fireEvent.press(screen.getByLabelText(MY_LOCATION_LABEL));
+ await waitFor(() => expect(mockGetCurrentPosition).toHaveBeenCalledTimes(attempt));
+ await act(async () => {});
+ }
+
+ // Three failed attempts armed three timers; none may still be live.
+ expect(setTimeoutSpy.mock.calls.filter((call) => call[1] === LOCATION_TIMEOUT_MS)).toHaveLength(3);
+ expect(armedLocationTimers()).toEqual([]);
+
+ unmount();
+ });
+
+ it('clears a still-armed timer when the picker unmounts mid-lookup', async () => {
+ // Never resolves, so the timer is genuinely outstanding at unmount.
+ mockGetCurrentPosition.mockImplementation(() => new Promise(() => {}));
+
+ const { unmount } = render( );
+
+ await waitFor(() => expect(mockGetCurrentPosition).toHaveBeenCalled());
+ // Guard the guard: if nothing were armed, the assertion after unmount would be vacuous.
+ expect(armedLocationTimers()).toHaveLength(1);
+
+ unmount();
+
+ expect(armedLocationTimers()).toEqual([]);
+ });
+ });
+
+ describe('map interaction', () => {
+ it('moves the selected coordinate and marker to the tapped point', async () => {
+ const { unmount } = render( );
+
+ await waitFor(() => expect(mockReverseGeocode).toHaveBeenCalledWith({ latitude: 40.7128, longitude: -74.006 }));
+
+ fireEvent(screen.getByTestId('map-view'), 'press', pointFeature(-122.4194, 37.7749));
+
+ expect(await screen.findByText('37.774900, -122.419400')).toBeTruthy();
+ expect(screen.getByTestId('map-point-annotation').props.coordinate).toEqual([-122.4194, 37.7749]);
+ expect(mockReverseGeocode).toHaveBeenCalledWith({ latitude: 37.7749, longitude: -122.4194 });
+
+ unmount();
+ });
+
+ it('does not re-fly the camera on a map tap', async () => {
+ const { unmount } = render( );
+
+ await waitFor(() => expect(mockSetCamera).toHaveBeenCalledTimes(1));
+
+ fireEvent(screen.getByTestId('map-view'), 'press', pointFeature(-122.4194, 37.7749));
+ await screen.findByText('37.774900, -122.419400');
+
+ expect(mockSetCamera).toHaveBeenCalledTimes(1);
+
+ unmount();
+ });
+
+ it('clears the "tap the map" hint once the user has tapped', async () => {
+ mockGetCurrentPosition.mockImplementation(() => new Promise(() => {}));
+
+ const { unmount } = render( );
+
+ await waitFor(() => expect(screen.getByText('common.tap_map_to_select')).toBeTruthy());
+
+ fireEvent(screen.getByTestId('map-view'), 'press', pointFeature(10.5, 20.25));
+
+ expect(await screen.findByText('20.250000, 10.500000')).toBeTruthy();
+ expect(screen.queryByText('common.tap_map_to_select')).toBeNull();
+
+ unmount();
+ });
+
+ it('ignores a press whose geometry carries no coordinates', async () => {
+ const { unmount } = render( );
+
+ await waitFor(() => expect(mockReverseGeocode).toHaveBeenCalledTimes(1));
+
+ fireEvent(screen.getByTestId('map-view'), 'press', {
+ type: 'Feature',
+ properties: {},
+ geometry: { type: 'GeometryCollection', geometries: [] },
+ } as unknown as GeoJSON.Feature);
+
+ expect(screen.getByText('40.712800, -74.006000')).toBeTruthy();
+ expect(mockReverseGeocode).toHaveBeenCalledTimes(1);
+
+ unmount();
+ });
+ });
+
+ describe('address lookup', () => {
+ it('renders the joined address and drops the duplicate name/street part', async () => {
+ const { unmount } = render( );
+
+ expect(await screen.findByText('123 Main St, Springfield, IL, 62704, USA')).toBeTruthy();
+
+ unmount();
+ });
+
+ it('keeps a place name that differs from the street', async () => {
+ mockReverseGeocode.mockResolvedValue([{ street: '1 Infinite Loop', name: 'Apple Park', city: 'Cupertino', region: 'CA', country: 'USA', postalCode: '95014' }]);
+
+ const { unmount } = render( );
+
+ expect(await screen.findByText('1 Infinite Loop, Apple Park, Cupertino, CA, 95014, USA')).toBeTruthy();
+
+ unmount();
+ });
+
+ it('shows the loading placeholder while the lookup is in flight', async () => {
+ let resolveGeocode: (value: unknown) => void = () => {};
+ mockReverseGeocode.mockImplementation(() => new Promise((resolve) => (resolveGeocode = resolve)));
+
+ const { unmount } = render( );
+
+ expect(await screen.findByText('common.loading_address')).toBeTruthy();
+
+ resolveGeocode([{ city: 'Springfield' }]);
+
+ expect(await screen.findByText('Springfield')).toBeTruthy();
+ expect(screen.queryByText('common.loading_address')).toBeNull();
+
+ unmount();
+ });
+
+ it('reports no address when the lookup comes back empty', async () => {
+ mockReverseGeocode.mockResolvedValue([]);
+
+ const { unmount } = render( );
+
+ expect(await screen.findByText('common.no_address_found')).toBeTruthy();
+
+ unmount();
+ });
+
+ it('reports no address when the lookup throws', async () => {
+ mockReverseGeocode.mockRejectedValue(new Error('offline'));
+
+ const { unmount } = render( );
+
+ expect(await screen.findByText('common.no_address_found')).toBeTruthy();
+ // Offline/geocoder-down is transient and the UI degrades gracefully, so warn not error.
+ expect(mockLoggerWarn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining('Reverse geocode failed') }));
+ expect(mockLoggerError).not.toHaveBeenCalled();
+
+ unmount();
+ });
+ });
+
+ describe('confirm and cancel', () => {
+ it('hands back the tapped coordinate with its resolved address and then closes', async () => {
+ const onLocationSelected = jest.fn();
+ const onClose = jest.fn();
+ const { unmount } = render( );
+
+ await screen.findByText('123 Main St, Springfield, IL, 62704, USA');
+
+ mockReverseGeocode.mockResolvedValue([{ city: 'San Francisco', region: 'CA' }]);
+ fireEvent(screen.getByTestId('map-view'), 'press', pointFeature(-122.4194, 37.7749));
+ await screen.findByText('San Francisco, CA');
+
+ fireEvent.press(screen.getByText('common.set_location'));
+
+ expect(onLocationSelected).toHaveBeenCalledWith({
+ latitude: 37.7749,
+ longitude: -122.4194,
+ address: 'San Francisco, CA',
+ });
+ expect(onClose).toHaveBeenCalledTimes(1);
+
+ unmount();
+ });
+
+ it('reports an undefined address when none could be resolved', async () => {
+ mockReverseGeocode.mockResolvedValue([]);
+ const onLocationSelected = jest.fn();
+ const { unmount } = render( );
+
+ await screen.findByText('common.no_address_found');
+
+ fireEvent.press(screen.getByText('common.set_location'));
+
+ expect(onLocationSelected).toHaveBeenCalledWith({ latitude: 40.7128, longitude: -74.006, address: undefined });
+
+ unmount();
+ });
+
+ it('closes without selecting anything when the close button is pressed', async () => {
+ const onLocationSelected = jest.fn();
+ const onClose = jest.fn();
+ const { unmount } = render( );
+
+ await waitFor(() => expect(mockReverseGeocode).toHaveBeenCalled());
+
+ fireEvent.press(screen.getByLabelText(CLOSE_LABEL));
+
+ expect(onClose).toHaveBeenCalledTimes(1);
+ expect(onLocationSelected).not.toHaveBeenCalled();
+
+ unmount();
+ });
+ });
+
+ describe('accessibility', () => {
+ it('gives both icon-only controls an accessible name and button role', async () => {
+ // Without these, a screen-reader user hears "button" twice with no way to tell the
+ // destructive close apart from the harmless re-locate.
+ const { unmount } = render( );
+
+ await waitFor(() => expect(mockReverseGeocode).toHaveBeenCalled());
+
+ for (const label of [CLOSE_LABEL, MY_LOCATION_LABEL]) {
+ expect(screen.getByLabelText(label).props.accessibilityRole).toBe('button');
+ }
+
+ unmount();
+ });
+ });
+
+ describe('my-location button', () => {
+ it('re-runs the device lookup and recentres on the result', async () => {
+ const { unmount } = render( );
+
+ await waitFor(() => expect(mockReverseGeocode).toHaveBeenCalled());
+ expect(mockGetCurrentPosition).not.toHaveBeenCalled();
+
+ fireEvent.press(screen.getByLabelText(MY_LOCATION_LABEL));
+
+ expect(await screen.findByText('51.507400, -0.127800')).toBeTruthy();
+ expect(mockSetCamera).toHaveBeenLastCalledWith({ centerCoordinate: [-0.1278, 51.5074], zoomLevel: 15, animationDuration: 1000 });
+
+ unmount();
+ });
+
+ it('swaps the icon for a spinner and blocks re-entry while locating', async () => {
+ let resolvePosition: (value: unknown) => void = () => {};
+ mockGetCurrentPosition.mockImplementation(() => new Promise((resolve) => (resolvePosition = resolve)));
+
+ const { unmount } = render( );
+
+ await waitFor(() => expect(mockReverseGeocode).toHaveBeenCalled());
+
+ fireEvent.press(screen.getByLabelText(MY_LOCATION_LABEL));
+ // requestForegroundPermissionsAsync is reached synchronously, so its call count is a
+ // direct, non-racy witness that a lookup was actually started.
+ expect(mockRequestPermissions).toHaveBeenCalledTimes(1);
+
+ await waitFor(() => expect(screen.queryByTestId('locate-icon')).toBeNull());
+ expect(screen.UNSAFE_getByType(ActivityIndicator)).toBeTruthy();
+ expect(mockGetCurrentPosition).toHaveBeenCalledTimes(1);
+
+ // The button keeps its accessible name while the spinner occupies it, so this presses
+ // the live control — a press that `disabled` must swallow.
+ fireEvent.press(screen.getByLabelText(MY_LOCATION_LABEL));
+ expect(mockRequestPermissions).toHaveBeenCalledTimes(1);
+ await act(async () => {});
+ expect(mockGetCurrentPosition).toHaveBeenCalledTimes(1);
+
+ resolvePosition({ coords: { latitude: 51.5074, longitude: -0.1278 } });
+ expect(await screen.findByTestId('locate-icon')).toBeTruthy();
+
+ unmount();
+ });
});
-});
\ No newline at end of file
+});
diff --git a/src/components/maps/__tests__/map-pins.test.tsx b/src/components/maps/__tests__/map-pins.test.tsx
new file mode 100644
index 00000000..83201049
--- /dev/null
+++ b/src/components/maps/__tests__/map-pins.test.tsx
@@ -0,0 +1,141 @@
+import { render } from '@testing-library/react-native';
+import React from 'react';
+
+import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData';
+
+import MapPins from '../map-pins';
+
+// Counts how many times each marker has been mounted. Stacking order is fixed
+// when a marker attaches to the map, so "the active pin re-attaches" is only
+// observable as a remount of that marker.
+const mockMarkerMounts: Record = {};
+
+jest.mock('@/components/maps/mapbox', () => {
+ const ReactActual = jest.requireActual('react');
+ const { View } = jest.requireActual('react-native');
+ const MarkerView = ({ children, ...props }: any) => {
+ ReactActual.useEffect(() => {
+ mockMarkerMounts[props.id] = (mockMarkerMounts[props.id] ?? 0) + 1;
+ }, []);
+ return ReactActual.createElement(View, { testID: props.id, ...props }, children);
+ };
+ return {
+ __esModule: true,
+ default: { MarkerView },
+ PointAnnotation: 'PointAnnotation',
+ };
+});
+
+jest.mock('nativewind', () => ({
+ useColorScheme: jest.fn(() => ({ colorScheme: 'light' })),
+ cssInterop: jest.fn((Component: any) => Component),
+}));
+
+const makePin = (overrides: Partial): MapMakerInfoData => ({
+ Id: 'pin-id',
+ Longitude: -74.0,
+ Latitude: 40.7,
+ Title: 'Pin',
+ zIndex: 0,
+ ImagePath: 'engine_available',
+ InfoWindowContent: '',
+ Color: '',
+ Type: 1,
+ Marker: '',
+ PoiImage: '',
+ ...overrides,
+});
+
+describe('MapPins', () => {
+ beforeEach(() => {
+ Object.keys(mockMarkerMounts).forEach((key) => delete mockMarkerMounts[key]);
+ });
+
+ const pins = [makePin({ Id: 'call-1', Type: 0, ImagePath: 'call', Title: 'Structure Fire' }), makePin({ Id: 'unit-1', Type: 1, Title: 'Engine 1' }), makePin({ Id: 'call-2', Type: 0, ImagePath: 'call', Title: 'MVA' })];
+
+ it('renders a marker per pin', () => {
+ const { getByTestId, unmount } = render( );
+ expect(getByTestId('pin-call-1')).toBeTruthy();
+ expect(getByTestId('pin-unit-1')).toBeTruthy();
+ expect(getByTestId('pin-call-2')).toBeTruthy();
+ unmount();
+ });
+
+ it('renders the active call last so it stacks above other markers', () => {
+ const { toJSON, unmount } = render( );
+ const tree = toJSON() as any[];
+ const ids = tree.map((node) => node.props.testID);
+ expect(ids[ids.length - 1]).toBe('pin-call-1');
+ expect(ids).toHaveLength(3);
+ unmount();
+ });
+
+ // Marker stacking is fixed at attach time (DOM insertion order on web, an
+ // imperative MarkerView attach on iOS), so reordering keyed children alone
+ // updates the ring but never restacks. The active flag is folded into the pin
+ // key so the pin whose active state changed remounts and re-attaches on top.
+ it('remounts a pin when it becomes the active call so it re-attaches on top', () => {
+ const { rerender, unmount } = render( );
+ expect(mockMarkerMounts['pin-call-1']).toBe(1);
+ expect(mockMarkerMounts['pin-unit-1']).toBe(1);
+
+ rerender( );
+
+ // The newly active pin re-attached...
+ expect(mockMarkerMounts['pin-call-1']).toBe(2);
+ // ...and unrelated pins did not churn.
+ expect(mockMarkerMounts['pin-unit-1']).toBe(1);
+ unmount();
+ });
+
+ it('remounts both the old and new active pin when the active call changes mid-session', () => {
+ const { getByTestId, getAllByTestId, rerender, unmount } = render( );
+ expect(mockMarkerMounts['pin-call-1']).toBe(1);
+ expect(mockMarkerMounts['pin-call-2']).toBe(1);
+
+ rerender( );
+
+ // Both change active state, so both re-attach — the new active call ends up
+ // attached last, above the one it replaced.
+ expect(mockMarkerMounts['pin-call-1']).toBe(2);
+ expect(mockMarkerMounts['pin-call-2']).toBe(2);
+ expect(mockMarkerMounts['pin-unit-1']).toBe(1);
+
+ // The highlight ring moved with it.
+ expect(getAllByTestId('pin-active-ring')).toHaveLength(1);
+ expect(getByTestId('pin-call-2')).toBeTruthy();
+ unmount();
+ });
+
+ it('does not remount pins when an unrelated prop changes', () => {
+ const onPinPress = jest.fn();
+ const { rerender, unmount } = render( );
+ const before = { ...mockMarkerMounts };
+
+ rerender( );
+
+ expect(mockMarkerMounts).toEqual(before);
+ unmount();
+ });
+
+ it('highlights only the active call pin', () => {
+ const { getAllByTestId, unmount } = render( );
+ expect(getAllByTestId('pin-active-ring')).toHaveLength(1);
+ unmount();
+ });
+
+ it('does not highlight a unit whose id happens to match the active call id', () => {
+ const unitPins = [makePin({ Id: 'shared-id', Type: 1, Title: 'Engine 1' })];
+ const { queryByTestId, unmount } = render( );
+ expect(queryByTestId('pin-active-ring')).toBeNull();
+ unmount();
+ });
+
+ it('keeps original order when there is no active call', () => {
+ const { toJSON, unmount } = render( );
+ const tree = toJSON() as any[];
+ const ids = tree.map((node) => node.props.testID);
+ expect(ids).toEqual(['pin-call-1', 'pin-unit-1', 'pin-call-2']);
+ unmount();
+ });
+});
diff --git a/src/components/maps/__tests__/unit-location-marker.test.tsx b/src/components/maps/__tests__/unit-location-marker.test.tsx
new file mode 100644
index 00000000..a991e2fd
--- /dev/null
+++ b/src/components/maps/__tests__/unit-location-marker.test.tsx
@@ -0,0 +1,58 @@
+import { render } from '@testing-library/react-native';
+import React from 'react';
+
+import UnitLocationMarker from '../unit-location-marker';
+
+jest.mock('@/components/maps/mapbox', () => {
+ const ReactActual = jest.requireActual('react');
+ const { View } = jest.requireActual('react-native');
+ const passthrough = (name: string) =>
+ Object.assign(({ children, ...props }: any) => ReactActual.createElement(View, { testID: props.id ?? name, ...props }, children), { displayName: name });
+ return {
+ __esModule: true,
+ default: {
+ ShapeSource: passthrough('ShapeSource'),
+ FillLayer: passthrough('FillLayer'),
+ LineLayer: passthrough('LineLayer'),
+ CircleLayer: passthrough('CircleLayer'),
+ SymbolLayer: passthrough('SymbolLayer'),
+ Images: passthrough('Images'),
+ },
+ };
+});
+
+describe('UnitLocationMarker', () => {
+ it('renders the location dot', () => {
+ const { getByTestId, unmount } = render( );
+ expect(getByTestId('unit-location-dot')).toBeTruthy();
+ unmount();
+ });
+
+ it('renders the accuracy circle when accuracy is known', () => {
+ const { getByTestId, unmount } = render( );
+ expect(getByTestId('unit-location-accuracy')).toBeTruthy();
+ expect(getByTestId('unit-location-accuracy-fill')).toBeTruthy();
+ unmount();
+ });
+
+ it('hides the accuracy circle when accuracy is missing or invalid', () => {
+ const { queryByTestId, unmount } = render( );
+ expect(queryByTestId('unit-location-accuracy')).toBeNull();
+ unmount();
+ });
+
+ it('renders the heading arrow rotated to the current heading', () => {
+ const { getByTestId, unmount } = render( );
+ const arrow = getByTestId('unit-location-heading');
+ expect(arrow.props.style.iconRotate).toBe(90);
+ expect(arrow.props.style.iconRotationAlignment).toBe('map');
+ unmount();
+ });
+
+ it('hides the heading arrow when there is no heading fix', () => {
+ // iOS reports "no heading" as -1
+ const { queryByTestId, unmount } = render( );
+ expect(queryByTestId('unit-location-heading')).toBeNull();
+ unmount();
+ });
+});
diff --git a/src/components/maps/full-screen-location-picker.tsx b/src/components/maps/full-screen-location-picker.tsx
index de36504b..685419c5 100644
--- a/src/components/maps/full-screen-location-picker.tsx
+++ b/src/components/maps/full-screen-location-picker.tsx
@@ -2,7 +2,7 @@ import * as Location from 'expo-location';
import { LocateIcon, MapPinIcon, XIcon } from 'lucide-react-native';
import React, { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
-import { ActivityIndicator, Dimensions, StyleSheet, TouchableOpacity } from 'react-native';
+import { ActivityIndicator, StyleSheet, TouchableOpacity, useWindowDimensions } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import Mapbox from '@/components/maps/mapbox';
@@ -10,6 +10,7 @@ import { Box } from '@/components/ui/box';
import { Button, ButtonText } from '@/components/ui/button';
import { Text } from '@/components/ui/text';
import { Env } from '@/lib/env';
+import { logger } from '@/lib/logging';
import { getDepartmentMapCenter } from '@/lib/map-center';
// Ensure Mapbox access token is set before using any Mapbox components
@@ -20,6 +21,9 @@ Mapbox.setAccessToken(Env.UNIT_MAPBOX_PUBKEY);
// Timeout for location fetching (in milliseconds)
const LOCATION_TIMEOUT = 10000;
+// Distinguishes our own timeout rejection from a genuine platform failure in the catch,
+// so an expected slow fix logs at warn instead of paging Sentry.
+const LOCATION_TIMEOUT_MESSAGE = 'Location timeout';
interface FullScreenLocationPickerProps {
initialLocation?: {
@@ -33,7 +37,9 @@ interface FullScreenLocationPickerProps {
const FullScreenLocationPicker: React.FC = ({ initialLocation, onLocationSelected, onClose }) => {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
- const mapRef = useRef>(null);
+ // Read live rather than at StyleSheet-create time — a module-level
+ // Dimensions.get('window') keeps the pre-rotation size forever.
+ const { width: windowWidth, height: windowHeight } = useWindowDimensions();
const cameraRef = useRef(null); // Using any due to imperative handle
// Always start with a location - either initial, or default
const [currentLocation, setCurrentLocation] = useState<{
@@ -45,6 +51,16 @@ const FullScreenLocationPicker: React.FC = ({ ini
const [address, setAddress] = useState(undefined);
const [hasUserLocation, setHasUserLocation] = useState(!!initialLocation);
const isMountedRef = useRef(true);
+ // Held in a ref so both the `finally` below and the unmount cleanup can cancel a pending
+ // timer. Left uncleared, every failed fix keeps a 10s closure over this component alive.
+ const locationTimeoutRef = useRef | undefined>(undefined);
+
+ const clearLocationTimeout = React.useCallback(() => {
+ if (locationTimeoutRef.current !== undefined) {
+ clearTimeout(locationTimeoutRef.current);
+ locationTimeoutRef.current = undefined;
+ }
+ }, []);
const reverseGeocode = React.useCallback(async (latitude: number, longitude: number) => {
if (!isMountedRef.current) return;
@@ -74,7 +90,8 @@ const FullScreenLocationPicker: React.FC = ({ ini
setAddress(undefined);
}
} catch (error) {
- console.error('Error reverse geocoding:', error);
+ // Transient (offline / geocoder unavailable) and the UI degrades to "no address found".
+ logger.warn({ message: 'Reverse geocode failed for the location picker', context: { error, latitude, longitude } });
if (isMountedRef.current) setAddress(undefined);
} finally {
if (isMountedRef.current) setIsReverseGeocoding(false);
@@ -88,14 +105,16 @@ const FullScreenLocationPicker: React.FC = ({ ini
try {
const { status } = await Location.requestForegroundPermissionsAsync();
if (status !== 'granted') {
- console.error('Location permission not granted');
+ // Expected outcome of a user choice, not a fault — the picker falls back to the map centre.
+ logger.warn({ message: 'Location permission not granted for the location picker', context: { status } });
return;
}
- // Create a timeout promise with cleanup
- let timeoutId: ReturnType | undefined;
+ // Any previous attempt's timer is cancelled before arming a new one, so repeated
+ // "my location" taps cannot stack timers.
+ clearLocationTimeout();
const timeoutPromise = new Promise((_, reject) => {
- timeoutId = setTimeout(() => reject(new Error('Location timeout')), LOCATION_TIMEOUT);
+ locationTimeoutRef.current = setTimeout(() => reject(new Error(LOCATION_TIMEOUT_MESSAGE)), LOCATION_TIMEOUT);
});
// Race between getting location and timeout
@@ -106,9 +125,6 @@ const FullScreenLocationPicker: React.FC = ({ ini
timeoutPromise,
]);
- // Clear timeout if location resolved first
- if (timeoutId !== undefined) clearTimeout(timeoutId);
-
if (!isMountedRef.current) return;
const newLocation = {
@@ -128,20 +144,39 @@ const FullScreenLocationPicker: React.FC = ({ ini
});
}
} catch (error) {
- console.error('Error getting location:', error);
// Don't update location - keep using whatever we have (initial or default)
+ if (error instanceof Error && error.message === LOCATION_TIMEOUT_MESSAGE) {
+ logger.warn({ message: 'Timed out getting the device location for the location picker', context: { timeoutMs: LOCATION_TIMEOUT } });
+ } else {
+ logger.error({ message: 'Failed to get the device location for the location picker', context: { error } });
+ }
} finally {
+ // Runs on every path — resolved, rejected and the permission-denied early return —
+ // so a rejected fix can never leave the 10s timer armed.
+ clearLocationTimeout();
if (isMountedRef.current) setIsLocating(false);
}
- }, [reverseGeocode]);
+ }, [reverseGeocode, clearLocationTimeout]);
+
+ // Depend on the coordinates rather than the object: `initialLocation` is a new identity on
+ // every parent render for any caller passing an object literal, which would re-run the effect
+ // and re-fly the camera, clobbering a location the user had already tapped.
+ const initialLatitude = initialLocation?.latitude;
+ const initialLongitude = initialLocation?.longitude;
useEffect(() => {
isMountedRef.current = true;
- if (initialLocation) {
- setCurrentLocation(initialLocation);
+ if (initialLatitude !== undefined && initialLongitude !== undefined) {
+ setCurrentLocation({ latitude: initialLatitude, longitude: initialLongitude });
setHasUserLocation(true);
- reverseGeocode(initialLocation.latitude, initialLocation.longitude);
+ reverseGeocode(initialLatitude, initialLongitude);
+ // Camera is imperative-only, so move it here rather than through props.
+ cameraRef.current?.setCamera({
+ centerCoordinate: [initialLongitude, initialLatitude],
+ zoomLevel: 15,
+ animationDuration: 1000,
+ });
} else {
// Try to get user location, but don't block the map from showing
getUserLocation();
@@ -149,8 +184,9 @@ const FullScreenLocationPicker: React.FC = ({ ini
return () => {
isMountedRef.current = false;
+ clearLocationTimeout();
};
- }, [initialLocation, getUserLocation, reverseGeocode]);
+ }, [initialLatitude, initialLongitude, getUserLocation, reverseGeocode, clearLocationTimeout]);
const handleMapPress = (event: GeoJSON.Feature) => {
if (event.geometry.type !== 'GeometryCollection' && 'coordinates' in event.geometry) {
@@ -175,9 +211,12 @@ const FullScreenLocationPicker: React.FC = ({ ini
};
return (
-
-
-
+
+
+ {/* Camera is driven imperatively only (see getUserLocation). Passing
+ centerCoordinate as well made every map tap re-fly the camera to the
+ tapped point and updated the camera mid-pan, fighting the gesture. */}
+
{/* Marker for the selected location */}
@@ -187,12 +226,12 @@ const FullScreenLocationPicker: React.FC = ({ ini
{/* Close button */}
-
+
{/* My Location button */}
-
+
{isLocating ? : }
@@ -222,8 +261,6 @@ const FullScreenLocationPicker: React.FC = ({ ini
const styles = StyleSheet.create({
container: {
flex: 1,
- width: Dimensions.get('window').width,
- height: Dimensions.get('window').height,
position: 'relative',
},
map: {
diff --git a/src/components/maps/location-picker.tsx b/src/components/maps/location-picker.tsx
index 14e4cfac..0a38d2ae 100644
--- a/src/components/maps/location-picker.tsx
+++ b/src/components/maps/location-picker.tsx
@@ -141,7 +141,10 @@ const LocationPicker: React.FC = ({ initialLocation, onLoca
return (
-
+ {/* Camera is driven imperatively only (see getUserLocation). Passing
+ centerCoordinate as well made every map tap re-fly the camera to the
+ tapped point and updated the camera mid-pan, fighting the gesture. */}
+
{/* Marker for the selected location */}
diff --git a/src/components/maps/map-pins.tsx b/src/components/maps/map-pins.tsx
index 39b4fed9..967facbe 100644
--- a/src/components/maps/map-pins.tsx
+++ b/src/components/maps/map-pins.tsx
@@ -1,4 +1,4 @@
-import React, { useCallback } from 'react';
+import React, { useCallback, useMemo } from 'react';
import Mapbox from '@/components/maps/mapbox';
import { type MAP_ICONS } from '@/constants/map-icons';
@@ -13,6 +13,13 @@ type MapIconKey = keyof typeof MAP_ICONS;
interface MapPinsProps {
pins: MapMakerInfoData[];
onPinPress?: (pin: MapMakerInfoData) => void;
+ /** Id of the department's active call — its pin is highlighted and drawn above the others. */
+ activeCallId?: string | null;
+}
+
+/** Call markers come back with Type 0 (or the legacy 'call' image). */
+function isCallPin(pin: MapMakerInfoData): boolean {
+ return pin.Type === 0 || pin.ImagePath?.toLowerCase() === 'call';
}
/**
@@ -22,7 +29,7 @@ interface MapPinsProps {
* POI markers use the SVG shape + icon rendering (per the "POI Map Icon Renderer"
* reference document). Non-POI markers use PNG images from the MAP_ICONS lookup.
*/
-const MapPin = React.memo(({ pin, onPinPress }: { pin: MapMakerInfoData; onPinPress?: (pin: MapMakerInfoData) => void }) => {
+const MapPin = React.memo(({ pin, onPinPress, isActiveCall }: { pin: MapMakerInfoData; onPinPress?: (pin: MapMakerInfoData) => void; isActiveCall?: boolean }) => {
const handlePress = useCallback(() => {
onPinPress?.(pin);
}, [onPinPress, pin]);
@@ -42,7 +49,7 @@ const MapPin = React.memo(({ pin, onPinPress }: { pin: MapMakerInfoData; onPinPr
{poi ? (
) : (
-
+
)}
);
@@ -50,12 +57,28 @@ const MapPin = React.memo(({ pin, onPinPress }: { pin: MapMakerInfoData; onPinPr
MapPin.displayName = 'MapPin';
-const MapPins: React.FC = ({ pins, onPinPress }) => {
+const MapPins: React.FC = ({ pins, onPinPress, activeCallId }) => {
+ // Markers stack in mount order on every platform (native views and DOM
+ // markers alike), so rendering the active call last keeps it on top of
+ // overlapping pins.
+ const orderedPins = useMemo(() => {
+ if (!activeCallId) return pins;
+ const activeIndex = pins.findIndex((pin) => isCallPin(pin) && pin.Id === activeCallId);
+ if (activeIndex === -1) return pins;
+ return [...pins.slice(0, activeIndex), ...pins.slice(activeIndex + 1), pins[activeIndex]];
+ }, [pins, activeCallId]);
+
return (
<>
- {pins.map((pin) => (
-
- ))}
+ {orderedPins.map((pin) => {
+ const isActiveCall = activeCallId != null && isCallPin(pin) && pin.Id === activeCallId;
+ // Stacking order is fixed when the marker attaches (DOM insertion order
+ // on web, imperative MarkerView attach on iOS), so reordering keyed
+ // children alone updates the ring but never restacks. Folding the active
+ // flag into the key remounts the pin whose active state changed, which
+ // re-attaches it on top.
+ return ;
+ })}
>
);
};
diff --git a/src/components/maps/map-view.web.tsx b/src/components/maps/map-view.web.tsx
index ec8adbd0..b0a38c46 100644
--- a/src/components/maps/map-view.web.tsx
+++ b/src/components/maps/map-view.web.tsx
@@ -21,6 +21,13 @@ export const MapContext = React.createContext(null);
// Context to share source ID from source components (ShapeSource, ImageSource, RasterSource) to layer children
const SourceContext = React.createContext(null);
+// mapbox-gl's setStyle() replaces the whole style document, dropping every
+// custom image, source and layer that was added on top of it. This counter is
+// bumped on each 'style.load' so the add-effects below re-run and re-register
+// their content after a theme swap (otherwise the unit dot, heading arrow,
+// accuracy circle, route lines and geofence vanish until the map remounts).
+const StyleGenerationContext = React.createContext(0);
+
// StyleURL constants matching native Mapbox SDK
export const StyleURL = {
Street: 'mapbox://styles/mapbox/streets-v12',
@@ -110,6 +117,10 @@ function toSymbolLayout(style: any) {
if (style?.iconAnchor !== undefined) l['icon-anchor'] = style.iconAnchor;
if (style?.iconOffset !== undefined) l['icon-offset'] = style.iconOffset;
if (style?.iconAllowOverlap !== undefined) l['icon-allow-overlap'] = style.iconAllowOverlap;
+ if (style?.iconIgnorePlacement !== undefined) l['icon-ignore-placement'] = style.iconIgnorePlacement;
+ if (style?.iconRotate !== undefined) l['icon-rotate'] = style.iconRotate;
+ if (style?.iconRotationAlignment !== undefined) l['icon-rotation-alignment'] = style.iconRotationAlignment;
+ if (style?.iconPitchAlignment !== undefined) l['icon-pitch-alignment'] = style.iconPitchAlignment;
if (style?.symbolPlacement !== undefined) l['symbol-placement'] = style.symbolPlacement;
if (style?.symbolSpacing !== undefined) l['symbol-spacing'] = style.symbolSpacing;
return l;
@@ -190,6 +201,17 @@ export const MapView = forwardRef(
const map = useRef(null);
const [isLoaded, setIsLoaded] = useState(false);
const [hasSize, setHasSize] = useState(false);
+ // Bumped on every 'style.load' so children re-add their images/sources/layers
+ // after a theme swap wipes them (see StyleGenerationContext).
+ const [styleGeneration, setStyleGeneration] = useState(0);
+
+ // The map is created once (deps: [hasSize]), so the 'moveend' handler would
+ // otherwise capture the first onCameraChanged forever. On the home map that
+ // callback is recreated per isMapLocked — which is MMKV-persisted — so a
+ // session booting locked kept a stale isMapLocked=true closure and never
+ // recorded user pans after unlocking. Read the latest prop through a ref.
+ const onCameraChangedRef = useRef(onCameraChanged);
+ onCameraChangedRef.current = onCameraChanged;
useImperativeHandle(ref, () => ({
getMap: () => map.current,
@@ -286,7 +308,13 @@ export const MapView = forwardRef(
// We tag all programmatic camera moves with { _programmatic: true } so the
// moveend handler can distinguish them from real user interactions.
const wasUser = !e._programmatic;
- onCameraChanged?.({ properties: { isUserInteraction: wasUser } });
+ onCameraChangedRef.current?.({ properties: { isUserInteraction: wasUser } });
+ });
+
+ // setStyle() drops every custom image/source/layer — tell the children to
+ // re-add theirs once the new style document is in place.
+ newMap.on('style.load', () => {
+ setStyleGeneration((generation) => generation + 1);
});
map.current = newMap;
@@ -395,6 +423,42 @@ export const MapView = forwardRef(
return () => ro.disconnect();
}, [isLoaded]);
+ // Gesture handlers are set at construction, so prop changes (e.g. the home
+ // map's scrollEnabled={!isMapLocked}) never reached the map — locking the
+ // map left every gesture live on web. Keep the handlers in sync.
+ useEffect(() => {
+ const instance = map.current;
+ if (!instance || instance.__removed) return;
+
+ const applyHandler = (handler: any, enabled: boolean) => {
+ try {
+ if (enabled) {
+ handler?.enable();
+ } else {
+ handler?.disable();
+ }
+ } catch {
+ /* handler may not exist on older mapbox-gl builds */
+ }
+ };
+
+ applyHandler(instance.dragPan, scrollEnabled);
+ applyHandler(instance.scrollZoom, zoomEnabled);
+ applyHandler(instance.doubleClickZoom, zoomEnabled);
+ applyHandler(instance.dragRotate, rotateEnabled);
+ // Touch pinch drives both zoom and rotate; keep it live only while at
+ // least one of them is allowed.
+ applyHandler(instance.touchZoomRotate, zoomEnabled || rotateEnabled);
+ // pitchWithRotate is a dragRotate option rather than its own handler.
+ try {
+ if (instance.dragRotate && '_pitchWithRotate' in instance.dragRotate) {
+ instance.dragRotate._pitchWithRotate = pitchEnabled;
+ }
+ } catch {
+ /* ignore — option is private and may move between mapbox-gl versions */
+ }
+ }, [isLoaded, scrollEnabled, zoomEnabled, rotateEnabled, pitchEnabled]);
+
// Update style when it changes
useEffect(() => {
if (map.current && styleURL) {
@@ -423,7 +487,11 @@ export const MapView = forwardRef(
minHeight: style?.height || style?.minHeight || 100,
}}
>
- {isLoaded && {children} }
+ {isLoaded ? (
+
+ {children}
+
+ ) : null}
);
}
@@ -448,162 +516,185 @@ interface CameraProps {
bounds?: { ne: [number, number]; sw: [number, number] };
/** Padding for bounds fitting */
padding?: { paddingTop?: number; paddingBottom?: number; paddingLeft?: number; paddingRight?: number };
+ /** Initial camera placement, matching the native Camera prop of the same name. */
+ defaultSettings?: { centerCoordinate?: [number, number]; zoomLevel?: number; heading?: number; pitch?: number };
}
// Camera component
-export const Camera = forwardRef(({ centerCoordinate, zoomLevel, heading, pitch, animationDuration = 1000, animationMode, followUserLocation, followZoomLevel, bounds, padding }, ref) => {
- const map = useContext(MapContext);
- const geolocateControl = useRef(null);
- const hasInitialized = useRef(false);
+export const Camera = forwardRef(
+ ({ centerCoordinate, zoomLevel, heading, pitch, animationDuration = 1000, animationMode, followUserLocation, followZoomLevel, bounds, padding, defaultSettings }, ref) => {
+ const map = useContext(MapContext);
+ const geolocateControl = useRef(null);
+ const hasInitialized = useRef(false);
- useImperativeHandle(ref, () => ({
- setCamera: (options: { centerCoordinate?: [number, number]; zoomLevel?: number; heading?: number; pitch?: number; animationDuration?: number }) => {
- if (!map) return;
+ useImperativeHandle(ref, () => ({
+ setCamera: (options: { centerCoordinate?: [number, number]; zoomLevel?: number; heading?: number; pitch?: number; animationDuration?: number }) => {
+ if (!map) return;
- if (options.centerCoordinate && (!isFinite(options.centerCoordinate[0]) || !isFinite(options.centerCoordinate[1]))) {
- return;
- }
+ if (options.centerCoordinate && (!isFinite(options.centerCoordinate[0]) || !isFinite(options.centerCoordinate[1]))) {
+ return;
+ }
- map.easeTo(
- {
- center: options.centerCoordinate,
- zoom: options.zoomLevel,
- bearing: options.heading,
- pitch: options.pitch,
- duration: options.animationDuration || 1000,
- },
- { _programmatic: true }
- );
- },
+ map.easeTo(
+ {
+ center: options.centerCoordinate,
+ zoom: options.zoomLevel,
+ bearing: options.heading,
+ pitch: options.pitch,
+ duration: options.animationDuration || 1000,
+ },
+ { _programmatic: true }
+ );
+ },
- /** flyTo supports both array form flyTo([lng, lat], duration) and options-object form */
- flyTo: (coordinatesOrOptions: any, duration?: number) => {
- if (!map) return;
+ /** flyTo supports both array form flyTo([lng, lat], duration) and options-object form */
+ flyTo: (coordinatesOrOptions: any, duration?: number) => {
+ if (!map) return;
- if (Array.isArray(coordinatesOrOptions)) {
- // Native Mapbox Camera API: flyTo([lng, lat], animationDuration)
- const [lng, lat] = coordinatesOrOptions;
- if (!isFinite(lng) || !isFinite(lat)) return;
- map.flyTo({ center: [lng, lat] as [number, number], duration: duration || 1000 }, { _programmatic: true });
- } else {
- // Options-object form: flyTo({ center, zoom, ... })
- const opts = coordinatesOrOptions;
- if (opts?.center && Array.isArray(opts.center) && (!isFinite(opts.center[0]) || !isFinite(opts.center[1]))) return;
- map.flyTo(opts, { _programmatic: true });
- }
- },
+ if (Array.isArray(coordinatesOrOptions)) {
+ // Native Mapbox Camera API: flyTo([lng, lat], animationDuration)
+ const [lng, lat] = coordinatesOrOptions;
+ if (!isFinite(lng) || !isFinite(lat)) return;
+ map.flyTo({ center: [lng, lat] as [number, number], duration: duration || 1000 }, { _programmatic: true });
+ } else {
+ // Options-object form: flyTo({ center, zoom, ... })
+ const opts = coordinatesOrOptions;
+ if (opts?.center && Array.isArray(opts.center) && (!isFinite(opts.center[0]) || !isFinite(opts.center[1]))) return;
+ map.flyTo(opts, { _programmatic: true });
+ }
+ },
- /** fitBounds(ne, sw, padding?, duration?) — matches native Mapbox Camera API */
- fitBounds: (ne: [number, number], sw: [number, number], pad?: number | number[], duration?: number) => {
- if (!map) return;
+ /** fitBounds(ne, sw, padding?, duration?) — matches native Mapbox Camera API */
+ fitBounds: (ne: [number, number], sw: [number, number], pad?: number | number[], duration?: number) => {
+ if (!map) return;
- const paddingObj = Array.isArray(pad) ? { top: pad[0] ?? 60, right: pad[1] ?? 60, bottom: pad[2] ?? 60, left: pad[3] ?? 60 } : { top: pad ?? 60, right: pad ?? 60, bottom: pad ?? 60, left: pad ?? 60 };
+ const paddingObj = Array.isArray(pad) ? { top: pad[0] ?? 60, right: pad[1] ?? 60, bottom: pad[2] ?? 60, left: pad[3] ?? 60 } : { top: pad ?? 60, right: pad ?? 60, bottom: pad ?? 60, left: pad ?? 60 };
+
+ try {
+ map.fitBounds(
+ [
+ [sw[0], sw[1]],
+ [ne[0], ne[1]],
+ ],
+ { padding: paddingObj, duration: duration || 1000 },
+ { _programmatic: true }
+ );
+ } catch {
+ // ignore projection errors
+ }
+ },
+ }));
+
+ // Handle bounds prop (declarative camera fitting)
+ useEffect(() => {
+ if (!map || !bounds) return;
+
+ const pad = padding ? { top: padding.paddingTop ?? 40, right: padding.paddingRight ?? 40, bottom: padding.paddingBottom ?? 40, left: padding.paddingLeft ?? 40 } : { top: 40, right: 40, bottom: 40, left: 40 };
try {
map.fitBounds(
[
- [sw[0], sw[1]],
- [ne[0], ne[1]],
+ [bounds.sw[0], bounds.sw[1]],
+ [bounds.ne[0], bounds.ne[1]],
],
- { padding: paddingObj, duration: duration || 1000 },
+ { padding: pad, duration: animationDuration ?? 0 },
{ _programmatic: true }
);
} catch {
// ignore projection errors
}
- },
- }));
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [map, bounds, padding]);
- // Handle bounds prop (declarative camera fitting)
- useEffect(() => {
- if (!map || !bounds) return;
+ // Honor defaultSettings for the very first placement, matching the native
+ // Camera. Without this the web map booted at the department center / zoom 4
+ // and only then animated to the unit.
+ useEffect(() => {
+ if (!map || hasInitialized.current || !defaultSettings) return;
- const pad = padding ? { top: padding.paddingTop ?? 40, right: padding.paddingRight ?? 40, bottom: padding.paddingBottom ?? 40, left: padding.paddingLeft ?? 40 } : { top: 40, right: 40, bottom: 40, left: 40 };
+ const center = defaultSettings.centerCoordinate;
+ if (!center || center.length !== 2 || !isFinite(center[0]) || !isFinite(center[1])) return;
- try {
- map.fitBounds(
- [
- [bounds.sw[0], bounds.sw[1]],
- [bounds.ne[0], bounds.ne[1]],
- ],
- { padding: pad, duration: animationDuration ?? 0 },
- { _programmatic: true }
- );
- } catch {
- // ignore projection errors
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [map, bounds, padding]);
+ hasInitialized.current = true;
+ try {
+ map.jumpTo({ center: center as [number, number], zoom: defaultSettings.zoomLevel, bearing: defaultSettings.heading, pitch: defaultSettings.pitch }, { _programmatic: true });
+ } catch {
+ // ignore projection errors during initialization
+ }
+ // Initial placement only — later moves go through setCamera/centerCoordinate.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [map]);
- // Handle centerCoordinate / zoomLevel changes
- useEffect(() => {
- if (!map) return;
+ // Handle centerCoordinate / zoomLevel changes
+ useEffect(() => {
+ if (!map) return;
- if (centerCoordinate && centerCoordinate.length === 2 && isFinite(centerCoordinate[0]) && isFinite(centerCoordinate[1])) {
- if (!hasInitialized.current) {
- hasInitialized.current = true;
- try {
- map.jumpTo({ center: centerCoordinate as [number, number], zoom: zoomLevel, bearing: heading, pitch: pitch }, { _programmatic: true });
- } catch {
- // ignore projection errors during initialization
+ if (centerCoordinate && centerCoordinate.length === 2 && isFinite(centerCoordinate[0]) && isFinite(centerCoordinate[1])) {
+ if (!hasInitialized.current) {
+ hasInitialized.current = true;
+ try {
+ map.jumpTo({ center: centerCoordinate as [number, number], zoom: zoomLevel, bearing: heading, pitch: pitch }, { _programmatic: true });
+ } catch {
+ // ignore projection errors during initialization
+ }
+ return;
}
- return;
- }
- const cameraOptions = {
- center: centerCoordinate as [number, number],
- zoom: zoomLevel,
- bearing: heading,
- pitch: pitch,
- duration: animationDuration,
- };
+ const cameraOptions = {
+ center: centerCoordinate as [number, number],
+ zoom: zoomLevel,
+ bearing: heading,
+ pitch: pitch,
+ duration: animationDuration,
+ };
- try {
- if (animationMode === 'flyTo') {
- map.flyTo(cameraOptions, { _programmatic: true });
- } else {
- map.easeTo(cameraOptions, { _programmatic: true });
+ try {
+ if (animationMode === 'flyTo') {
+ map.flyTo(cameraOptions, { _programmatic: true });
+ } else {
+ map.easeTo(cameraOptions, { _programmatic: true });
+ }
+ } catch {
+ // Suppress projection-matrix errors during resize/transition
}
- } catch {
- // Suppress projection-matrix errors during resize/transition
}
- }
- }, [map, centerCoordinate, zoomLevel, heading, pitch, animationDuration, animationMode]);
+ }, [map, centerCoordinate, zoomLevel, heading, pitch, animationDuration, animationMode]);
- // Handle followUserLocation
- useEffect(() => {
- if (!map || !followUserLocation) return;
+ // Handle followUserLocation
+ useEffect(() => {
+ if (!map || !followUserLocation) return;
- let triggerTimeoutId: any;
+ let triggerTimeoutId: any;
- if (!geolocateControl.current) {
- geolocateControl.current = new mapboxgl.GeolocateControl({
- positionOptions: { enableHighAccuracy: true },
- trackUserLocation: true,
- showUserHeading: true,
- });
- map.addControl(geolocateControl.current);
- }
+ if (!geolocateControl.current) {
+ geolocateControl.current = new mapboxgl.GeolocateControl({
+ positionOptions: { enableHighAccuracy: true },
+ trackUserLocation: true,
+ showUserHeading: true,
+ });
+ map.addControl(geolocateControl.current);
+ }
- triggerTimeoutId = setTimeout(() => {
- geolocateControl.current?.trigger();
- }, 100);
+ triggerTimeoutId = setTimeout(() => {
+ geolocateControl.current?.trigger();
+ }, 100);
- return () => {
- if (triggerTimeoutId) clearTimeout(triggerTimeoutId);
- if (geolocateControl.current) {
- try {
- map.removeControl(geolocateControl.current);
- } catch {
- // map may already be destroyed during route transitions
+ return () => {
+ if (triggerTimeoutId) clearTimeout(triggerTimeoutId);
+ if (geolocateControl.current) {
+ try {
+ map.removeControl(geolocateControl.current);
+ } catch {
+ // map may already be destroyed during route transitions
+ }
+ geolocateControl.current = null;
}
- geolocateControl.current = null;
- }
- };
- }, [map, followUserLocation, followZoomLevel]);
+ };
+ }, [map, followUserLocation, followZoomLevel]);
- return null;
-});
+ return null;
+ }
+);
Camera.displayName = 'Camera';
@@ -617,6 +708,23 @@ interface PointAnnotationProps {
onSelected?: () => void;
}
+/**
+ * Native anchors are fractional {x, y} offsets into the marker box; mapbox-gl
+ * takes a named anchor instead. Measuring the element to convert (the previous
+ * approach) always read 0×0 because the React root has not rendered yet when
+ * the marker is created, so every pin ended up centered on its coordinate.
+ */
+function toMarkerAnchor(anchor: string | { x: number; y: number } | undefined): string {
+ if (typeof anchor === 'string') return anchor;
+ if (!anchor || typeof anchor.x !== 'number' || typeof anchor.y !== 'number') return 'center';
+
+ const vertical = anchor.y >= 0.75 ? 'bottom' : anchor.y <= 0.25 ? 'top' : '';
+ const horizontal = anchor.x >= 0.75 ? 'right' : anchor.x <= 0.25 ? 'left' : '';
+
+ if (vertical && horizontal) return `${vertical}-${horizontal}`;
+ return vertical || horizontal || 'center';
+}
+
// PointAnnotation component
export const PointAnnotation: React.FC = ({ id, coordinate, title, children, anchor = { x: 0.5, y: 0.5 }, onSelected }) => {
const map = useContext(MapContext);
@@ -635,21 +743,10 @@ export const PointAnnotation: React.FC = ({ id, coordinate
const root = createRoot(container);
containerRootRef.current = root;
- const markerOptions: any = { element: container };
-
- if (typeof anchor === 'string') {
- markerOptions.anchor = anchor as any;
- }
+ const markerOptions: any = { element: container, anchor: toMarkerAnchor(anchor) };
markerRef.current = new mapboxgl.Marker(markerOptions).setLngLat(coordinate).addTo(map);
- if (typeof anchor === 'object' && anchor !== null && 'x' in anchor && 'y' in anchor) {
- const rect = container.getBoundingClientRect();
- const xOffset = (anchor.x - 0.5) * rect.width;
- const yOffset = (anchor.y - 0.5) * rect.height;
- markerRef.current.setOffset([xOffset, yOffset]);
- }
-
if (title) {
markerRef.current.setPopup(new mapboxgl.Popup().setText(title));
}
@@ -741,9 +838,12 @@ export const UserLocation: React.FC<{ visible?: boolean; showsUserHeadingIndicat
};
// MarkerView component
-export const MarkerView: React.FC<{ coordinate: [number, number]; children?: React.ReactNode }> = ({ coordinate, children }) => {
+// A stable `id` keeps the underlying mapbox-gl Marker alive across coordinate
+// updates; without one the id is derived from the coordinate, so every location
+// change tears down and recreates the DOM marker.
+export const MarkerView: React.FC<{ id?: string; coordinate: [number, number]; children?: React.ReactNode; anchor?: { x: number; y: number }; allowOverlap?: boolean }> = ({ id, coordinate, children, anchor }) => {
return (
-
+
{children}
);
@@ -764,7 +864,15 @@ interface ShapeSourceProps {
*/
export const ShapeSource: React.FC = ({ id, shape, children, onPress }) => {
const map = useContext(MapContext);
- const [sourceReady, setSourceReady] = useState(false);
+ const styleGeneration = useContext(StyleGenerationContext);
+ // Tracked as a generation rather than a boolean so a style swap forces the
+ // source back through the "not ready" phase: child layers then unregister and
+ // only re-add once the source exists again in the new style document.
+ const [readyGeneration, setReadyGeneration] = useState(-1);
+ const sourceReady = readyGeneration === styleGeneration;
+ // Guards the deferred removal below against tearing down a source that a
+ // newer add-effect (e.g. after a style swap) has already re-registered.
+ const addTokenRef = useRef(0);
// Use a ref so the click handler always sees the latest onPress without re-registering
const onPressRef = useRef(onPress);
onPressRef.current = onPress;
@@ -774,6 +882,7 @@ export const ShapeSource: React.FC = ({ id, shape, children, o
if (!map) return;
const data: GeoJSON.GeoJSON = shape || { type: 'FeatureCollection', features: [] };
+ const token = ++addTokenRef.current;
try {
if (map.getSource(id)) {
@@ -781,17 +890,22 @@ export const ShapeSource: React.FC = ({ id, shape, children, o
} else {
map.addSource(id, { type: 'geojson', data });
}
- setSourceReady(true);
+ setReadyGeneration(styleGeneration);
} catch (e) {
console.warn('[ShapeSource] Failed to add source:', id, e);
}
return () => {
- setSourceReady(false);
+ setReadyGeneration(-1);
// Defer source removal so child layer cleanups run first
- setTimeout(() => safeRemoveSource(map, id), 0);
+ setTimeout(() => {
+ // Reading the CURRENT token is the point: if a newer add-effect has
+ // claimed this source id (e.g. after a style swap) we must not remove it.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ if (addTokenRef.current === token) safeRemoveSource(map, id);
+ }, 0);
};
- }, [map, id]); // eslint-disable-line react-hooks/exhaustive-deps
+ }, [map, id, styleGeneration]); // eslint-disable-line react-hooks/exhaustive-deps
// Update source data when shape changes (without removing/re-adding source)
useEffect(() => {
@@ -845,27 +959,37 @@ interface ImageSourceProps {
*/
export const ImageSource: React.FC = ({ id, url, coordinates, children }) => {
const map = useContext(MapContext);
- const [sourceReady, setSourceReady] = useState(false);
+ const styleGeneration = useContext(StyleGenerationContext);
+ const [readyGeneration, setReadyGeneration] = useState(-1);
+ const sourceReady = readyGeneration === styleGeneration;
+ const addTokenRef = useRef(0);
useEffect(() => {
if (!map || !url) return;
+ const token = ++addTokenRef.current;
+
try {
if (map.getSource(id)) {
(map.getSource(id) as any).updateImage({ url, coordinates });
} else {
map.addSource(id, { type: 'image', url, coordinates });
}
- setSourceReady(true);
+ setReadyGeneration(styleGeneration);
} catch (e) {
console.warn('[ImageSource] Failed to add source:', id, e);
}
return () => {
- setSourceReady(false);
- setTimeout(() => safeRemoveSource(map, id), 0);
+ setReadyGeneration(-1);
+ setTimeout(() => {
+ // Reading the CURRENT token is the point: if a newer add-effect has
+ // claimed this source id (e.g. after a style swap) we must not remove it.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ if (addTokenRef.current === token) safeRemoveSource(map, id);
+ }, 0);
};
- }, [map, id, url]); // eslint-disable-line react-hooks/exhaustive-deps
+ }, [map, id, url, styleGeneration]); // eslint-disable-line react-hooks/exhaustive-deps
// Update coordinates if they change
useEffect(() => {
@@ -892,25 +1016,35 @@ interface RasterSourceProps {
*/
export const RasterSource: React.FC = ({ id, tileUrlTemplates, tileSize = 256, children }) => {
const map = useContext(MapContext);
- const [sourceReady, setSourceReady] = useState(false);
+ const styleGeneration = useContext(StyleGenerationContext);
+ const [readyGeneration, setReadyGeneration] = useState(-1);
+ const sourceReady = readyGeneration === styleGeneration;
+ const addTokenRef = useRef(0);
useEffect(() => {
if (!map || !tileUrlTemplates?.length) return;
+ const token = ++addTokenRef.current;
+
try {
if (!map.getSource(id)) {
map.addSource(id, { type: 'raster', tiles: tileUrlTemplates, tileSize });
}
- setSourceReady(true);
+ setReadyGeneration(styleGeneration);
} catch (e) {
console.warn('[RasterSource] Failed to add source:', id, e);
}
return () => {
- setSourceReady(false);
- setTimeout(() => safeRemoveSource(map, id), 0);
+ setReadyGeneration(-1);
+ setTimeout(() => {
+ // Reading the CURRENT token is the point: if a newer add-effect has
+ // claimed this source id (e.g. after a style swap) we must not remove it.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ if (addTokenRef.current === token) safeRemoveSource(map, id);
+ }, 0);
};
- }, [map, id, tileUrlTemplates, tileSize]); // eslint-disable-line react-hooks/exhaustive-deps
+ }, [map, id, tileUrlTemplates, tileSize, styleGeneration]); // eslint-disable-line react-hooks/exhaustive-deps
return {children} ;
};
@@ -927,6 +1061,7 @@ interface LayerProps {
*/
export const LineLayer: React.FC = ({ id, style }) => {
const map = useContext(MapContext);
+ const styleGeneration = useContext(StyleGenerationContext);
const sourceId = useContext(SourceContext); // null until source is ready
useEffect(() => {
@@ -941,7 +1076,7 @@ export const LineLayer: React.FC = ({ id, style }) => {
}
return () => safeRemoveLayer(map, id);
- }, [map, sourceId, id]); // eslint-disable-line react-hooks/exhaustive-deps
+ }, [map, sourceId, id, styleGeneration]); // eslint-disable-line react-hooks/exhaustive-deps
// Update paint when style changes
useEffect(() => {
@@ -964,6 +1099,7 @@ export const LineLayer: React.FC = ({ id, style }) => {
*/
export const FillLayer: React.FC = ({ id, style }) => {
const map = useContext(MapContext);
+ const styleGeneration = useContext(StyleGenerationContext);
const sourceId = useContext(SourceContext);
useEffect(() => {
@@ -978,7 +1114,7 @@ export const FillLayer: React.FC = ({ id, style }) => {
}
return () => safeRemoveLayer(map, id);
- }, [map, sourceId, id]); // eslint-disable-line react-hooks/exhaustive-deps
+ }, [map, sourceId, id, styleGeneration]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (!map || !sourceId || !map.getLayer(id)) return;
@@ -998,6 +1134,7 @@ export const FillLayer: React.FC = ({ id, style }) => {
*/
export const CircleLayer: React.FC = ({ id, style }) => {
const map = useContext(MapContext);
+ const styleGeneration = useContext(StyleGenerationContext);
const sourceId = useContext(SourceContext);
useEffect(() => {
@@ -1012,7 +1149,7 @@ export const CircleLayer: React.FC = ({ id, style }) => {
}
return () => safeRemoveLayer(map, id);
- }, [map, sourceId, id]); // eslint-disable-line react-hooks/exhaustive-deps
+ }, [map, sourceId, id, styleGeneration]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (!map || !sourceId || !map.getLayer(id)) return;
@@ -1032,6 +1169,7 @@ export const CircleLayer: React.FC = ({ id, style }) => {
*/
export const SymbolLayer: React.FC = ({ id, style }) => {
const map = useContext(MapContext);
+ const styleGeneration = useContext(StyleGenerationContext);
const sourceId = useContext(SourceContext);
useEffect(() => {
@@ -1046,7 +1184,7 @@ export const SymbolLayer: React.FC = ({ id, style }) => {
}
return () => safeRemoveLayer(map, id);
- }, [map, sourceId, id]); // eslint-disable-line react-hooks/exhaustive-deps
+ }, [map, sourceId, id, styleGeneration]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (!map || !sourceId || !map.getLayer(id)) return;
@@ -1068,6 +1206,7 @@ export const SymbolLayer: React.FC = ({ id, style }) => {
*/
export const RasterLayer: React.FC = ({ id, style }) => {
const map = useContext(MapContext);
+ const styleGeneration = useContext(StyleGenerationContext);
const sourceId = useContext(SourceContext);
useEffect(() => {
@@ -1082,7 +1221,7 @@ export const RasterLayer: React.FC = ({ id, style }) => {
}
return () => safeRemoveLayer(map, id);
- }, [map, sourceId, id]); // eslint-disable-line react-hooks/exhaustive-deps
+ }, [map, sourceId, id, styleGeneration]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (!map || !sourceId || !map.getLayer(id)) return;
@@ -1097,8 +1236,71 @@ export const RasterLayer: React.FC = ({ id, style }) => {
return null;
};
-// Passthrough / no-op components for API compatibility
-export const Images: React.FC = () => null;
+/**
+ * Images — registers named images with the map style so SymbolLayers can
+ * reference them via iconImage. Values may be URI strings (including data:
+ * URIs) or {uri} objects; entries that don't resolve to a string are skipped.
+ */
+export const Images: React.FC<{ images?: Record; children?: React.ReactNode }> = ({ images }) => {
+ const map = useContext(MapContext);
+ // setStyle() drops custom images along with everything else, so re-register
+ // them whenever the style document is replaced.
+ const styleGeneration = useContext(StyleGenerationContext);
+
+ useEffect(() => {
+ if (!map || !images) return;
+
+ const added: string[] = [];
+ // Image decoding is async, so onload can land after this effect is cleaned
+ // up. Without this flag the late add re-registered an image that the
+ // cleanup had already passed over, leaking it into the style forever.
+ let cancelled = false;
+
+ Object.entries(images).forEach(([name, source]) => {
+ const uri = typeof source === 'string' ? source : typeof source?.uri === 'string' ? source.uri : undefined;
+ if (!uri) return;
+
+ try {
+ if (map.hasImage(name)) return;
+ } catch {
+ return;
+ }
+
+ const img = new window.Image();
+ img.crossOrigin = 'anonymous';
+ img.onload = () => {
+ try {
+ if (map.__removed) return;
+ if (cancelled) {
+ // Effect already torn down — drop anything that slipped in.
+ if (map.hasImage(name)) map.removeImage(name);
+ return;
+ }
+ if (!map.hasImage(name)) {
+ map.addImage(name, img);
+ added.push(name);
+ }
+ } catch {
+ /* map may already be destroyed */
+ }
+ };
+ img.src = uri;
+ });
+
+ return () => {
+ cancelled = true;
+ added.forEach((name) => {
+ try {
+ if (!map.__removed && map.hasImage(name)) map.removeImage(name);
+ } catch {
+ /* ignore */
+ }
+ });
+ };
+ }, [map, images, styleGeneration]);
+
+ return null;
+};
export const Callout: React.FC = ({ children }) => <>{children}>;
// Default export matching native structure
diff --git a/src/components/maps/pin-marker.tsx b/src/components/maps/pin-marker.tsx
index a8ec6af7..4e712815 100644
--- a/src/components/maps/pin-marker.tsx
+++ b/src/components/maps/pin-marker.tsx
@@ -1,6 +1,6 @@
import { useColorScheme } from 'nativewind';
import React from 'react';
-import { Image, StyleSheet, Text, TouchableOpacity } from 'react-native';
+import { Image, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import type { PointAnnotation } from '@/components/maps/mapbox';
import { MAP_ICONS } from '@/constants/map-icons';
@@ -13,10 +13,12 @@ interface PinMarkerProps {
title: string;
size?: number;
markerRef?: React.ComponentRef | null;
+ /** Highlights the marker with a ring — used for the department's active call. */
+ isActive?: boolean;
onPress?: () => void;
}
-const PinMarker: React.FC = React.memo(({ imagePath, poiImage, title, size = 32, onPress }) => {
+const PinMarker: React.FC = React.memo(({ imagePath, poiImage, title, size = 32, isActive = false, onPress }) => {
const { colorScheme } = useColorScheme();
// Prefer poiImage (new field) over imagePath (null for POIs after backend fix),
@@ -26,10 +28,19 @@ const PinMarker: React.FC = React.memo(({ imagePath, poiImage, t
// Unknown markers fall back to a neutral pin, not the call icon -- that one is a flame.
const icon = iconKey && MAP_ICONS[iconKey] ? MAP_ICONS[iconKey] : MAP_ICONS['flag'];
+ const ringSize = size + 16;
+
return (
-
-
+ {isActive ? (
+
+
+
+
+ ) : (
+
+ )}
+
{title}
@@ -43,6 +54,16 @@ const styles = StyleSheet.create({
alignItems: 'center',
justifyContent: 'center',
},
+ iconWrapper: {
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ activeRing: {
+ position: 'absolute',
+ backgroundColor: 'rgba(239, 68, 68, 0.2)',
+ borderWidth: 3,
+ borderColor: '#ef4444',
+ },
image: {
overflow: 'visible',
resizeMode: 'cover',
@@ -54,6 +75,11 @@ const styles = StyleSheet.create({
fontWeight: '600',
textAlign: 'center',
},
+ // The active call label keeps a fixed accent color (readable on both themes)
+ // so the highlighted pin stands out from the rest.
+ titleActive: {
+ color: '#ef4444',
+ },
});
export default PinMarker;
diff --git a/src/components/maps/unit-location-marker.tsx b/src/components/maps/unit-location-marker.tsx
new file mode 100644
index 00000000..2d9ff799
--- /dev/null
+++ b/src/components/maps/unit-location-marker.tsx
@@ -0,0 +1,114 @@
+import React, { useMemo } from 'react';
+
+import Mapbox from '@/components/maps/mapbox';
+import { createCirclePolygon, normalizeHeading } from '@/lib/map-camera';
+import { isWeb } from '@/lib/platform';
+
+/**
+ * The unit's own location indicator, rendered as native map layers instead of a
+ * view-based annotation. PointAnnotation children are rasterized to a snapshot
+ * on iOS (and often render blank until refresh()), which is why the previous
+ * view-based marker was invisible; GL layers always draw, rotate with the map,
+ * and keep a correct screen size at every zoom.
+ *
+ * Bottom to top: GPS-accuracy circle (meters, geographic), heading arrow
+ * (map-aligned so it points at the true ground direction even when the camera
+ * is rotated), location dot.
+ */
+
+interface UnitLocationMarkerProps {
+ latitude: number;
+ longitude: number;
+ /** Raw GPS heading — may be null or -1 when there is no fix. */
+ heading: number | null;
+ /** GPS fix accuracy radius in meters, or null when unknown. */
+ accuracy: number | null;
+}
+
+const LOCATION_BLUE = '#3b82f6';
+
+// Native resolves the bundled asset; mapbox-gl on web can't consume Metro asset
+// ids, so web registers the same 64x56 chevron as a data URI.
+const ARROW_IMAGE_NATIVE = require('@assets/mapping/direction_arrow.png');
+const ARROW_IMAGE_WEB =
+ 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAAA4CAYAAABNGP5yAAACL0lEQVR42u2ZPU7DQBCFfYMcIUfIAShScgAO4AtwAAokS3RUCxINBQUViihSgpAQUEGFK4REgTuQaCJR5D8smcgLo8Qm61k77G7mSU+y4rWlN57xfoqD4J8kpRTyVyJYJ00DN+SiGutUgDijAPG6hI9U4rvn8cxIke/h61N3IOln70tu7nfl1mFvdpwKztV9LsCNSnpwOZAbe92Z4RjpxtfwoUr4mIx/wivDb0ihb+FrqvVB0PbzBQiPe7gAsLbmUwHaKtnJ7XAhvDKcQ2r7Er6pEr28T3LDK791JrgITR9aP1Fptk/7SwsAa5ASp0cB427rYbQ0vDKsdR6TMe5CW8Oer1sAWIvYwE1Mxri70+prh1eGa5zF5HncLRpe2UlMzsJdagGcxOQ83KXaKUxehrtUO4HJOrhLtROYrIu7VFuNyUVxl2orMZmCu1RbiclU3KXaKkw2wV2qrcJkU9yl2gpMLgt3ncTkMnHXSUwuA3d3zwed66exOLsfCTh2BpNNcBeemrgYJK8fkxBvYXA8LUYI54p200oxmYq7sH8fXQ3bOvACa2At4K91mFwUd1WbU+YTrtEdj5Vgsi7u5rW5SdfpjEelmKyDu0Xa3OQh5I1HpZj8F+6atLnJNpw1HpVgchbult3mZY1HJZiMcRcqXHWbm47H3AsxNr1phLYX4cKfkimlCrRdRyY3ilPwce7zVPriDtMMddINAk/k1ad2FovFYrFYLBaLxWKxWCxTfQPDAf6eowsOPgAAAABJRU5ErkJggg==';
+
+const UnitLocationMarker: React.FC = ({ latitude, longitude, heading, accuracy }) => {
+ const safeHeading = normalizeHeading(heading);
+
+ const locationPoint = useMemo(
+ (): GeoJSON.Feature => ({
+ type: 'Feature',
+ properties: {},
+ geometry: { type: 'Point', coordinates: [longitude, latitude] },
+ }),
+ [longitude, latitude]
+ );
+
+ const accuracyPolygon = useMemo(() => {
+ if (accuracy == null || !Number.isFinite(accuracy) || accuracy <= 0) return null;
+ return createCirclePolygon(longitude, latitude, accuracy);
+ }, [longitude, latitude, accuracy]);
+
+ const arrowImages = useMemo(() => ({ 'unit-heading-arrow': isWeb ? ARROW_IMAGE_WEB : ARROW_IMAGE_NATIVE }), []);
+
+ return (
+ <>
+ {accuracyPolygon ? (
+
+
+
+
+ ) : null}
+
+
+ {safeHeading != null ? (
+
+
+
+ ) : null}
+
+
+
+ >
+ );
+};
+
+export default React.memo(UnitLocationMarker);
diff --git a/src/components/notifications/NotificationButton.tsx b/src/components/notifications/NotificationButton.tsx
index 34293665..605dd4bc 100644
--- a/src/components/notifications/NotificationButton.tsx
+++ b/src/components/notifications/NotificationButton.tsx
@@ -1,6 +1,7 @@
import { useCounts } from '@novu/react-native';
import { BellIcon } from 'lucide-react-native';
import React from 'react';
+import { useTranslation } from 'react-i18next';
import { ActivityIndicator, Pressable, View } from '@/components/ui';
import { Text } from '@/components/ui/text';
@@ -9,6 +10,7 @@ interface NotificationButtonProps {
}
export const NotificationButton = ({ onPress }: NotificationButtonProps) => {
+ const { t } = useTranslation();
const { counts, isLoading } = useCounts({
filters: [
{
@@ -20,7 +22,7 @@ export const NotificationButton = ({ onPress }: NotificationButtonProps) => {
if (isLoading) return ;
return (
-
+
diff --git a/src/components/notifications/NotificationInbox.tsx b/src/components/notifications/NotificationInbox.tsx
index c0c1d9ef..e8a42f7d 100644
--- a/src/components/notifications/NotificationInbox.tsx
+++ b/src/components/notifications/NotificationInbox.tsx
@@ -1,8 +1,10 @@
import { useNotifications } from '@novu/react-native';
import { router } from 'expo-router';
import { CheckCircle, ChevronRight, Circle, ExternalLink, MoreVertical, Trash2, X } from 'lucide-react-native';
+import { useColorScheme } from 'nativewind';
import React, { useEffect, useRef, useState } from 'react';
-import { ActivityIndicator, Animated, Appearance, Dimensions, Platform, Pressable, RefreshControl, SafeAreaView, StatusBar, StyleSheet, View } from 'react-native';
+import { useTranslation } from 'react-i18next';
+import { ActivityIndicator, Animated, Platform, Pressable, RefreshControl, SafeAreaView, StatusBar, StyleSheet, useWindowDimensions, View } from 'react-native';
import { deleteMessage } from '@/api/novu/inbox';
import { NotificationDetail } from '@/components/notifications/NotificationDetail';
@@ -16,10 +18,43 @@ import { useToastStore } from '@/stores/toast/store';
import { type NotificationPayload } from '@/types/notification';
// Constants
-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;
+const getSidebarWidth = (windowWidth: number) => Math.min(windowWidth * 0.85, 400);
+
+/** Color-dependent style fragments; computed per render from the reactive color scheme. */
+const getThemedStyles = (isDark: boolean) =>
+ ({
+ sidebarContainer: {
+ backgroundColor: isDark ? '#171717' : '#fff',
+ shadowColor: isDark ? '#262626' : '#e5e5e5',
+ },
+ selectionCount: {
+ color: isDark ? '#ffffff' : '#000000',
+ },
+ notificationItem: {
+ borderBottomColor: isDark ? '#333333' : '#eee',
+ },
+ unreadNotificationItem: {
+ backgroundColor: isDark ? '#262626' : '#f0f7ff',
+ },
+ selectedNotificationItem: {
+ backgroundColor: isDark ? '#1e3a8a' : '#dbeafe',
+ },
+ unreadIndicator: {
+ backgroundColor: isDark ? '#60a5fa' : '#3b82f6',
+ },
+ notificationBody: {
+ color: isDark ? '#e5e5e5' : '#333333',
+ },
+ unreadNotificationText: {
+ color: isDark ? '#ffffff' : '#000000',
+ },
+ timestamp: {
+ color: isDark ? '#a3a3a3' : '#666',
+ },
+ }) as const;
+
interface NotificationInboxProps {
isOpen: boolean;
onClose: () => void;
@@ -37,6 +72,8 @@ interface NotificationRowProps {
const NotificationRow = React.memo(
({ notification, unread, isSelectionMode, isSelected, onPress, onLongPress, onNavigateToReference }: NotificationRowProps) => {
+ const { colorScheme } = useColorScheme();
+ const themed = React.useMemo(() => getThemedStyles(colorScheme === 'dark'), [colorScheme]);
const handlePress = React.useCallback(() => onPress(notification), [onPress, notification]);
const handleLongPress = React.useCallback(() => onLongPress(notification), [onLongPress, notification]);
const handleNavigate = React.useCallback(
@@ -45,8 +82,12 @@ const NotificationRow = React.memo(
);
return (
-
- {unread ? : null}
+
+ {unread ? : null}
{isSelectionMode ? (
@@ -55,8 +96,8 @@ const NotificationRow = React.memo(
) : null}
- {notification.title}
-
+ {notification.title}
+
{new Date(notification.createdAt).toLocaleDateString()} {new Date(notification.createdAt).toLocaleTimeString()}
@@ -89,6 +130,11 @@ const NotificationRow = React.memo(
NotificationRow.displayName = 'NotificationRow';
export const NotificationInbox = ({ isOpen, onClose }: NotificationInboxProps) => {
+ const { t } = useTranslation();
+ const { colorScheme } = useColorScheme();
+ const { width: windowWidth } = useWindowDimensions();
+ const sidebarWidth = getSidebarWidth(windowWidth);
+ const themed = React.useMemo(() => getThemedStyles(colorScheme === 'dark'), [colorScheme]);
const activeUnitId = useCoreStore((state) => state.activeUnitId);
const config = useCoreStore((state: any) => state.config);
const { notifications, isLoading, fetchMore, hasMore, refetch } = useNotifications();
@@ -100,7 +146,7 @@ export const NotificationInbox = ({ isOpen, onClose }: NotificationInboxProps) =
const [isDeletingSelected, setIsDeletingSelected] = useState(false);
// Animation values
- const slideAnim = useRef(new Animated.Value(SIDEBAR_WIDTH)).current;
+ const slideAnim = useRef(new Animated.Value(sidebarWidth)).current;
const fadeAnim = useRef(new Animated.Value(0)).current;
useEffect(() => {
@@ -122,7 +168,7 @@ export const NotificationInbox = ({ isOpen, onClose }: NotificationInboxProps) =
// Animate out and reset state
Animated.parallel([
Animated.timing(slideAnim, {
- toValue: SIDEBAR_WIDTH,
+ toValue: sidebarWidth,
duration: 300,
useNativeDriver: true,
}),
@@ -139,7 +185,7 @@ export const NotificationInbox = ({ isOpen, onClose }: NotificationInboxProps) =
setSelectedNotification(null);
setShowDeleteConfirmModal(false);
}
- }, [isOpen, slideAnim, fadeAnim]);
+ }, [isOpen, slideAnim, fadeAnim, sidebarWidth]);
const toggleNotificationSelection = React.useCallback((notificationId: string) => {
setSelectedNotificationIds((prev) => {
@@ -210,27 +256,27 @@ export const NotificationInbox = ({ isOpen, onClose }: NotificationInboxProps) =
const deletePromises = Array.from(selectedNotificationIds).map((id) => deleteMessage(id));
await Promise.all(deletePromises);
- showToast('success', `${selectedNotificationIds.size} notification${selectedNotificationIds.size > 1 ? 's' : ''} removed`);
+ showToast('success', selectedNotificationIds.size > 1 ? t('notifications.removed_count', { count: selectedNotificationIds.size }) : t('notifications.removed_one'));
exitSelectionMode();
refetch();
} catch (error) {
- showToast('error', 'Failed to remove notifications');
+ showToast('error', t('notifications.remove_failed_count'));
} finally {
setIsDeletingSelected(false);
}
- }, [selectedNotificationIds, showToast, exitSelectionMode, refetch]);
+ }, [selectedNotificationIds, showToast, exitSelectionMode, refetch, t]);
const handleDeleteNotification = React.useCallback(
async (_id: string) => {
try {
await deleteMessage(_id);
- showToast('success', 'Notification removed');
+ showToast('success', t('notifications.removed_one'));
refetch();
} catch (error) {
- showToast('error', 'Failed to remove notification');
+ showToast('error', t('notifications.remove_failed_one'));
}
},
- [showToast, refetch]
+ [showToast, refetch, t]
);
const handleNavigateToReference = React.useCallback(
@@ -296,7 +342,7 @@ export const NotificationInbox = ({ isOpen, onClose }: NotificationInboxProps) =
const renderEmpty = () => (
- No updates available
+ {t('notifications.empty')}
);
@@ -317,7 +363,7 @@ export const NotificationInbox = ({ isOpen, onClose }: NotificationInboxProps) =
{/* Sidebar container */}
-
+
{selectedNotification ? (
setSelectedNotification(null)} onDelete={handleDeleteNotification} onNavigateToReference={handleNavigateToReference} />
@@ -327,28 +373,28 @@ export const NotificationInbox = ({ isOpen, onClose }: NotificationInboxProps) =
{isSelectionMode ? (
<>
- {selectedNotificationIds.size} selected
+ {t('notifications.selected_count', { count: selectedNotificationIds.size })}
- {selectedNotificationIds.size === notifications?.length ? 'Deselect All' : 'Select All'}
+ {selectedNotificationIds.size === notifications?.length ? t('notifications.deselect_all') : t('notifications.select_all')}
-
+
{isDeletingSelected ? : }
- Cancel
+ {t('common.cancel')}
>
) : (
<>
- Notifications
+ {t('notifications.title')}
-
+
-
+
@@ -362,7 +408,7 @@ export const NotificationInbox = ({ isOpen, onClose }: NotificationInboxProps) =
) : !activeUnitId || !config ? (
- Unable to load notifications
+ {t('notifications.unable_to_load')}
) : (
- Confirm Delete
+ {t('notifications.confirm_delete_title')}
-
- Are you sure you want to delete {selectedNotificationIds.size} notification{selectedNotificationIds.size > 1 ? 's' : ''}? This action cannot be undone.
-
+ {selectedNotificationIds.size > 1 ? t('notifications.confirm_delete_message_count', { count: selectedNotificationIds.size }) : t('notifications.confirm_delete_message_one')}
setShowDeleteConfirmModal(false)} className="mr-2">
- Cancel
+ {t('common.cancel')}
- Delete
+ {t('common.delete')}
@@ -421,10 +465,7 @@ const styles = StyleSheet.create({
position: 'absolute',
top: 0,
right: 0,
- width: SIDEBAR_WIDTH,
height: '100%',
- backgroundColor: Appearance.getColorScheme() === 'dark' ? '#171717' : '#fff',
- shadowColor: Appearance.getColorScheme() === 'dark' ? '#262626' : '#e5e5e5',
shadowOffset: {
width: -2,
height: 0,
@@ -470,7 +511,6 @@ const styles = StyleSheet.create({
selectionCount: {
fontSize: 16,
fontWeight: '600',
- color: Appearance.getColorScheme() === 'dark' ? '#ffffff' : '#000000',
},
selectionActions: {
flexDirection: 'row',
@@ -481,22 +521,14 @@ const styles = StyleSheet.create({
alignItems: 'center',
padding: 16,
borderBottomWidth: 1,
- borderBottomColor: Appearance.getColorScheme() === 'dark' ? '#333333' : '#eee',
position: 'relative',
},
- unreadNotificationItem: {
- backgroundColor: Appearance.getColorScheme() === 'dark' ? '#262626' : '#f0f7ff',
- },
- selectedNotificationItem: {
- backgroundColor: Appearance.getColorScheme() === 'dark' ? '#1e3a8a' : '#dbeafe',
- },
unreadIndicator: {
position: 'absolute',
left: 0,
top: 0,
width: 4,
height: '100%',
- backgroundColor: Appearance.getColorScheme() === 'dark' ? '#60a5fa' : '#3b82f6',
},
selectionIndicator: {
marginRight: 12,
@@ -508,15 +540,12 @@ const styles = StyleSheet.create({
notificationBody: {
fontSize: 16,
marginBottom: 4,
- color: Appearance.getColorScheme() === 'dark' ? '#e5e5e5' : '#333333',
},
unreadNotificationText: {
fontWeight: '600',
- color: Appearance.getColorScheme() === 'dark' ? '#ffffff' : '#000000',
},
timestamp: {
fontSize: 12,
- color: Appearance.getColorScheme() === 'dark' ? '#a3a3a3' : '#666',
},
actionButtons: {
flexDirection: 'row',
diff --git a/src/components/notifications/__tests__/NotificationInbox.i18n-theme.test.tsx b/src/components/notifications/__tests__/NotificationInbox.i18n-theme.test.tsx
new file mode 100644
index 00000000..94389619
--- /dev/null
+++ b/src/components/notifications/__tests__/NotificationInbox.i18n-theme.test.tsx
@@ -0,0 +1,202 @@
+/**
+ * Renders the real NotificationInbox (the sibling suite substitutes a hand-written
+ * stand-in, so it never exercised this file). Covers the two regressions fixed here:
+ * hardcoded English copy, and a theme frozen at module-evaluation time because the
+ * colors were baked into a module-scope StyleSheet via Appearance.getColorScheme().
+ */
+import { render, screen } from '@testing-library/react-native';
+import React from 'react';
+
+let mockColorScheme = 'light';
+
+// Only useColorScheme is overridden; gluestack needs the real `styled` from this module.
+jest.mock('nativewind', () => ({
+ ...jest.requireActual('nativewind'),
+ useColorScheme: () => ({ colorScheme: mockColorScheme }),
+}));
+
+jest.mock('@novu/react-native', () => ({
+ useNotifications: jest.fn(),
+}));
+
+jest.mock('@/stores/app/core-store', () => ({
+ useCoreStore: jest.fn(),
+}));
+
+jest.mock('@/stores/toast/store', () => ({
+ useToastStore: jest.fn(),
+}));
+
+jest.mock('@/api/novu/inbox', () => ({
+ deleteMessage: jest.fn().mockResolvedValue(undefined),
+}));
+
+jest.mock('@/lib/logging', () => ({
+ logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() },
+}));
+
+jest.mock('@/components/notifications/NotificationDetail', () => ({
+ NotificationDetail: () => null,
+}));
+
+// Interpolated values are surfaced so assertions can prove the count reaches t().
+jest.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string, options?: Record) => (options && 'count' in options ? `${key}:${options.count}` : key),
+ }),
+}));
+
+jest.mock('lucide-react-native', () => {
+ const React = require('react');
+ const { View } = require('react-native');
+ const icon = React.forwardRef((props: Record, ref: unknown) => React.createElement(View, { ...props, ref }));
+ return new Proxy({}, { get: () => icon });
+});
+
+import { useNotifications } from '@novu/react-native';
+
+import { useCoreStore } from '@/stores/app/core-store';
+import { useToastStore } from '@/stores/toast/store';
+
+import { NotificationInbox } from '../NotificationInbox';
+
+const mockUseNotifications = useNotifications as unknown as jest.Mock;
+const mockUseCoreStore = useCoreStore as unknown as jest.Mock;
+const mockUseToastStore = useToastStore as unknown as jest.Mock;
+
+const notifications = [
+ {
+ id: '1',
+ subject: 'Structure fire dispatched',
+ body: 'Engine 6 responding',
+ createdAt: '2024-01-01T10:00:00Z',
+ isRead: false,
+ type: 'info',
+ payload: {},
+ },
+];
+
+/** Flattens the RN style prop (arrays/nested arrays) into one object. */
+const flattenStyle = (style: unknown): Record => {
+ if (Array.isArray(style)) return style.reduce>((acc, entry) => ({ ...acc, ...flattenStyle(entry) }), {});
+ return (style ?? {}) as Record;
+};
+
+/** Every backgroundColor present anywhere in the rendered tree. */
+const collectBackgroundColors = (node: unknown): string[] => {
+ if (!node || typeof node !== 'object') return [];
+ const element = node as { props?: { style?: unknown }; children?: unknown[] };
+ const own = flattenStyle(element.props?.style).backgroundColor;
+ const fromChildren = (element.children ?? []).flatMap(collectBackgroundColors);
+ return typeof own === 'string' ? [own, ...fromChildren] : fromChildren;
+};
+
+describe('NotificationInbox', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockColorScheme = 'light';
+
+ mockUseNotifications.mockReturnValue({
+ notifications,
+ isLoading: false,
+ fetchMore: jest.fn(),
+ hasMore: false,
+ refetch: jest.fn(),
+ });
+
+ mockUseCoreStore.mockImplementation((selector: (state: unknown) => unknown) =>
+ selector({
+ activeUnitId: 'unit-1',
+ config: {
+ NovuApplicationId: 'app-id',
+ NovuBackendApiUrl: 'backend-url',
+ NovuSocketUrl: 'socket-url',
+ },
+ })
+ );
+
+ mockUseToastStore.mockImplementation((selector: (state: unknown) => unknown) => selector({ showToast: jest.fn() }));
+ });
+
+ it('renders header and controls through t() rather than hardcoded English', () => {
+ const { unmount } = render( );
+
+ expect(screen.getByText('notifications.title')).toBeTruthy();
+ expect(screen.queryByText('Notifications')).toBeNull();
+
+ unmount();
+ });
+
+ it('labels the icon-only header controls for screen readers', () => {
+ const { unmount } = render( );
+
+ expect(screen.getByLabelText('notifications.enter_selection_mode')).toBeTruthy();
+ expect(screen.getByLabelText('common.close')).toBeTruthy();
+
+ unmount();
+ });
+
+ it('renders the translated empty state when there are no notifications', () => {
+ mockUseNotifications.mockReturnValue({
+ notifications: [],
+ isLoading: false,
+ fetchMore: jest.fn(),
+ hasMore: false,
+ refetch: jest.fn(),
+ });
+
+ const { unmount } = render( );
+
+ expect(screen.getByText('notifications.empty')).toBeTruthy();
+ expect(screen.queryByText('No updates available')).toBeNull();
+
+ unmount();
+ });
+
+ it('repaints the sidebar when the color scheme changes', () => {
+ const { toJSON, unmount } = render( );
+
+ expect(collectBackgroundColors(toJSON())).toContain('#fff');
+
+ // A scheme flip must repaint without an app restart — the previous module-scope
+ // Appearance.getColorScheme() baked these colors in at import time, so the sidebar
+ // kept the scheme that was active when the bundle first loaded.
+ mockColorScheme = 'dark';
+ screen.rerender( );
+
+ const darkBackgrounds = collectBackgroundColors(toJSON());
+ expect(darkBackgrounds).toContain('#171717');
+ expect(darkBackgrounds).not.toContain('#fff');
+
+ unmount();
+ });
+
+ it('sizes the sidebar from the current window width rather than a frozen Dimensions read', () => {
+ const { toJSON, unmount } = render( );
+
+ // jest-expo reports a 750pt-wide window; 85% of that is below the 400 cap.
+ const widths: unknown[] = [];
+ const walk = (node: unknown) => {
+ if (!node || typeof node !== 'object') return;
+ const element = node as { props?: { style?: unknown }; children?: unknown[] };
+ const width = flattenStyle(element.props?.style).width;
+ if (typeof width === 'number') widths.push(width);
+ (element.children ?? []).forEach(walk);
+ };
+ walk(toJSON());
+
+ expect(widths).toContain(400);
+
+ unmount();
+ });
+
+ it('renders nothing when Novu config is incomplete', () => {
+ mockUseCoreStore.mockImplementation((selector: (state: unknown) => unknown) => selector({ activeUnitId: null, config: null }));
+
+ const { toJSON, unmount } = render( );
+
+ expect(toJSON()).toBeNull();
+
+ unmount();
+ });
+});
diff --git a/src/components/routes/active-routes-list.tsx b/src/components/routes/active-routes-list.tsx
index 06d73e80..ebdcfcba 100644
--- a/src/components/routes/active-routes-list.tsx
+++ b/src/components/routes/active-routes-list.tsx
@@ -1,6 +1,6 @@
import { router } from 'expo-router';
import { MapPin, Navigation, Route, Search, X } from 'lucide-react-native';
-import React, { useEffect, useMemo, useState } from 'react';
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Pressable, RefreshControl, ScrollView, View } from 'react-native';
@@ -78,16 +78,33 @@ export const ActiveRoutesList: React.FC = () => {
) : null;
- const handleRoutePress = (route: RoutePlanResultData) => {
- if (activeInstance && activeInstance.RoutePlanId === route.RoutePlanId) {
- const routeInstanceId = activeInstance.RouteInstanceId;
- const activeRouteUrl = routeInstanceId && routeInstanceId !== 'undefined' ? `/routes/active?planId=${route.RoutePlanId}&instanceId=${routeInstanceId}` : `/routes/active?planId=${route.RoutePlanId}`;
- router.push(activeRouteUrl as any);
- return;
- }
+ const handleRoutePress = useCallback(
+ (route: RoutePlanResultData) => {
+ if (activeInstance && activeInstance.RoutePlanId === route.RoutePlanId) {
+ const routeInstanceId = activeInstance.RouteInstanceId;
+ const activeRouteUrl = routeInstanceId && routeInstanceId !== 'undefined' ? `/routes/active?planId=${route.RoutePlanId}&instanceId=${routeInstanceId}` : `/routes/active?planId=${route.RoutePlanId}`;
+ router.push(activeRouteUrl as any);
+ return;
+ }
- router.push(`/routes/start?planId=${route.RoutePlanId}` as any);
- };
+ router.push(`/routes/start?planId=${route.RoutePlanId}` as any);
+ },
+ [activeInstance]
+ );
+
+ // Stable renderItem: an inline closure here is recreated every render, forcing the list to re-render all rows.
+ const renderRouteItem = useCallback(
+ ({ item }: { item: RoutePlanResultData }) => {
+ const isMyUnit = item.UnitId != null && String(item.UnitId) === String(activeUnitId);
+ const unitName = item.UnitId != null ? unitMap[item.UnitId] || (isMyUnit ? (activeUnit?.Name ?? '') : '') : '';
+ return (
+ handleRoutePress(item)}>
+
+
+ );
+ },
+ [activeInstance, activeUnit, activeUnitId, handleRoutePress, unitMap]
+ );
const filteredRoutes = useMemo(() => {
const activeRoutes = routePlans.filter((route) => route.RouteStatus === 1);
@@ -139,15 +156,7 @@ export const ActiveRoutesList: React.FC = () => {
testID="routes-list"
data={filteredRoutes}
ListHeaderComponent={activeRouteBanner}
- renderItem={({ item }) => {
- const isMyUnit = item.UnitId != null && String(item.UnitId) === String(activeUnitId);
- const unitName = item.UnitId != null ? unitMap[item.UnitId] || (isMyUnit ? (activeUnit?.Name ?? '') : '') : '';
- return (
- handleRoutePress(item)}>
-
-
- );
- }}
+ renderItem={renderRouteItem}
keyExtractor={(item) => item.RoutePlanId}
refreshControl={ }
ListEmptyComponent={
diff --git a/src/components/sidebar/__tests__/call-sidebar.test.tsx b/src/components/sidebar/__tests__/call-sidebar.test.tsx
index 35ddc62b..9442ec46 100644
--- a/src/components/sidebar/__tests__/call-sidebar.test.tsx
+++ b/src/components/sidebar/__tests__/call-sidebar.test.tsx
@@ -27,11 +27,7 @@ jest.mock('@/lib/navigation');
jest.mock('@/components/ui/bottom-sheet', () => ({
CustomBottomSheet: ({ children, isOpen, onClose, isLoading, testID }: any) => {
const { View, Text } = require('react-native');
- return isOpen ? (
-
- {isLoading ? Loading... : children}
-
- ) : null;
+ return isOpen ? {isLoading ? Loading... : children} : null;
},
}));
@@ -75,14 +71,18 @@ jest.mock('@/components/ui/hstack', () => ({
}));
jest.mock('@/components/ui/button', () => ({
- Button: ({ children, onPress, testID }: any) => {
+ Button: ({ children, onPress, testID, accessibilityLabel }: any) => {
const { TouchableOpacity } = require('react-native');
return (
-
+
{children}
);
},
+ ButtonText: ({ children }: any) => {
+ const { Text } = require('react-native');
+ return {children} ;
+ },
ButtonIcon: ({ as: Icon, testID }: any) => {
const { View, Text } = require('react-native');
// Create a testID based on the icon type
@@ -111,23 +111,43 @@ jest.mock('@/components/ui/button', () => ({
jest.mock('lucide-react-native', () => ({
Check: (props: any) => {
const { View, Text } = require('react-native');
- return Check ;
+ return (
+
+ Check
+
+ );
},
CircleX: (props: any) => {
const { View, Text } = require('react-native');
- return CircleX ;
+ return (
+
+ CircleX
+
+ );
},
Eye: (props: any) => {
const { View, Text } = require('react-native');
- return Eye ;
+ return (
+
+ Eye
+
+ );
},
MapPin: (props: any) => {
const { View, Text } = require('react-native');
- return MapPin ;
+ return (
+
+ MapPin
+
+ );
},
Navigation: (props: any) => {
const { View, Text } = require('react-native');
- return Navigation ;
+ return (
+
+ Navigation
+
+ );
},
}));
@@ -215,27 +235,36 @@ describe('SidebarCallCard', () => {
toggleColorScheme: jest.fn(),
});
- mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
- activeCall: null,
- activePriority: null,
- setActiveCall: mockSetActiveCall,
- }) : {
- activeCall: null,
- activePriority: null,
- setActiveCall: mockSetActiveCall,
- });
+ mockUseCoreStore.mockImplementation((selector: any) =>
+ typeof selector === 'function'
+ ? selector({
+ activeCall: null,
+ activePriority: null,
+ setActiveCall: mockSetActiveCall,
+ })
+ : {
+ activeCall: null,
+ activePriority: null,
+ setActiveCall: mockSetActiveCall,
+ }
+ );
- mockUseCallsStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
- calls: [],
- fetchCalls: mockFetchCalls,
- }) : {
- calls: [],
- fetchCalls: mockFetchCalls,
- });
+ mockUseCallsStore.mockImplementation((selector: any) =>
+ typeof selector === 'function'
+ ? selector({
+ calls: [],
+ fetchCalls: mockFetchCalls,
+ })
+ : {
+ calls: [],
+ fetchCalls: mockFetchCalls,
+ }
+ );
mockUseQuery.mockReturnValue({
data: [],
isLoading: false,
+ isError: false,
error: null,
refetch: jest.fn(),
} as any);
@@ -257,15 +286,19 @@ describe('SidebarCallCard', () => {
});
it('should render with active call', () => {
- mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
- activeCall: mockCall,
- activePriority: mockPriority,
- setActiveCall: mockSetActiveCall,
- }) : {
- activeCall: mockCall,
- activePriority: mockPriority,
- setActiveCall: mockSetActiveCall,
- });
+ mockUseCoreStore.mockImplementation((selector: any) =>
+ typeof selector === 'function'
+ ? selector({
+ activeCall: mockCall,
+ activePriority: mockPriority,
+ setActiveCall: mockSetActiveCall,
+ })
+ : {
+ activeCall: mockCall,
+ activePriority: mockPriority,
+ setActiveCall: mockSetActiveCall,
+ }
+ );
render( );
@@ -276,15 +309,19 @@ describe('SidebarCallCard', () => {
});
it('should show action buttons when active call exists with coordinates', () => {
- mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
- activeCall: mockCall,
- activePriority: mockPriority,
- setActiveCall: mockSetActiveCall,
- }) : {
- activeCall: mockCall,
- activePriority: mockPriority,
- setActiveCall: mockSetActiveCall,
- });
+ mockUseCoreStore.mockImplementation((selector: any) =>
+ typeof selector === 'function'
+ ? selector({
+ activeCall: mockCall,
+ activePriority: mockPriority,
+ setActiveCall: mockSetActiveCall,
+ })
+ : {
+ activeCall: mockCall,
+ activePriority: mockPriority,
+ setActiveCall: mockSetActiveCall,
+ }
+ );
render( );
@@ -301,15 +338,19 @@ describe('SidebarCallCard', () => {
Address: '123 Test Street',
};
- mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
- activeCall: callWithAddressOnly,
- activePriority: mockPriority,
- setActiveCall: mockSetActiveCall,
- }) : {
- activeCall: callWithAddressOnly,
- activePriority: mockPriority,
- setActiveCall: mockSetActiveCall,
- });
+ mockUseCoreStore.mockImplementation((selector: any) =>
+ typeof selector === 'function'
+ ? selector({
+ activeCall: callWithAddressOnly,
+ activePriority: mockPriority,
+ setActiveCall: mockSetActiveCall,
+ })
+ : {
+ activeCall: callWithAddressOnly,
+ activePriority: mockPriority,
+ setActiveCall: mockSetActiveCall,
+ }
+ );
render( );
@@ -326,15 +367,19 @@ describe('SidebarCallCard', () => {
Address: '',
};
- mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
- activeCall: callWithoutLocation,
- activePriority: mockPriority,
- setActiveCall: mockSetActiveCall,
- }) : {
- activeCall: callWithoutLocation,
- activePriority: mockPriority,
- setActiveCall: mockSetActiveCall,
- });
+ mockUseCoreStore.mockImplementation((selector: any) =>
+ typeof selector === 'function'
+ ? selector({
+ activeCall: callWithoutLocation,
+ activePriority: mockPriority,
+ setActiveCall: mockSetActiveCall,
+ })
+ : {
+ activeCall: callWithoutLocation,
+ activePriority: mockPriority,
+ setActiveCall: mockSetActiveCall,
+ }
+ );
render( );
@@ -351,15 +396,19 @@ describe('SidebarCallCard', () => {
Address: ' ',
};
- mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
- activeCall: callWithEmptyAddress,
- activePriority: mockPriority,
- setActiveCall: mockSetActiveCall,
- }) : {
- activeCall: callWithEmptyAddress,
- activePriority: mockPriority,
- setActiveCall: mockSetActiveCall,
- });
+ mockUseCoreStore.mockImplementation((selector: any) =>
+ typeof selector === 'function'
+ ? selector({
+ activeCall: callWithEmptyAddress,
+ activePriority: mockPriority,
+ setActiveCall: mockSetActiveCall,
+ })
+ : {
+ activeCall: callWithEmptyAddress,
+ activePriority: mockPriority,
+ setActiveCall: mockSetActiveCall,
+ }
+ );
render( );
@@ -429,15 +478,19 @@ describe('SidebarCallCard', () => {
describe('Action Buttons', () => {
beforeEach(() => {
- mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
- activeCall: mockCall,
- activePriority: mockPriority,
- setActiveCall: mockSetActiveCall,
- }) : {
- activeCall: mockCall,
- activePriority: mockPriority,
- setActiveCall: mockSetActiveCall,
- });
+ mockUseCoreStore.mockImplementation((selector: any) =>
+ typeof selector === 'function'
+ ? selector({
+ activeCall: mockCall,
+ activePriority: mockPriority,
+ setActiveCall: mockSetActiveCall,
+ })
+ : {
+ activeCall: mockCall,
+ activePriority: mockPriority,
+ setActiveCall: mockSetActiveCall,
+ }
+ );
});
it('should navigate to call detail when eye button is pressed', () => {
@@ -456,10 +509,7 @@ describe('SidebarCallCard', () => {
expect(mockAlert.alert).toHaveBeenCalledWith(
'calls.confirm_deselect_title',
'calls.confirm_deselect_message',
- expect.arrayContaining([
- expect.objectContaining({ text: 'common.cancel' }),
- expect.objectContaining({ text: 'common.confirm' }),
- ]),
+ expect.arrayContaining([expect.objectContaining({ text: 'common.cancel' }), expect.objectContaining({ text: 'common.confirm' })]),
{ cancelable: true }
);
});
@@ -469,11 +519,7 @@ describe('SidebarCallCard', () => {
fireEvent.press(screen.getByTestId('map-pin-icon'));
- expect(mockOpenMapsWithDirections).toHaveBeenCalledWith(
- mockCall.Latitude,
- mockCall.Longitude,
- mockCall.Address
- );
+ expect(mockOpenMapsWithDirections).toHaveBeenCalledWith(mockCall.Latitude, mockCall.Longitude, mockCall.Address);
expect(mockOpenMapsWithAddress).not.toHaveBeenCalled();
});
@@ -485,15 +531,19 @@ describe('SidebarCallCard', () => {
Address: '123 Test Street',
};
- mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
- activeCall: callWithAddressOnly,
- activePriority: mockPriority,
- setActiveCall: mockSetActiveCall,
- }) : {
- activeCall: callWithAddressOnly,
- activePriority: mockPriority,
- setActiveCall: mockSetActiveCall,
- });
+ mockUseCoreStore.mockImplementation((selector: any) =>
+ typeof selector === 'function'
+ ? selector({
+ activeCall: callWithAddressOnly,
+ activePriority: mockPriority,
+ setActiveCall: mockSetActiveCall,
+ })
+ : {
+ activeCall: callWithAddressOnly,
+ activePriority: mockPriority,
+ setActiveCall: mockSetActiveCall,
+ }
+ );
render( );
@@ -511,13 +561,9 @@ describe('SidebarCallCard', () => {
fireEvent.press(screen.getByTestId('map-pin-icon'));
// Wait for the async operation to complete
- await new Promise(resolve => setTimeout(resolve, 0));
+ await new Promise((resolve) => setTimeout(resolve, 0));
- expect(mockAlert.alert).toHaveBeenCalledWith(
- 'calls.no_location_title',
- 'calls.no_location_message',
- [{ text: 'common.ok' }]
- );
+ expect(mockAlert.alert).toHaveBeenCalledWith('calls.no_location_title', 'calls.no_location_message', [{ text: 'common.ok' }]);
});
it('should show error alert when openMapsWithAddress fails', async () => {
@@ -528,15 +574,19 @@ describe('SidebarCallCard', () => {
Address: '123 Test Street',
};
- mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
- activeCall: callWithAddressOnly,
- activePriority: mockPriority,
- setActiveCall: mockSetActiveCall,
- }) : {
- activeCall: callWithAddressOnly,
- activePriority: mockPriority,
- setActiveCall: mockSetActiveCall,
- });
+ mockUseCoreStore.mockImplementation((selector: any) =>
+ typeof selector === 'function'
+ ? selector({
+ activeCall: callWithAddressOnly,
+ activePriority: mockPriority,
+ setActiveCall: mockSetActiveCall,
+ })
+ : {
+ activeCall: callWithAddressOnly,
+ activePriority: mockPriority,
+ setActiveCall: mockSetActiveCall,
+ }
+ );
mockOpenMapsWithAddress.mockRejectedValue(new Error('Address navigation failed'));
@@ -545,13 +595,9 @@ describe('SidebarCallCard', () => {
fireEvent.press(screen.getByTestId('map-pin-icon'));
// Wait for the async operation to complete
- await new Promise(resolve => setTimeout(resolve, 0));
+ await new Promise((resolve) => setTimeout(resolve, 0));
- expect(mockAlert.alert).toHaveBeenCalledWith(
- 'calls.no_location_title',
- 'calls.no_location_message',
- [{ text: 'common.ok' }]
- );
+ expect(mockAlert.alert).toHaveBeenCalledWith('calls.no_location_title', 'calls.no_location_message', [{ text: 'common.ok' }]);
});
it('should show error alert when openMapsWithDirections resolves false', async () => {
@@ -562,13 +608,9 @@ describe('SidebarCallCard', () => {
fireEvent.press(screen.getByTestId('map-pin-icon'));
// Wait for the async operation to complete
- await new Promise(resolve => setTimeout(resolve, 0));
+ await new Promise((resolve) => setTimeout(resolve, 0));
- expect(mockAlert.alert).toHaveBeenCalledWith(
- 'calls.no_location_title',
- 'calls.no_location_message',
- [{ text: 'common.ok' }]
- );
+ expect(mockAlert.alert).toHaveBeenCalledWith('calls.no_location_title', 'calls.no_location_message', [{ text: 'common.ok' }]);
});
it('should show error alert when openMapsWithAddress resolves false', async () => {
@@ -579,15 +621,19 @@ describe('SidebarCallCard', () => {
Address: '123 Test Street',
};
- mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
- activeCall: callWithAddressOnly,
- activePriority: mockPriority,
- setActiveCall: mockSetActiveCall,
- }) : {
- activeCall: callWithAddressOnly,
- activePriority: mockPriority,
- setActiveCall: mockSetActiveCall,
- });
+ mockUseCoreStore.mockImplementation((selector: any) =>
+ typeof selector === 'function'
+ ? selector({
+ activeCall: callWithAddressOnly,
+ activePriority: mockPriority,
+ setActiveCall: mockSetActiveCall,
+ })
+ : {
+ activeCall: callWithAddressOnly,
+ activePriority: mockPriority,
+ setActiveCall: mockSetActiveCall,
+ }
+ );
mockOpenMapsWithAddress.mockResolvedValue(false);
@@ -596,13 +642,9 @@ describe('SidebarCallCard', () => {
fireEvent.press(screen.getByTestId('map-pin-icon'));
// Wait for the async operation to complete
- await new Promise(resolve => setTimeout(resolve, 0));
+ await new Promise((resolve) => setTimeout(resolve, 0));
- expect(mockAlert.alert).toHaveBeenCalledWith(
- 'calls.no_location_title',
- 'calls.no_location_message',
- [{ text: 'common.ok' }]
- );
+ expect(mockAlert.alert).toHaveBeenCalledWith('calls.no_location_title', 'calls.no_location_message', [{ text: 'common.ok' }]);
});
});
@@ -617,15 +659,19 @@ describe('SidebarCallCard', () => {
};
it('should show destination button when active call has a destination POI', () => {
- mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
- activeCall: mockCallWithDestination,
- activePriority: mockPriority,
- setActiveCall: mockSetActiveCall,
- }) : {
- activeCall: mockCallWithDestination,
- activePriority: mockPriority,
- setActiveCall: mockSetActiveCall,
- });
+ mockUseCoreStore.mockImplementation((selector: any) =>
+ typeof selector === 'function'
+ ? selector({
+ activeCall: mockCallWithDestination,
+ activePriority: mockPriority,
+ setActiveCall: mockSetActiveCall,
+ })
+ : {
+ activeCall: mockCallWithDestination,
+ activePriority: mockPriority,
+ setActiveCall: mockSetActiveCall,
+ }
+ );
render( );
@@ -633,15 +679,19 @@ describe('SidebarCallCard', () => {
});
it('should not show destination button when active call has no destination', () => {
- mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
- activeCall: mockCall,
- activePriority: mockPriority,
- setActiveCall: mockSetActiveCall,
- }) : {
- activeCall: mockCall,
- activePriority: mockPriority,
- setActiveCall: mockSetActiveCall,
- });
+ mockUseCoreStore.mockImplementation((selector: any) =>
+ typeof selector === 'function'
+ ? selector({
+ activeCall: mockCall,
+ activePriority: mockPriority,
+ setActiveCall: mockSetActiveCall,
+ })
+ : {
+ activeCall: mockCall,
+ activePriority: mockPriority,
+ setActiveCall: mockSetActiveCall,
+ }
+ );
render( );
@@ -649,15 +699,19 @@ describe('SidebarCallCard', () => {
});
it('should route to destination coordinates when destination button is pressed', () => {
- mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
- activeCall: mockCallWithDestination,
- activePriority: mockPriority,
- setActiveCall: mockSetActiveCall,
- }) : {
- activeCall: mockCallWithDestination,
- activePriority: mockPriority,
- setActiveCall: mockSetActiveCall,
- });
+ mockUseCoreStore.mockImplementation((selector: any) =>
+ typeof selector === 'function'
+ ? selector({
+ activeCall: mockCallWithDestination,
+ activePriority: mockPriority,
+ setActiveCall: mockSetActiveCall,
+ })
+ : {
+ activeCall: mockCallWithDestination,
+ activePriority: mockPriority,
+ setActiveCall: mockSetActiveCall,
+ }
+ );
render( );
@@ -677,15 +731,19 @@ describe('SidebarCallCard', () => {
DestinationLongitude: null,
};
- mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
- activeCall: destinationAddressOnly,
- activePriority: mockPriority,
- setActiveCall: mockSetActiveCall,
- }) : {
- activeCall: destinationAddressOnly,
- activePriority: mockPriority,
- setActiveCall: mockSetActiveCall,
- });
+ mockUseCoreStore.mockImplementation((selector: any) =>
+ typeof selector === 'function'
+ ? selector({
+ activeCall: destinationAddressOnly,
+ activePriority: mockPriority,
+ setActiveCall: mockSetActiveCall,
+ })
+ : {
+ activeCall: destinationAddressOnly,
+ activePriority: mockPriority,
+ setActiveCall: mockSetActiveCall,
+ }
+ );
render( );
@@ -704,15 +762,19 @@ describe('SidebarCallCard', () => {
DestinationLongitude: 0,
};
- mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
- activeCall: destinationZeroCoords,
- activePriority: mockPriority,
- setActiveCall: mockSetActiveCall,
- }) : {
- activeCall: destinationZeroCoords,
- activePriority: mockPriority,
- setActiveCall: mockSetActiveCall,
- });
+ mockUseCoreStore.mockImplementation((selector: any) =>
+ typeof selector === 'function'
+ ? selector({
+ activeCall: destinationZeroCoords,
+ activePriority: mockPriority,
+ setActiveCall: mockSetActiveCall,
+ })
+ : {
+ activeCall: destinationZeroCoords,
+ activePriority: mockPriority,
+ setActiveCall: mockSetActiveCall,
+ }
+ );
render( );
@@ -731,15 +793,19 @@ describe('SidebarCallCard', () => {
DestinationLongitude: 0,
};
- mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
- activeCall: destinationZeroNoAddress,
- activePriority: mockPriority,
- setActiveCall: mockSetActiveCall,
- }) : {
- activeCall: destinationZeroNoAddress,
- activePriority: mockPriority,
- setActiveCall: mockSetActiveCall,
- });
+ mockUseCoreStore.mockImplementation((selector: any) =>
+ typeof selector === 'function'
+ ? selector({
+ activeCall: destinationZeroNoAddress,
+ activePriority: mockPriority,
+ setActiveCall: mockSetActiveCall,
+ })
+ : {
+ activeCall: destinationZeroNoAddress,
+ activePriority: mockPriority,
+ setActiveCall: mockSetActiveCall,
+ }
+ );
render( );
@@ -747,15 +813,19 @@ describe('SidebarCallCard', () => {
});
it('should show error alert when destination openMapsWithDirections resolves false', async () => {
- mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
- activeCall: mockCallWithDestination,
- activePriority: mockPriority,
- setActiveCall: mockSetActiveCall,
- }) : {
- activeCall: mockCallWithDestination,
- activePriority: mockPriority,
- setActiveCall: mockSetActiveCall,
- });
+ mockUseCoreStore.mockImplementation((selector: any) =>
+ typeof selector === 'function'
+ ? selector({
+ activeCall: mockCallWithDestination,
+ activePriority: mockPriority,
+ setActiveCall: mockSetActiveCall,
+ })
+ : {
+ activeCall: mockCallWithDestination,
+ activePriority: mockPriority,
+ setActiveCall: mockSetActiveCall,
+ }
+ );
mockOpenMapsWithDirections.mockResolvedValue(false);
@@ -764,13 +834,9 @@ describe('SidebarCallCard', () => {
fireEvent.press(screen.getByTestId('call-destination-directions-button'));
// Wait for the async operation to complete
- await new Promise(resolve => setTimeout(resolve, 0));
+ await new Promise((resolve) => setTimeout(resolve, 0));
- expect(mockAlert.alert).toHaveBeenCalledWith(
- 'calls.no_location_title',
- 'calls.no_location_message',
- [{ text: 'common.ok' }]
- );
+ expect(mockAlert.alert).toHaveBeenCalledWith('calls.no_location_title', 'calls.no_location_message', [{ text: 'common.ok' }]);
});
it('should show error alert when destination openMapsWithAddress resolves false', async () => {
@@ -782,15 +848,19 @@ describe('SidebarCallCard', () => {
DestinationLongitude: null,
};
- mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
- activeCall: destinationAddressOnly,
- activePriority: mockPriority,
- setActiveCall: mockSetActiveCall,
- }) : {
- activeCall: destinationAddressOnly,
- activePriority: mockPriority,
- setActiveCall: mockSetActiveCall,
- });
+ mockUseCoreStore.mockImplementation((selector: any) =>
+ typeof selector === 'function'
+ ? selector({
+ activeCall: destinationAddressOnly,
+ activePriority: mockPriority,
+ setActiveCall: mockSetActiveCall,
+ })
+ : {
+ activeCall: destinationAddressOnly,
+ activePriority: mockPriority,
+ setActiveCall: mockSetActiveCall,
+ }
+ );
mockOpenMapsWithAddress.mockResolvedValue(false);
@@ -799,13 +869,9 @@ describe('SidebarCallCard', () => {
fireEvent.press(screen.getByTestId('call-destination-directions-button'));
// Wait for the async operation to complete
- await new Promise(resolve => setTimeout(resolve, 0));
+ await new Promise((resolve) => setTimeout(resolve, 0));
- expect(mockAlert.alert).toHaveBeenCalledWith(
- 'calls.no_location_title',
- 'calls.no_location_message',
- [{ text: 'common.ok' }]
- );
+ expect(mockAlert.alert).toHaveBeenCalledWith('calls.no_location_title', 'calls.no_location_message', [{ text: 'common.ok' }]);
});
});
@@ -815,5 +881,76 @@ describe('SidebarCallCard', () => {
expect(screen.getByTestId('call-selection-trigger')).toBeTruthy();
});
+
+ it('labels the icon-only action buttons', () => {
+ mockUseCoreStore.mockImplementation((selector: any) =>
+ typeof selector === 'function'
+ ? selector({
+ activeCall: mockCall,
+ activePriority: mockPriority,
+ setActiveCall: mockSetActiveCall,
+ })
+ : {
+ activeCall: mockCall,
+ activePriority: mockPriority,
+ setActiveCall: mockSetActiveCall,
+ }
+ );
+
+ const { unmount } = render( );
+
+ // Eye / MapPin / CircleX are icon-only, so a screen reader has nothing to
+ // announce without an explicit label.
+ expect(screen.getByLabelText('map.view_call_details')).toBeTruthy();
+ expect(screen.getByLabelText('calls.directions')).toBeTruthy();
+ expect(screen.getByLabelText('calls.deselect')).toBeTruthy();
+
+ unmount();
+ });
+ });
+
+ describe('open-calls load failure', () => {
+ it('shows a translated error and a retry control instead of an empty sheet', () => {
+ const mockRefetch = jest.fn();
+ mockUseQuery.mockReturnValue({
+ data: undefined,
+ isLoading: false,
+ isError: true,
+ error: new Error('Failed to fetch calls'),
+ refetch: mockRefetch,
+ } as any);
+
+ const { unmount } = render( );
+
+ fireEvent.press(screen.getByTestId('call-selection-trigger'));
+
+ expect(screen.getByText('calls.errors.load_failed')).toBeTruthy();
+ // The "no open calls" message must NOT be what a failed load looks like
+ expect(screen.queryByTestId('no-calls-message')).toBeNull();
+
+ fireEvent.press(screen.getByTestId('call-selection-retry-button'));
+ expect(mockRefetch).toHaveBeenCalled();
+
+ unmount();
+ });
+
+ it('still renders the call list when the query succeeds', () => {
+ mockUseQuery.mockReturnValue({
+ data: [mockCall],
+ isLoading: false,
+ isError: false,
+ error: null,
+ refetch: jest.fn(),
+ } as any);
+
+ const { unmount } = render( );
+
+ fireEvent.press(screen.getByTestId('call-selection-trigger'));
+
+ expect(screen.queryByText('calls.errors.load_failed')).toBeNull();
+ expect(screen.getByTestId(`call-item-${mockCall.CallId}`)).toBeTruthy();
+
+ unmount();
+ });
});
-});
\ No newline at end of file
+});
diff --git a/src/components/sidebar/call-sidebar.tsx b/src/components/sidebar/call-sidebar.tsx
index 5c2ac3ed..fe30ac49 100644
--- a/src/components/sidebar/call-sidebar.tsx
+++ b/src/components/sidebar/call-sidebar.tsx
@@ -9,12 +9,13 @@ import { Alert, Platform, Pressable, ScrollView } from 'react-native';
import { CustomBottomSheet } from '@/components/ui/bottom-sheet';
import { Text } from '@/components/ui/text';
import { VStack } from '@/components/ui/vstack';
+import { logger } from '@/lib/logging';
import { openMapsWithAddress, openMapsWithDirections } from '@/lib/navigation';
import { useCoreStore } from '@/stores/app/core-store';
import { useCallsStore } from '@/stores/calls/store';
import { CallCard } from '../calls/call-card';
-import { Button, ButtonIcon } from '../ui/button';
+import { Button, ButtonIcon, ButtonText } from '../ui/button';
import { Card } from '../ui/card';
import { HStack } from '../ui/hstack';
@@ -27,18 +28,34 @@ export const SidebarCallCard = () => {
const [isBottomSheetOpen, setIsBottomSheetOpen] = React.useState(false);
const { t } = useTranslation();
- // Fetch calls data when bottom sheet opens
- const { data: openCallsData, isLoading } = useQuery({
+ // Fetch calls data when bottom sheet opens.
+ // The store swallows fetch failures into its own `error` field and never rejects, so
+ // the query has to read that field and throw — otherwise a failed load rendered an
+ // empty sheet that looked like "there are no open calls".
+ const {
+ data: openCallsData,
+ isLoading,
+ isError,
+ refetch,
+ } = useQuery({
queryKey: ['calls', 'open'],
queryFn: async () => {
// Only fetch when bottom sheet is open
if (!isBottomSheetOpen) return [];
- await useCallsStore.getState().fetchCalls();
- return useCallsStore.getState().calls;
+ await useCallsStore.getState().fetchCalls(true);
+ const { calls, error } = useCallsStore.getState();
+ if (error) {
+ throw new Error(error);
+ }
+ return calls;
},
enabled: isBottomSheetOpen, // Only run query when bottom sheet is open
});
+ const handleRetry = React.useCallback(() => {
+ void refetch();
+ }, [refetch]);
+
const handleDeselect = () => {
if (Platform.OS === 'web') {
const confirmed = window.confirm(`${t('calls.confirm_deselect_title')}\n${t('calls.confirm_deselect_message')}`);
@@ -170,13 +187,14 @@ export const SidebarCallCard = () => {
)}
- {activeCall && (
+ {activeCall ? (
{
router.push(`/call/${activeCall.CallId}`);
}}
@@ -184,68 +202,85 @@ export const SidebarCallCard = () => {
- {hasLocationData(activeCall) && (
-
+ {hasLocationData(activeCall) ? (
+
- )}
+ ) : null}
{hasDestinationData(activeCall) ? (
-
+
) : null}
-
+
- )}
+ ) : null}
setIsBottomSheetOpen(false)} isLoading={isLoading} loadingText={t('common.loading')} snapPoints={[60]} testID="call-selection-bottom-sheet">
{t('calls.select_active_call')}
-
-
- {openCallsData?.map((call) => (
- {
- const handleCallSelect = async () => {
- try {
- await setActiveCall(call.CallId);
- setIsBottomSheetOpen(false);
- } catch (error) {
- console.error('Failed to set active call:', error);
- }
- };
- handleCallSelect().catch((error) => {
- console.error('Failed to handle call selection:', error);
- });
- }}
- className={`rounded-lg border p-4 ${colorScheme === 'dark' ? 'border-neutral-800 bg-neutral-800' : 'border-neutral-200 bg-neutral-50'} ${
- activeCall?.CallId === call.CallId ? (colorScheme === 'dark' ? 'bg-primary-900' : 'bg-primary-50') : ''
- }`}
- testID={`call-item-${call.CallId}`}
- >
-
-
- {call.Name}
-
- {call.Type}
-
-
- {activeCall?.CallId === call.CallId && }
-
-
- ))}
- {!isLoading && openCallsData?.length === 0 && (
-
- {t('calls.no_open_calls')}
-
- )}
+ {isError ? (
+
+ {t('calls.errors.load_failed')}
+
+ {t('common.retry')}
+
-
+ ) : (
+
+
+ {openCallsData?.map((call) => (
+ {
+ const handleCallSelect = async () => {
+ try {
+ await setActiveCall(call.CallId);
+ setIsBottomSheetOpen(false);
+ } catch (error) {
+ logger.error({ message: 'Failed to set active call', context: { error, callId: call.CallId } });
+ }
+ };
+ handleCallSelect().catch((error) => {
+ logger.error({ message: 'Failed to handle call selection', context: { error, callId: call.CallId } });
+ });
+ }}
+ className={`rounded-lg border p-4 ${colorScheme === 'dark' ? 'border-neutral-800 bg-neutral-800' : 'border-neutral-200 bg-neutral-50'} ${
+ activeCall?.CallId === call.CallId ? (colorScheme === 'dark' ? 'bg-primary-900' : 'bg-primary-50') : ''
+ }`}
+ testID={`call-item-${call.CallId}`}
+ >
+
+
+ {call.Name}
+
+ {call.Type}
+
+
+ {activeCall?.CallId === call.CallId ? : null}
+
+
+ ))}
+ {!isLoading && openCallsData?.length === 0 ? (
+
+ {t('calls.no_open_calls')}
+
+ ) : null}
+
+
+ )}
>
diff --git a/src/components/sidebar/sidebar-content.tsx b/src/components/sidebar/sidebar-content.tsx
index 287af309..898f0d38 100644
--- a/src/components/sidebar/sidebar-content.tsx
+++ b/src/components/sidebar/sidebar-content.tsx
@@ -52,7 +52,7 @@ const Sidebar = ({ onClose }: SidebarProps) => {
{/* First row - Two cards side by side */}
-
+
@@ -66,7 +66,7 @@ const Sidebar = ({ onClose }: SidebarProps) => {
{/* Chat + Assistant navigation (hidden when the Chat.System feature flag is off) */}
- {isChatEnabled && (
+ {isChatEnabled ? (
@@ -77,7 +77,7 @@ const Sidebar = ({ onClose }: SidebarProps) => {
{t('tabs.assistant')}
- )}
+ ) : null}
{/* Third row - Status buttons or empty state */}
{isActiveStatusesEmpty ? (
diff --git a/src/components/sidebar/unit-sidebar.tsx b/src/components/sidebar/unit-sidebar.tsx
index 5457bd7d..4af5b509 100644
--- a/src/components/sidebar/unit-sidebar.tsx
+++ b/src/components/sidebar/unit-sidebar.tsx
@@ -1,5 +1,6 @@
import { Lock, Mic, Phone, Radio, Unlock } from 'lucide-react-native';
import * as React from 'react';
+import { useTranslation } from 'react-i18next';
import { StyleSheet, TouchableOpacity, View } from 'react-native';
import { AudioStreamBottomSheet } from '@/components/audio-stream/audio-stream-bottom-sheet';
@@ -19,6 +20,7 @@ type ItemProps = {
};
export const SidebarUnitCard = ({ unitName: defaultUnitName, unitType: defaultUnitType, unitGroup: defaultUnitGroup, bgColor }: ItemProps) => {
+ const { t } = useTranslation();
const activeUnit = useCoreStore((state) => state.activeUnit);
const setIsBottomSheetVisible = useLiveKitStore((state) => state.setIsBottomSheetVisible);
const ensureMicrophonePermission = useLiveKitStore((state) => state.ensureMicrophonePermission);
@@ -70,15 +72,35 @@ export const SidebarUnitCard = ({ unitName: defaultUnitName, unitType: defaultUn
) : null}
-
+
{isMapLocked ? : }
-
+
-
+
diff --git a/src/components/status/__tests__/status-bottom-sheet.test.tsx b/src/components/status/__tests__/status-bottom-sheet.test.tsx
index a8958bff..d1ef81e0 100644
--- a/src/components/status/__tests__/status-bottom-sheet.test.tsx
+++ b/src/components/status/__tests__/status-bottom-sheet.test.tsx
@@ -3,6 +3,12 @@ jest.mock('react-i18next', () => ({
useTranslation: jest.fn(),
}));
+// Controllable keyboard height (plain function, so jest.clearAllMocks can't wipe it)
+let mockKeyboardHeight = 0;
+jest.mock('@/hooks/use-keyboard-height', () => ({
+ useKeyboardHeight: () => mockKeyboardHeight,
+}));
+
// Mock all UI components based on actual imports
jest.mock('@/components/ui/actionsheet', () => {
const mockReact = require('react');
@@ -824,6 +830,43 @@ describe('StatusBottomSheet', () => {
expect(screen.getByText('Submit')).toBeTruthy();
});
+ it('should scroll the note step to the end when the keyboard opens', async () => {
+ const { ScrollView } = require('react-native');
+ const scrollToEndSpy = jest.spyOn(ScrollView.prototype, 'scrollToEnd').mockImplementation(() => {});
+
+ const selectedStatus = {
+ Id: 'status-1',
+ Text: 'Available',
+ Detail: 1,
+ Note: 1,
+ };
+
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ selectedStatus,
+ currentStep: 'add-note',
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
+ });
+
+ try {
+ mockKeyboardHeight = 300;
+ render( );
+
+ await waitFor(() => {
+ expect(scrollToEndSpy).toHaveBeenCalledWith({ animated: true });
+ });
+ } finally {
+ mockKeyboardHeight = 0;
+ scrollToEndSpy.mockRestore();
+ }
+ });
+
it('should handle previous button on note step', () => {
const selectedStatus = {
Id: 'status-1',
@@ -1850,6 +1893,73 @@ describe('StatusBottomSheet', () => {
});
});
+ it('should scroll the auto-selected active call into view once its row lays out', async () => {
+ const { ScrollView } = require('react-native');
+ const scrollToSpy = jest.spyOn(ScrollView.prototype, 'scrollTo').mockImplementation(() => {});
+
+ const activeCall = {
+ CallId: 'active-call-123',
+ Number: 'C123',
+ Name: 'Active Emergency Call',
+ Address: '123 Active St',
+ };
+
+ const selectedStatus = {
+ Id: 'status-1',
+ Text: 'Responding',
+ Detail: 2, // Show calls
+ Note: 0,
+ };
+
+ const coreStoreWithActiveCall = {
+ ...defaultCoreStore,
+ activeCallId: 'active-call-123',
+ };
+ mockGetState.mockReturnValue(coreStoreWithActiveCall as any);
+ mockUseCoreStore.mockImplementation((selector: any) => {
+ if (selector) {
+ return selector(coreStoreWithActiveCall);
+ }
+ return coreStoreWithActiveCall;
+ });
+
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ selectedStatus,
+ availableCalls: [{ CallId: 'other-call-456', Number: 'C456', Name: 'Other Call', Address: '456 Other St' }, activeCall],
+ isLoading: false,
+ selectedCall: null,
+ selectedDestinationType: 'none',
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
+ });
+
+ render( );
+
+ await waitFor(() => {
+ expect(mockSetSelectedCall).toHaveBeenCalledWith(activeCall);
+ });
+
+ // The active call's row reports its position — the list should scroll to it
+ const activeRow = screen.getByText('C123 - Active Emergency Call');
+ fireEvent(activeRow, 'layout', { nativeEvent: { layout: { x: 0, y: 240, width: 320, height: 76 } } });
+
+ expect(scrollToSpy).toHaveBeenCalledWith({ y: 232, animated: true });
+
+ // A different row laying out must not scroll (only the pending active call does)
+ scrollToSpy.mockClear();
+ const otherRow = screen.getByText('C456 - Other Call');
+ fireEvent(otherRow, 'layout', { nativeEvent: { layout: { x: 0, y: 0, width: 320, height: 76 } } });
+ expect(scrollToSpy).not.toHaveBeenCalled();
+
+ scrollToSpy.mockRestore();
+ });
+
it('should not pre-select active call when calls are not enabled (detailLevel 1)', () => {
const activeCall = {
CallId: 'active-call-123',
diff --git a/src/components/status/status-bottom-sheet.tsx b/src/components/status/status-bottom-sheet.tsx
index 53646503..4fc7ccab 100644
--- a/src/components/status/status-bottom-sheet.tsx
+++ b/src/components/status/status-bottom-sheet.tsx
@@ -2,7 +2,7 @@ import { ArrowLeft, ArrowRight, Check } from 'lucide-react-native';
import { useColorScheme } from 'nativewind';
import React from 'react';
import { useTranslation } from 'react-i18next';
-import { ScrollView, TouchableOpacity } from 'react-native';
+import { InteractionManager, ScrollView, TouchableOpacity } from 'react-native';
import { useKeyboardHeight } from '@/hooks/use-keyboard-height';
import { logger } from '@/lib/logging';
@@ -173,6 +173,64 @@ export const StatusBottomSheet = () => {
return availableCalls.find((call) => call.CallId === activeCallId) ?? null;
}, [activeCallId, availableCalls]);
+ // When the active call is auto-selected the destination list should scroll it
+ // into view. The id is parked here until the selected row's onLayout reports
+ // where it landed (the list may not even be mounted yet — e.g. the user is
+ // still on the status step).
+ const destinationScrollRef = React.useRef(null);
+ const pendingScrollToCallIdRef = React.useRef(null);
+ // Row offsets captured from onLayout. onLayout only fires when a row is
+ // (re)measured, so a scroll request armed after the list already settled would
+ // otherwise never be acted on — these let it resolve immediately instead.
+ const callRowOffsetsRef = React.useRef>({});
+
+ const scrollDestinationToOffset = React.useCallback((y: number) => {
+ destinationScrollRef.current?.scrollTo({ y: Math.max(0, y - 8), animated: true });
+ }, []);
+
+ /** Run a pending scroll request as soon as the target row's offset is known. */
+ const flushPendingCallScroll = React.useCallback(() => {
+ const callId = pendingScrollToCallIdRef.current;
+ if (!callId) {
+ return;
+ }
+
+ const offset = callRowOffsetsRef.current[callId];
+ if (offset == null) {
+ return;
+ }
+
+ pendingScrollToCallIdRef.current = null;
+ scrollDestinationToOffset(offset);
+ }, [scrollDestinationToOffset]);
+
+ React.useEffect(() => {
+ if (!isOpen) {
+ pendingScrollToCallIdRef.current = null;
+ callRowOffsetsRef.current = {};
+ }
+ }, [isOpen]);
+
+ // The keyboard padding on ActionsheetContent reserves the covered strip, but it
+ // doesn't move the note field there — if the field sits below the fold it stays
+ // hidden under the keyboard. The note and its submit button are the last content
+ // in both note-entry steps, so scrolling to the end brings them into view once
+ // the padded layout settles.
+ const noteScrollRef = React.useRef(null);
+ const isOnNoteEntryStep = currentStep === 'add-note' || (currentStep === 'select-destination' && !shouldShowDestinationStep);
+
+ React.useEffect(() => {
+ if (keyboardHeight <= 0 || !isOnNoteEntryStep) {
+ return;
+ }
+
+ const timeoutId = setTimeout(() => {
+ noteScrollRef.current?.scrollToEnd({ animated: true });
+ }, 50);
+
+ return () => clearTimeout(timeoutId);
+ }, [keyboardHeight, isOnNoteEntryStep]);
+
React.useEffect(() => {
if (isOpen && activeUnit) {
fetchDestinationData(activeUnit.UnitId);
@@ -239,7 +297,13 @@ export const StatusBottomSheet = () => {
setSelectedCall(activeCallCandidate);
setSelectedDestinationType('call');
- }, [activeCallCandidate, detailLevel, isOpen, selectedCall, selectedDestinationType, selectedPoi, selectedStation, selectedStatus, setSelectedCall, setSelectedDestinationType]);
+ pendingScrollToCallIdRef.current = activeCallCandidate.CallId;
+
+ // If the rows are already laid out no further onLayout will fire, so drive
+ // the scroll ourselves once the current interactions settle.
+ const interaction = InteractionManager.runAfterInteractions(flushPendingCallScroll);
+ return () => interaction.cancel();
+ }, [activeCallCandidate, detailLevel, flushPendingCallScroll, isOpen, selectedCall, selectedDestinationType, selectedPoi, selectedStation, selectedStatus, setSelectedCall, setSelectedDestinationType]);
// Auto-pick the initial tab when the sheet opens or the status changes.
// After that the user's manual tab taps must win — recomputing the preferred
@@ -628,7 +692,10 @@ export const StatusBottomSheet = () => {
-
+ {/* flex-1 (not just shrink) so the column fills the snap-point-sized sheet:
+ the step's list flexes into the space and the action buttons sit at the
+ bottom instead of floating above a dead zone. */}
+
{t('common.step')} {getStepNumber()} {t('common.of')} {getTotalSteps()}
@@ -640,10 +707,10 @@ export const StatusBottomSheet = () => {
{currentStep === 'select-status' ? (
-
+
{t('status.select_status_type')}
-
+
{activeStatuses?.Statuses && activeStatuses.Statuses.length > 0 ? (
activeStatuses.Statuses.map((status) => {
@@ -691,7 +758,7 @@ export const StatusBottomSheet = () => {
) : null}
{currentStep === 'select-destination' && shouldShowDestinationStep ? (
-
+
{t('status.select_destination_type')}
{
) : null}
-
+
{showCalls ? (
{isLoading ? (
@@ -730,6 +797,16 @@ export const StatusBottomSheet = () => {
handleCallSelect(call.CallId)}
+ onLayout={(event) => {
+ // Bring the auto-selected active call into view once
+ // its row reports where it landed in the list.
+ callRowOffsetsRef.current[call.CallId] = event.nativeEvent.layout.y;
+ if (pendingScrollToCallIdRef.current !== call.CallId) {
+ return;
+ }
+ pendingScrollToCallIdRef.current = null;
+ scrollDestinationToOffset(event.nativeEvent.layout.y);
+ }}
className={`mb-3 rounded-lg border-2 p-3 ${selectedCall?.CallId === call.CallId ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20' : 'border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800'}`}
>
@@ -834,32 +911,44 @@ export const StatusBottomSheet = () => {
) : null}
{currentStep === 'select-destination' && !shouldShowDestinationStep ? (
-
- {isNoteRequired || isNoteOptional ? (
- <>
- {t('status.add_note')}
-
- >
- ) : null}
-
- {cameFromStatusSelection ? (
-
-
- {t('common.previous')}
-
- ) : (
-
- {t('common.cancel')}
+ /* Same plain-ScrollView treatment as the add-note step below: the sheet's
+ keyboard padding reserves the covered strip, and this scroll container
+ lets the note field and buttons compress/scroll into it instead of
+ being clipped by the fixed-height sheet. */
+
+
+ {isNoteRequired || isNoteOptional ? (
+ <>
+ {t('status.add_note')}
+
+ >
+ ) : null}
+
+ {cameFromStatusSelection ? (
+
+
+ {t('common.previous')}
+
+ ) : (
+
+ {t('common.cancel')}
+
+ )}
+ void handleSubmit()} className="bg-blue-600 px-4 py-2" isDisabled={(isNoteRequired && !note.trim()) || isSubmitting}>
+ {isSubmitting ? : null}
+ {isSubmitting ? t('common.submitting') : t('common.submit')}
- )}
- void handleSubmit()} className="bg-blue-600 px-4 py-2" isDisabled={(isNoteRequired && !note.trim()) || isSubmitting}>
- {isSubmitting ? : null}
- {isSubmitting ? t('common.submitting') : t('common.submit')}
-
-
-
+
+
+
) : null}
{currentStep === 'add-note' ? (
@@ -867,7 +956,13 @@ export const StatusBottomSheet = () => {
the ActionsheetContent paddingBottom. KeyboardAwareScrollView also reacts to
the keyboard (its events are window-agnostic), so it compensated a second
time and pushed the note field out of the sheet's visible area. */
-
+
{t('status.selected_status')}:
diff --git a/src/hooks/__tests__/use-quick-check-in.test.ts b/src/hooks/__tests__/use-quick-check-in.test.ts
index 19753d76..2f538ec5 100644
--- a/src/hooks/__tests__/use-quick-check-in.test.ts
+++ b/src/hooks/__tests__/use-quick-check-in.test.ts
@@ -40,14 +40,18 @@ jest.mock('@/stores/app/core-store', () => ({
),
}));
-jest.mock('@/stores/app/location-store', () => ({
- useLocationStore: jest.fn((selector: any) =>
- selector({
- latitude: 40.7128,
- longitude: -74.006,
- })
- ),
-}));
+// The hook reads location via getState() inside quickCheckIn rather than subscribing,
+// so the mock has to expose getState alongside the selector call signature.
+const mockLocationState = {
+ latitude: 40.7128,
+ longitude: -74.006,
+};
+
+jest.mock('@/stores/app/location-store', () => {
+ const useLocationStore: any = jest.fn((selector: any) => selector(mockLocationState));
+ useLocationStore.getState = jest.fn(() => mockLocationState);
+ return { useLocationStore };
+});
jest.mock('@/stores/toast/store', () => ({
useToastStore: jest.fn((selector: any) =>
diff --git a/src/hooks/use-quick-check-in.ts b/src/hooks/use-quick-check-in.ts
index 64bbe0cb..74c235eb 100644
--- a/src/hooks/use-quick-check-in.ts
+++ b/src/hooks/use-quick-check-in.ts
@@ -14,13 +14,14 @@ export function useQuickCheckIn(callId: number, checkInType?: number) {
const isCheckingIn = useCheckInTimerStore((state) => state.isCheckingIn);
const performCheckInAction = useCheckInTimerStore((state) => state.performCheckIn);
const activeUnit = useCoreStore((state) => state.activeUnit);
- const latitude = useLocationStore((state) => state.latitude);
- const longitude = useLocationStore((state) => state.longitude);
const showToast = useToastStore((state) => state.showToast);
+ // NOTE: location is read via useLocationStore.getState() inside quickCheckIn instead
+ // of subscribing — subscribing re-rendered every consumer of this hook on each GPS fix.
const quickCheckIn = useCallback(async () => {
const resolvedCheckInType = checkInType ?? (activeUnit ? CHECK_IN_TARGET_TYPE.UNIT_TYPE : CHECK_IN_TARGET_TYPE.PERSONNEL);
const shouldUseActiveUnit = resolvedCheckInType === CHECK_IN_TARGET_TYPE.UNIT_TYPE && activeUnit !== null;
+ const { latitude, longitude } = useLocationStore.getState();
const input: PerformCheckInInput = {
CallId: callId,
CheckInType: resolvedCheckInType,
@@ -40,7 +41,7 @@ export function useQuickCheckIn(callId: number, checkInType?: number) {
}
return result;
- }, [callId, checkInType, activeUnit, latitude, longitude, performCheckInAction, showToast, t]);
+ }, [callId, checkInType, activeUnit, performCheckInAction, showToast, t]);
return { quickCheckIn, isCheckingIn };
}
diff --git a/src/lib/__tests__/map-camera.test.ts b/src/lib/__tests__/map-camera.test.ts
new file mode 100644
index 00000000..ec35bd22
--- /dev/null
+++ b/src/lib/__tests__/map-camera.test.ts
@@ -0,0 +1,238 @@
+import { applyPitchHysteresis, applyZoomHysteresis, createCirclePolygon, FOLLOW_PITCH_MOVING, normalizeHeading, normalizeSpeed, smoothSpeed, wrapLongitude, zoomForSpeed } from '../map-camera';
+
+describe('zoomForSpeed', () => {
+ it('returns the tightest zoom when stationary', () => {
+ expect(zoomForSpeed(0)).toBe(17);
+ });
+
+ it('treats invalid speeds as stationary', () => {
+ expect(zoomForSpeed(-1)).toBe(17);
+ expect(zoomForSpeed(NaN)).toBe(17);
+ });
+
+ it('stays near street level at walking pace', () => {
+ // ~1.4 m/s = typical walking speed
+ const zoom = zoomForSpeed(1.4);
+ expect(zoom).toBeGreaterThan(16);
+ expect(zoom).toBeLessThanOrEqual(17);
+ });
+
+ it('zooms out for urban driving speeds', () => {
+ // ~13.4 m/s = 30 mph
+ const zoom = zoomForSpeed(13.4);
+ expect(zoom).toBeGreaterThan(14);
+ expect(zoom).toBeLessThan(15.5);
+ });
+
+ it('zooms out further at highway speed', () => {
+ // ~29 m/s = 65 mph
+ const zoom = zoomForSpeed(29);
+ expect(zoom).toBeGreaterThan(12.5);
+ expect(zoom).toBeLessThan(13.5);
+ });
+
+ it('clamps at the widest zoom for extreme speeds', () => {
+ expect(zoomForSpeed(100)).toBe(12.5);
+ });
+
+ it('is monotonically non-increasing as speed rises', () => {
+ let previous = Infinity;
+ for (let speed = 0; speed <= 45; speed += 0.5) {
+ const zoom = zoomForSpeed(speed);
+ expect(zoom).toBeLessThanOrEqual(previous);
+ previous = zoom;
+ }
+ });
+
+ it('interpolates linearly between stops', () => {
+ // Midpoint of the [4, 16] → [9, 15] segment
+ expect(zoomForSpeed(6.5)).toBeCloseTo(15.5, 5);
+ });
+});
+
+describe('normalizeHeading', () => {
+ it('passes through valid headings', () => {
+ expect(normalizeHeading(0)).toBe(0);
+ expect(normalizeHeading(180)).toBe(180);
+ expect(normalizeHeading(359.9)).toBeCloseTo(359.9);
+ });
+
+ it('wraps headings of 360 and above', () => {
+ expect(normalizeHeading(360)).toBe(0);
+ expect(normalizeHeading(450)).toBe(90);
+ });
+
+ it('returns null for missing or invalid headings', () => {
+ expect(normalizeHeading(null)).toBeNull();
+ expect(normalizeHeading(undefined)).toBeNull();
+ // iOS reports "no heading fix" as -1
+ expect(normalizeHeading(-1)).toBeNull();
+ expect(normalizeHeading(NaN)).toBeNull();
+ });
+});
+
+describe('normalizeSpeed', () => {
+ it('passes through valid speeds', () => {
+ expect(normalizeSpeed(5)).toBe(5);
+ });
+
+ it('clamps missing or invalid speeds to zero', () => {
+ expect(normalizeSpeed(null)).toBe(0);
+ expect(normalizeSpeed(undefined)).toBe(0);
+ // iOS reports "no speed fix" as -1
+ expect(normalizeSpeed(-1)).toBe(0);
+ expect(normalizeSpeed(NaN)).toBe(0);
+ });
+});
+
+describe('smoothSpeed', () => {
+ it('adopts the first sample directly', () => {
+ expect(smoothSpeed(null, 10)).toBe(10);
+ });
+
+ it('moves partway toward the new sample', () => {
+ expect(smoothSpeed(0, 10, 0.4)).toBeCloseTo(4);
+ expect(smoothSpeed(10, 0, 0.4)).toBeCloseTo(6);
+ });
+
+ it('converges to a steady speed over repeated samples', () => {
+ let smoothed: number | null = null;
+ for (let i = 0; i < 30; i++) {
+ smoothed = smoothSpeed(smoothed, 20);
+ }
+ expect(smoothed).toBeCloseTo(20, 1);
+ });
+
+ it('normalizes invalid samples to zero', () => {
+ expect(smoothSpeed(10, -1, 0.5)).toBe(5);
+ });
+});
+
+describe('applyZoomHysteresis', () => {
+ it('adopts the target when there is no current zoom', () => {
+ expect(applyZoomHysteresis(null, 16)).toBe(16);
+ });
+
+ it('keeps the current zoom for changes below the threshold', () => {
+ expect(applyZoomHysteresis(16, 16.1)).toBe(16);
+ expect(applyZoomHysteresis(16, 15.9)).toBe(16);
+ });
+
+ it('adopts the target for changes at or above the threshold', () => {
+ expect(applyZoomHysteresis(16, 16.5)).toBe(16.5);
+ expect(applyZoomHysteresis(16, 15)).toBe(15);
+ });
+});
+
+describe('applyPitchHysteresis', () => {
+ it('tilts up once clearly moving', () => {
+ expect(applyPitchHysteresis(0, 2)).toBe(FOLLOW_PITCH_MOVING);
+ });
+
+ it('returns to top-down once clearly stopped', () => {
+ expect(applyPitchHysteresis(FOLLOW_PITCH_MOVING, 0.2)).toBe(0);
+ });
+
+ it('holds the current pitch inside the dead band', () => {
+ // Between the tilt-down (0.7) and tilt-up (1.5) thresholds the pitch sticks,
+ // so a speed hovering around 1 m/s can't flip the camera every fix.
+ expect(applyPitchHysteresis(0, 1)).toBe(0);
+ expect(applyPitchHysteresis(FOLLOW_PITCH_MOVING, 1)).toBe(FOLLOW_PITCH_MOVING);
+ });
+
+ it('does not oscillate when speed jitters around 1 m/s', () => {
+ const speeds = [0.9, 1.1, 0.95, 1.2, 1.05, 0.85];
+ let pitch = 0;
+ for (const speed of speeds) {
+ pitch = applyPitchHysteresis(pitch, speed);
+ expect(pitch).toBe(0);
+ }
+ });
+
+ it('defaults to top-down when there is no previous pitch', () => {
+ expect(applyPitchHysteresis(null, 1)).toBe(0);
+ });
+
+ it('treats invalid speeds as stationary', () => {
+ expect(applyPitchHysteresis(FOLLOW_PITCH_MOVING, -1)).toBe(0);
+ expect(applyPitchHysteresis(FOLLOW_PITCH_MOVING, NaN)).toBe(0);
+ });
+});
+
+describe('wrapLongitude', () => {
+ it('passes through in-range longitudes', () => {
+ expect(wrapLongitude(0)).toBe(0);
+ expect(wrapLongitude(-122.4)).toBeCloseTo(-122.4);
+ });
+
+ it('wraps longitudes past the antimeridian', () => {
+ expect(wrapLongitude(181)).toBeCloseTo(-179);
+ expect(wrapLongitude(-181)).toBeCloseTo(179);
+ expect(wrapLongitude(540)).toBeCloseTo(-180);
+ });
+});
+
+describe('createCirclePolygon', () => {
+ it('produces a closed polygon ring', () => {
+ const circle = createCirclePolygon(-122.4, 47.6, 100);
+ const ring = circle.geometry.coordinates[0];
+ expect(ring.length).toBe(65);
+ expect(ring[0]).toEqual(ring[ring.length - 1]);
+ });
+
+ it('closes the ring exactly rather than relying on sin/cos at 2π', () => {
+ const ring = createCirclePolygon(-122.4, 47.6, 100).geometry.coordinates[0];
+ const first = ring[0];
+ const last = ring[ring.length - 1];
+ // Strict equality — GeoJSON requires an identical closing position, and
+ // computing it from sin(2π)/cos(2π) leaves floating point residue.
+ expect(last[0]).toBe(first[0]);
+ expect(last[1]).toBe(first[1]);
+ });
+
+ it('normalizes a center longitude given outside [-180, 180)', () => {
+ const wrapped = createCirclePolygon(181, 0, 100).geometry.coordinates[0];
+ const direct = createCirclePolygon(-179, 0, 100).geometry.coordinates[0];
+ expect(wrapped[0][0]).toBeCloseTo(direct[0][0], 9);
+ });
+
+ it('keeps a ring spanning the antimeridian contiguous', () => {
+ // Centered just west of the antimeridian with a large radius, the ring's
+ // eastern vertices run past +180. They must stay contiguous (not jump to
+ // -180) or the polygon smears right across the map.
+ const ring = createCirclePolygon(179.999, 0, 50000).geometry.coordinates[0];
+ const lons = ring.map((c) => c[0]);
+ expect(Math.max(...lons)).toBeGreaterThan(180);
+ for (let i = 1; i < lons.length; i++) {
+ expect(Math.abs(lons[i] - lons[i - 1])).toBeLessThan(180);
+ }
+ });
+
+ it('clamps latitudes so a polar circle stays a valid coordinate', () => {
+ const ring = createCirclePolygon(0, 89.999, 200000).geometry.coordinates[0];
+ ring.forEach(([, lat]) => {
+ expect(lat).toBeLessThanOrEqual(90);
+ expect(lat).toBeGreaterThanOrEqual(-90);
+ });
+ });
+
+ it('centers the ring on the given coordinate', () => {
+ const circle = createCirclePolygon(-122.4, 47.6, 100);
+ const ring = circle.geometry.coordinates[0];
+ const avgLon = ring.slice(0, -1).reduce((sum, c) => sum + c[0], 0) / (ring.length - 1);
+ const avgLat = ring.slice(0, -1).reduce((sum, c) => sum + c[1], 0) / (ring.length - 1);
+ expect(avgLon).toBeCloseTo(-122.4, 5);
+ expect(avgLat).toBeCloseTo(47.6, 5);
+ });
+
+ it('widens longitude spacing away from the equator so the circle stays round', () => {
+ const equator = createCirclePolygon(0, 0, 1000);
+ const north = createCirclePolygon(0, 60, 1000);
+ const lonSpan = (feature: GeoJSON.Feature) => {
+ const lons = feature.geometry.coordinates[0].map((c) => c[0]);
+ return Math.max(...lons) - Math.min(...lons);
+ };
+ // cos(60°) = 0.5 → the ring must span about twice as many degrees of longitude
+ expect(lonSpan(north) / lonSpan(equator)).toBeCloseTo(2, 1);
+ });
+});
diff --git a/src/lib/auth/__tests__/api-timeout.test.ts b/src/lib/auth/__tests__/api-timeout.test.ts
new file mode 100644
index 00000000..06691aac
--- /dev/null
+++ b/src/lib/auth/__tests__/api-timeout.test.ts
@@ -0,0 +1,50 @@
+/**
+ * The auth client talks to /connect/token. Without a timeout a hung request pins
+ * the single-flight refresh promise (lib/auth/refresh-lock.ts) and every request
+ * queued behind a 401 waits on it forever.
+ */
+const mockCreateConfigs: Record[] = [];
+
+const mockAuthApiInstance = Object.assign(jest.fn(), {
+ interceptors: {
+ request: { use: jest.fn() },
+ response: { use: jest.fn() },
+ },
+ post: jest.fn(),
+});
+
+jest.mock('axios', () => ({
+ __esModule: true,
+ default: {
+ create: jest.fn((config: Record) => {
+ mockCreateConfigs.push(config);
+ return mockAuthApiInstance;
+ }),
+ },
+}));
+
+jest.mock('@/lib/logging', () => ({
+ logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() },
+}));
+
+jest.mock('@/lib/storage/app', () => ({
+ getBaseApiUrl: jest.fn(() => 'https://example.test'),
+}));
+
+describe('auth API client configuration', () => {
+ beforeAll(() => {
+ jest.isolateModules(() => {
+ require('@/lib/auth/api');
+ });
+ });
+
+ it('sets a timeout on the token endpoint client', () => {
+ expect(mockCreateConfigs[0]).toMatchObject({ timeout: 15000 });
+ });
+
+ it('keeps the timeout shorter than the main API client timeout', () => {
+ // The refresh must give up before a request waiting on it would, so a stalled
+ // refresh surfaces as a transient failure rather than cascading timeouts.
+ expect(mockCreateConfigs[0].timeout as number).toBeLessThan(30000);
+ });
+});
diff --git a/src/lib/auth/__tests__/jwt.test.ts b/src/lib/auth/__tests__/jwt.test.ts
new file mode 100644
index 00000000..5883b68c
--- /dev/null
+++ b/src/lib/auth/__tests__/jwt.test.ts
@@ -0,0 +1,74 @@
+import { describe, expect, it } from '@jest/globals';
+
+import { decodeJwtPayload, getJwtExpiryMs } from '../jwt';
+
+/** Build a JWT whose payload is base64url-encoded (RFC 7515): '-'/'_' alphabet, no padding. */
+const makeJwt = (payload: Record): string => {
+ const encoded = Buffer.from(JSON.stringify(payload)).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
+ return `header.${encoded}.signature`;
+};
+
+describe('decodeJwtPayload', () => {
+ it('decodes a payload containing base64url characters (- and _)', () => {
+ // These byte sequences force '+' and '/' in standard base64, which become
+ // '-' and '_' in base64url — the exact input react-native-base64 rejects.
+ const payload = { sub: 'user-1', name: 'Unit ~~~ ûÿ', exp: 1893456000 };
+ const token = makeJwt(payload);
+
+ // Guard: the fixture really does exercise the base64url alphabet.
+ expect(token.split('.')[1]).toMatch(/[-_]/);
+
+ expect(JSON.parse(decodeJwtPayload(token))).toMatchObject({ sub: 'user-1', name: 'Unit ~~~ ûÿ', exp: 1893456000 });
+ });
+
+ // react-native-base64 hands back one character per byte (latin1), so without
+ // reinterpreting those bytes as UTF-8 every non-ASCII display name arrives
+ // mojibake'd — "Tëst" as "Tëst". The app ships nine non-English locales, so
+ // departments really do have names outside ASCII.
+ it.each([
+ ['Latin accents', 'Tëst Ünit Nº3'],
+ ['Cyrillic', 'Пожежна частина 7'],
+ ['Greek', 'Πυροσβεστική Μονάδα'],
+ ['Arabic', 'وحدة الإطفاء'],
+ ['four-byte sequences', 'Station 🚒 Alpha'],
+ ])('round-trips %s in a name claim', (_label, name) => {
+ expect(JSON.parse(decodeJwtPayload(makeJwt({ sub: 'user-1', name })))).toMatchObject({ name });
+ });
+
+ it('falls back to the raw bytes rather than throwing on invalid UTF-8', () => {
+ // 0x80 is a bare continuation byte — not a legal UTF-8 sequence start.
+ const encoded = Buffer.from([0x80, 0x41]).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
+ expect(() => decodeJwtPayload(`header.${encoded}.signature`)).not.toThrow();
+ });
+
+ it('decodes an unpadded payload (base64url drops = padding)', () => {
+ const token = makeJwt({ sub: 'abc' });
+ expect(token.split('.')[1]).not.toContain('=');
+ expect(JSON.parse(decodeJwtPayload(token))).toEqual({ sub: 'abc' });
+ });
+
+ it('throws when the token has no payload segment', () => {
+ expect(() => decodeJwtPayload('not-a-jwt')).toThrow('Invalid JWT: missing payload segment');
+ });
+});
+
+describe('getJwtExpiryMs', () => {
+ it('returns the exp claim converted to epoch milliseconds', () => {
+ expect(getJwtExpiryMs(makeJwt({ sub: 'a', exp: 1893456000 }))).toBe(1893456000 * 1000);
+ });
+
+ it('reads exp from a payload that uses base64url characters', () => {
+ const token = makeJwt({ sub: 'user-1', name: 'Unit ~~~ ûÿ', exp: 1893456000 });
+ expect(getJwtExpiryMs(token)).toBe(1893456000 * 1000);
+ });
+
+ it('returns null when there is no numeric exp claim', () => {
+ expect(getJwtExpiryMs(makeJwt({ sub: 'a' }))).toBeNull();
+ expect(getJwtExpiryMs(makeJwt({ sub: 'a', exp: 'soon' }))).toBeNull();
+ });
+
+ it('returns null for an opaque (non-JWT) token instead of throwing', () => {
+ expect(getJwtExpiryMs('opaque-access-token')).toBeNull();
+ expect(getJwtExpiryMs('')).toBeNull();
+ });
+});
diff --git a/src/lib/auth/api.tsx b/src/lib/auth/api.tsx
index b429f059..d5d293a5 100644
--- a/src/lib/auth/api.tsx
+++ b/src/lib/auth/api.tsx
@@ -9,6 +9,10 @@ import type { AuthResponse, LoginCredentials, LoginResponse, SsoLoginCredentials
const authApi = axios.create({
baseURL: getBaseApiUrl(),
+ // Axios defaults to no timeout — a hung /connect/token call would otherwise
+ // pin the single-flight refresh promise and stall every queued 401 retry. A
+ // timeout surfaces as a transient failure (no response), never a logout.
+ timeout: 15000,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
diff --git a/src/lib/auth/index.tsx b/src/lib/auth/index.tsx
index 305bb219..7bfed690 100644
--- a/src/lib/auth/index.tsx
+++ b/src/lib/auth/index.tsx
@@ -10,7 +10,7 @@ export * from './types';
// Utility hooks and selectors
export const useAuth = () => {
- const { accessToken, status, error, login, ssoLogin, logout, hydrate } = useAuthStore(
+ const { accessToken, status, error, login, ssoLogin, logout } = useAuthStore(
useShallow((state) => ({
accessToken: state.accessToken,
status: state.status,
@@ -18,7 +18,6 @@ export const useAuth = () => {
login: state.login,
ssoLogin: state.ssoLogin,
logout: state.logout,
- hydrate: state.hydrate,
}))
);
return {
@@ -29,6 +28,5 @@ export const useAuth = () => {
ssoLogin,
logout,
status,
- hydrate,
};
};
diff --git a/src/lib/auth/jwt.ts b/src/lib/auth/jwt.ts
new file mode 100644
index 00000000..6730a364
--- /dev/null
+++ b/src/lib/auth/jwt.ts
@@ -0,0 +1,51 @@
+import base64 from 'react-native-base64';
+
+/**
+ * Reinterpret a byte string as UTF-8.
+ *
+ * react-native-base64's decode() returns one character per byte (latin1), so a
+ * multi-byte claim such as a name with an accent arrives mojibake'd ("Tëst"
+ * instead of "Tëst"). Percent-encoding each byte and letting decodeURIComponent
+ * reassemble the sequences recovers the original text. Falls back to the raw
+ * byte string if the bytes are not valid UTF-8, so this can never throw where
+ * the previous latin1 behavior merely garbled.
+ */
+const bytesToUtf8 = (binary: string): string => {
+ try {
+ return decodeURIComponent(Array.from(binary, (char) => `%${(char.charCodeAt(0) & 0xff).toString(16).padStart(2, '0')}`).join(''));
+ } catch {
+ return binary;
+ }
+};
+
+/**
+ * Decode the payload (second segment) of a JWT to its JSON string.
+ *
+ * JWT segments are base64url-encoded (RFC 7515): the alphabet uses '-' and '_'
+ * instead of '+' and '/', and drops '=' padding. react-native-base64's decode()
+ * only understands the standard alphabet and throws on '-'/'_', so normalize to
+ * standard base64 and re-pad before decoding.
+ */
+export const decodeJwtPayload = (token: string): string => {
+ const segment = token.split('.')[1];
+ if (!segment) {
+ throw new Error('Invalid JWT: missing payload segment');
+ }
+
+ const normalized = segment.replace(/-/g, '+').replace(/_/g, '/');
+ const padded = normalized.padEnd(normalized.length + ((4 - (normalized.length % 4)) % 4), '=');
+ return bytesToUtf8(base64.decode(padded));
+};
+
+/**
+ * Best-effort extraction of a JWT's `exp` claim as epoch milliseconds.
+ * Returns null when the token is not a decodable JWT or carries no numeric exp.
+ */
+export const getJwtExpiryMs = (token: string): number | null => {
+ try {
+ const payload = JSON.parse(decodeJwtPayload(token)) as { exp?: unknown };
+ return typeof payload.exp === 'number' && Number.isFinite(payload.exp) ? payload.exp * 1000 : null;
+ } catch {
+ return null;
+ }
+};
diff --git a/src/lib/auth/types.tsx b/src/lib/auth/types.tsx
index 140c86ac..109141c6 100644
--- a/src/lib/auth/types.tsx
+++ b/src/lib/auth/types.tsx
@@ -58,7 +58,6 @@ export interface AuthState {
ssoLogin: (credentials: SsoLoginCredentials) => Promise;
logout: () => Promise;
refreshAccessToken: () => Promise;
- hydrate: () => void;
isFirstTime: boolean;
isAuthenticated: () => boolean;
setIsOnboarding: () => void;
diff --git a/src/lib/hooks/__tests__/use-keep-alive.test.tsx b/src/lib/hooks/__tests__/use-keep-alive.test.tsx
new file mode 100644
index 00000000..43ec2951
--- /dev/null
+++ b/src/lib/hooks/__tests__/use-keep-alive.test.tsx
@@ -0,0 +1,122 @@
+import { act, renderHook } from '@testing-library/react-native';
+import { activateKeepAwakeAsync, deactivateKeepAwake } from 'expo-keep-awake';
+
+import { logger } from '@/lib/logging';
+
+import { loadKeepAliveState, useKeepAlive } from '../use-keep-alive';
+import { storage } from '../../storage';
+
+jest.mock('expo-keep-awake', () => ({
+ activateKeepAwakeAsync: jest.fn(),
+ deactivateKeepAwake: jest.fn(),
+}));
+
+jest.mock('@/lib/logging', () => ({
+ logger: {
+ info: jest.fn(),
+ debug: jest.fn(),
+ warn: jest.fn(),
+ error: jest.fn(),
+ },
+}));
+
+jest.mock('react-native-mmkv', () => ({
+ useMMKVBoolean: jest.fn(() => [false, jest.fn()]),
+ MMKV: jest.fn().mockImplementation(() => ({
+ getBoolean: jest.fn(),
+ set: jest.fn(),
+ delete: jest.fn(),
+ })),
+}));
+
+jest.mock('../../storage', () => ({
+ storage: {
+ getBoolean: jest.fn(),
+ set: jest.fn(),
+ delete: jest.fn(),
+ },
+}));
+
+const mockActivate = activateKeepAwakeAsync as jest.MockedFunction;
+const mockDeactivate = deactivateKeepAwake as jest.MockedFunction;
+const mockStorage = storage as jest.Mocked;
+
+describe('useKeepAlive', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockActivate.mockResolvedValue(undefined as never);
+ });
+
+ it('activates keep awake when enabled', async () => {
+ const { result } = renderHook(() => useKeepAlive());
+
+ await act(async () => {
+ await result.current.setKeepAliveEnabled(true);
+ });
+
+ expect(mockActivate).toHaveBeenCalledWith('settings');
+ });
+
+ it('deactivates keep awake when disabled', async () => {
+ const { result } = renderHook(() => useKeepAlive());
+
+ await act(async () => {
+ await result.current.setKeepAliveEnabled(false);
+ });
+
+ expect(mockDeactivate).toHaveBeenCalledWith('settings');
+ });
+
+ it('reports a failure through the logger rather than console', async () => {
+ const failure = new Error('keep awake unavailable');
+ mockActivate.mockRejectedValue(failure);
+
+ const { result } = renderHook(() => useKeepAlive());
+
+ await act(async () => {
+ await result.current.setKeepAliveEnabled(true);
+ });
+
+ expect(logger.error).toHaveBeenCalledWith({
+ message: 'Failed to update keep alive state',
+ context: { error: failure, enabled: true },
+ });
+ });
+});
+
+describe('loadKeepAliveState', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockActivate.mockResolvedValue(undefined as never);
+ });
+
+ it('activates keep awake when the persisted flag is set', async () => {
+ mockStorage.getBoolean.mockReturnValue(true);
+
+ await loadKeepAliveState();
+
+ expect(mockActivate).toHaveBeenCalledWith('settings');
+ });
+
+ it('does nothing when the persisted flag is unset', async () => {
+ mockStorage.getBoolean.mockReturnValue(false);
+
+ await loadKeepAliveState();
+
+ expect(mockActivate).not.toHaveBeenCalled();
+ });
+
+ it('reports a startup failure through the logger rather than console', async () => {
+ const failure = new Error('storage unavailable');
+ mockStorage.getBoolean.mockImplementation(() => {
+ throw failure;
+ });
+
+ await loadKeepAliveState();
+
+ expect(logger.error).toHaveBeenCalledWith({
+ message: 'Failed to load keep alive state on startup',
+ context: { error: failure },
+ });
+ });
+});
diff --git a/src/lib/hooks/use-keep-alive.tsx b/src/lib/hooks/use-keep-alive.tsx
index a643bb18..de5388b7 100644
--- a/src/lib/hooks/use-keep-alive.tsx
+++ b/src/lib/hooks/use-keep-alive.tsx
@@ -2,6 +2,8 @@ import { activateKeepAwakeAsync, deactivateKeepAwake } from 'expo-keep-awake';
import React from 'react';
import { useMMKVBoolean } from 'react-native-mmkv';
+import { logger } from '@/lib/logging';
+
import { storage } from '../storage';
const KEEP_ALIVE_ENABLED = 'KEEP_ALIVE_ENABLED';
@@ -24,7 +26,10 @@ export const useKeepAlive = () => {
}
_setKeepAliveEnabled(enabled);
} catch (error) {
- console.error('Failed to update keep alive state:', error);
+ logger.error({
+ message: 'Failed to update keep alive state',
+ context: { error, enabled },
+ });
}
},
[_setKeepAliveEnabled]
@@ -42,6 +47,9 @@ export const loadKeepAliveState = async () => {
await activateKeepAwakeAsync('settings');
}
} catch (error) {
- console.error('Failed to load keep alive state on startup:', error);
+ logger.error({
+ message: 'Failed to load keep alive state on startup',
+ context: { error },
+ });
}
};
diff --git a/src/lib/map-camera.ts b/src/lib/map-camera.ts
new file mode 100644
index 00000000..8b5c1854
--- /dev/null
+++ b/src/lib/map-camera.ts
@@ -0,0 +1,141 @@
+/**
+ * Pure helpers for the map's follow-the-unit camera and location indicator.
+ *
+ * The camera zoom scales with how fast the unit is moving so the view works for
+ * both crews on foot (walking pace, tight zoom) and vehicles (highway speed,
+ * wide zoom) — the same way turn-by-turn navigation apps frame the road ahead.
+ */
+
+/** Speed (m/s) → zoom level anchor points, interpolated linearly between them. */
+const SPEED_ZOOM_STOPS: readonly (readonly [number, number])[] = [
+ [0, 17], // stationary — tight view for crews on foot
+ [1.5, 16.5], // walking pace (~5 km/h)
+ [4, 16], // jogging / vehicle crawling
+ [9, 15], // ~32 km/h urban response
+ [18, 14], // ~65 km/h
+ [28, 13.25], // ~100 km/h highway
+ [40, 12.5], // upper bound
+];
+
+/**
+ * Zoom level for a given ground speed in meters/second. Piecewise-linear over
+ * SPEED_ZOOM_STOPS and clamped to the outer stops, so walking speeds resolve to
+ * street-level zoom and highway speeds pull the camera out for lookahead.
+ */
+export function zoomForSpeed(speedMps: number): number {
+ const speed = normalizeSpeed(speedMps);
+
+ const [firstSpeed, firstZoom] = SPEED_ZOOM_STOPS[0];
+ if (speed <= firstSpeed) return firstZoom;
+
+ for (let i = 1; i < SPEED_ZOOM_STOPS.length; i++) {
+ const [stopSpeed, stopZoom] = SPEED_ZOOM_STOPS[i];
+ if (speed <= stopSpeed) {
+ const [prevSpeed, prevZoom] = SPEED_ZOOM_STOPS[i - 1];
+ const ratio = (speed - prevSpeed) / (stopSpeed - prevSpeed);
+ return prevZoom + (stopZoom - prevZoom) * ratio;
+ }
+ }
+
+ return SPEED_ZOOM_STOPS[SPEED_ZOOM_STOPS.length - 1][1];
+}
+
+/**
+ * GPS heading is degrees clockwise from north, but platforms report "no fix"
+ * as -1 (iOS) or null. Returns a value in [0, 360) or null when there is no
+ * usable heading.
+ */
+export function normalizeHeading(heading: number | null | undefined): number | null {
+ if (heading == null || !Number.isFinite(heading) || heading < 0) {
+ return null;
+ }
+ return heading % 360;
+}
+
+/** GPS speed is m/s, with "no fix" reported as -1 (iOS) or null. Clamps to >= 0. */
+export function normalizeSpeed(speed: number | null | undefined): number {
+ if (speed == null || !Number.isFinite(speed) || speed < 0) {
+ return 0;
+ }
+ return speed;
+}
+
+/**
+ * Exponential moving average over successive speed fixes. GPS speed is noisy —
+ * smoothing keeps the camera from pumping zoom in and out on every fix.
+ */
+export function smoothSpeed(previous: number | null, next: number, alpha: number = 0.4): number {
+ const target = normalizeSpeed(next);
+ if (previous == null) return target;
+ return previous + (target - previous) * alpha;
+}
+
+/**
+ * Apply hysteresis to a proposed zoom change: keep the current zoom unless the
+ * new target differs by at least `threshold` levels. Prevents oscillation when
+ * a speed hovers around a stop boundary.
+ */
+export function applyZoomHysteresis(currentZoom: number | null, targetZoom: number, threshold: number = 0.25): number {
+ if (currentZoom == null) return targetZoom;
+ return Math.abs(targetZoom - currentZoom) >= threshold ? targetZoom : currentZoom;
+}
+
+/** Camera pitch while the unit is moving (navigation-style tilt behind the unit). */
+export const FOLLOW_PITCH_MOVING = 45;
+/** Smoothed speed (m/s) above which the camera tilts up behind the unit. */
+export const PITCH_TILT_UP_SPEED_MPS = 1.5;
+/** Smoothed speed (m/s) below which the camera returns to a top-down view. */
+export const PITCH_TILT_DOWN_SPEED_MPS = 0.7;
+
+/**
+ * Pitch for a given smoothed ground speed, with hysteresis so the camera
+ * doesn't flip between tilted and top-down when speed hovers around the
+ * moving/stationary boundary (mirrors the zoom hysteresis above): tilt up
+ * above PITCH_TILT_UP_SPEED_MPS, tilt down below PITCH_TILT_DOWN_SPEED_MPS,
+ * and hold the current pitch in between.
+ */
+export function applyPitchHysteresis(currentPitch: number | null, speedMps: number): number {
+ const speed = normalizeSpeed(speedMps);
+ if (speed >= PITCH_TILT_UP_SPEED_MPS) return FOLLOW_PITCH_MOVING;
+ if (speed <= PITCH_TILT_DOWN_SPEED_MPS) return 0;
+ return currentPitch ?? 0;
+}
+
+/** Wrap a longitude into [-180, 180). */
+export function wrapLongitude(longitude: number): number {
+ return ((((longitude + 180) % 360) + 360) % 360) - 180;
+}
+
+/**
+ * GeoJSON circle polygon around a coordinate, radius in meters. Corrects
+ * longitude spacing by latitude so the circle stays round away from the
+ * equator (one degree of longitude shrinks with cos(latitude)).
+ */
+export function createCirclePolygon(longitude: number, latitude: number, radiusMeters: number, points: number = 64): GeoJSON.Feature {
+ // Normalize an out-of-range center into [-180, 180). Vertex offsets are then
+ // applied WITHOUT re-wrapping: a ring that pokes just past ±180 must stay
+ // contiguous for Mapbox to render it across the antimeridian (re-wrapping
+ // individual vertices would flip them to the far side of the world).
+ const centerLon = wrapLongitude(longitude);
+ const latRadiusDeg = radiusMeters / 110574;
+ const cosLat = Math.cos((latitude * Math.PI) / 180);
+ // Near the poles cos(lat) approaches zero; floor it so the division stays finite.
+ const lonRadiusDeg = radiusMeters / (111320 * Math.max(Math.abs(cosLat), 0.01));
+
+ const coords: number[][] = [];
+ for (let i = 0; i < points; i++) {
+ const angle = (i / points) * 2 * Math.PI;
+ // Clamp latitude so a large radius near a pole stays a valid coordinate.
+ const lat = Math.min(90, Math.max(-90, latitude + latRadiusDeg * Math.sin(angle)));
+ coords.push([centerLon + lonRadiusDeg * Math.cos(angle), lat]);
+ }
+ // Close the ring exactly — sin/cos at 2π carry float error, and GeoJSON
+ // requires the first and last positions to be identical.
+ coords.push([...coords[0]]);
+
+ return {
+ type: 'Feature',
+ properties: {},
+ geometry: { type: 'Polygon', coordinates: [coords] },
+ };
+}
diff --git a/src/services/__tests__/aptabase.service.test.ts b/src/services/__tests__/aptabase.service.test.ts
new file mode 100644
index 00000000..3e9162e6
--- /dev/null
+++ b/src/services/__tests__/aptabase.service.test.ts
@@ -0,0 +1,82 @@
+import { aptabaseService, countlyService } from '../aptabase.service';
+
+jest.mock('countly-sdk-react-native-bridge', () => ({
+ __esModule: true,
+ default: {
+ events: {
+ recordEvent: jest.fn(),
+ },
+ },
+}));
+
+jest.mock('../../lib/logging', () => ({
+ logger: {
+ error: jest.fn(),
+ info: jest.fn(),
+ debug: jest.fn(),
+ warn: jest.fn(),
+ },
+}));
+
+describe('aptabase service', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ jest.clearAllTimers();
+ jest.useFakeTimers();
+ countlyService.reset();
+ });
+
+ afterEach(() => {
+ jest.runOnlyPendingTimers();
+ jest.useRealTimers();
+ });
+
+ it('keeps the legacy aptabaseService alias pointing at the same instance', () => {
+ expect(aptabaseService).toBe(countlyService);
+ });
+
+ it('resets the retry counter after a successful event', () => {
+ const Countly = require('countly-sdk-react-native-bridge').default;
+
+ Countly.events.recordEvent.mockImplementationOnce(() => {
+ throw new Error('Network error');
+ });
+ countlyService.trackEvent('test_event');
+ expect(countlyService.getStatus().retryCount).toBe(1);
+
+ Countly.events.recordEvent.mockImplementation(() => undefined);
+ countlyService.trackEvent('test_event');
+
+ expect(countlyService.getStatus().retryCount).toBe(0);
+ });
+
+ it('does not disable analytics for isolated failures separated by successes', () => {
+ const Countly = require('countly-sdk-react-native-bridge').default;
+
+ for (let i = 0; i < 5; i += 1) {
+ Countly.events.recordEvent.mockImplementationOnce(() => {
+ throw new Error('Network error');
+ });
+ countlyService.trackEvent('test_event');
+
+ Countly.events.recordEvent.mockImplementation(() => undefined);
+ countlyService.trackEvent('test_event');
+ }
+
+ // Without the reset, two lifetime errors would have disabled analytics for
+ // the full 10-minute window.
+ expect(countlyService.isAnalyticsDisabled()).toBe(false);
+ });
+
+ it('still disables analytics after consecutive failures', () => {
+ const Countly = require('countly-sdk-react-native-bridge').default;
+ Countly.events.recordEvent.mockImplementation(() => {
+ throw new Error('Network error');
+ });
+
+ countlyService.trackEvent('test_event');
+ countlyService.trackEvent('test_event');
+
+ expect(countlyService.isAnalyticsDisabled()).toBe(true);
+ });
+});
diff --git a/src/services/__tests__/bluetooth-audio.service.test.ts b/src/services/__tests__/bluetooth-audio.service.test.ts
index 10c6ec67..fc491c2d 100644
--- a/src/services/__tests__/bluetooth-audio.service.test.ts
+++ b/src/services/__tests__/bluetooth-audio.service.test.ts
@@ -95,8 +95,10 @@ describe('BluetoothAudioService Refactoring', () => {
jest.clearAllMocks();
});
- afterEach(() => {
- bluetoothAudioService.destroy();
+ afterEach(async () => {
+ // destroy() awaits its stopScanning/disconnectDevice teardown before
+ // resetting state, so it must be awaited.
+ await bluetoothAudioService.destroy();
});
it('should be defined and accessible', () => {
@@ -131,25 +133,25 @@ describe('BluetoothAudioService Refactoring', () => {
it('should track hasAttemptedPreferredDeviceConnection flag for single-call semantics', () => {
const service = bluetoothAudioService as any;
-
+
// Initially should be false
expect(service.hasAttemptedPreferredDeviceConnection).toBe(false);
-
+
// Can be set to true (simulating attempt)
service.hasAttemptedPreferredDeviceConnection = true;
expect(service.hasAttemptedPreferredDeviceConnection).toBe(true);
});
- it('should reset flags on destroy method', () => {
+ it('should reset flags on destroy method', async () => {
const service = bluetoothAudioService as any;
-
+
// Set flags to true
service.hasAttemptedPreferredDeviceConnection = true;
service.isInitialized = true;
-
- // Call destroy
- bluetoothAudioService.destroy();
-
+
+ // Call destroy — it awaits its async teardown before resetting flags
+ await bluetoothAudioService.destroy();
+
// Verify flags are reset for single-call logic
expect(service.hasAttemptedPreferredDeviceConnection).toBe(false);
expect(service.isInitialized).toBe(false);
@@ -157,13 +159,13 @@ describe('BluetoothAudioService Refactoring', () => {
it('should support iOS state change handling through attemptReconnectToPreferredDevice', () => {
const service = bluetoothAudioService as any;
-
+
// Set up scenario: connection was previously attempted
service.hasAttemptedPreferredDeviceConnection = true;
-
+
// Verify the method exists for iOS poweredOn state handling
expect(typeof service.attemptReconnectToPreferredDevice).toBe('function');
-
+
// This method should be called when Bluetooth state changes to poweredOn on iOS
// It resets the flag and attempts preferred device connection again
});
@@ -172,30 +174,30 @@ describe('BluetoothAudioService Refactoring', () => {
describe('Single-Call Logic Validation', () => {
it('should implement single-call semantics for preferred device connection', () => {
const service = bluetoothAudioService as any;
-
+
// Simulate first call - should set flag
service.hasAttemptedPreferredDeviceConnection = false;
// In actual implementation, attemptPreferredDeviceConnection would set this to true
-
+
// Simulate second call - should not execute due to flag
expect(service.hasAttemptedPreferredDeviceConnection).toBe(false);
-
+
// After first attempt
service.hasAttemptedPreferredDeviceConnection = true;
expect(service.hasAttemptedPreferredDeviceConnection).toBe(true);
-
+
// Second attempt should be blocked by this flag
});
- it('should allow re-attempting connection after destroy', () => {
+ it('should allow re-attempting connection after destroy', async () => {
const service = bluetoothAudioService as any;
-
+
// Simulate connection attempt
service.hasAttemptedPreferredDeviceConnection = true;
-
+
// Destroy service (resets flags)
- bluetoothAudioService.destroy();
-
+ await bluetoothAudioService.destroy();
+
// Flag should be reset, allowing new attempts
expect(service.hasAttemptedPreferredDeviceConnection).toBe(false);
});
@@ -314,4 +316,107 @@ describe('BluetoothAudioService Refactoring', () => {
expect(mockHandleButtonEvent).toHaveBeenCalledTimes(1);
});
});
+
+ describe('Read polling only covers characteristics whose notifications are not working', () => {
+ it('should mark a characteristic confirmed when a GATT notification arrives', () => {
+ const service = bluetoothAudioService as any;
+ service.connectedDevice = { id: 'device-1', name: 'PTT' };
+ service.monitoredReadCharacteristics = [{ serviceUuid: 'service-1', characteristicUuid: 'characteristic-1', lastHexValue: null, notificationConfirmed: false }];
+
+ service.handleCharacteristicValueUpdate({
+ peripheral: 'device-1',
+ service: 'service-1',
+ characteristic: 'characteristic-1',
+ value: [0x01],
+ });
+
+ expect(service.monitoredReadCharacteristics[0].notificationConfirmed).toBe(true);
+ });
+
+ it('should stop reading a characteristic once its notifications are proven to work', async () => {
+ const service = bluetoothAudioService as any;
+ const bleManagerMock = require('react-native-ble-manager').default;
+
+ service.connectedDevice = { id: 'device-1', name: 'PTT' };
+ service.monitoredReadCharacteristics = [
+ { serviceUuid: 'confirmed-svc', characteristicUuid: 'confirmed-chr', lastHexValue: null, notificationConfirmed: true },
+ { serviceUuid: 'silent-svc', characteristicUuid: 'silent-chr', lastHexValue: null, notificationConfirmed: false },
+ ];
+
+ bleManagerMock.read = jest.fn().mockResolvedValue([0x00]);
+
+ await service.pollReadCharacteristics('device-1');
+
+ // Only the characteristic that has never delivered a notification is polled.
+ expect(bleManagerMock.read).toHaveBeenCalledTimes(1);
+ expect(bleManagerMock.read).toHaveBeenCalledWith('device-1', 'silent-svc', 'silent-chr');
+ });
+
+ it('should keep polling characteristics that genuinely never notify (screen-off PTT)', async () => {
+ const service = bluetoothAudioService as any;
+ const bleManagerMock = require('react-native-ble-manager').default;
+
+ service.connectedDevice = { id: 'device-1', name: 'PTT' };
+ service.monitoredReadCharacteristics = [{ serviceUuid: 'silent-svc', characteristicUuid: 'silent-chr', lastHexValue: null, notificationConfirmed: false }];
+
+ bleManagerMock.read = jest.fn().mockResolvedValue([0x00]);
+
+ await service.pollReadCharacteristics('device-1');
+ await service.pollReadCharacteristics('device-1');
+
+ // Background PTT depends on this fallback, so it must not be paused.
+ expect(bleManagerMock.read).toHaveBeenCalledTimes(2);
+ });
+
+ it('should register new polling entries as unconfirmed so a re-subscribe resumes polling', () => {
+ const service = bluetoothAudioService as any;
+ service.monitoredReadCharacteristics = [];
+
+ service.registerReadPollingCharacteristic('service-1', 'characteristic-1');
+
+ expect(service.monitoredReadCharacteristics[0]).toMatchObject({
+ serviceUuid: 'service-1',
+ characteristicUuid: 'characteristic-1',
+ notificationConfirmed: false,
+ });
+ });
+ });
+
+ describe('connectToDevice concurrency guard', () => {
+ it('should ignore a duplicate connect while one is already in flight', async () => {
+ const service = bluetoothAudioService as any;
+ const bleManagerMock = require('react-native-ble-manager').default;
+
+ bleManagerMock.connect = jest.fn().mockResolvedValue(undefined);
+ service.isConnecting = true;
+
+ try {
+ await bluetoothAudioService.connectToDevice('device-1');
+ // The dead connectionTimeout guard let overlapping discovery events start
+ // concurrent connects; the real flag must short-circuit instead.
+ expect(bleManagerMock.connect).not.toHaveBeenCalled();
+ } finally {
+ service.isConnecting = false;
+ }
+ });
+
+ it('should clear the connecting flag when a connect fails', async () => {
+ jest.useFakeTimers();
+ const service = bluetoothAudioService as any;
+ const bleManagerMock = require('react-native-ble-manager').default;
+
+ service.isConnecting = false;
+ bleManagerMock.stopScan = jest.fn().mockResolvedValue(undefined);
+ bleManagerMock.connect = jest.fn().mockRejectedValue(new Error('connect failed'));
+
+ const attempt = bluetoothAudioService.connectToDevice('device-1');
+ const assertion = expect(attempt).rejects.toThrow('connect failed');
+
+ await jest.advanceTimersByTimeAsync(600);
+ await assertion;
+
+ expect(service.isConnecting).toBe(false);
+ jest.useRealTimers();
+ });
+ });
});
diff --git a/src/services/__tests__/countly.service.test.ts b/src/services/__tests__/countly.service.test.ts
index c362d321..cd674172 100644
--- a/src/services/__tests__/countly.service.test.ts
+++ b/src/services/__tests__/countly.service.test.ts
@@ -39,9 +39,9 @@ describe('CountlyService', () => {
it('should track events', () => {
const Countly = require('countly-sdk-react-native-bridge').default;
-
+
countlyService.trackEvent('test_event', { prop1: 'value1' });
-
+
expect(Countly.events.recordEvent).toHaveBeenCalledWith('test_event', { prop1: 'value1' }, 1);
expect(mockLogger.debug).toHaveBeenCalledWith({
message: 'Tracking Countly event',
@@ -55,13 +55,13 @@ describe('CountlyService', () => {
it('should not track events when disabled', () => {
const Countly = require('countly-sdk-react-native-bridge').default;
-
+
// Manually disable the service
countlyService.reset();
countlyService['isDisabled'] = true;
-
+
countlyService.trackEvent('test_event', { prop1: 'value1' });
-
+
expect(Countly.events.recordEvent).not.toHaveBeenCalled();
expect(mockLogger.debug).toHaveBeenCalledWith({
message: 'Analytics event skipped - service is disabled',
@@ -93,6 +93,43 @@ describe('CountlyService', () => {
});
});
+ it('should reset the retry counter after a successful event', () => {
+ const Countly = require('countly-sdk-react-native-bridge').default;
+ countlyService.reset();
+
+ // One failure...
+ Countly.events.recordEvent.mockImplementationOnce(() => {
+ throw new Error('Network error');
+ });
+ countlyService.trackEvent('test_event');
+ expect(countlyService.getStatus().retryCount).toBe(1);
+
+ // ...then a success proves the SDK is healthy again.
+ Countly.events.recordEvent.mockImplementation(() => undefined);
+ countlyService.trackEvent('test_event');
+
+ expect(countlyService.getStatus().retryCount).toBe(0);
+ });
+
+ it('should not disable analytics for isolated failures separated by successes', () => {
+ const Countly = require('countly-sdk-react-native-bridge').default;
+ countlyService.reset();
+
+ for (let i = 0; i < 5; i += 1) {
+ Countly.events.recordEvent.mockImplementationOnce(() => {
+ throw new Error('Network error');
+ });
+ countlyService.trackEvent('test_event');
+
+ Countly.events.recordEvent.mockImplementation(() => undefined);
+ countlyService.trackEvent('test_event');
+ }
+
+ // Without the reset, two lifetime errors would have disabled analytics
+ // for the full 10-minute window.
+ expect(countlyService.isAnalyticsDisabled()).toBe(false);
+ });
+
it('should disable after max retries', () => {
const Countly = require('countly-sdk-react-native-bridge').default;
Countly.events.recordEvent.mockImplementation(() => {
@@ -147,7 +184,7 @@ describe('CountlyService', () => {
describe('service management', () => {
it('should provide status information', () => {
const status = countlyService.getStatus();
-
+
expect(status).toEqual({
retryCount: expect.any(Number),
isDisabled: expect.any(Boolean),
@@ -165,12 +202,12 @@ describe('CountlyService', () => {
// Cause some errors first
countlyService.trackEvent('test_event');
countlyService.trackEvent('test_event');
-
+
expect(countlyService.isAnalyticsDisabled()).toBe(true);
-
+
// Reset should clear the state
countlyService.reset();
-
+
expect(countlyService.isAnalyticsDisabled()).toBe(false);
expect(countlyService.getStatus().retryCount).toBe(0);
});
diff --git a/src/services/__tests__/location.test.ts b/src/services/__tests__/location.test.ts
index 9349537b..8fe11f9e 100644
--- a/src/services/__tests__/location.test.ts
+++ b/src/services/__tests__/location.test.ts
@@ -211,6 +211,66 @@ describe('LocationService', () => {
(locationService as any).locationSubscription = null;
(locationService as any).backgroundSubscription = null;
(locationService as any).isBackgroundGeolocationEnabled = false;
+ (locationService as any).startPromise = null;
+ (locationService as any).startBackgroundPromise = null;
+ });
+
+ describe('Concurrent start/stop safety', () => {
+ it('should create only one foreground watcher when starts overlap', async () => {
+ mockLocation.watchPositionAsync.mockResolvedValue(mockLocationSubscription);
+ mockTaskManager.isTaskRegisteredAsync.mockResolvedValue(false);
+
+ // Two callers race before the first watcher is assigned — the pre-fix
+ // guard let both through and leaked a duplicate watcher.
+ const first = locationService.startLocationUpdates();
+ const second = locationService.startLocationUpdates();
+ await Promise.all([first, second]);
+
+ expect(mockLocation.watchPositionAsync).toHaveBeenCalledTimes(1);
+ });
+
+ it('should not leave an orphaned watcher when stop interleaves with a start', async () => {
+ mockLocation.watchPositionAsync.mockResolvedValue(mockLocationSubscription);
+ mockTaskManager.isTaskRegisteredAsync.mockResolvedValue(false);
+
+ const start = locationService.startLocationUpdates();
+ // Stop issued while the watcher is still being created.
+ const stop = locationService.stopLocationUpdates();
+ await Promise.all([start, stop]);
+
+ // The stop waited for the start, so the watcher it created was removed.
+ expect(mockLocationSubscription.remove).toHaveBeenCalled();
+ expect((locationService as any).locationSubscription).toBeNull();
+ });
+
+ it('should create only one background watcher when starts overlap', async () => {
+ (locationService as any).isBackgroundGeolocationEnabled = true;
+ mockTaskManager.isTaskRegisteredAsync.mockResolvedValue(false);
+ mockLocation.watchPositionAsync.mockResolvedValue(mockLocationSubscription);
+
+ const first = locationService.startBackgroundUpdates();
+ const second = locationService.startBackgroundUpdates();
+ await Promise.all([first, second]);
+
+ expect(mockLocation.watchPositionAsync).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('App state handler resilience', () => {
+ it('should log a warning instead of rejecting when a backgrounded start fails', async () => {
+ (locationService as any).isBackgroundGeolocationEnabled = true;
+ mockTaskManager.isTaskRegisteredAsync.mockRejectedValue(new Error('permission revoked'));
+
+ // The handler is registered with AppState and its result is never awaited,
+ // so a throw here would surface as an unhandled rejection.
+ await expect((locationService as any).handleAppStateChange('background')).resolves.toBeUndefined();
+
+ expect(mockLogger.warn).toHaveBeenCalledWith(
+ expect.objectContaining({
+ message: 'Failed to handle location app state change',
+ })
+ );
+ });
});
describe('Singleton Pattern', () => {
@@ -821,7 +881,9 @@ describe('LocationService', () => {
await sendLocation();
// Counter restarted, so this is a first rejection again, not a third.
- expect(mockLogger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: 'Backing off location updates after server rejection', context: expect.objectContaining({ consecutiveRejections: 1, backoffMs: 30_000 }) }));
+ expect(mockLogger.warn).toHaveBeenCalledWith(
+ expect.objectContaining({ message: 'Backing off location updates after server rejection', context: expect.objectContaining({ consecutiveRejections: 1, backoffMs: 30_000 }) })
+ );
});
it('does not back off on server errors, which are transient', async () => {
diff --git a/src/services/__tests__/offline-event-manager.service.test.ts b/src/services/__tests__/offline-event-manager.service.test.ts
index e7781b42..eee43f94 100644
--- a/src/services/__tests__/offline-event-manager.service.test.ts
+++ b/src/services/__tests__/offline-event-manager.service.test.ts
@@ -37,6 +37,7 @@ jest.mock('@/stores/offline-queue/store', () => ({
useOfflineQueueStore: {
getState: jest.fn(),
},
+ setOfflineQueueActivityListener: jest.fn(),
}));
// Mock logger
@@ -89,6 +90,22 @@ const mockAppState = AppState as jest.Mocked;
describe('OfflineEventManager', () => {
let mockStoreState: any;
+ // The processing timer only runs while the queue has something to do, so any
+ // test that expects a timer has to give the queue work first.
+ const seedPendingWork = (): void => {
+ const event = {
+ id: 'seeded-event',
+ type: QueuedEventType.UNIT_STATUS,
+ status: QueuedEventStatus.PENDING,
+ data: { unitId: 'unit-1', statusType: 'available', timestamp: '2023-01-01T00:00:00Z', timestampUtc: 'Sun, 01 Jan 2023 00:00:00 GMT' },
+ retryCount: 0,
+ maxRetries: 3,
+ createdAt: Date.now(),
+ };
+ mockStoreState.getPendingEvents.mockReturnValue([event]);
+ mockStoreState.queuedEvents = [event];
+ };
+
beforeAll(() => {
// Use fake timers for the entire test suite
jest.useFakeTimers();
@@ -103,7 +120,7 @@ describe('OfflineEventManager', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.clearAllTimers();
-
+
mockStoreState = {
isConnected: true,
isNetworkReachable: true,
@@ -112,6 +129,8 @@ describe('OfflineEventManager', () => {
removeEvent: jest.fn(),
getPendingEvents: jest.fn().mockReturnValue([]),
getFailedEvents: jest.fn().mockReturnValue([]),
+ pruneFailedEvents: jest.fn(),
+ queuedEvents: [],
initializeNetworkListener: jest.fn(),
retryAllFailedEvents: jest.fn(),
clearCompletedEvents: jest.fn(),
@@ -121,7 +140,7 @@ describe('OfflineEventManager', () => {
};
mockUseOfflineQueueStore.getState.mockReturnValue(mockStoreState);
-
+
// Setup AppState mock
mockAppState.addEventListener.mockReturnValue({ remove: jest.fn() });
});
@@ -138,14 +157,7 @@ describe('OfflineEventManager', () => {
describe('queueUnitStatusEvent', () => {
it('should queue a unit status event', () => {
- const eventId = offlineEventManager.queueUnitStatusEvent(
- 'unit-1',
- 'available',
- 'Test note',
- 'call-1',
- 2,
- [{ roleId: 'role-1', userId: 'user-1' }]
- );
+ const eventId = offlineEventManager.queueUnitStatusEvent('unit-1', 'available', 'Test note', 'call-1', 2, [{ roleId: 'role-1', userId: 'user-1' }]);
expect(eventId).toBe('test-event-id');
expect(mockStoreState.addEvent).toHaveBeenCalledWith(
@@ -183,14 +195,7 @@ describe('OfflineEventManager', () => {
describe('queueLocationUpdateEvent', () => {
it('should queue a location update event', () => {
- const eventId = offlineEventManager.queueLocationUpdateEvent(
- 'unit-1',
- 40.7128,
- -74.0060,
- 10,
- 45,
- 25
- );
+ const eventId = offlineEventManager.queueLocationUpdateEvent('unit-1', 40.7128, -74.006, 10, 45, 25);
expect(eventId).toBe('test-event-id');
expect(mockStoreState.addEvent).toHaveBeenCalledWith(
@@ -198,7 +203,7 @@ describe('OfflineEventManager', () => {
expect.objectContaining({
unitId: 'unit-1',
latitude: 40.7128,
- longitude: -74.0060,
+ longitude: -74.006,
accuracy: 10,
heading: 45,
speed: 25,
@@ -208,7 +213,7 @@ describe('OfflineEventManager', () => {
});
it('should queue location update event without optional parameters', () => {
- const eventId = offlineEventManager.queueLocationUpdateEvent('unit-1', 40.7128, -74.0060);
+ const eventId = offlineEventManager.queueLocationUpdateEvent('unit-1', 40.7128, -74.006);
expect(eventId).toBe('test-event-id');
expect(mockStoreState.addEvent).toHaveBeenCalledWith(
@@ -216,7 +221,7 @@ describe('OfflineEventManager', () => {
expect.objectContaining({
unitId: 'unit-1',
latitude: 40.7128,
- longitude: -74.0060,
+ longitude: -74.006,
accuracy: undefined,
heading: undefined,
speed: undefined,
@@ -227,15 +232,7 @@ describe('OfflineEventManager', () => {
describe('queueCallImageUploadEvent', () => {
it('should queue a call image upload event', () => {
- const eventId = offlineEventManager.queueCallImageUploadEvent(
- 'call-1',
- 'user-1',
- 'Test note',
- 'image.jpg',
- '/path/to/image.jpg',
- 40.7128,
- -74.0060
- );
+ const eventId = offlineEventManager.queueCallImageUploadEvent('call-1', 'user-1', 'Test note', 'image.jpg', '/path/to/image.jpg', 40.7128, -74.006);
expect(eventId).toBe('test-event-id');
expect(mockStoreState.addEvent).toHaveBeenCalledWith(
@@ -247,19 +244,13 @@ describe('OfflineEventManager', () => {
name: 'image.jpg',
filePath: '/path/to/image.jpg',
latitude: 40.7128,
- longitude: -74.0060,
+ longitude: -74.006,
})
);
});
it('should queue call image upload event without optional parameters', () => {
- const eventId = offlineEventManager.queueCallImageUploadEvent(
- 'call-1',
- 'user-1',
- 'Test note',
- 'image.jpg',
- '/path/to/image.jpg'
- );
+ const eventId = offlineEventManager.queueCallImageUploadEvent('call-1', 'user-1', 'Test note', 'image.jpg', '/path/to/image.jpg');
expect(eventId).toBe('test-event-id');
expect(mockStoreState.addEvent).toHaveBeenCalledWith(
@@ -321,32 +312,87 @@ describe('OfflineEventManager', () => {
});
describe('startProcessing', () => {
- it('should start processing interval', () => {
+ it('should start processing interval when the queue has work', () => {
+ seedPendingWork();
const setIntervalSpy = jest.spyOn(global, 'setInterval');
-
+
offlineEventManager.startProcessing();
// Verify setInterval was called
expect(setIntervalSpy).toHaveBeenCalledWith(expect.any(Function), 10000);
-
+
// Verify immediate processing call
expect(mockStoreState.getPendingEvents).toHaveBeenCalled();
});
it('should not start multiple intervals', () => {
+ seedPendingWork();
const setIntervalSpy = jest.spyOn(global, 'setInterval');
-
+
offlineEventManager.startProcessing();
offlineEventManager.startProcessing();
expect(setIntervalSpy).toHaveBeenCalledTimes(1);
});
+
+ it('should not arm a timer while the queue is empty', () => {
+ const setIntervalSpy = jest.spyOn(global, 'setInterval');
+
+ offlineEventManager.startProcessing();
+
+ expect(setIntervalSpy).not.toHaveBeenCalled();
+ });
+
+ it('should stop the timer once the queue drains', async () => {
+ // Arm the timer while offline so the initial run returns before it starts
+ // draining (which would otherwise still be in flight below).
+ seedPendingWork();
+ mockStoreState.isConnected = false;
+ mockStoreState.isNetworkReachable = false;
+
+ const clearIntervalSpy = jest.spyOn(global, 'clearInterval');
+ offlineEventManager.startProcessing();
+ clearIntervalSpy.mockClear();
+
+ // Queue drains — the next tick has nothing to do and must not keep ticking.
+ mockStoreState.isConnected = true;
+ mockStoreState.isNetworkReachable = true;
+ mockStoreState.getPendingEvents.mockReturnValue([]);
+ mockStoreState.queuedEvents = [];
+
+ await (offlineEventManager as any).processQueuedEvents();
+
+ expect(clearIntervalSpy).toHaveBeenCalled();
+ });
+
+ it('should evict permanently failed events while processing', async () => {
+ seedPendingWork();
+
+ await (offlineEventManager as any).processQueuedEvents();
+
+ expect(mockStoreState.pruneFailedEvents).toHaveBeenCalled();
+ });
+
+ it('should keep the timer armed for events waiting on a retry backoff', async () => {
+ // Not returned by getPendingEvents (backoff has not elapsed) but still live.
+ mockStoreState.getPendingEvents.mockReturnValue([]);
+ mockStoreState.queuedEvents = [{ id: 'backoff', status: QueuedEventStatus.FAILED, retryCount: 1, maxRetries: 3, nextRetryAt: Date.now() + 60000 }];
+
+ const clearIntervalSpy = jest.spyOn(global, 'clearInterval');
+ offlineEventManager.startProcessing();
+ clearIntervalSpy.mockClear();
+
+ await (offlineEventManager as any).processQueuedEvents();
+
+ expect(clearIntervalSpy).not.toHaveBeenCalled();
+ });
});
describe('stopProcessing', () => {
it('should stop processing interval', () => {
+ seedPendingWork();
const clearIntervalSpy = jest.spyOn(global, 'clearInterval');
-
+
offlineEventManager.startProcessing();
offlineEventManager.stopProcessing();
@@ -362,18 +408,18 @@ describe('OfflineEventManager', () => {
});
it('should set up processing interval but skip processing when offline', () => {
+ seedPendingWork();
mockStoreState.isConnected = false;
mockStoreState.isNetworkReachable = false;
-
+
const setIntervalSpy = jest.spyOn(global, 'setInterval');
offlineEventManager.startProcessing();
- // The interval should still be set up, even if offline
+ // The interval stays armed while offline so the queued work drains as soon
+ // as connectivity returns; processing itself returns early.
expect(setIntervalSpy).toHaveBeenCalled();
-
- // When offline, processQueuedEvents will return early and not call getPendingEvents
- // So we just verify the interval was set up
+ expect(mockStoreState.updateEventStatus).not.toHaveBeenCalled();
});
it('should set up processing interval when online', () => {
@@ -393,6 +439,7 @@ describe('OfflineEventManager', () => {
};
mockStoreState.getPendingEvents.mockReturnValue([mockEvent]);
+ mockStoreState.queuedEvents = [mockEvent];
const setIntervalSpy = jest.spyOn(global, 'setInterval');
// Trigger processing
@@ -400,7 +447,7 @@ describe('OfflineEventManager', () => {
// The interval should be set up
expect(setIntervalSpy).toHaveBeenCalled();
-
+
// Verify that getPendingEvents is called immediately when online
expect(mockStoreState.getPendingEvents).toHaveBeenCalled();
});
@@ -411,14 +458,14 @@ describe('OfflineEventManager', () => {
// The AppState listener should have been set up when the module was imported
// Even if the mock wasn't capturing it initially, we can test the behavior
// by directly calling the handler method that would be triggered
-
+
// Create a spy to verify the method calls
const startProcessingSpy = jest.spyOn(offlineEventManager, 'startProcessing');
-
+
// Since we can't easily test the private method directly, let's test via initialize
// which calls handleAppStateChange with current state
offlineEventManager.initialize();
-
+
// The initialize method calls handleAppStateChange with AppState.currentState ('active')
// which should trigger startProcessing
expect(startProcessingSpy).toHaveBeenCalled();
@@ -430,7 +477,7 @@ describe('OfflineEventManager', () => {
expect(() => {
offlineEventManager.initialize();
}).not.toThrow();
-
+
// Verify the store initialization was called
expect(mockStoreState.initializeNetworkListener).toHaveBeenCalled();
});
diff --git a/src/services/__tests__/signalr.service.reconnect-fix.test.ts b/src/services/__tests__/signalr.service.reconnect-fix.test.ts
index dbfdee74..2012fc2a 100644
--- a/src/services/__tests__/signalr.service.reconnect-fix.test.ts
+++ b/src/services/__tests__/signalr.service.reconnect-fix.test.ts
@@ -96,13 +96,13 @@ describe('SignalRService - Reconnect Self-Blocking Fix', () => {
it('should allow direct connection attempts when hub is in reconnecting state', async () => {
// Set hub to reconnecting state
(signalRService as any).setHubState(mockConfig.name, HubConnectingState.RECONNECTING);
-
+
// Verify hub is in reconnecting state
expect(signalRService.isHubReconnecting(mockConfig.name)).toBe(true);
-
+
// Attempt direct connection - should not be blocked
await signalRService.connectToHubWithEventingUrl(mockConfig);
-
+
// Should have attempted the connection
expect(mockHubConnectionBuilder).toHaveBeenCalled();
expect(mockLogger.info).toHaveBeenCalledWith({
@@ -113,10 +113,10 @@ describe('SignalRService - Reconnect Self-Blocking Fix', () => {
it('should prevent duplicate direct connections', async () => {
// Set hub to direct-connecting state
(signalRService as any).setHubState(mockConfig.name, HubConnectingState.DIRECT_CONNECTING);
-
+
// Attempt another direct connection - should be blocked
await signalRService.connectToHubWithEventingUrl(mockConfig);
-
+
// Should not have attempted the connection
expect(mockHubConnectionBuilder).not.toHaveBeenCalled();
expect(mockLogger.info).toHaveBeenCalledWith({
@@ -127,10 +127,10 @@ describe('SignalRService - Reconnect Self-Blocking Fix', () => {
it('should clean up direct-connecting state on successful connection', async () => {
// Start with idle state
expect((signalRService as any).isHubConnecting(mockConfig.name)).toBe(false);
-
+
// Attempt connection
await signalRService.connectToHubWithEventingUrl(mockConfig);
-
+
// Should be back to idle state after successful connection
expect((signalRService as any).isHubConnecting(mockConfig.name)).toBe(false);
expect((signalRService as any).hubStates.get(mockConfig.name)).toBeUndefined();
@@ -139,17 +139,17 @@ describe('SignalRService - Reconnect Self-Blocking Fix', () => {
it('should clean up direct-connecting state on failed connection', async () => {
// Mock connection failure
mockStart.mockRejectedValueOnce(new Error('Connection failed'));
-
+
// Start with idle state
expect((signalRService as any).isHubConnecting(mockConfig.name)).toBe(false);
-
+
// Attempt connection (should fail)
await expect(signalRService.connectToHubWithEventingUrl(mockConfig)).rejects.toThrow('Connection failed');
-
+
// Should be back to idle state after failed connection
expect((signalRService as any).isHubConnecting(mockConfig.name)).toBe(false);
expect((signalRService as any).hubStates.get(mockConfig.name)).toBeUndefined();
-
+
// Reset mock for future tests
mockStart.mockResolvedValue(undefined);
});
@@ -157,14 +157,14 @@ describe('SignalRService - Reconnect Self-Blocking Fix', () => {
it('should maintain backward compatibility with legacy reconnectingHubs set', async () => {
// Set hub to reconnecting state
(signalRService as any).setHubState(mockConfig.name, HubConnectingState.RECONNECTING);
-
+
// Legacy reconnectingHubs set should also be updated
expect((signalRService as any).reconnectingHubs.has(mockConfig.name)).toBe(true);
expect(signalRService.isHubReconnecting(mockConfig.name)).toBe(true);
-
+
// Clear state
(signalRService as any).setHubState(mockConfig.name, HubConnectingState.IDLE);
-
+
// Legacy set should be cleared too
expect((signalRService as any).reconnectingHubs.has(mockConfig.name)).toBe(false);
expect(signalRService.isHubReconnecting(mockConfig.name)).toBe(false);
@@ -177,12 +177,12 @@ describe('SignalRService - Reconnect Self-Blocking Fix', () => {
(signalRService as any).setHubState(mockConfig.name, HubConnectingState.RECONNECTING);
expect(signalRService.isHubReconnecting(mockConfig.name)).toBe(true);
expect((signalRService as any).isHubConnecting(mockConfig.name)).toBe(true);
-
+
// Set to direct-connecting
(signalRService as any).setHubState(mockConfig.name, HubConnectingState.DIRECT_CONNECTING);
expect(signalRService.isHubReconnecting(mockConfig.name)).toBe(false);
expect((signalRService as any).isHubConnecting(mockConfig.name)).toBe(true);
-
+
// Set to idle
(signalRService as any).setHubState(mockConfig.name, HubConnectingState.IDLE);
expect(signalRService.isHubReconnecting(mockConfig.name)).toBe(false);
@@ -191,26 +191,75 @@ describe('SignalRService - Reconnect Self-Blocking Fix', () => {
it('should properly manage isHubAvailable with new states', () => {
const hubName = 'testHub';
-
+
// Not connected, not connecting
expect(signalRService.isHubAvailable(hubName)).toBe(false);
-
+
// Reconnecting
(signalRService as any).setHubState(hubName, HubConnectingState.RECONNECTING);
expect(signalRService.isHubAvailable(hubName)).toBe(true);
-
+
// Direct connecting
(signalRService as any).setHubState(hubName, HubConnectingState.DIRECT_CONNECTING);
expect(signalRService.isHubAvailable(hubName)).toBe(true);
-
+
// Add actual connection
(signalRService as any).connections.set(hubName, mockConnection);
(signalRService as any).setHubState(hubName, HubConnectingState.IDLE);
expect(signalRService.isHubAvailable(hubName)).toBe(true);
-
+
// Clean up
(signalRService as any).connections.delete(hubName);
expect(signalRService.isHubAvailable(hubName)).toBe(false);
});
});
+
+ describe('connectToHub stores a reconnectable config', () => {
+ it('should store the hub config so handleConnectionClose can reconnect', async () => {
+ await signalRService.connectToHub({
+ name: 'urlHub',
+ url: 'https://api.example.com/eventing/eventingHub',
+ methods: ['method1'],
+ });
+
+ // Without a stored config handleConnectionClose logs "No stored config
+ // found" and gives up instead of reconnecting.
+ const stored = (signalRService as any).hubConfigs.get('urlHub');
+ expect(stored).toEqual({
+ name: 'urlHub',
+ eventingUrl: 'https://api.example.com/eventing',
+ hubName: 'eventingHub',
+ methods: ['method1'],
+ });
+ });
+
+ it('should preserve query parameters when deriving the eventing url', async () => {
+ await signalRService.connectToHub({
+ name: 'queryHub',
+ url: 'https://api.example.com/eventing/eventingHub?tenant=42',
+ methods: ['method1'],
+ });
+
+ const stored = (signalRService as any).hubConfigs.get('queryHub');
+ expect(stored.eventingUrl).toBe('https://api.example.com/eventing?tenant=42');
+ expect(stored.hubName).toBe('eventingHub');
+ });
+
+ it('should not report a missing config when the connection closes', () => {
+ (signalRService as any).hubConfigs.set('urlHub', {
+ name: 'urlHub',
+ eventingUrl: 'https://api.example.com/eventing',
+ hubName: 'eventingHub',
+ methods: ['method1'],
+ });
+
+ (signalRService as any).handleConnectionClose('urlHub');
+
+ expect(mockLogger.error).not.toHaveBeenCalledWith({
+ message: 'No stored config found for hub: urlHub, cannot attempt reconnection',
+ });
+
+ (signalRService as any).clearReconnectTimer('urlHub');
+ });
+ });
});
diff --git a/src/services/__tests__/signalr.service.test.ts b/src/services/__tests__/signalr.service.test.ts
index f180a9a6..f3a31970 100644
--- a/src/services/__tests__/signalr.service.test.ts
+++ b/src/services/__tests__/signalr.service.test.ts
@@ -13,7 +13,7 @@ jest.mock('@/lib/env', () => ({
// Mock the auth store
jest.mock('@/stores/auth/store', () => {
const mockRefreshAccessToken = jest.fn().mockResolvedValue(undefined);
- const mockGetState = jest.fn(() => ({
+ const mockGetState = jest.fn(() => ({
accessToken: 'mock-token',
refreshAccessToken: mockRefreshAccessToken,
}));
@@ -81,7 +81,7 @@ describe('SignalRService', () => {
mockRefreshAccessToken.mockResolvedValue(undefined);
// Mock auth store
- mockGetState.mockReturnValue({
+ mockGetState.mockReturnValue({
accessToken: 'mock-token',
refreshAccessToken: mockRefreshAccessToken,
});
@@ -127,14 +127,12 @@ describe('SignalRService', () => {
});
it('should throw error if no access token is available', async () => {
- mockGetState.mockReturnValue({
+ mockGetState.mockReturnValue({
accessToken: '',
refreshAccessToken: mockRefreshAccessToken,
});
- await expect(signalRService.connectToHubWithEventingUrl(mockConfig)).rejects.toThrow(
- 'No authentication token available'
- );
+ await expect(signalRService.connectToHubWithEventingUrl(mockConfig)).rejects.toThrow('No authentication token available');
});
it('should add trailing slash to EventingUrl if missing', async () => {
@@ -147,10 +145,7 @@ describe('SignalRService', () => {
await signalRService.connectToHubWithEventingUrl(configWithoutSlash);
- expect(mockBuilderInstance.withUrl).toHaveBeenCalledWith(
- 'https://api.example.com/eventingHub',
- expect.any(Object),
- );
+ expect(mockBuilderInstance.withUrl).toHaveBeenCalledWith('https://api.example.com/eventingHub', expect.any(Object));
});
it('should use accessTokenFactory for geolocation hub authentication (no token in URL)', async () => {
@@ -197,7 +192,7 @@ describe('SignalRService', () => {
it('should not put access token with special chars in URL for geolocation hub', async () => {
// Set up a token that needs encoding
- mockGetState.mockReturnValue({
+ mockGetState.mockReturnValue({
accessToken: 'token with spaces & special chars',
refreshAccessToken: mockRefreshAccessToken,
});
@@ -225,7 +220,7 @@ describe('SignalRService', () => {
it('should not put complex access tokens in URL for geolocation hub', async () => {
// Set up a complex token with various characters that need encoding
- mockGetState.mockReturnValue({
+ mockGetState.mockReturnValue({
accessToken: 'Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9+/=?#&',
refreshAccessToken: mockRefreshAccessToken,
});
@@ -280,27 +275,22 @@ describe('SignalRService', () => {
await signalRService.connectToHubWithEventingUrl(configWithSlash);
- expect(mockBuilderInstance.withUrl).toHaveBeenCalledWith(
- 'https://api.example.com/eventingHub',
- expect.any(Object),
- );
+ expect(mockBuilderInstance.withUrl).toHaveBeenCalledWith('https://api.example.com/eventingHub', expect.any(Object));
});
it('should throw error if EventingUrl is not provided', async () => {
const configWithoutUrl = { ...mockConfig, eventingUrl: '' };
- await expect(signalRService.connectToHubWithEventingUrl(configWithoutUrl)).rejects.toThrow(
- 'EventingUrl is required for SignalR connection'
- );
+ await expect(signalRService.connectToHubWithEventingUrl(configWithoutUrl)).rejects.toThrow('EventingUrl is required for SignalR connection');
});
it('should not connect if already connected', async () => {
// Connect first time
await signalRService.connectToHubWithEventingUrl(mockConfig);
-
+
// Reset mocks to verify second call behavior
jest.clearAllMocks();
-
+
// Try to connect again
await signalRService.connectToHubWithEventingUrl(mockConfig);
@@ -316,7 +306,13 @@ describe('SignalRService', () => {
await expect(signalRService.connectToHubWithEventingUrl(mockConfig)).rejects.toThrow(error);
- expect(mockLogger.error).toHaveBeenCalledWith({
+ // Transient connect failures are retried by the reconnect logic, so they
+ // log at warn and never reach Sentry.
+ expect(mockLogger.warn).toHaveBeenCalledWith({
+ message: `Failed to connect to hub: ${mockConfig.name}`,
+ context: { error },
+ });
+ expect(mockLogger.error).not.toHaveBeenCalledWith({
message: `Failed to connect to hub: ${mockConfig.name}`,
context: { error },
});
@@ -355,7 +351,7 @@ describe('SignalRService', () => {
it('should disconnect from hub successfully', async () => {
// Connect first
await signalRService.connectToHubWithEventingUrl(mockConfig);
-
+
// Then disconnect
await signalRService.disconnectFromHub(mockConfig.name);
@@ -367,10 +363,10 @@ describe('SignalRService', () => {
it('should handle disconnect errors gracefully', async () => {
const error = new Error('Disconnect failed');
-
+
// Connect first
await signalRService.connectToHubWithEventingUrl(mockConfig);
-
+
// Mock stop to throw error
mockConnection.stop.mockRejectedValue(error);
@@ -399,10 +395,10 @@ describe('SignalRService', () => {
it('should invoke method on connected hub', async () => {
const methodData = { test: 'data' };
-
+
// Connect first
await signalRService.connectToHubWithEventingUrl(mockConfig);
-
+
// Invoke method
await signalRService.invoke(mockConfig.name, 'testMethod', methodData);
@@ -412,10 +408,10 @@ describe('SignalRService', () => {
it('should handle invoke errors gracefully', async () => {
const error = new Error('Invoke failed');
const methodData = { test: 'data' };
-
+
// Connect first
await signalRService.connectToHubWithEventingUrl(mockConfig);
-
+
// Mock invoke to throw error
mockConnection.invoke.mockRejectedValue(error);
@@ -428,9 +424,7 @@ describe('SignalRService', () => {
});
it('should throw error if hub is not connected', async () => {
- await expect(signalRService.invoke('nonExistentHub', 'testMethod', {})).rejects.toThrow(
- 'Cannot invoke method testMethod on hub nonExistentHub: hub is not connected'
- );
+ await expect(signalRService.invoke('nonExistentHub', 'testMethod', {})).rejects.toThrow('Cannot invoke method testMethod on hub nonExistentHub: hub is not connected');
expect(mockConnection.invoke).not.toHaveBeenCalled();
});
@@ -473,37 +467,35 @@ describe('SignalRService', () => {
it('should handle received messages and emit events', async () => {
const eventCallback = jest.fn();
-
+
// Set up event listener
signalRService.on('testMethod', eventCallback);
-
+
// Connect to hub
await signalRService.connectToHubWithEventingUrl(mockConfig);
-
+
// Get the registered callback for the method
- const registeredCallback = mockConnection.on.mock.calls.find(
- call => call[0] === 'testMethod'
- )?.[1];
-
+ const registeredCallback = mockConnection.on.mock.calls.find((call) => call[0] === 'testMethod')?.[1];
+
expect(registeredCallback).toBeDefined();
-
+
// Simulate receiving a message
const testData = { message: 'test' };
registeredCallback!(testData);
-
+
// Verify the event was emitted
expect(eventCallback).toHaveBeenCalledWith(testData);
});
it('should remove event listeners', () => {
const eventCallback = jest.fn();
-
+
signalRService.on('testEvent', eventCallback);
signalRService.off('testEvent', eventCallback);
-
+
// Emit an event (this would be called internally)
signalRService['emit']('testEvent', { test: 'data' });
-
+
// Callback should not have been called
expect(eventCallback).not.toHaveBeenCalled();
});
@@ -545,7 +537,7 @@ describe('SignalRService', () => {
it('should skip connection if hub is already reconnecting', async () => {
// Set reconnecting state using new state management
(signalRService as any).setHubState(mockConfig.name, HubConnectingState.RECONNECTING);
-
+
// Try to connect
await signalRService.connectToHubWithEventingUrl(mockConfig);
@@ -569,14 +561,12 @@ describe('SignalRService', () => {
it('should throw specific error when hub is reconnecting', async () => {
// Set reconnecting state
(signalRService as any).reconnectingHubs.add(mockConfig.name);
-
- await expect(signalRService.invoke(mockConfig.name, 'testMethod', {}))
- .rejects.toThrow(`Cannot invoke method testMethod on hub ${mockConfig.name}: hub is currently reconnecting`);
+
+ await expect(signalRService.invoke(mockConfig.name, 'testMethod', {})).rejects.toThrow(`Cannot invoke method testMethod on hub ${mockConfig.name}: hub is currently reconnecting`);
});
it('should throw generic error when hub is not connected', async () => {
- await expect(signalRService.invoke('nonExistentHub', 'testMethod', {}))
- .rejects.toThrow(`Cannot invoke method testMethod on hub nonExistentHub: hub is not connected`);
+ await expect(signalRService.invoke('nonExistentHub', 'testMethod', {})).rejects.toThrow(`Cannot invoke method testMethod on hub nonExistentHub: hub is not connected`);
});
});
@@ -591,10 +581,10 @@ describe('SignalRService', () => {
it('should clear reconnecting flag when disconnecting from connected hub', async () => {
// Connect first
await signalRService.connectToHubWithEventingUrl(mockConfig);
-
+
// Set reconnecting state
(signalRService as any).reconnectingHubs.add(mockConfig.name);
-
+
// Disconnect
await signalRService.disconnectFromHub(mockConfig.name);
@@ -607,7 +597,7 @@ describe('SignalRService', () => {
(signalRService as any).reconnectingHubs.add(mockConfig.name);
(signalRService as any).reconnectAttempts.set(mockConfig.name, 2);
(signalRService as any).hubConfigs.set(mockConfig.name, mockConfig);
-
+
// Disconnect
await signalRService.disconnectFromHub(mockConfig.name);
@@ -628,13 +618,13 @@ describe('SignalRService', () => {
it('should set reconnecting flag during reconnection attempt', async () => {
jest.useFakeTimers();
-
+
// Connect to hub
await signalRService.connectToHubWithEventingUrl(mockConfig);
-
+
// Get the onclose callback
const onCloseCallback = mockConnection.onclose.mock.calls[0][0];
-
+
// Spy on the connectToHubWithEventingUrl method
const connectSpy = jest.spyOn(signalRService, 'connectToHubWithEventingUrl');
connectSpy.mockImplementation(() => {
@@ -642,33 +632,33 @@ describe('SignalRService', () => {
expect((signalRService as any).reconnectingHubs.has(mockConfig.name)).toBe(true);
return Promise.resolve();
});
-
+
// Remove the connection to simulate it being closed
(signalRService as any).connections.delete(mockConfig.name);
-
+
// Trigger connection close
onCloseCallback();
-
+
// Advance timers to trigger reconnection
jest.advanceTimersByTime(5000);
await jest.runAllTicks();
-
+
// Should have called reconnection
expect(connectSpy).toHaveBeenCalled();
-
+
jest.useRealTimers();
connectSpy.mockRestore();
});
it('should clear reconnecting flag on successful reconnection', async () => {
jest.useFakeTimers();
-
+
// Connect to hub
await signalRService.connectToHubWithEventingUrl(mockConfig);
-
+
// Get the onclose callback
const onCloseCallback = mockConnection.onclose.mock.calls[0][0];
-
+
// Spy on the connectToHubWithEventingUrl method to succeed
const connectSpy = jest.spyOn(signalRService, 'connectToHubWithEventingUrl');
connectSpy.mockImplementation(async (config) => {
@@ -676,33 +666,33 @@ describe('SignalRService', () => {
(signalRService as any).reconnectingHubs.delete(config.name);
return Promise.resolve();
});
-
+
// Remove the connection to simulate it being closed
(signalRService as any).connections.delete(mockConfig.name);
-
+
// Trigger connection close
onCloseCallback();
-
+
// Advance timers to trigger reconnection
jest.advanceTimersByTime(5000);
await jest.runAllTicks();
-
+
// Should have cleared reconnecting flag
expect((signalRService as any).reconnectingHubs.has(mockConfig.name)).toBe(false);
-
+
jest.useRealTimers();
connectSpy.mockRestore();
});
it('should clear reconnecting flag on failed reconnection', async () => {
jest.useFakeTimers();
-
+
// Connect to hub
await signalRService.connectToHubWithEventingUrl(mockConfig);
-
+
// Get the onclose callback
const onCloseCallback = mockConnection.onclose.mock.calls[0][0];
-
+
// Spy on the connectToHubWithEventingUrl method to fail
const connectSpy = jest.spyOn(signalRService, 'connectToHubWithEventingUrl');
connectSpy.mockImplementation(async (config) => {
@@ -710,33 +700,33 @@ describe('SignalRService', () => {
(signalRService as any).reconnectingHubs.delete(config.name);
throw new Error('Reconnection failed');
});
-
+
// Remove the connection to simulate it being closed
(signalRService as any).connections.delete(mockConfig.name);
-
+
// Trigger connection close
onCloseCallback();
-
+
// Advance timers to trigger reconnection
jest.advanceTimersByTime(5000);
await jest.runAllTicks();
-
+
// Should have cleared reconnecting flag even on failure
expect((signalRService as any).reconnectingHubs.has(mockConfig.name)).toBe(false);
-
+
jest.useRealTimers();
connectSpy.mockRestore();
});
it('should clear reconnecting flag when max attempts reached', async () => {
jest.useFakeTimers();
-
+
// Connect to hub first
await signalRService.connectToHubWithEventingUrl(mockConfig);
-
+
// Get the onclose callback
const onCloseCallback = mockConnection.onclose.mock.calls[0][0];
-
+
// Set up spy to make reconnection attempts fail
const connectSpy = jest.spyOn(signalRService, 'connectToHubWithEventingUrl');
connectSpy.mockImplementation(async (config) => {
@@ -744,10 +734,10 @@ describe('SignalRService', () => {
(signalRService as any).reconnectingHubs.delete(config.name);
throw new Error('Connection failed');
});
-
+
// Remove the connection to simulate it being closed
(signalRService as any).connections.delete(mockConfig.name);
-
+
// Simulate multiple failed reconnection attempts
for (let i = 0; i < 6; i++) {
onCloseCallback();
@@ -756,10 +746,10 @@ describe('SignalRService', () => {
// Simulate each attempt failing by removing the connection
(signalRService as any).connections.delete(mockConfig.name);
}
-
+
// Should have cleared reconnecting flag after max attempts
expect((signalRService as any).reconnectingHubs.has(mockConfig.name)).toBe(false);
-
+
jest.useRealTimers();
connectSpy.mockRestore();
});
@@ -797,7 +787,7 @@ describe('SignalRService', () => {
mockHubConnectionBuilder.mockImplementation(() => mockBuilderInstance);
// Reset auth store mock
- mockGetState.mockReturnValue({
+ mockGetState.mockReturnValue({
accessToken: 'mock-token',
refreshAccessToken: mockRefreshAccessToken,
});
@@ -808,75 +798,75 @@ describe('SignalRService', () => {
it('should attempt reconnection on connection close', async () => {
// Use fake timers to control setTimeout behavior
jest.useFakeTimers();
-
+
// Connect to hub
await signalRService.connectToHubWithEventingUrl(mockConfig);
-
+
// Verify onclose was called to register the callback
expect(mockConnection.onclose).toHaveBeenCalled();
-
+
// Get the onclose callback from the first call
const onCloseCallback = mockConnection.onclose.mock.calls[0][0];
-
+
// Spy on the connectToHubWithEventingUrl method to track reconnection attempts
const connectSpy = jest.spyOn(signalRService, 'connectToHubWithEventingUrl');
connectSpy.mockResolvedValue(); // Mock the reconnection call
-
+
// Remove the connection to simulate it being closed
(signalRService as any).connections.delete(mockConfig.name);
-
+
// Trigger connection close
onCloseCallback();
-
+
// Advance timers by the exact reconnect interval (5000ms)
jest.advanceTimersByTime(5000);
-
+
// Wait for all promises to resolve
await jest.runAllTicks();
-
+
// Should have called refreshAccessToken
expect(mockRefreshAccessToken).toHaveBeenCalled();
-
+
// Should have called connectToHubWithEventingUrl for reconnection
expect(connectSpy).toHaveBeenCalledWith(mockConfig);
-
+
jest.useRealTimers();
connectSpy.mockRestore();
}, 10000);
it('should stop reconnection attempts after max attempts', async () => {
jest.useFakeTimers();
-
+
// Connect to hub first
await signalRService.connectToHubWithEventingUrl(mockConfig);
-
+
// Verify onclose was called and get the callback
expect(mockConnection.onclose).toHaveBeenCalled();
const onCloseCallback = mockConnection.onclose.mock.calls[0][0];
-
+
// Now set up the spy to make reconnection attempts fail
const connectSpy = jest.spyOn(signalRService, 'connectToHubWithEventingUrl');
connectSpy.mockRejectedValue(new Error('Connection failed'));
-
+
// Remove the connection to simulate it being closed
(signalRService as any).connections.delete(mockConfig.name);
-
+
// Trigger the initial close — schedules attempt 1
onCloseCallback();
-
+
// Each failed attempt now reschedules automatically (retry-until-cap).
// One large advance covers the full linear backoff schedule
// (5s, 10s, ..., 50s — sum is 275s).
await jest.advanceTimersByTimeAsync(600000);
-
+
// Should have retried 10 times before giving up
expect(connectSpy).toHaveBeenCalledTimes(10);
-
+
// Should log max attempts reached error
expect(mockLogger.error).toHaveBeenCalledWith({
message: `Max reconnection attempts (10) reached for hub: ${mockConfig.name}`,
});
-
+
jest.useRealTimers();
connectSpy.mockRestore();
});
@@ -884,14 +874,14 @@ describe('SignalRService', () => {
it('should reset reconnection attempts on successful reconnection', async () => {
// Connect to hub
await signalRService.connectToHubWithEventingUrl(mockConfig);
-
+
// Verify onreconnected was called and get the callback
expect(mockConnection.onreconnected).toHaveBeenCalled();
const onReconnectedCallback = mockConnection.onreconnected.mock.calls[0][0];
-
+
// Trigger reconnection
onReconnectedCallback('new-connection-id');
-
+
expect(mockLogger.info).toHaveBeenCalledWith({
message: `Reconnected to hub: ${mockConfig.name}`,
context: { connectionId: 'new-connection-id' },
@@ -900,52 +890,54 @@ describe('SignalRService', () => {
it('should handle token refresh failure during reconnection', async () => {
jest.useFakeTimers();
-
+
// Setup refresh token to fail
mockRefreshAccessToken.mockRejectedValue(new Error('Token refresh failed'));
-
+
// Connect to hub
await signalRService.connectToHubWithEventingUrl(mockConfig);
-
+
// Verify onclose was called and get the callback
expect(mockConnection.onclose).toHaveBeenCalled();
const onCloseCallback = mockConnection.onclose.mock.calls[0][0];
-
+
// Spy on the connectToHubWithEventingUrl method to ensure it's not called when token refresh fails
const connectSpy = jest.spyOn(signalRService, 'connectToHubWithEventingUrl');
connectSpy.mockResolvedValue();
-
+
// Remove the connection to simulate it being closed
(signalRService as any).connections.delete(mockConfig.name);
-
+
// Trigger connection close
onCloseCallback();
-
+
// Fast-forward time to trigger the setTimeout callback
await jest.advanceTimersByTimeAsync(5000);
-
+
// Should have attempted to refresh token
expect(mockRefreshAccessToken).toHaveBeenCalled();
-
- // Should have logged the failure and rescheduled (retry-until-cap semantics)
- expect(mockLogger.error).toHaveBeenCalledWith({
+
+ // Should have logged the failure and rescheduled (retry-until-cap semantics).
+ // Each individual attempt is transient, so it warns; only exhausting the
+ // full attempt budget reports an error to Sentry.
+ expect(mockLogger.warn).toHaveBeenCalledWith({
message: `Reconnection attempt 1/10 failed for hub: ${mockConfig.name}`,
context: { error: expect.any(Error) },
});
-
+
// Should NOT have called connectToHubWithEventingUrl due to token refresh failure
expect(connectSpy).not.toHaveBeenCalled();
-
+
// A retry should have been scheduled (attempt counter advanced)
expect((signalRService as any).reconnectAttempts.get(mockConfig.name)).toBe(2);
-
+
jest.useRealTimers();
connectSpy.mockRestore();
});
it('should abort reconnection and clean up when no token remains after refresh', async () => {
jest.useFakeTimers();
-
+
// Refresh succeeds but yields no access token (logged out)
mockRefreshAccessToken.mockImplementation(async () => {
mockGetState.mockReturnValue({
@@ -953,35 +945,35 @@ describe('SignalRService', () => {
refreshAccessToken: mockRefreshAccessToken,
});
});
-
+
// Connect to hub
await signalRService.connectToHubWithEventingUrl(mockConfig);
-
+
const onCloseCallback = mockConnection.onclose.mock.calls[0][0];
-
+
const connectSpy = jest.spyOn(signalRService, 'connectToHubWithEventingUrl');
connectSpy.mockResolvedValue();
-
+
// Remove the connection to simulate it being closed
(signalRService as any).connections.delete(mockConfig.name);
-
+
// Trigger connection close
onCloseCallback();
-
+
await jest.advanceTimersByTimeAsync(5000);
-
+
// Should abort with a warn log
expect(mockLogger.warn).toHaveBeenCalledWith({
message: `No valid authentication token after refresh, aborting reconnect for hub: ${mockConfig.name}`,
});
-
+
// Should NOT attempt to reconnect
expect(connectSpy).not.toHaveBeenCalled();
-
+
// Hub config and attempts should be cleaned up — no further retries
expect((signalRService as any).hubConfigs.has(mockConfig.name)).toBe(false);
expect((signalRService as any).reconnectAttempts.has(mockConfig.name)).toBe(false);
-
+
jest.useRealTimers();
connectSpy.mockRestore();
});
diff --git a/src/services/analytics.service.ts b/src/services/analytics.service.ts
index d954de02..2f5831fa 100644
--- a/src/services/analytics.service.ts
+++ b/src/services/analytics.service.ts
@@ -57,6 +57,11 @@ class CountlyService {
Countly.events.recordEvent(eventName, segmentation, 1);
+ // A successful record proves the SDK is healthy. Without this the counter
+ // only ever grows, so two unrelated errors hours apart would disable
+ // analytics for the full disable timeout.
+ this.retryCount = 0;
+
if (this.enableLogging) {
logger.debug({
message: 'Analytics event tracked successfully',
diff --git a/src/services/aptabase.service.ts b/src/services/aptabase.service.ts
index e1319dcc..2c0be0bb 100644
--- a/src/services/aptabase.service.ts
+++ b/src/services/aptabase.service.ts
@@ -57,6 +57,11 @@ class CountlyService {
Countly.events.recordEvent(eventName, segmentation, 1);
+ // A successful record proves the SDK is healthy. Without this the counter
+ // only ever grows, so two unrelated errors hours apart would disable
+ // analytics for the full disable timeout.
+ this.retryCount = 0;
+
if (this.enableLogging) {
logger.debug({
message: 'Analytics event tracked successfully',
diff --git a/src/services/bluetooth-audio.service.ts b/src/services/bluetooth-audio.service.ts
index aa011d1f..a66acc96 100644
--- a/src/services/bluetooth-audio.service.ts
+++ b/src/services/bluetooth-audio.service.ts
@@ -54,7 +54,9 @@ class BluetoothAudioService {
private static instance: BluetoothAudioService;
private connectedDevice: Device | null = null;
private scanTimeout: ReturnType | null = null;
- private connectionTimeout: NodeJS.Timeout | null = null;
+ // Guards connectToDevice against overlapping runs — discovery events can fire
+ // repeatedly for the preferred device while a connection is still in flight.
+ private isConnecting: boolean = false;
private isInitialized: boolean = false;
private hasAttemptedPreferredDeviceConnection: boolean = false;
private eventListeners: { remove: () => void }[] = [];
@@ -63,7 +65,7 @@ class BluetoothAudioService {
private monitoringWatchdogInterval: ReturnType | null = null;
private readPollingInterval: ReturnType | null = null;
private isReadPollingInFlight: boolean = false;
- private monitoredReadCharacteristics: { serviceUuid: string; characteristicUuid: string; lastHexValue: string | null }[] = [];
+ private monitoredReadCharacteristics: { serviceUuid: string; characteristicUuid: string; lastHexValue: string | null; notificationConfirmed: boolean }[] = [];
private mediaButtonEventListener: { remove: () => void } | null = null;
private mediaButtonListeningActive: boolean = false;
private pttPressActive: boolean = false;
@@ -311,10 +313,37 @@ class BluetoothAudioService {
return;
}
+ // A real GATT notification arrived for this characteristic — the
+ // subscription is proven to work, so the read-polling fallback can stop
+ // polling it.
+ this.markNotificationConfirmed(data.service, data.characteristic);
+
// Handle button events based on service and characteristic UUIDs
this.handleButtonEventFromCharacteristic(data.peripheral, data.service, data.characteristic, value);
}
+ /**
+ * Record that a GATT notification was actually delivered for a characteristic.
+ *
+ * The read-polling fallback exists for devices whose notifications silently
+ * never fire (screen-off PTT must keep working), but polling every monitored
+ * characteristic every 700ms forever wastes battery/radio when notifications
+ * do work. Once a characteristic has delivered a notification it is excluded
+ * from polling; a re-subscribe (reconnect) rebuilds the entries unconfirmed,
+ * which resumes polling until notifications prove themselves again.
+ */
+ private markNotificationConfirmed(serviceUuid: string, characteristicUuid: string): void {
+ for (const entry of this.monitoredReadCharacteristics) {
+ if (!entry.notificationConfirmed && this.areUuidsEqual(entry.serviceUuid, serviceUuid) && this.areUuidsEqual(entry.characteristicUuid, characteristicUuid)) {
+ entry.notificationConfirmed = true;
+ logger.info({
+ message: 'GATT notifications confirmed for characteristic; read-polling fallback no longer needed for it',
+ context: { serviceUuid, characteristicUuid },
+ });
+ }
+ }
+ }
+
private handleScanStopped(): void {
useBluetoothAudioStore.getState().setIsScanning(false);
logger.info({
@@ -877,7 +906,7 @@ class BluetoothAudioService {
// 1. This is the preferred device
// 2. No device is currently connected
// 3. We're not already in the process of connecting
- if (preferredDevice?.id === device.id && !connectedDevice && !this.connectionTimeout) {
+ if (preferredDevice?.id === device.id && !connectedDevice && !this.isConnecting) {
try {
logger.info({
message: 'Auto-connecting to preferred Bluetooth device',
@@ -926,6 +955,14 @@ class BluetoothAudioService {
async connectToDevice(deviceId: string): Promise {
if (this.isWeb) return;
+ if (this.isConnecting) {
+ logger.info({
+ message: 'Bluetooth device connection already in progress, ignoring duplicate connect request',
+ context: { deviceId },
+ });
+ return;
+ }
+ this.isConnecting = true;
try {
useBluetoothAudioStore.getState().clearConnectionError();
useBluetoothAudioStore.getState().setIsConnecting(true);
@@ -1038,6 +1075,8 @@ class BluetoothAudioService {
useBluetoothAudioStore.getState().setIsConnecting(false);
useBluetoothAudioStore.getState().setConnectionError(errorMessage);
throw error;
+ } finally {
+ this.isConnecting = false;
}
}
@@ -1472,6 +1511,7 @@ class BluetoothAudioService {
serviceUuid,
characteristicUuid,
lastHexValue: null,
+ notificationConfirmed: false,
});
}
@@ -1494,6 +1534,13 @@ class BluetoothAudioService {
return;
}
+ // Every subscription has delivered a real notification — nothing left
+ // that needs the polling fallback.
+ if (this.monitoredReadCharacteristics.every((entry) => entry.notificationConfirmed)) {
+ this.stopReadPollingFallback();
+ return;
+ }
+
if (this.isReadPollingInFlight) {
return;
}
@@ -1507,6 +1554,11 @@ class BluetoothAudioService {
private async pollReadCharacteristics(deviceId: string): Promise {
for (const entry of this.monitoredReadCharacteristics) {
+ // Notifications are proven to work for this characteristic — reading it
+ // on a timer would only duplicate events and burn battery.
+ if (entry.notificationConfirmed) {
+ continue;
+ }
try {
const readValue = await BleManager.read(deviceId, entry.serviceUuid, entry.characteristicUuid);
const nextHexValue = Buffer.from(readValue).toString('hex');
@@ -2423,9 +2475,23 @@ class BluetoothAudioService {
}
}
- destroy(): void {
- this.stopScanning();
- this.disconnectDevice();
+ async destroy(): Promise {
+ try {
+ await this.stopScanning();
+ } catch (error) {
+ logger.warn({
+ message: 'Error stopping scan during Bluetooth service destroy',
+ context: { error },
+ });
+ }
+ try {
+ await this.disconnectDevice();
+ } catch (error) {
+ logger.warn({
+ message: 'Error disconnecting device during Bluetooth service destroy',
+ context: { error },
+ });
+ }
useBluetoothAudioStore.getState().setIsHeadsetButtonMonitoring(false);
this.clearPttReleaseFallback();
this.clearMicApplyRetry();
@@ -2441,14 +2507,8 @@ class BluetoothAudioService {
});
this.eventListeners = [];
- if (this.connectionTimeout) {
- clearTimeout(this.connectionTimeout);
- this.connectionTimeout = null;
- }
-
// Reset initialization flags
this.isInitialized = false;
- this.isInitialized = false;
this.hasAttemptedPreferredDeviceConnection = false;
}
diff --git a/src/services/location.ts b/src/services/location.ts
index 3019ec40..003691ec 100644
--- a/src/services/location.ts
+++ b/src/services/location.ts
@@ -184,6 +184,13 @@ class LocationService {
private backgroundSubscription: Location.LocationSubscription | null = null;
private appStateSubscription: { remove: () => void } | null = null;
private isBackgroundGeolocationEnabled = false;
+ // 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
+ // and drop the fresh subscription. Concurrent callers share the in-flight
+ // promise instead, and stop* waits for it before tearing anything down.
+ private startPromise: Promise | null = null;
+ private startBackgroundPromise: Promise | null = null;
private constructor() {
this.initializeAppStateListener();
@@ -208,10 +215,20 @@ class LocationService {
context: { nextAppState, backgroundEnabled: this.isBackgroundGeolocationEnabled },
});
- if (nextAppState === 'background' && this.isBackgroundGeolocationEnabled) {
- await this.startBackgroundUpdates();
- } else if (nextAppState === 'active') {
- await this.stopBackgroundUpdates();
+ // AppState invokes this without awaiting, so anything that throws here (a
+ // permission revoked while backgrounded is the common one) becomes an
+ // unhandled rejection instead of a recoverable, logged failure.
+ try {
+ if (nextAppState === 'background' && this.isBackgroundGeolocationEnabled) {
+ await this.startBackgroundUpdates();
+ } else if (nextAppState === 'active') {
+ await this.stopBackgroundUpdates();
+ }
+ } catch (error) {
+ logger.warn({
+ message: 'Failed to handle location app state change',
+ context: { error, nextAppState },
+ });
}
};
@@ -239,6 +256,20 @@ class LocationService {
}
async startLocationUpdates(): Promise {
+ if (this.startPromise) {
+ return this.startPromise;
+ }
+
+ const promise = this.performStartLocationUpdates().finally(() => {
+ if (this.startPromise === promise) {
+ this.startPromise = null;
+ }
+ });
+ this.startPromise = promise;
+ return promise;
+ }
+
+ private async performStartLocationUpdates(): Promise {
// On web, use a lightweight browser geolocation watcher instead of expo-location/TaskManager
if (isWeb) {
if (!('geolocation' in navigator)) {
@@ -357,6 +388,20 @@ class LocationService {
}
async startBackgroundUpdates(): Promise {
+ if (this.startBackgroundPromise) {
+ return this.startBackgroundPromise;
+ }
+
+ const promise = this.performStartBackgroundUpdates().finally(() => {
+ if (this.startBackgroundPromise === promise) {
+ this.startBackgroundPromise = null;
+ }
+ });
+ this.startBackgroundPromise = promise;
+ return promise;
+ }
+
+ private async performStartBackgroundUpdates(): Promise {
if (isWeb) return; // Background location not supported on web
if (this.backgroundSubscription || !this.isBackgroundGeolocationEnabled) {
return;
@@ -401,6 +446,11 @@ class LocationService {
async stopBackgroundUpdates(): Promise {
if (isWeb) return;
+ // A start still in flight assigns its subscription after this runs, which
+ // would leave an orphaned watcher running forever.
+ if (this.startBackgroundPromise) {
+ await this.startBackgroundPromise.catch(() => {});
+ }
if (this.backgroundSubscription) {
logger.info({
message: 'Stopping background location updates',
@@ -472,6 +522,13 @@ class LocationService {
}
async stopLocationUpdates(): Promise {
+ // Wait out any start still in flight: it assigns this.locationSubscription
+ // after its await, so stopping first would clear a null field and leave the
+ // watcher that lands afterwards running with nothing tracking it.
+ if (this.startPromise) {
+ await this.startPromise.catch(() => {});
+ }
+
if (this.locationSubscription) {
if (isWeb) {
// On web the subscription is our own shim wrapping clearWatch
diff --git a/src/services/offline-event-manager.service.ts b/src/services/offline-event-manager.service.ts
index f5ff88d4..36e58187 100644
--- a/src/services/offline-event-manager.service.ts
+++ b/src/services/offline-event-manager.service.ts
@@ -16,7 +16,7 @@ import {
} from '@/models/offline-queue/queued-event';
import { SaveUnitLocationInput } from '@/models/v4/unitLocation/saveUnitLocationInput';
import { SaveUnitStatusInput, SaveUnitStatusRoleInput } from '@/models/v4/unitStatus/saveUnitStatusInput';
-import { useOfflineQueueStore } from '@/stores/offline-queue/store';
+import { setOfflineQueueActivityListener, useOfflineQueueStore } from '@/stores/offline-queue/store';
class OfflineEventManager {
private static instance: OfflineEventManager;
@@ -48,10 +48,36 @@ class OfflineEventManager {
// Initialize network listener
useOfflineQueueStore.getState().initializeNetworkListener();
+ // The processing timer stops itself once the queue has no work left, so the
+ // queue wakes it again when an event is enqueued, retried, or the device
+ // comes back online.
+ setOfflineQueueActivityListener(() => {
+ if (AppState.currentState !== 'inactive') {
+ this.startProcessing();
+ }
+ });
+
// Start processing when app becomes active
this.handleAppStateChange(AppState.currentState);
}
+ /**
+ * True when the queue still holds something the processor can act on. Events
+ * whose retries are exhausted are inert, so a queue holding only those must
+ * not keep the 10s timer alive.
+ */
+ private hasPendingWork(): boolean {
+ const store = useOfflineQueueStore.getState();
+
+ if (store.getPendingEvents().length > 0) {
+ return true;
+ }
+
+ // Events parked on a retry backoff are not pending *yet*, but they will be —
+ // dropping the timer now would strand them until the next enqueue.
+ return (store.queuedEvents ?? []).some((event) => event.status === QueuedEventStatus.PROCESSING || (event.status === QueuedEventStatus.FAILED && event.retryCount < event.maxRetries));
+ }
+
/**
* Start background processing of queued events
*/
@@ -63,6 +89,15 @@ class OfflineEventManager {
return;
}
+ // Nothing to drain — starting the timer here would just tick against an
+ // empty queue until something is enqueued, which wakes it anyway.
+ if (!this.hasPendingWork()) {
+ logger.debug({
+ message: 'Offline queue is empty, not starting event processing',
+ });
+ return;
+ }
+
logger.info({
message: 'Starting offline event processing',
});
@@ -206,9 +241,21 @@ class OfflineEventManager {
return;
}
+ // Age out permanently failed events before deciding whether there is work
+ // left — a queue holding only dead events is an idle queue.
+ useOfflineQueueStore.getState().pruneFailedEvents();
+
+ if (!this.hasPendingWork()) {
+ // Nothing actionable: stop the timer instead of ticking every 10s forever.
+ // addEvent / retry / NetInfo reconnect restart it via the activity listener.
+ this.stopProcessing();
+ return;
+ }
+
const store = useOfflineQueueStore.getState();
- // Don't process if offline
+ // Don't process if offline. The timer stays armed so the queue drains as
+ // soon as connectivity returns.
if (!store.isConnected || !store.isNetworkReachable) {
logger.debug({
message: 'Device is offline, skipping event processing',
@@ -408,6 +455,7 @@ class OfflineEventManager {
*/
public cleanup(): void {
this.stopProcessing();
+ setOfflineQueueActivityListener(null);
if (this.appStateSubscription) {
this.appStateSubscription.remove();
diff --git a/src/services/signalr.service.ts b/src/services/signalr.service.ts
index 0d782a49..ef73d22f 100644
--- a/src/services/signalr.service.ts
+++ b/src/services/signalr.service.ts
@@ -272,7 +272,9 @@ class SignalRService {
// Clear the direct-connecting state on failed connection
this.setHubState(config.name, HubConnectingState.IDLE);
- logger.error({
+ // Transient network/backend failure — reconnect logic retries, so warn
+ // rather than reporting every attempt to Sentry.
+ logger.warn({
message: `Failed to connect to hub: ${config.name}`,
context: { error },
});
@@ -343,6 +345,21 @@ class SignalRService {
context: { config },
});
+ // Store an equivalent eventing-style config so handleConnectionClose can
+ // rebuild this connection after the automatic-reconnect budget is
+ // exhausted — manual reconnection always goes through
+ // connectToHubWithEventingUrl, which re-appends the hub name to the path.
+ const parsedUrl = new URL(config.url);
+ const pathSegments = parsedUrl.pathname.split('/').filter(Boolean);
+ const derivedHubName = pathSegments.pop() ?? '';
+ const basePath = pathSegments.length > 0 ? `/${pathSegments.join('/')}` : '/';
+ this.hubConfigs.set(config.name, {
+ name: config.name,
+ eventingUrl: `${parsedUrl.protocol}//${parsedUrl.host}${basePath}${parsedUrl.search}`,
+ hubName: derivedHubName,
+ methods: config.methods,
+ });
+
const signalRLogLevel = Platform.OS === 'web' ? LogLevel.Warning : LogLevel.Information;
const connection = new HubConnectionBuilder()
@@ -410,7 +427,9 @@ class SignalRService {
// Clear the direct-connecting state on failed connection
this.setHubState(config.name, HubConnectingState.IDLE);
- logger.error({
+ // Transient network/backend failure — reconnect logic retries, so warn
+ // rather than reporting every attempt to Sentry.
+ logger.warn({
message: `Failed to connect to hub: ${config.name}`,
context: { error },
});
@@ -525,7 +544,9 @@ class SignalRService {
// dead until the next app background/resume cycle.
this.setHubState(hubName, HubConnectingState.IDLE);
- logger.error({
+ // Each failed attempt is a transient, retried failure — only the
+ // max-attempts-exhausted case above reports to Sentry as an error.
+ logger.warn({
message: `Reconnection attempt ${currentAttempts}/${this.MAX_RECONNECT_ATTEMPTS} failed for hub: ${hubName}`,
context: { error },
});
diff --git a/src/stores/app/__tests__/core-store.test.ts b/src/stores/app/__tests__/core-store.test.ts
index e1c9c990..fd143c7c 100644
--- a/src/stores/app/__tests__/core-store.test.ts
+++ b/src/stores/app/__tests__/core-store.test.ts
@@ -65,6 +65,9 @@ import { getActiveUnitId, getActiveCallId } from '@/lib/storage/app';
import { getConfig } from '@/api/config';
import { logger } from '@/lib/logging';
import { GetConfigResultData } from '@/models/v4/configs/getConfigResultData';
+import { getAllUnitStatuses } from '@/api/satuses/statuses';
+import { getUnitStatus } from '@/api/units/unitStatuses';
+import { useUnitsStore } from '@/stores/units/store';
const mockGetActiveUnitId = getActiveUnitId as jest.MockedFunction;
const mockGetActiveCallId = getActiveCallId as jest.MockedFunction;
@@ -184,7 +187,7 @@ describe('Core Store', () => {
it('should fetch config first during initialization', async () => {
mockGetActiveUnitId.mockReturnValue(null);
mockGetActiveCallId.mockReturnValue(null);
-
+
const mockConfigData = {
EventingUrl: 'https://eventing.example.com/',
GoogleMapsKey: 'test-google-key',
@@ -210,7 +213,7 @@ describe('Core Store', () => {
it('should handle config fetch errors during initialization', async () => {
mockGetActiveUnitId.mockReturnValue(null);
mockGetActiveCallId.mockReturnValue(null);
-
+
const configError = new Error('Failed to fetch config');
mockGetConfig.mockRejectedValue(configError);
@@ -309,6 +312,74 @@ describe('Core Store', () => {
});
});
+ describe('setActiveUnit', () => {
+ const activeUnit = { UnitId: 'unit-1', Name: 'Engine 6', Type: '3' } as any;
+ const unitStatuses = [
+ { UnitType: '0', StatusId: 's0', Statuses: [{ Text: 'Available' }] },
+ { UnitType: '3', StatusId: 's3', Statuses: [{ Text: 'Available' }] },
+ ] as any[];
+
+ beforeEach(() => {
+ (useUnitsStore.getState as jest.Mock).mockReturnValue({
+ fetchUnits: jest.fn(async () => undefined),
+ units: [activeUnit],
+ unitStatuses,
+ });
+ (getUnitStatus as jest.Mock).mockImplementation(async () => ({ Data: { State: 'Available' } }));
+ });
+
+ it('should reuse the statuses fetchUnits already loaded instead of refetching them', async () => {
+ await useCoreStore.getState().setActiveUnit('unit-1');
+
+ // fetchUnits() already issues /Statuses/GetAllUnitStatuses — calling it a
+ // second time here was a duplicate request on every unit selection.
+ expect(getAllUnitStatuses).not.toHaveBeenCalled();
+ });
+
+ it('should resolve activeStatuses for the unit type from the already-fetched data', async () => {
+ await useCoreStore.getState().setActiveUnit('unit-1');
+
+ expect(useCoreStore.getState().activeStatuses).toEqual(unitStatuses[1]);
+ expect(useCoreStore.getState().activeUnit).toEqual(activeUnit);
+ });
+
+ it('should fall back to the default unit type statuses when the type has none', async () => {
+ (useUnitsStore.getState as jest.Mock).mockReturnValue({
+ fetchUnits: jest.fn(async () => undefined),
+ units: [{ ...activeUnit, Type: '99' }],
+ unitStatuses,
+ });
+
+ await useCoreStore.getState().setActiveUnit('unit-1');
+
+ expect(useCoreStore.getState().activeStatuses).toEqual(unitStatuses[0]);
+ });
+
+ it('should log a network failure at warn rather than error', async () => {
+ // Axios "Network Error" — no response received (offline / background launch)
+ const networkError = Object.assign(new Error('Network Error'), {
+ isAxiosError: true,
+ code: 'ERR_NETWORK',
+ });
+ (useUnitsStore.getState as jest.Mock).mockReturnValue({
+ fetchUnits: jest.fn(async () => {
+ throw networkError;
+ }),
+ units: [],
+ unitStatuses: [],
+ });
+
+ await useCoreStore.getState().setActiveUnit('unit-1');
+
+ expect(logger.warn).toHaveBeenCalledWith(
+ expect.objectContaining({
+ message: 'Failed to set active unit due to network connectivity',
+ })
+ );
+ expect(logger.error).not.toHaveBeenCalled();
+ });
+ });
+
describe('Store State', () => {
it('should have correct initial state', () => {
const { result } = renderHook(() => useCoreStore());
diff --git a/src/stores/app/__tests__/livekit-store-room-switch.test.ts b/src/stores/app/__tests__/livekit-store-room-switch.test.ts
index 4758e2f4..a42b360b 100644
--- a/src/stores/app/__tests__/livekit-store-room-switch.test.ts
+++ b/src/stores/app/__tests__/livekit-store-room-switch.test.ts
@@ -238,4 +238,54 @@ describe('LiveKit store room switching', () => {
isConnected: true,
});
});
+
+ it('disconnects the room it created when connect fails, so no live room leaks', async () => {
+ const room = createMockRoom('failed-participant');
+ room.connect.mockRejectedValue(new Error('connect refused'));
+ room.disconnect.mockResolvedValue(undefined);
+ (Room as unknown as jest.Mock).mockImplementationOnce(() => room);
+
+ await useLiveKitStore.getState().connectToRoom(createRoomInfo('bad', 'Bad Room'), 'token');
+
+ // withTimeout rejects without cancelling room.connect(), so the room object
+ // must be torn down explicitly or its websocket/audio session stays open.
+ expect(room.disconnect).toHaveBeenCalled();
+ expect(useLiveKitStore.getState().currentRoom).toBeNull();
+ expect(useLiveKitStore.getState().isConnecting).toBe(false);
+ });
+
+ it('disconnects the room it created when a post-connect step throws', async () => {
+ const room = createMockRoom('post-connect-participant');
+ room.disconnect.mockResolvedValue(undefined);
+ (Room as unknown as jest.Mock).mockImplementationOnce(() => room);
+
+ await useLiveKitStore.getState().connectToRoom(createRoomInfo('ok', 'Room'), 'token');
+ expect(useLiveKitStore.getState().currentRoom).toBe(room);
+
+ // A room already committed to the store is owned by the store's own
+ // teardown paths and must NOT be disconnected by the failure handler.
+ room.disconnect.mockClear();
+
+ const second = createMockRoom('second-participant');
+ second.connect.mockRejectedValue(new Error('boom'));
+ second.disconnect.mockResolvedValue(undefined);
+ (Room as unknown as jest.Mock).mockImplementationOnce(() => second);
+
+ useLiveKitStore.setState({ isConnecting: false, isConnected: false });
+ await useLiveKitStore.getState().connectToRoom(createRoomInfo('two', 'Room Two'), 'token');
+
+ expect(second.disconnect).toHaveBeenCalled();
+ });
+
+ it('swallows a disconnect failure in the error path rather than masking the original error', async () => {
+ const room = createMockRoom('unstoppable');
+ room.connect.mockRejectedValue(new Error('connect refused'));
+ room.disconnect.mockRejectedValue(new Error('disconnect also failed'));
+ (Room as unknown as jest.Mock).mockImplementationOnce(() => room);
+
+ await expect(useLiveKitStore.getState().connectToRoom(createRoomInfo('bad', 'Bad Room'), 'token')).resolves.toBeUndefined();
+
+ expect(room.disconnect).toHaveBeenCalled();
+ expect(useLiveKitStore.getState().isConnecting).toBe(false);
+ });
});
diff --git a/src/stores/app/core-store.ts b/src/stores/app/core-store.ts
index dc61688b..4f94b211 100644
--- a/src/stores/app/core-store.ts
+++ b/src/stores/app/core-store.ts
@@ -4,7 +4,6 @@ import { create } from 'zustand';
import { createJSONStorage, persist } from 'zustand/middleware';
import { getConfig } from '@/api/config';
-import { getAllUnitStatuses } from '@/api/satuses/statuses';
import { getUnitStatus } from '@/api/units/unitStatuses';
import { logger } from '@/lib/logging';
import { zustandStorage } from '@/lib/storage';
@@ -144,12 +143,14 @@ export const useCoreStore = create()(
const unitStatuses = useUnitsStore.getState().unitStatuses;
const activeUnit = units.find((unit) => unit.UnitId === unitId);
if (activeUnit) {
+ // fetchUnits() above already fetched /Statuses/GetAllUnitStatuses and
+ // stored the identical payload as unitStatuses — reuse it instead of
+ // issuing the same request a second time.
let activeStatuses: UnitTypeStatusResultData | undefined = undefined;
- const allStatuses = await getAllUnitStatuses();
- const defaultStatuses = find(allStatuses.Data, ['UnitType', '0']);
+ const defaultStatuses = find(unitStatuses, ['UnitType', '0']);
if (activeUnit.Type) {
- const statusesForType = find(allStatuses.Data, ['UnitType', activeUnit.Type.toString()]);
+ const statusesForType = find(unitStatuses, ['UnitType', activeUnit.Type.toString()]);
if (statusesForType) {
activeStatuses = statusesForType;
@@ -205,10 +206,17 @@ export const useCoreStore = create()(
//await useRolesStore.getState().fetchRolesForUnit(unitId);
} catch (error) {
set({ error: 'Failed to set active unit', isLoading: false });
- logger.error({
- message: 'Failed to set active unit',
- context: { error },
- });
+ if (isNetworkError(error)) {
+ logger.warn({
+ message: 'Failed to set active unit due to network connectivity',
+ context: { error },
+ });
+ } else {
+ logger.error({
+ message: 'Failed to set active unit',
+ context: { error },
+ });
+ }
}
},
setActiveUnitWithFetch: async (unitId: string) => {
@@ -231,10 +239,17 @@ export const useCoreStore = create()(
error: 'Failed to fetch and set active unit',
isLoading: false,
});
- logger.error({
- message: 'Failed to fetch and set active unit',
- context: { error },
- });
+ if (isNetworkError(error)) {
+ logger.warn({
+ message: 'Failed to fetch and set active unit due to network connectivity',
+ context: { error },
+ });
+ } else {
+ logger.error({
+ message: 'Failed to fetch and set active unit',
+ context: { error },
+ });
+ }
}
},
// Lightweight status-only refresh — used by the SignalR status hook so a
@@ -246,10 +261,19 @@ export const useCoreStore = create()(
set({ activeUnitStatus: unitStatus.Data });
}
} catch (error) {
- logger.error({
- message: 'Failed to refresh active unit status',
- context: { error },
- });
+ // Driven by SignalR unitStatusUpdated events, so a transient failure is
+ // repaired by the next event or the next resume.
+ if (isNetworkError(error)) {
+ logger.warn({
+ message: 'Failed to refresh active unit status due to network connectivity',
+ context: { error },
+ });
+ } else {
+ logger.error({
+ message: 'Failed to refresh active unit status',
+ context: { error },
+ });
+ }
}
},
setActiveCall: async (callId: string | null) => {
diff --git a/src/stores/app/livekit-store.ts b/src/stores/app/livekit-store.ts
index 785346ab..3c055457 100644
--- a/src/stores/app/livekit-store.ts
+++ b/src/stores/app/livekit-store.ts
@@ -457,6 +457,12 @@ export const useLiveKitStore = create((set, get) => ({
}
}, CONNECT_OVERALL_TIMEOUT_MS);
+ // The room created by this attempt, kept in outer scope so the failure path
+ // can always tear it down — withTimeout rejects without cancelling
+ // room.connect(), so a late-completing connect would otherwise leak a live
+ // room (open websocket + audio session).
+ let connectingRoom: Room | null = null;
+
try {
const { currentRoom, voipServerWebsocketSslAddress } = get();
@@ -538,6 +544,7 @@ export const useLiveKitStore = create((set, get) => ({
// Create a new room
const room = new Room();
+ connectingRoom = room;
// Setup room event listeners
room.on(RoomEvent.ParticipantConnected, (participant) => {
@@ -795,6 +802,15 @@ export const useLiveKitStore = create((set, get) => ({
context: { error, roomName: roomInfo?.Name },
});
+ // Always tear down the locally created room: a connect that completes
+ // after the timeout rejection would otherwise stay live (websocket +
+ // audio) with nothing referencing it. Only rooms not committed to the
+ // store are torn down here — the intentional room-switch path manages the
+ // committed room itself (isConnected is cleared before it disconnects).
+ if (connectingRoom && get().currentRoom !== connectingRoom) {
+ await connectingRoom.disconnect().catch(() => {});
+ }
+
// Stop audio session on failure since we started it above
if (Platform.OS !== 'web') {
try {
diff --git a/src/stores/auth/__tests__/store-cold-start.test.ts b/src/stores/auth/__tests__/store-cold-start.test.ts
new file mode 100644
index 00000000..606d23d1
--- /dev/null
+++ b/src/stores/auth/__tests__/store-cold-start.test.ts
@@ -0,0 +1,361 @@
+/**
+ * Cold-start session restore, logout idempotence and the refresh single-flight.
+ *
+ * These three behaviours are load-bearing for an emergency-response app: a
+ * responder relaunching the app must be signed in instantly (and stay signed in
+ * offline), a backend incident must never log anyone out, and a genuine
+ * credential rejection must wipe the session exactly once.
+ */
+import { AxiosError, type AxiosResponse } from 'axios';
+
+const mockLoginRequest = jest.fn();
+const mockSsoExternalTokenRequest = jest.fn();
+const mockRefreshTokenRequest = jest.fn();
+
+jest.mock('@/lib/auth/api', () => ({
+ loginRequest: (...args: unknown[]) => mockLoginRequest(...args),
+ ssoExternalTokenRequest: (...args: unknown[]) => mockSsoExternalTokenRequest(...args),
+ refreshTokenRequest: (...args: unknown[]) => mockRefreshTokenRequest(...args),
+}));
+
+jest.mock('@/lib/logging', () => ({
+ logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() },
+}));
+
+jest.mock('@/lib/storage', () => ({
+ zustandStorage: {
+ // Nothing persisted: the store rehydrates empty and each test drives
+ // restoreSession() with the persisted shape it wants to exercise.
+ getItem: jest.fn(() => null),
+ setItem: jest.fn(),
+ removeItem: jest.fn(),
+ },
+ setItem: jest.fn(),
+ removeItem: jest.fn(),
+ getItem: jest.fn(() => null),
+}));
+
+jest.mock('@/lib/cache/cache-manager', () => ({
+ cacheManager: { clear: jest.fn(), remove: jest.fn(), prune: jest.fn() },
+}));
+
+jest.mock('@/lib/cache/cache-scope', () => ({
+ setCacheScope: jest.fn(),
+ clearCacheScope: jest.fn(),
+}));
+
+import { registerSessionCleanupHandler } from '@/lib/auth/session-cleanup';
+
+import { resetInFlightRefresh } from '../../../lib/auth/refresh-lock';
+import useAuthStore, { restoreSession } from '../store';
+
+/** A JWT-shaped access token whose payload is base64url-encoded, expiring at `expiresAtMs`. */
+const makeAccessToken = (expiresAtMs: number): string => {
+ const encoded = Buffer.from(JSON.stringify({ sub: 'user-1', exp: Math.floor(expiresAtMs / 1000) }))
+ .toString('base64')
+ .replace(/\+/g, '-')
+ .replace(/\//g, '_')
+ .replace(/=+$/, '');
+ return `header.${encoded}.signature`;
+};
+
+const axiosErrorWithStatus = (status: number): AxiosError => {
+ const response = { status, statusText: '', data: {}, headers: {}, config: {} as never } as AxiosResponse;
+ return new AxiosError('Request failed', 'ERR_BAD_RESPONSE', undefined, undefined, response);
+};
+
+const networkError = (): AxiosError => new AxiosError('Network Error', 'ERR_NETWORK');
+
+const profile = { sub: 'user-1', name: 'Unit 1' } as never;
+
+const refreshResponse = {
+ access_token: 'new-access-token',
+ refresh_token: 'new-refresh-token',
+ id_token: 'header.payload.signature',
+ expires_in: 3600,
+ token_type: 'Bearer',
+ expiration_date: '',
+};
+
+const sessionCleanup = jest.fn(async () => undefined);
+
+const resetStore = () => {
+ useAuthStore.setState({
+ accessToken: null,
+ refreshToken: null,
+ refreshTokenExpiresOn: null,
+ status: 'idle',
+ error: null,
+ profile: null,
+ userId: null,
+ refreshTimeoutId: null,
+ });
+};
+
+describe('auth store cold start', () => {
+ beforeAll(() => {
+ registerSessionCleanupHandler(sessionCleanup);
+ });
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ jest.useFakeTimers();
+ // Pin the clock to a whole second: a JWT `exp` claim has second resolution,
+ // so a sub-second "now" would make the scheduled refresh delay non-deterministic.
+ jest.setSystemTime(new Date('2026-01-01T00:00:00.000Z'));
+ resetInFlightRefresh();
+ resetStore();
+ mockRefreshTokenRequest.mockResolvedValue(refreshResponse);
+ });
+
+ afterEach(() => {
+ jest.clearAllTimers();
+ jest.useRealTimers();
+ });
+
+ describe('restoreSession', () => {
+ it('restores a valid access token instantly — no delay, no network on the critical path', async () => {
+ const expiresAt = Date.now() + 60 * 60 * 1000;
+
+ restoreSession({
+ accessToken: makeAccessToken(expiresAt),
+ refreshToken: 'stored-refresh-token',
+ refreshTokenExpiresOn: String(expiresAt),
+ profile,
+ });
+
+ // Signed in synchronously: no 2s wait, no refresh round-trip first.
+ expect(useAuthStore.getState().status).toBe('signedIn');
+ expect(mockRefreshTokenRequest).not.toHaveBeenCalled();
+
+ // Still no network after the old fixed 2s delay would have elapsed.
+ await jest.advanceTimersByTimeAsync(2000);
+ expect(mockRefreshTokenRequest).not.toHaveBeenCalled();
+ expect(useAuthStore.getState().status).toBe('signedIn');
+ });
+
+ it('schedules the proactive refresh one minute before the token expires', async () => {
+ const expiresAt = Date.now() + 60 * 60 * 1000;
+
+ restoreSession({
+ accessToken: makeAccessToken(expiresAt),
+ refreshToken: 'stored-refresh-token',
+ refreshTokenExpiresOn: String(expiresAt),
+ profile,
+ });
+
+ // One second before the scheduled refresh: still nothing.
+ await jest.advanceTimersByTimeAsync(59 * 60 * 1000 - 1);
+ expect(mockRefreshTokenRequest).not.toHaveBeenCalled();
+
+ await jest.advanceTimersByTimeAsync(1);
+ expect(mockRefreshTokenRequest).toHaveBeenCalledTimes(1);
+ });
+
+ it('falls back to the persisted expiry when the access token is opaque (not a JWT)', () => {
+ restoreSession({
+ accessToken: 'opaque-access-token',
+ refreshToken: 'stored-refresh-token',
+ refreshTokenExpiresOn: String(Date.now() + 60 * 60 * 1000),
+ profile,
+ });
+
+ expect(useAuthStore.getState().status).toBe('signedIn');
+ expect(mockRefreshTokenRequest).not.toHaveBeenCalled();
+ });
+
+ it('keeps an expired access token signed in and refreshes in the background', async () => {
+ restoreSession({
+ accessToken: makeAccessToken(Date.now() - 60 * 1000),
+ refreshToken: 'stored-refresh-token',
+ refreshTokenExpiresOn: String(Date.now() - 60 * 1000),
+ profile,
+ });
+
+ // Optimistically signed in: the 401 interceptor covers the expired token,
+ // so the user never sees the login form.
+ expect(useAuthStore.getState().status).toBe('signedIn');
+
+ await jest.advanceTimersByTimeAsync(1);
+
+ expect(mockRefreshTokenRequest).toHaveBeenCalledTimes(1);
+ expect(useAuthStore.getState().accessToken).toBe('new-access-token');
+ expect(useAuthStore.getState().status).toBe('signedIn');
+ });
+
+ it('treats a token expiring within the skew window as expired', async () => {
+ restoreSession({
+ accessToken: makeAccessToken(Date.now() + 10 * 1000),
+ refreshToken: 'stored-refresh-token',
+ refreshTokenExpiresOn: String(Date.now() + 10 * 1000),
+ profile,
+ });
+
+ expect(useAuthStore.getState().status).toBe('signedIn');
+ await jest.advanceTimersByTimeAsync(1);
+ expect(mockRefreshTokenRequest).toHaveBeenCalledTimes(1);
+ });
+
+ it('refreshes immediately (no fixed delay) when only a refresh token was persisted', async () => {
+ restoreSession({
+ accessToken: null,
+ refreshToken: 'stored-refresh-token',
+ refreshTokenExpiresOn: null,
+ profile,
+ });
+
+ // Nothing to authorize requests with, so loading is correct here — but the
+ // refresh starts right away rather than after an arbitrary 2s wait.
+ expect(useAuthStore.getState().status).toBe('loading');
+ await jest.advanceTimersByTimeAsync(1);
+ expect(mockRefreshTokenRequest).toHaveBeenCalledTimes(1);
+ });
+
+ it('does nothing when no session was persisted', () => {
+ restoreSession({ accessToken: null, refreshToken: null, refreshTokenExpiresOn: null, profile: null });
+ restoreSession(undefined);
+
+ expect(useAuthStore.getState().status).toBe('idle');
+ expect(mockRefreshTokenRequest).not.toHaveBeenCalled();
+ });
+
+ it('never leaves the persisted status as loading when tokens are usable', () => {
+ const expiresAt = Date.now() + 60 * 60 * 1000;
+ restoreSession({
+ accessToken: makeAccessToken(expiresAt),
+ refreshToken: 'stored-refresh-token',
+ refreshTokenExpiresOn: String(expiresAt),
+ profile,
+ });
+
+ expect(useAuthStore.getState().status).not.toBe('loading');
+ });
+ });
+
+ describe('offline cold start', () => {
+ it('keeps the session (does not bounce to login) when the refresh fails offline', async () => {
+ mockRefreshTokenRequest.mockRejectedValue(networkError());
+
+ restoreSession({
+ accessToken: makeAccessToken(Date.now() - 60 * 1000),
+ refreshToken: 'stored-refresh-token',
+ refreshTokenExpiresOn: String(Date.now() - 60 * 1000),
+ profile,
+ });
+
+ expect(useAuthStore.getState().status).toBe('signedIn');
+
+ await jest.advanceTimersByTimeAsync(1);
+
+ expect(mockRefreshTokenRequest).toHaveBeenCalledTimes(1);
+ // Session preserved, refresh token intact, cleanup never ran.
+ expect(useAuthStore.getState().status).toBe('signedIn');
+ expect(useAuthStore.getState().refreshToken).toBe('stored-refresh-token');
+ expect(sessionCleanup).not.toHaveBeenCalled();
+ });
+
+ it('retries the failed refresh in the background after 30s', async () => {
+ mockRefreshTokenRequest.mockRejectedValue(networkError());
+ useAuthStore.setState({ refreshToken: 'stored-refresh-token', status: 'signedIn' });
+
+ await useAuthStore.getState().refreshAccessToken();
+ expect(mockRefreshTokenRequest).toHaveBeenCalledTimes(1);
+
+ resetInFlightRefresh();
+ await jest.advanceTimersByTimeAsync(30000);
+
+ expect(mockRefreshTokenRequest).toHaveBeenCalledTimes(2);
+ expect(useAuthStore.getState().status).toBe('signedIn');
+ });
+
+ it.each([500, 502, 503, 429])('preserves the session when the token endpoint returns %i', async (status) => {
+ mockRefreshTokenRequest.mockRejectedValue(axiosErrorWithStatus(status));
+ useAuthStore.setState({ refreshToken: 'stored-refresh-token', status: 'signedIn' });
+
+ await useAuthStore.getState().refreshAccessToken();
+
+ expect(useAuthStore.getState().status).toBe('signedIn');
+ expect(sessionCleanup).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('credential rejection', () => {
+ it.each([400, 401])('logs out when the token endpoint rejects the refresh token (%i)', async (status) => {
+ mockRefreshTokenRequest.mockRejectedValue(axiosErrorWithStatus(status));
+ useAuthStore.setState({ refreshToken: 'stored-refresh-token', accessToken: 'stale', status: 'signedIn', userId: 'user-1' });
+
+ await useAuthStore.getState().refreshAccessToken();
+
+ expect(useAuthStore.getState().status).toBe('signedOut');
+ expect(useAuthStore.getState().accessToken).toBeNull();
+ expect(useAuthStore.getState().refreshToken).toBeNull();
+ expect(sessionCleanup).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('logout idempotence', () => {
+ it('runs the session cleanup once when concurrent refreshes are all rejected', async () => {
+ mockRefreshTokenRequest.mockRejectedValue(axiosErrorWithStatus(401));
+ useAuthStore.setState({ refreshToken: 'stored-refresh-token', status: 'signedIn' });
+
+ // Three independent callers (proactive timer + two queued 401s) race.
+ await Promise.all([useAuthStore.getState().refreshAccessToken(), useAuthStore.getState().refreshAccessToken(), useAuthStore.getState().refreshAccessToken()]);
+
+ expect(sessionCleanup).toHaveBeenCalledTimes(1);
+ expect(useAuthStore.getState().status).toBe('signedOut');
+ });
+
+ it('runs the cleanup once for concurrent direct logout() calls', async () => {
+ useAuthStore.setState({ refreshToken: 'stored-refresh-token', status: 'signedIn' });
+
+ await Promise.all([useAuthStore.getState().logout(), useAuthStore.getState().logout()]);
+
+ expect(sessionCleanup).toHaveBeenCalledTimes(1);
+ });
+
+ it('still performs a later logout after the guard has settled', async () => {
+ useAuthStore.setState({ refreshToken: 'stored-refresh-token', status: 'signedIn' });
+ await useAuthStore.getState().logout();
+ expect(sessionCleanup).toHaveBeenCalledTimes(1);
+
+ useAuthStore.setState({ refreshToken: 'second-session', status: 'signedIn' });
+ await useAuthStore.getState().logout();
+
+ expect(sessionCleanup).toHaveBeenCalledTimes(2);
+ expect(useAuthStore.getState().status).toBe('signedOut');
+ });
+ });
+
+ describe('refresh single-flight', () => {
+ it('shares one token request across concurrent callers', async () => {
+ useAuthStore.setState({ refreshToken: 'stored-refresh-token', status: 'signedIn' });
+
+ const results = await Promise.all([useAuthStore.getState().refreshAccessToken(), useAuthStore.getState().refreshAccessToken(), useAuthStore.getState().refreshAccessToken()]);
+
+ // Rotation-safe: the refresh token is presented to the server exactly once.
+ expect(mockRefreshTokenRequest).toHaveBeenCalledTimes(1);
+ expect(results).toEqual([true, true, true]);
+ expect(useAuthStore.getState().accessToken).toBe('new-access-token');
+ expect(useAuthStore.getState().refreshToken).toBe('new-refresh-token');
+ });
+
+ it('records the new access-token expiry so the next cold start can restore instantly', async () => {
+ useAuthStore.setState({ refreshToken: 'stored-refresh-token', status: 'signedIn' });
+
+ await useAuthStore.getState().refreshAccessToken();
+
+ const expiresOn = Number(useAuthStore.getState().refreshTokenExpiresOn);
+ expect(expiresOn).toBe(Date.now() + refreshResponse.expires_in * 1000);
+ });
+
+ it('logs out when there is no refresh token at all', async () => {
+ useAuthStore.setState({ refreshToken: null, status: 'signedIn' });
+
+ const result = await useAuthStore.getState().refreshAccessToken();
+
+ expect(result).toBe(false);
+ expect(mockRefreshTokenRequest).not.toHaveBeenCalled();
+ expect(useAuthStore.getState().status).toBe('signedOut');
+ });
+ });
+});
diff --git a/src/stores/auth/store.tsx b/src/stores/auth/store.tsx
index 151de100..b22ff6dc 100644
--- a/src/stores/auth/store.tsx
+++ b/src/stores/auth/store.tsx
@@ -1,5 +1,4 @@
import * as Sentry from '@sentry/react-native';
-import base64 from 'react-native-base64';
import { create } from 'zustand';
import { createJSONStorage, persist } from 'zustand/middleware';
@@ -8,14 +7,21 @@ import { clearCacheScope, setCacheScope } from '@/lib/cache/cache-scope';
import { logger } from '@/lib/logging';
import { loginRequest, ssoExternalTokenRequest } from '../../lib/auth/api';
+import { decodeJwtPayload, getJwtExpiryMs } from '../../lib/auth/jwt';
import { refreshTokenSingleFlight } from '../../lib/auth/refresh-lock';
import { runSessionCleanup } from '../../lib/auth/session-cleanup';
import { isRefreshCredentialRejection } from '../../lib/auth/token-refresh';
import type { AuthResponse, AuthState, LoginCredentials, SsoLoginCredentials } from '../../lib/auth/types';
import { type ProfileModel } from '../../lib/auth/types';
-import { getAuth } from '../../lib/auth/utils';
import { removeItem, setItem, zustandStorage } from '../../lib/storage';
+// Single-flight guard for logout: on a refresh credential rejection every queued
+// 401 caller independently reaches logout(), and without a guard the full
+// session cleanup (app-data wipe) runs once per caller, concurrently. All
+// concurrent callers share one logout run; the guard clears when it settles so
+// a later, genuine logout still executes.
+let logoutInFlight: Promise | null = null;
+
const useAuthStore = create()(
persist(
(set, get) => ({
@@ -34,7 +40,7 @@ const useAuthStore = create()(
const response = await loginRequest(credentials);
if (response.successful) {
- const payload = sanitizeJson(base64.decode(response.authResponse!.id_token!.split('.')[1]));
+ const payload = sanitizeJson(decodeJwtPayload(response.authResponse!.id_token!));
setItem('authResponse', response.authResponse!);
const now = new Date();
@@ -100,7 +106,7 @@ const useAuthStore = create()(
const response = await ssoExternalTokenRequest(credentials);
if (response.successful && response.authResponse) {
- const payload = sanitizeJson(base64.decode(response.authResponse.id_token!.split('.')[1]));
+ const payload = sanitizeJson(decodeJwtPayload(response.authResponse.id_token!));
setItem('authResponse', response.authResponse);
const expiresOn = new Date(Date.now() + response.authResponse.expires_in * 1000).getTime().toString();
@@ -143,50 +149,63 @@ const useAuthStore = create()(
},
logout: async () => {
- // Clear any pending refresh timer to prevent stacked timeouts
- const existingTimeoutId = get().refreshTimeoutId;
- if (existingTimeoutId !== null) {
- clearTimeout(existingTimeoutId);
+ // Single-flight: concurrent logout triggers (every 401 caller queued
+ // behind a rejected refresh) share one run so the full data wipe never
+ // executes twice in parallel.
+ if (logoutInFlight) {
+ return logoutInFlight;
}
- set({
- accessToken: null,
- refreshToken: null,
- status: 'signedOut',
- error: null,
- profile: null,
- // Clearing the user id is what drops the API cache scope; leaving it set keeps this user's
- // cache keys live for whoever signs in next on the same device.
- userId: null,
- isFirstTime: true,
- refreshTimeoutId: null,
- });
- Sentry.setUser(null);
- // Remove the standalone stored auth response so no valid refresh
- // token is left on the device after logout.
- try {
- await removeItem('authResponse');
- } catch (error) {
- logger.warn({
- message: 'Failed to remove stored auth response on logout',
- context: { error },
+ logoutInFlight = (async () => {
+ // Clear any pending refresh timer to prevent stacked timeouts
+ const existingTimeoutId = get().refreshTimeoutId;
+ if (existingTimeoutId !== null) {
+ clearTimeout(existingTimeoutId);
+ }
+ set({
+ accessToken: null,
+ refreshToken: null,
+ status: 'signedOut',
+ error: null,
+ profile: null,
+ // Clearing the user id is what drops the API cache scope; leaving it set keeps this user's
+ // cache keys live for whoever signs in next on the same device.
+ userId: null,
+ isFirstTime: true,
+ refreshTimeoutId: null,
});
- }
+ Sentry.setUser(null);
- // Route EVERY logout (manual, forced by the 401 interceptor, refresh
- // credential rejection) through the full app-data reset so a different
- // user logging in on the same device never sees the previous user's
- // data — and the previous user's queued offline events never replay
- // under the new account. The reset service registers its handler in
- // the leaf session-cleanup module (avoids a static import cycle).
- try {
- await runSessionCleanup();
- } catch (error) {
- logger.error({
- message: 'Failed to clear app data on logout',
- context: { error },
- });
- }
+ // Remove the standalone stored auth response so no valid refresh
+ // token is left on the device after logout.
+ try {
+ await removeItem('authResponse');
+ } catch (error) {
+ logger.warn({
+ message: 'Failed to remove stored auth response on logout',
+ context: { error },
+ });
+ }
+
+ // Route EVERY logout (manual, forced by the 401 interceptor, refresh
+ // credential rejection) through the full app-data reset so a different
+ // user logging in on the same device never sees the previous user's
+ // data — and the previous user's queued offline events never replay
+ // under the new account. The reset service registers its handler in
+ // the leaf session-cleanup module (avoids a static import cycle).
+ try {
+ await runSessionCleanup();
+ } catch (error) {
+ logger.error({
+ message: 'Failed to clear app data on logout',
+ context: { error },
+ });
+ }
+ })().finally(() => {
+ logoutInFlight = null;
+ });
+
+ return logoutInFlight;
},
refreshAccessToken: async (): Promise => {
@@ -211,6 +230,9 @@ const useAuthStore = create()(
set({
accessToken: response.access_token,
refreshToken: response.refresh_token,
+ // Keep the persisted access-token expiry current so a later cold
+ // start can restore the session instantly (see restoreSession).
+ refreshTokenExpiresOn: new Date(Date.now() + response.expires_in * 1000).getTime().toString(),
status: 'signedIn',
error: null,
});
@@ -260,60 +282,6 @@ const useAuthStore = create()(
return false;
}
},
- hydrate: () => {
- try {
- const authResponse = getAuth();
- if (authResponse !== null && authResponse.refresh_token) {
- // We have stored auth data, try to restore the session
- try {
- const payload = sanitizeJson(base64.decode(authResponse.id_token!.split('.')[1]));
- const profileData = JSON.parse(payload) as ProfileModel;
-
- set({
- accessToken: authResponse.access_token,
- refreshToken: authResponse.refresh_token,
- status: 'signedIn',
- error: null,
- profile: profileData,
- userId: profileData.sub,
- });
-
- Sentry.setUser({ id: profileData.sub, username: profileData.name });
-
- logger.info({
- message: 'Auth state hydrated from storage, token refresh will be scheduled by onRehydrateStorage',
- });
-
- // Note: Token refresh scheduling is handled by onRehydrateStorage to avoid duplicate refreshes
- } catch (parseError) {
- // Token parsing failed, but we have a refresh token - try to refresh
- logger.warn({
- message: 'Failed to parse stored token, refresh will be attempted by onRehydrateStorage',
- context: { error: parseError instanceof Error ? parseError.message : String(parseError) },
- });
-
- set({
- refreshToken: authResponse.refresh_token,
- status: 'loading',
- });
-
- // Note: Token refresh is handled by onRehydrateStorage to avoid duplicate refreshes
- }
- } else {
- logger.info({
- message: 'No stored auth data found, user needs to login',
- });
- get().logout();
- }
- } catch (e) {
- logger.error({
- message: 'Failed to hydrate auth state',
- context: { error: e instanceof Error ? e.message : String(e) },
- });
- // Don't logout here - let the user try to use the app
- // and handle auth errors via the axios interceptor
- }
- },
isAuthenticated: (): boolean => {
return get().status === 'signedIn' && get().accessToken !== null;
},
@@ -365,27 +333,7 @@ const useAuthStore = create()(
// Defer execution to ensure useAuthStore is fully initialized
setTimeout(() => {
- // status/error are no longer persisted, so a stored refresh token is
- // the only signal that a session existed — always try to restore it.
- if (state && state.refreshToken) {
- logger.info({
- message: 'Found refresh token in storage, attempting to restore session',
- context: { hasAccessToken: !!state.accessToken },
- });
-
- // Clear any existing refresh timer before scheduling a new one
- const existingTimeoutId = useAuthStore.getState().refreshTimeoutId;
- if (existingTimeoutId !== null) {
- clearTimeout(existingTimeoutId);
- }
- // Set status to loading while we try to refresh
- useAuthStore.setState({ status: 'loading' });
-
- const timeoutId = setTimeout(() => {
- useAuthStore.getState().refreshAccessToken();
- }, 2000);
- useAuthStore.setState({ refreshTimeoutId: timeoutId });
- }
+ restoreSession(state);
}, 0);
};
},
@@ -397,6 +345,110 @@ const sanitizeJson = (json: string) => {
return json.replace(/[\u0000]+/g, '');
};
+// Treat an access token expiring within this window as already expired so we
+// never restore "signedIn with a valid token" on a token about to lapse.
+const ACCESS_TOKEN_EXPIRY_SKEW_MS = 60 * 1000;
+
+/**
+ * Resolve when the stored access token expires (epoch ms). Prefers the token's
+ * own `exp` claim; falls back to the persisted expiry timestamp captured at
+ * login/refresh time (`refreshTokenExpiresOn` -- historical name, it holds the
+ * access-token expiry). Returns null when neither is usable.
+ */
+const getAccessTokenExpiryMs = (accessToken: string | null | undefined, storedExpiresOn: string | null | undefined): number | null => {
+ if (accessToken) {
+ const jwtExpiry = getJwtExpiryMs(accessToken);
+ if (jwtExpiry !== null) {
+ return jwtExpiry;
+ }
+ }
+ if (storedExpiresOn) {
+ const parsed = Number(storedExpiresOn);
+ if (Number.isFinite(parsed) && parsed > 0) {
+ return parsed;
+ }
+ }
+ return null;
+};
+
+type PersistedSession = Pick;
+
+/**
+ * Cold-start session restore, invoked once after zustand rehydrates the
+ * persisted auth state. Exported for tests.
+ *
+ * - Valid access token: signedIn immediately (no network, no artificial delay)
+ * with a proactive refresh scheduled in the background before expiry.
+ * - Expired/undecodable access token + refresh token: signedIn optimistically --
+ * any API call that hits a 401 rides the interceptor's single-flight refresh --
+ * while an immediate background refresh runs.
+ * - Refresh token only: nothing to call the API with, so show 'loading' while
+ * refreshing immediately (no fixed delay).
+ * - Transient refresh failures keep the session (refreshAccessToken retries
+ * every 30s); only a definitive credential rejection (400/401 from
+ * /connect/token) logs the user out.
+ */
+export const restoreSession = (state: PersistedSession | undefined): void => {
+ if (!state || !state.refreshToken) {
+ // No stored session -- leave status as-is; the tab layout redirects to login.
+ return;
+ }
+
+ // Clear any existing refresh timer before scheduling a new one
+ const existingTimeoutId = useAuthStore.getState().refreshTimeoutId;
+ if (existingTimeoutId !== null) {
+ clearTimeout(existingTimeoutId);
+ }
+
+ if (state.profile?.sub) {
+ Sentry.setUser({ id: state.profile.sub, username: state.profile.name });
+ }
+
+ // The persist middleware has already merged these into the store; re-applying
+ // them keeps this function self-contained, so the scheduled refresh below can
+ // never observe a store without the credentials it was told to restore.
+ const restoredTokens = {
+ accessToken: state.accessToken,
+ refreshToken: state.refreshToken,
+ refreshTokenExpiresOn: state.refreshTokenExpiresOn,
+ };
+
+ const expiresOn = getAccessTokenExpiryMs(state.accessToken, state.refreshTokenExpiresOn);
+ const now = Date.now();
+
+ if (state.accessToken && expiresOn !== null && expiresOn - now > ACCESS_TOKEN_EXPIRY_SKEW_MS) {
+ // Access token still valid -- restore instantly and refresh in the
+ // background shortly before it expires (1 minute early, like the login path).
+ const refreshDelayMs = Math.max(expiresOn - now - 60 * 1000, 1000);
+ logger.info({
+ message: 'Restored session from storage with valid access token',
+ context: { refreshDelayMs },
+ });
+ const timeoutId = setTimeout(() => useAuthStore.getState().refreshAccessToken(), refreshDelayMs);
+ useAuthStore.setState({ ...restoredTokens, status: 'signedIn', error: null, refreshTimeoutId: timeoutId });
+ return;
+ }
+
+ if (state.accessToken) {
+ // Access token expired (or expiry unknown) but a refresh token exists. Stay
+ // signed in optimistically: the 401 interceptor refreshes on demand, and an
+ // offline cold start keeps the session instead of bouncing to login.
+ logger.info({
+ message: 'Restored session from storage with expired access token, refreshing in background',
+ });
+ const timeoutId = setTimeout(() => useAuthStore.getState().refreshAccessToken(), 0);
+ useAuthStore.setState({ ...restoredTokens, status: 'signedIn', error: null, refreshTimeoutId: timeoutId });
+ return;
+ }
+
+ // Refresh token only -- refresh immediately, no artificial delay.
+ logger.info({
+ message: 'Found refresh token in storage without access token, attempting to restore session',
+ });
+ const timeoutId = setTimeout(() => useAuthStore.getState().refreshAccessToken(), 0);
+ useAuthStore.setState({ ...restoredTokens, status: 'loading', refreshTimeoutId: timeoutId });
+};
+
// Keep the API cache scoped to whoever is signed in. Cache keys embed this identity, so stamping it
// here means a second user on the same device can never be served the first user's cached rosters,
// units or contacts -- and signing out drops the scope so nothing leaks into an anonymous session.
diff --git a/src/stores/calls/__tests__/store.test.ts b/src/stores/calls/__tests__/store.test.ts
index 6cf915df..874886de 100644
--- a/src/stores/calls/__tests__/store.test.ts
+++ b/src/stores/calls/__tests__/store.test.ts
@@ -43,6 +43,7 @@ describe('useCallsStore', () => {
callPriorities: [],
callTypes: [],
isLoading: false,
+ isInitialized: false,
error: null,
});
});
@@ -186,5 +187,141 @@ describe('useCallsStore', () => {
expect(mockGetCallPriorities).toHaveBeenCalledTimes(1);
expect(mockGetCallTypes).toHaveBeenCalledTimes(1);
});
+
+ it('clears isLoading and leaves isInitialized false when a fetch throws, so init can be retried', async () => {
+ mockGetCalls.mockRejectedValue(new Error('Network down'));
+
+ const { result } = renderHook(() => useCallsStore());
+
+ await act(async () => {
+ await result.current.init();
+ });
+
+ await waitFor(() => {
+ // isLoading must clear or the guard at the top of init() blocks every retry forever
+ expect(result.current.isLoading).toBe(false);
+ // isInitialized must stay false so the TabLayout retry loop can call init() again
+ expect(result.current.isInitialized).toBe(false);
+ expect(result.current.error).toBe('Failed to initialize calls');
+ });
+ });
+
+ it('allows a subsequent init() to succeed after an initial failure', async () => {
+ mockGetCalls.mockRejectedValueOnce(new Error('Network down'));
+
+ const { result } = renderHook(() => useCallsStore());
+
+ await act(async () => {
+ await result.current.init();
+ });
+ expect(result.current.isInitialized).toBe(false);
+
+ const mockCallsData = [{ Id: '1', Name: 'Test Call' }];
+ mockGetCalls.mockResolvedValue({ Data: mockCallsData } as any);
+ mockGetCallPriorities.mockResolvedValue({ Data: [] } as any);
+ mockGetCallTypes.mockResolvedValue({ Data: [] } as any);
+
+ await act(async () => {
+ await result.current.init();
+ });
+
+ await waitFor(() => {
+ expect(result.current.isInitialized).toBe(true);
+ expect(result.current.calls).toEqual(mockCallsData);
+ expect(result.current.isLoading).toBe(false);
+ });
+ });
+ });
+
+ describe('fetchCalls', () => {
+ it('sets isLoading while fetching when there is no data yet', async () => {
+ let resolveCalls: (value: any) => void = () => {};
+ mockGetCalls.mockReturnValue(new Promise((resolve) => (resolveCalls = resolve)) as any);
+
+ const { result } = renderHook(() => useCallsStore());
+
+ let pending: Promise;
+ act(() => {
+ pending = result.current.fetchCalls();
+ });
+
+ expect(useCallsStore.getState().isLoading).toBe(true);
+
+ await act(async () => {
+ resolveCalls({ Data: [{ CallId: '1' }] });
+ await pending!;
+ });
+
+ expect(useCallsStore.getState().isLoading).toBe(false);
+ });
+
+ it('does NOT set isLoading when calls are already present (stale-while-revalidate)', async () => {
+ useCallsStore.setState({ calls: [{ CallId: 'existing' }] as any });
+
+ let resolveCalls: (value: any) => void = () => {};
+ mockGetCalls.mockReturnValue(new Promise((resolve) => (resolveCalls = resolve)) as any);
+
+ const { result } = renderHook(() => useCallsStore());
+
+ let pending: Promise;
+ act(() => {
+ pending = result.current.fetchCalls(true);
+ });
+
+ // The stale list stays on screen instead of being replaced by a full-screen loader
+ expect(useCallsStore.getState().isLoading).toBe(false);
+ expect(useCallsStore.getState().calls).toHaveLength(1);
+
+ await act(async () => {
+ resolveCalls({ Data: [{ CallId: 'fresh' }] });
+ await pending!;
+ });
+
+ expect(useCallsStore.getState().calls).toEqual([{ CallId: 'fresh' }]);
+ });
+
+ it('defaults forceRefresh to false and forwards an explicit true to getCalls', async () => {
+ mockGetCalls.mockResolvedValue({ Data: [] } as any);
+
+ const { result } = renderHook(() => useCallsStore());
+
+ await act(async () => {
+ await result.current.fetchCalls();
+ });
+ expect(mockGetCalls).toHaveBeenLastCalledWith(false);
+
+ await act(async () => {
+ await result.current.fetchCalls(true);
+ });
+ expect(mockGetCalls).toHaveBeenLastCalledWith(true);
+ });
+
+ it('records an error and clears isLoading when the fetch fails', async () => {
+ mockGetCalls.mockRejectedValue(new Error('API Error'));
+
+ const { result } = renderHook(() => useCallsStore());
+
+ await act(async () => {
+ await result.current.fetchCalls();
+ });
+
+ await waitFor(() => {
+ expect(result.current.error).toBe('Failed to fetch calls');
+ expect(result.current.isLoading).toBe(false);
+ });
+ });
+
+ it('clears a previous error on a new fetch', async () => {
+ useCallsStore.setState({ error: 'Failed to fetch calls' });
+ mockGetCalls.mockResolvedValue({ Data: [{ CallId: '1' }] } as any);
+
+ const { result } = renderHook(() => useCallsStore());
+
+ await act(async () => {
+ await result.current.fetchCalls(true);
+ });
+
+ expect(result.current.error).toBeNull();
+ });
});
});
diff --git a/src/stores/calls/store.ts b/src/stores/calls/store.ts
index 4d8866a6..56da2694 100644
--- a/src/stores/calls/store.ts
+++ b/src/stores/calls/store.ts
@@ -26,7 +26,7 @@ interface CallsState {
isCallFormDataLoaded: boolean;
error: string | null;
lastFetchedAt: number;
- fetchCalls: () => Promise;
+ fetchCalls: (forceRefresh?: boolean) => Promise;
fetchCallPriorities: () => Promise;
fetchCallTypes: () => Promise;
fetchCallFormData: () => Promise;
@@ -53,22 +53,35 @@ export const useCallsStore = create((set, get) => ({
return;
}
set({ isLoading: true, error: null });
- const callsResponse = await getCalls();
- const callPrioritiesResponse = await getCallPriorities();
- const callTypesResponse = await getCallTypes();
- set({
- calls: Array.isArray(callsResponse.Data) ? callsResponse.Data : [],
- callPriorities: Array.isArray(callPrioritiesResponse.Data) ? callPrioritiesResponse.Data : [],
- callTypes: Array.isArray(callTypesResponse.Data) ? callTypesResponse.Data : [],
- isLoading: false,
- isInitialized: true,
- lastFetchedAt: Date.now(),
- });
+ try {
+ const callsResponse = await getCalls();
+ const callPrioritiesResponse = await getCallPriorities();
+ const callTypesResponse = await getCallTypes();
+ set({
+ calls: Array.isArray(callsResponse.Data) ? callsResponse.Data : [],
+ callPriorities: Array.isArray(callPrioritiesResponse.Data) ? callPrioritiesResponse.Data : [],
+ callTypes: Array.isArray(callTypesResponse.Data) ? callTypesResponse.Data : [],
+ isLoading: false,
+ isInitialized: true,
+ lastFetchedAt: Date.now(),
+ });
+ } catch (error) {
+ // isInitialized stays false so the TabLayout retry loop can call init() again;
+ // isLoading must clear or the guard above would block every retry forever.
+ logger.error({ message: 'Failed to initialize calls store', context: { error } });
+ set({ error: 'Failed to initialize calls', isLoading: false });
+ }
},
- fetchCalls: async () => {
- set({ isLoading: true, error: null });
+ fetchCalls: async (forceRefresh = false) => {
+ // Stale-while-revalidate: only show the blocking loader when there is nothing
+ // to display yet — refreshes with data on screen happen in the background.
+ if (get().calls.length === 0) {
+ set({ isLoading: true, error: null });
+ } else {
+ set({ error: null });
+ }
try {
- const response = await getCalls();
+ const response = await getCalls(forceRefresh);
const newCalls = Array.isArray(response.Data) ? response.Data : [];
// Evict dispatches for calls no longer in the active list to prevent unbounded memory growth
diff --git a/src/stores/chat/__tests__/hub-invoke-args.test.ts b/src/stores/chat/__tests__/hub-invoke-args.test.ts
index b7e46ea8..febbc001 100644
--- a/src/stores/chat/__tests__/hub-invoke-args.test.ts
+++ b/src/stores/chat/__tests__/hub-invoke-args.test.ts
@@ -191,7 +191,9 @@ describe('incoming message normalization', () => {
});
it('keeps existing reactions when a later payload omits them', () => {
- useChatStore.getState().handleMessageReceived({ ChatMessageId: 'm1', ChatChannelId: 'channel-1', MessageSeq: 10, Body: 'hi', SentOn: new Date(0).toISOString(), Reactions: [{ Emoji: '\u{1F44D}', UserId: 'user-2' }] });
+ useChatStore
+ .getState()
+ .handleMessageReceived({ ChatMessageId: 'm1', ChatChannelId: 'channel-1', MessageSeq: 10, Body: 'hi', SentOn: new Date(0).toISOString(), Reactions: [{ Emoji: '\u{1F44D}', UserId: 'user-2' }] });
useChatStore.getState().handleMessageEdited({ ChatMessageId: 'm1', ChatChannelId: 'channel-1', MessageSeq: 10, Body: 'hi (edited)', SentOn: new Date(0).toISOString() });
const stored = useChatStore.getState().messagesByChannel['channel-1']?.[0];
@@ -236,3 +238,65 @@ describe('chat typing events', () => {
expect(useChatStore.getState().typingByChannel['channel-1']?.[0]?.userId).toBe('user-2');
});
});
+
+describe('chat fetch failures stay out of Sentry', () => {
+ const chatApi = require('@/api/chat/chat');
+ const { logger } = require('@/lib/logging');
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ useChatStore.setState({ messagesByChannel: {}, channels: [], loadingMessagesByChannel: {} });
+ });
+
+ afterEach(() => {
+ chatApi.getChannels.mockResolvedValue({ Data: [] });
+ chatApi.getMessages.mockResolvedValue({ Data: [] });
+ });
+
+ afterAll(() => {
+ // reset() clears the store's typing/outbox timers so Jest can exit cleanly.
+ useChatStore.getState().reset();
+ });
+
+ it('logs a channel fetch failure at warn, mirroring the outbox send path', async () => {
+ chatApi.getChannels.mockRejectedValue(new Error('offline'));
+
+ await useChatStore.getState().fetchChannels();
+
+ expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: 'chat: failed to fetch channels' }));
+ expect(logger.error).not.toHaveBeenCalled();
+ expect(useChatStore.getState().isLoadingChannels).toBe(false);
+ });
+
+ it('logs an initial message load failure at warn', async () => {
+ chatApi.getMessages.mockRejectedValue(new Error('offline'));
+
+ await useChatStore.getState().loadInitialMessages('channel-1');
+
+ expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: 'chat: failed to load messages' }));
+ expect(logger.error).not.toHaveBeenCalled();
+ expect(useChatStore.getState().loadingMessagesByChannel['channel-1']).toBe(false);
+ });
+
+ it('logs an older-message load failure at warn', async () => {
+ useChatStore.setState({
+ messagesByChannel: { 'channel-1': [{ ChatMessageId: 'm1', ChatChannelId: 'channel-1', MessageSeq: 5, SentOn: new Date().toISOString(), Reactions: [], Attachments: [] } as any] },
+ hasMoreByChannel: { 'channel-1': true },
+ });
+ chatApi.getMessages.mockRejectedValue(new Error('offline'));
+
+ await useChatStore.getState().loadOlderMessages('channel-1');
+
+ expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: 'chat: failed to load older messages' }));
+ expect(logger.error).not.toHaveBeenCalled();
+ });
+
+ it('logs a delta sync failure at warn', async () => {
+ chatApi.getMessagesAfter = jest.fn().mockRejectedValue(new Error('offline'));
+
+ await useChatStore.getState().loadNewerMessages('channel-1');
+
+ expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: 'chat: delta sync failed' }));
+ expect(logger.error).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/stores/chat/store.ts b/src/stores/chat/store.ts
index 0ad03e4c..2ead2950 100644
--- a/src/stores/chat/store.ts
+++ b/src/stores/chat/store.ts
@@ -290,7 +290,7 @@ export const useChatStore = create()(
const response = await chatApi.getChannels(unitId);
set({ channels: response.Data ?? [], isLoadingChannels: false });
} catch (error) {
- logger.error({ message: 'chat: failed to fetch channels', context: { error } });
+ logger.warn({ message: 'chat: failed to fetch channels', context: { error } });
set({ isLoadingChannels: false });
}
},
@@ -321,7 +321,7 @@ export const useChatStore = create()(
};
});
} catch (error) {
- logger.error({ message: 'chat: failed to load messages', context: { error, channelId } });
+ logger.warn({ message: 'chat: failed to load messages', context: { error, channelId } });
set((state) => ({ loadingMessagesByChannel: { ...state.loadingMessagesByChannel, [channelId]: false } }));
}
},
@@ -349,7 +349,7 @@ export const useChatStore = create()(
};
});
} catch (error) {
- logger.error({ message: 'chat: failed to load older messages', context: { error, channelId } });
+ logger.warn({ message: 'chat: failed to load older messages', context: { error, channelId } });
set((s) => ({ loadingMessagesByChannel: { ...s.loadingMessagesByChannel, [channelId]: false } }));
}
},
@@ -370,7 +370,7 @@ export const useChatStore = create()(
after = highestRealSeq(get().messagesByChannel[channelId]);
}
} catch (error) {
- logger.error({ message: 'chat: delta sync failed', context: { error, channelId } });
+ logger.warn({ message: 'chat: delta sync failed', context: { error, channelId } });
}
},
diff --git a/src/stores/check-in-timers/__tests__/store.test.ts b/src/stores/check-in-timers/__tests__/store.test.ts
index 1b998023..f3399c05 100644
--- a/src/stores/check-in-timers/__tests__/store.test.ts
+++ b/src/stores/check-in-timers/__tests__/store.test.ts
@@ -22,6 +22,7 @@ import { describe, expect, it, jest, beforeEach } from '@jest/globals';
import { act, renderHook, waitFor } from '@testing-library/react-native';
import { getCheckInHistory, getTimerStatuses, getTimersForCall, performCheckIn } from '@/api/check-in-timers/check-in-timers';
+import { logger } from '@/lib/logging';
import { useCheckInTimerStore } from '../store';
jest.mock('@/api/check-in-timers/check-in-timers');
@@ -108,6 +109,19 @@ describe('useCheckInTimerStore', () => {
expect(result.current.isLoadingStatuses).toBe(false);
});
});
+
+ it('should log a poll failure at warn so a 30s poll cannot flood Sentry', async () => {
+ mockGetTimerStatuses.mockRejectedValue(new Error('Network error'));
+
+ const { result } = renderHook(() => useCheckInTimerStore());
+
+ await act(async () => {
+ await result.current.fetchTimerStatuses(1);
+ });
+
+ expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: 'Failed to fetch timer statuses' }));
+ expect(logger.error).not.toHaveBeenCalledWith(expect.objectContaining({ message: 'Failed to fetch timer statuses' }));
+ });
});
describe('performCheckIn', () => {
diff --git a/src/stores/check-in-timers/store.ts b/src/stores/check-in-timers/store.ts
index c0d555ab..f68d38e2 100644
--- a/src/stores/check-in-timers/store.ts
+++ b/src/stores/check-in-timers/store.ts
@@ -57,7 +57,9 @@ export const useCheckInTimerStore = create((set, get) => ({
set({ timerStatuses: sorted, isLoadingStatuses: false });
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to fetch timer statuses';
- logger.error({ message: 'Failed to fetch timer statuses', context: { error, callId } });
+ // Polled every 30s while a call is open — a transient failure here is
+ // retried on the next tick and must not flood Sentry.
+ logger.warn({ message: 'Failed to fetch timer statuses', context: { error, callId } });
set({ statusError: message, isLoadingStatuses: false });
}
},
diff --git a/src/stores/feature-flags/__tests__/store.test.ts b/src/stores/feature-flags/__tests__/store.test.ts
index 322b2889..28bddc77 100644
--- a/src/stores/feature-flags/__tests__/store.test.ts
+++ b/src/stores/feature-flags/__tests__/store.test.ts
@@ -41,6 +41,7 @@ jest.mock('../../security/store', () => ({
}));
const { getAllFeatureFlags } = require('@/api/feature-flags/feature-flags');
+const { logger } = require('@/lib/logging');
const useAuthStore = require('../../auth/store').default;
const { securityStore } = require('../../security/store');
@@ -94,6 +95,19 @@ describe('Feature Flags Store', () => {
expect(state.error).toBe('network down');
});
+ it('should log a fetch failure at warn, not error, so it never reaches Sentry', async () => {
+ getAllFeatureFlags.mockRejectedValue(new Error('network down'));
+
+ await featureFlagsStore.getState().fetchFlags();
+
+ expect(logger.warn).toHaveBeenCalledWith(
+ expect.objectContaining({
+ message: 'Failed to fetch feature flags',
+ })
+ );
+ expect(logger.error).not.toHaveBeenCalled();
+ });
+
it('should clear flags from a different department before fetching so a failed fetch cannot reuse them', async () => {
featureFlagsStore.setState({
flags: { [FeatureFlagKeys.ChatSystem]: { enabled: true, value: null } },
@@ -211,6 +225,29 @@ describe('Feature Flags Store', () => {
});
});
+ describe('persistence', () => {
+ it('should persist flags, isLoaded and identityKey but never the transient error', () => {
+ featureFlagsStore.setState({
+ flags: { [FeatureFlagKeys.ChatSystem]: { enabled: true, value: null } },
+ isLoaded: true,
+ identityKey: 'user-1:dept-1',
+ error: 'transient failure',
+ });
+
+ const partialize = (featureFlagsStore as any).persist.getOptions().partialize;
+ const persisted = partialize(featureFlagsStore.getState());
+
+ // isLoaded is kept on purpose: rehydrating without it leaves gated screens
+ // on 'unknown' instead of resolving fail-closed.
+ expect(persisted).toEqual({
+ flags: { [FeatureFlagKeys.ChatSystem]: { enabled: true, value: null } },
+ isLoaded: true,
+ identityKey: 'user-1:dept-1',
+ });
+ expect(persisted).not.toHaveProperty('error');
+ });
+ });
+
describe('isEnabled', () => {
it('should return the flag state when present and the default when missing', () => {
featureFlagsStore.setState({
diff --git a/src/stores/feature-flags/store.ts b/src/stores/feature-flags/store.ts
index 9bdc5909..0e35e3c3 100644
--- a/src/stores/feature-flags/store.ts
+++ b/src/stores/feature-flags/store.ts
@@ -84,7 +84,9 @@ export const featureFlagsStore = create()(
// check above already cleared them if they belonged to a different identity. Marking
// isLoaded resolves flags with no persisted entry fail-closed (disabled) instead of
// leaving consumers waiting on 'unknown' forever.
- logger.error({
+ // Transient/recoverable: persisted flags keep gating stable and the next
+ // fetch repairs it, so this must not report to Sentry.
+ logger.warn({
message: 'Failed to fetch feature flags',
context: { error },
});
@@ -96,6 +98,14 @@ export const featureFlagsStore = create()(
{
name: 'feature-flags-storage',
storage: createJSONStorage(() => zustandStorage),
+ // isLoaded is persisted deliberately: a rehydrated identity resolves flags
+ // with no persisted entry fail-closed instead of leaving gated screens on
+ // 'unknown' forever. error is transient and must not survive a restart.
+ partialize: (state) => ({
+ flags: state.flags,
+ isLoaded: state.isLoaded,
+ identityKey: state.identityKey,
+ }),
}
)
);
diff --git a/src/stores/offline-queue/__tests__/store.test.ts b/src/stores/offline-queue/__tests__/store.test.ts
index bf693332..ae1e96b4 100644
--- a/src/stores/offline-queue/__tests__/store.test.ts
+++ b/src/stores/offline-queue/__tests__/store.test.ts
@@ -1,5 +1,5 @@
import { QueuedEventStatus, QueuedEventType } from '@/models/offline-queue/queued-event';
-import { useOfflineQueueStore } from '@/stores/offline-queue/store';
+import { setOfflineQueueActivityListener, useOfflineQueueStore } from '@/stores/offline-queue/store';
// Mock NetInfo
jest.mock('@react-native-community/netinfo', () => ({
@@ -55,25 +55,25 @@ describe('OfflineQueueStore', () => {
failedEvents: 0,
completedEvents: 0,
});
-
+
// Reset event ID counter and mock
eventIdCounter = 0;
mockGenerateEventId.mockImplementation(() => `test-event-id-${++eventIdCounter}`);
-
+
jest.clearAllMocks();
});
describe('addEvent', () => {
it('should add a new event to the queue', () => {
const store = useOfflineQueueStore.getState();
-
+
const eventId = store.addEvent(QueuedEventType.UNIT_STATUS, {
unitId: 'unit-1',
statusType: 'available',
});
expect(eventId).toBe('test-event-id-1');
-
+
const state = useOfflineQueueStore.getState();
expect(state.queuedEvents).toHaveLength(1);
expect(state.queuedEvents[0]).toMatchObject({
@@ -92,7 +92,7 @@ describe('OfflineQueueStore', () => {
it('should add event with custom max retries', () => {
const store = useOfflineQueueStore.getState();
-
+
store.addEvent(QueuedEventType.UNIT_STATUS, { test: 'data' }, 5);
const state = useOfflineQueueStore.getState();
@@ -102,7 +102,7 @@ describe('OfflineQueueStore', () => {
describe('updateEventStatus', () => {
let eventId: string;
-
+
beforeEach(() => {
// Add an event first
const store = useOfflineQueueStore.getState();
@@ -111,7 +111,7 @@ describe('OfflineQueueStore', () => {
it('should update event status to completed', () => {
const store = useOfflineQueueStore.getState();
-
+
store.updateEventStatus(eventId, QueuedEventStatus.COMPLETED);
const state = useOfflineQueueStore.getState();
@@ -121,7 +121,7 @@ describe('OfflineQueueStore', () => {
it('should update event status to failed and increment retry count', () => {
const store = useOfflineQueueStore.getState();
-
+
store.updateEventStatus(eventId, QueuedEventStatus.FAILED, 'Network error');
const state = useOfflineQueueStore.getState();
@@ -134,7 +134,7 @@ describe('OfflineQueueStore', () => {
it('should not set nextRetryAt if max retries exceeded', () => {
const store = useOfflineQueueStore.getState();
-
+
// Set retry count to max
store.updateEventStatus(eventId, QueuedEventStatus.FAILED);
store.updateEventStatus(eventId, QueuedEventStatus.FAILED);
@@ -147,7 +147,7 @@ describe('OfflineQueueStore', () => {
describe('removeEvent', () => {
let eventId: string;
-
+
beforeEach(() => {
const store = useOfflineQueueStore.getState();
eventId = store.addEvent(QueuedEventType.UNIT_STATUS, { test: 'data' });
@@ -155,7 +155,7 @@ describe('OfflineQueueStore', () => {
it('should remove event from queue', () => {
const store = useOfflineQueueStore.getState();
-
+
store.removeEvent(eventId);
const state = useOfflineQueueStore.getState();
@@ -165,7 +165,7 @@ describe('OfflineQueueStore', () => {
describe('getEventById', () => {
let eventId: string;
-
+
beforeEach(() => {
const store = useOfflineQueueStore.getState();
eventId = store.addEvent(QueuedEventType.UNIT_STATUS, { test: 'data' });
@@ -173,7 +173,7 @@ describe('OfflineQueueStore', () => {
it('should return event by ID', () => {
const store = useOfflineQueueStore.getState();
-
+
const event = store.getEventById(eventId);
expect(event).toBeDefined();
@@ -182,7 +182,7 @@ describe('OfflineQueueStore', () => {
it('should return undefined for non-existent ID', () => {
const store = useOfflineQueueStore.getState();
-
+
const event = store.getEventById('non-existent');
expect(event).toBeUndefined();
@@ -198,7 +198,7 @@ describe('OfflineQueueStore', () => {
it('should return events of specified type', () => {
const store = useOfflineQueueStore.getState();
-
+
const events = store.getEventsByType(QueuedEventType.UNIT_STATUS);
expect(events).toHaveLength(1);
@@ -209,7 +209,7 @@ describe('OfflineQueueStore', () => {
describe('getPendingEvents', () => {
let eventId1: string;
let eventId2: string;
-
+
beforeEach(() => {
const store = useOfflineQueueStore.getState();
eventId1 = store.addEvent(QueuedEventType.UNIT_STATUS, { test: 'data1' });
@@ -218,7 +218,7 @@ describe('OfflineQueueStore', () => {
it('should return events with pending status', () => {
const store = useOfflineQueueStore.getState();
-
+
const events = store.getPendingEvents();
expect(events).toHaveLength(2);
@@ -229,7 +229,7 @@ describe('OfflineQueueStore', () => {
it('should include failed events ready for retry', () => {
const store = useOfflineQueueStore.getState();
-
+
// Mark one event as failed with retry time in the past
store.updateEventStatus(eventId1, QueuedEventStatus.FAILED);
const state = useOfflineQueueStore.getState();
@@ -243,7 +243,7 @@ describe('OfflineQueueStore', () => {
it('should exclude failed events not ready for retry', () => {
const store = useOfflineQueueStore.getState();
-
+
// Mark one event as failed with retry time in the future
store.updateEventStatus(eventId1, QueuedEventStatus.FAILED);
const state = useOfflineQueueStore.getState();
@@ -259,7 +259,7 @@ describe('OfflineQueueStore', () => {
describe('getFailedEvents', () => {
let eventId1: string;
let eventId2: string;
-
+
beforeEach(() => {
const store = useOfflineQueueStore.getState();
eventId1 = store.addEvent(QueuedEventType.UNIT_STATUS, { test: 'data1' });
@@ -268,7 +268,7 @@ describe('OfflineQueueStore', () => {
it('should return events that have exceeded max retries', () => {
const store = useOfflineQueueStore.getState();
-
+
// Fail first event beyond max retries
store.updateEventStatus(eventId1, QueuedEventStatus.FAILED);
store.updateEventStatus(eventId1, QueuedEventStatus.FAILED);
@@ -285,7 +285,7 @@ describe('OfflineQueueStore', () => {
describe('clearCompletedEvents', () => {
let eventId1: string;
let eventId2: string;
-
+
beforeEach(() => {
const store = useOfflineQueueStore.getState();
eventId1 = store.addEvent(QueuedEventType.UNIT_STATUS, { test: 'data1' });
@@ -295,7 +295,7 @@ describe('OfflineQueueStore', () => {
it('should remove completed events', () => {
const store = useOfflineQueueStore.getState();
-
+
store.clearCompletedEvents();
const state = useOfflineQueueStore.getState();
@@ -313,7 +313,7 @@ describe('OfflineQueueStore', () => {
it('should clear all events and reset counters', () => {
const store = useOfflineQueueStore.getState();
-
+
store.clearAllEvents();
const state = useOfflineQueueStore.getState();
@@ -335,7 +335,7 @@ describe('OfflineQueueStore', () => {
it('should reset failed event to pending', () => {
const store = useOfflineQueueStore.getState();
-
+
store.retryEvent('test-event-id');
const state = useOfflineQueueStore.getState();
@@ -355,7 +355,7 @@ describe('OfflineQueueStore', () => {
it('should reset all failed events to pending', () => {
const store = useOfflineQueueStore.getState();
-
+
store.retryAllFailedEvents();
const state = useOfflineQueueStore.getState();
@@ -372,7 +372,7 @@ describe('OfflineQueueStore', () => {
describe('network state management', () => {
it('should update network state', () => {
const store = useOfflineQueueStore.getState();
-
+
store._setNetworkState(false, false);
const state = useOfflineQueueStore.getState();
@@ -382,7 +382,7 @@ describe('OfflineQueueStore', () => {
it('should update processing state', () => {
const store = useOfflineQueueStore.getState();
-
+
store._setProcessing(true, 'event-123');
const state = useOfflineQueueStore.getState();
@@ -390,4 +390,109 @@ describe('OfflineQueueStore', () => {
expect(state.processingEventId).toBe('event-123');
});
});
+
+ describe('pruneFailedEvents', () => {
+ const DAY_MS = 24 * 60 * 60 * 1000;
+
+ const makeFailedEvent = (id: string, ageDays: number) => ({
+ id,
+ type: QueuedEventType.UNIT_STATUS,
+ status: QueuedEventStatus.FAILED,
+ data: {},
+ retryCount: 3,
+ maxRetries: 3,
+ createdAt: Date.now() - ageDays * DAY_MS,
+ lastAttemptAt: Date.now() - ageDays * DAY_MS,
+ });
+
+ it('should evict permanently failed events older than the retention window', () => {
+ useOfflineQueueStore.setState({
+ queuedEvents: [makeFailedEvent('old', 8), makeFailedEvent('recent', 1)] as any,
+ });
+
+ useOfflineQueueStore.getState().pruneFailedEvents();
+
+ const ids = useOfflineQueueStore.getState().queuedEvents.map((event) => event.id);
+ expect(ids).toEqual(['recent']);
+ });
+
+ it('should cap retained failed events at the newest 50', () => {
+ const events = Array.from({ length: 60 }, (_, index) => makeFailedEvent(`failed-${index}`, index / 24));
+ useOfflineQueueStore.setState({ queuedEvents: events as any });
+
+ useOfflineQueueStore.getState().pruneFailedEvents();
+
+ expect(useOfflineQueueStore.getState().queuedEvents).toHaveLength(50);
+ });
+
+ it('should never evict events that still have retries left', () => {
+ const retryable = { ...makeFailedEvent('retryable', 30), retryCount: 1, maxRetries: 3 };
+ useOfflineQueueStore.setState({ queuedEvents: [retryable] as any });
+
+ useOfflineQueueStore.getState().pruneFailedEvents();
+
+ expect(useOfflineQueueStore.getState().queuedEvents).toHaveLength(1);
+ });
+
+ it('should leave the lifetime stat counters alone', () => {
+ useOfflineQueueStore.setState({
+ queuedEvents: [makeFailedEvent('old', 8)] as any,
+ failedEvents: 7,
+ totalEvents: 9,
+ });
+
+ useOfflineQueueStore.getState().pruneFailedEvents();
+
+ const state = useOfflineQueueStore.getState();
+ expect(state.failedEvents).toBe(7);
+ expect(state.totalEvents).toBe(9);
+ });
+ });
+
+ describe('queue activity listener', () => {
+ afterEach(() => {
+ setOfflineQueueActivityListener(null);
+ });
+
+ it('should notify on enqueue so a stopped processing timer restarts', () => {
+ const listener = jest.fn();
+ setOfflineQueueActivityListener(listener);
+
+ useOfflineQueueStore.getState().addEvent(QueuedEventType.UNIT_STATUS, { unitId: 'unit-1' });
+
+ expect(listener).toHaveBeenCalled();
+ });
+
+ it('should notify when the device comes back online', () => {
+ useOfflineQueueStore.setState({ isConnected: false, isNetworkReachable: false });
+ const listener = jest.fn();
+ setOfflineQueueActivityListener(listener);
+
+ useOfflineQueueStore.getState()._setNetworkState(true, true);
+
+ expect(listener).toHaveBeenCalled();
+ });
+
+ it('should not notify while already online', () => {
+ useOfflineQueueStore.setState({ isConnected: true, isNetworkReachable: true });
+ const listener = jest.fn();
+ setOfflineQueueActivityListener(listener);
+
+ useOfflineQueueStore.getState()._setNetworkState(true, true);
+
+ expect(listener).not.toHaveBeenCalled();
+ });
+
+ it('should notify when failed events are queued for retry', () => {
+ useOfflineQueueStore.setState({
+ queuedEvents: [{ id: 'f1', type: QueuedEventType.UNIT_STATUS, status: QueuedEventStatus.FAILED, data: {}, retryCount: 3, maxRetries: 3, createdAt: Date.now() }] as any,
+ });
+ const listener = jest.fn();
+ setOfflineQueueActivityListener(listener);
+
+ useOfflineQueueStore.getState().retryAllFailedEvents();
+
+ expect(listener).toHaveBeenCalled();
+ });
+ });
});
diff --git a/src/stores/offline-queue/store.ts b/src/stores/offline-queue/store.ts
index 1f305a7c..dc93e11c 100644
--- a/src/stores/offline-queue/store.ts
+++ b/src/stores/offline-queue/store.ts
@@ -31,6 +31,7 @@ interface OfflineQueueState {
getEventsByType: (type: QueuedEventType) => QueuedEvent[];
getPendingEvents: () => QueuedEvent[];
getFailedEvents: () => QueuedEvent[];
+ pruneFailedEvents: () => void;
clearCompletedEvents: () => void;
clearAllEvents: () => void;
retryEvent: (eventId: string) => void;
@@ -44,9 +45,27 @@ interface OfflineQueueState {
const DEFAULT_MAX_RETRIES = 3;
const RETRY_DELAY_BASE = 1000; // 1 second base delay
+// Permanently failed events (retries exhausted) are kept only long enough to be
+// surfaced/retried by the user. Without a bound they accumulate in MMKV forever.
+const FAILED_EVENT_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
+const MAX_RETAINED_FAILED_EVENTS = 50;
+
// Module-level handle so initialize() never stacks duplicate NetInfo listeners.
let netInfoUnsubscribe: (() => void) | null = null;
+// Set by the offline event manager so the queue can wake its processing timer
+// when work arrives. A direct import would close an offline-queue <-> manager
+// cycle, so the dependency points one way and the manager registers itself.
+let queueActivityListener: (() => void) | null = null;
+
+export const setOfflineQueueActivityListener = (listener: (() => void) | null): void => {
+ queueActivityListener = listener;
+};
+
+const isPermanentlyFailed = (event: QueuedEvent): boolean => event.status === QueuedEventStatus.FAILED && event.retryCount >= event.maxRetries;
+
+const failedEventTimestamp = (event: QueuedEvent): number => event.lastAttemptAt ?? event.createdAt;
+
export const useOfflineQueueStore = create()(
persist(
(set, get) => ({
@@ -117,6 +136,10 @@ export const useOfflineQueueStore = create()(
context: { eventId, type, dataKeys: Object.keys(data) },
});
+ // The processing timer stops itself when the queue runs dry, so a new
+ // event has to wake it back up.
+ queueActivityListener?.();
+
return eventId;
},
@@ -184,7 +207,38 @@ export const useOfflineQueueStore = create()(
// Get failed events
getFailedEvents: () => {
- return get().queuedEvents.filter((event) => event.status === QueuedEventStatus.FAILED && event.retryCount >= event.maxRetries);
+ return get().queuedEvents.filter(isPermanentlyFailed);
+ },
+
+ // Evict permanently failed events that are past the retention window, and
+ // cap the rest at the newest MAX_RETAINED_FAILED_EVENTS. Nothing retries
+ // these, so without eviction they persist to MMKV forever. Stat counters
+ // are deliberately left alone — they are lifetime totals, not queue depth.
+ pruneFailedEvents: () => {
+ const failed = get().queuedEvents.filter(isPermanentlyFailed);
+ if (failed.length === 0) {
+ return;
+ }
+
+ const now = Date.now();
+ const retained = failed
+ .filter((event) => now - failedEventTimestamp(event) < FAILED_EVENT_TTL_MS)
+ .sort((a, b) => failedEventTimestamp(b) - failedEventTimestamp(a))
+ .slice(0, MAX_RETAINED_FAILED_EVENTS);
+
+ if (retained.length === failed.length) {
+ return;
+ }
+
+ const retainedIds = new Set(retained.map((event) => event.id));
+ set((state) => ({
+ queuedEvents: state.queuedEvents.filter((event) => !isPermanentlyFailed(event) || retainedIds.has(event.id)),
+ }));
+
+ logger.info({
+ message: 'Evicted permanently failed events from offline queue',
+ context: { evicted: failed.length - retained.length, retained: retained.length },
+ });
},
// Clear completed events
@@ -234,6 +288,8 @@ export const useOfflineQueueStore = create()(
message: 'Event marked for retry',
context: { eventId },
});
+
+ queueActivityListener?.();
},
// Retry all failed events
@@ -255,11 +311,21 @@ export const useOfflineQueueStore = create()(
logger.info({
message: 'All failed events marked for retry',
});
+
+ queueActivityListener?.();
},
// Internal actions
_setNetworkState: (isConnected: boolean, isReachable: boolean) => {
+ const previous = get();
+ const wasOnline = previous.isConnected && previous.isNetworkReachable;
set({ isConnected, isNetworkReachable: isReachable });
+
+ // Coming back online is the moment a queue parked while offline can
+ // drain, so restart the processing timer if it stopped.
+ if (!wasOnline && isConnected && isReachable) {
+ queueActivityListener?.();
+ }
},
_setProcessing: (isProcessing: boolean, eventId?: string) => {
@@ -289,6 +355,10 @@ export const useOfflineQueueStore = create()(
queuedEvents: current.queuedEvents.map((event) => (event.status === QueuedEventStatus.PROCESSING ? { ...event, status: QueuedEventStatus.PENDING } : event)),
}));
}
+
+ // Drop permanently failed events that aged out while the app was
+ // closed, so a long-dead event never rides along forever.
+ state?.pruneFailedEvents?.();
};
},
}
diff --git a/src/stores/security/__tests__/store.test.ts b/src/stores/security/__tests__/store.test.ts
index f06ca6f8..ec027778 100644
--- a/src/stores/security/__tests__/store.test.ts
+++ b/src/stores/security/__tests__/store.test.ts
@@ -37,11 +37,27 @@ jest.mock('react-native', () => ({
},
}));
+jest.mock('@/lib/logging', () => ({
+ logger: {
+ info: jest.fn(),
+ debug: jest.fn(),
+ warn: jest.fn(),
+ error: jest.fn(),
+ },
+}));
+
+jest.mock('@/utils/network', () => ({
+ isNetworkError: jest.fn(() => false),
+}));
+
// Import after mocks
import { securityStore, useSecurityStore } from '../store';
import { getCurrentUsersRights } from '@/api/security/security';
+import { logger } from '@/lib/logging';
+import { isNetworkError } from '@/utils/network';
const mockGetCurrentUsersRights = getCurrentUsersRights as jest.MockedFunction;
+const mockIsNetworkError = isNetworkError as jest.MockedFunction;
describe('useSecurityStore', () => {
const mockRightsData: DepartmentRightsResultData = {
@@ -69,6 +85,7 @@ describe('useSecurityStore', () => {
beforeEach(() => {
jest.clearAllMocks();
+ mockIsNetworkError.mockReturnValue(false);
// Reset the store before each test
act(() => {
securityStore.setState({
@@ -100,7 +117,7 @@ describe('useSecurityStore', () => {
});
expect(mockGetCurrentUsersRights).toHaveBeenCalledTimes(1);
-
+
// Check that the store was updated
const storeState = securityStore.getState();
expect(storeState.rights).toEqual(mockRightsData);
@@ -117,11 +134,87 @@ describe('useSecurityStore', () => {
});
expect(mockGetCurrentUsersRights).toHaveBeenCalledTimes(1);
-
+
// Store should not be updated on error
const storeState = securityStore.getState();
expect(storeState.rights).toBeNull();
});
+
+ it('logs non-network failures as errors with context', async () => {
+ mockIsNetworkError.mockReturnValue(false);
+ mockGetCurrentUsersRights.mockRejectedValue(new Error('boom'));
+
+ const { result } = renderHook(() => useSecurityStore());
+
+ await act(async () => {
+ await result.current.getRights();
+ });
+
+ expect(logger.error).toHaveBeenCalledWith(
+ expect.objectContaining({
+ message: 'Failed to fetch user rights',
+ })
+ );
+ expect(securityStore.getState().error).toBe('boom');
+ });
+
+ it('logs transient network failures at warn so they never reach Sentry', async () => {
+ mockIsNetworkError.mockReturnValue(true);
+ mockGetCurrentUsersRights.mockRejectedValue(new Error('Network Error'));
+
+ const { result } = renderHook(() => useSecurityStore());
+
+ await act(async () => {
+ await result.current.getRights();
+ });
+
+ expect(logger.warn).toHaveBeenCalledWith(
+ expect.objectContaining({
+ message: 'Failed to fetch user rights due to network connectivity',
+ })
+ );
+ expect(logger.error).not.toHaveBeenCalled();
+ });
+
+ it('keeps previously loaded rights when a refresh fails', async () => {
+ act(() => {
+ securityStore.setState({ rights: mockRightsData });
+ });
+ mockGetCurrentUsersRights.mockRejectedValue(new Error('boom'));
+
+ const { result } = renderHook(() => useSecurityStore());
+
+ await act(async () => {
+ await result.current.getRights();
+ });
+
+ // Init must be able to continue with the rights it already has.
+ expect(securityStore.getState().rights).toEqual(mockRightsData);
+ });
+
+ it('clears a previous error once a fetch succeeds', async () => {
+ act(() => {
+ securityStore.setState({ error: 'previous failure' });
+ });
+ mockGetCurrentUsersRights.mockResolvedValue({
+ Data: mockRightsData,
+ PageSize: 0,
+ Timestamp: '',
+ Version: '',
+ Node: '',
+ RequestId: '',
+ Status: '',
+ Environment: '',
+ });
+
+ const { result } = renderHook(() => useSecurityStore());
+
+ await act(async () => {
+ await result.current.getRights();
+ });
+
+ expect(securityStore.getState().error).toBeNull();
+ });
});
describe('permission checks', () => {
@@ -185,7 +278,7 @@ describe('useSecurityStore', () => {
it('returns undefined for all permission checks', () => {
const { result } = renderHook(() => useSecurityStore());
-
+
expect(result.current.isUserDepartmentAdmin).toBeUndefined();
expect(result.current.canUserCreateCalls).toBeUndefined();
expect(result.current.canUserCreateNotes).toBeUndefined();
diff --git a/src/stores/security/store.ts b/src/stores/security/store.ts
index 6012c1ef..6dca85c9 100644
--- a/src/stores/security/store.ts
+++ b/src/stores/security/store.ts
@@ -6,6 +6,7 @@ import { cacheManager } from '@/lib/cache/cache-manager';
import { setCacheScope } from '@/lib/cache/cache-scope';
import { logger } from '@/lib/logging';
import { type DepartmentRightsResultData } from '@/models/v4/security/departmentRightsResultData';
+import { isNetworkError } from '@/utils/network';
import { zustandStorage } from '../../lib/storage';
@@ -28,10 +29,29 @@ export const securityStore = create()(
if (!current || JSON.stringify(current) !== JSON.stringify(response.Data)) {
set({
rights: response.Data,
+ error: null,
});
+ } else if (_get().error) {
+ // Clear a previous failure even when the rights themselves are unchanged.
+ set({ error: null });
}
} catch (error) {
- // If refresh fails, log out the user
+ // Rights are refreshed on init and on resume; a failure here leaves the
+ // previously persisted rights in place rather than blocking startup.
+ // Transient connectivity failures stay at warn so they never reach
+ // Sentry — genuine server/parse failures still report as errors.
+ if (isNetworkError(error)) {
+ logger.warn({
+ message: 'Failed to fetch user rights due to network connectivity',
+ context: { error },
+ });
+ } else {
+ logger.error({
+ message: 'Failed to fetch user rights',
+ context: { error },
+ });
+ }
+ set({ error: error instanceof Error ? error.message : 'Failed to fetch user rights' });
}
},
}),
diff --git a/src/stores/signalr/__tests__/signalr-store.test.ts b/src/stores/signalr/__tests__/signalr-store.test.ts
index 6927a4fe..e884efc6 100644
--- a/src/stores/signalr/__tests__/signalr-store.test.ts
+++ b/src/stores/signalr/__tests__/signalr-store.test.ts
@@ -39,10 +39,10 @@ jest.mock('../../app/core-store', () => {
mockStore.subscribe = jest.fn();
mockStore.setState = jest.fn();
mockStore.destroy = jest.fn();
-
+
return mockStore;
};
-
+
return {
useCoreStore: createMockStore(),
};
@@ -58,6 +58,15 @@ jest.mock('../../feature-flags/store', () => ({
},
}));
+const mockFetchCalls = jest.fn().mockResolvedValue(undefined);
+jest.mock('../../calls/store', () => ({
+ useCallsStore: {
+ getState: jest.fn(() => ({
+ fetchCalls: mockFetchCalls,
+ })),
+ },
+}));
+
jest.mock('../../security/store', () => ({
securityStore: {
getState: jest.fn(() => ({
@@ -154,7 +163,7 @@ describe('useSignalRStore', () => {
expect(typeof result.current.disconnectUpdateHub).toBe('function');
expect(typeof result.current.connectGeolocationHub).toBe('function');
expect(typeof result.current.disconnectGeolocationHub).toBe('function');
-
+
expect(result.current.isUpdateHubConnected).toBe(false);
expect(result.current.isGeolocationHubConnected).toBe(false);
expect(result.current.lastUpdateMessage).toBeNull();
@@ -184,9 +193,7 @@ describe('useSignalRStore', () => {
});
expect(signalRService.connectToHubWithEventingUrl).not.toHaveBeenCalled();
- expect(result.current.error).toEqual(
- new Error('EventingUrl not available in config. Please ensure config is loaded first.')
- );
+ expect(result.current.error).toEqual(new Error('EventingUrl not available in config. Please ensure config is loaded first.'));
expect(logger.error).toHaveBeenCalledWith({
message: 'EventingUrl not available in config. Please ensure config is loaded first.',
@@ -206,12 +213,10 @@ describe('useSignalRStore', () => {
});
expect(signalRService.connectToHubWithEventingUrl).not.toHaveBeenCalled();
- expect(result.current.error).toEqual(
- new Error('EventingUrl not available in config. Please ensure config is loaded first.')
- );
+ expect(result.current.error).toEqual(new Error('EventingUrl not available in config. Please ensure config is loaded first.'));
});
- it('should handle connection errors', async () => {
+ it('should handle connection errors without double-reporting what the service already logged', async () => {
const connectionError = new Error('Connection failed');
(signalRService.connectToHubWithEventingUrl as jest.Mock).mockRejectedValue(connectionError);
@@ -222,10 +227,58 @@ describe('useSignalRStore', () => {
});
expect(result.current.error).toEqual(connectionError);
- expect(logger.warn).toHaveBeenCalledWith({
- message: 'Failed to connect to SignalR hubs',
- context: { error: connectionError },
+ // The service logs the connect failure with hub context; the store must not
+ // log the same transient failure a second time.
+ expect(logger.warn).not.toHaveBeenCalledWith(
+ expect.objectContaining({
+ message: 'Failed to connect to SignalR hubs',
+ })
+ );
+ });
+
+ it('should register listeners BEFORE starting the connection so an early onConnected is not dropped', async () => {
+ const registeredBeforeConnect: string[] = [];
+ let connectStarted = false;
+
+ (signalRService.on as jest.Mock).mockImplementation((event: string) => {
+ if (!connectStarted) {
+ registeredBeforeConnect.push(event);
+ }
+ });
+ (signalRService.connectToHubWithEventingUrl as jest.Mock).mockImplementation(async () => {
+ connectStarted = true;
});
+
+ const { result } = renderHook(() => useSignalRStore());
+
+ await act(async () => {
+ await result.current.connectUpdateHub();
+ });
+
+ // onConnected is the flag-setting listener the race dropped.
+ expect(registeredBeforeConnect).toContain('onConnected');
+ expect(registeredBeforeConnect).toContain('callAdded');
+ expect(registeredBeforeConnect).toContain('__hubReconnected:eventingHub');
+ });
+
+ it('should set isUpdateHubConnected when onConnected fires during the connect call', async () => {
+ const handlers: Record void> = {};
+ (signalRService.on as jest.Mock).mockImplementation((event: string, handler: (message: unknown) => void) => {
+ handlers[event] = handler;
+ });
+ // Simulate the server raising onConnected while connectToHubWithEventingUrl
+ // is still in flight — the exact race the fix addresses.
+ (signalRService.connectToHubWithEventingUrl as jest.Mock).mockImplementation(async () => {
+ handlers['onConnected']?.();
+ });
+
+ const { result } = renderHook(() => useSignalRStore());
+
+ await act(async () => {
+ await result.current.connectUpdateHub();
+ });
+
+ expect(result.current.isUpdateHubConnected).toBe(true);
});
it('should join the department group with the parsed DepartmentId', async () => {
@@ -353,6 +406,47 @@ describe('useSignalRStore', () => {
expect(result.current.lastUpdateTimestamps.weatherAlertReceived).toBe(result.current.lastUpdateTimestamp);
});
+ it('should refresh the calls list once for a burst of call events (debounced)', async () => {
+ jest.useFakeTimers();
+ try {
+ const handlers: Record void> = {};
+ (signalRService.on as jest.Mock).mockImplementation((event: string, handler: (message: unknown) => void) => {
+ handlers[event] = handler;
+ });
+
+ const { result } = renderHook(() => useSignalRStore());
+
+ await act(async () => {
+ await result.current.connectUpdateHub();
+ });
+
+ mockFetchCalls.mockClear();
+
+ act(() => {
+ handlers['callAdded']({ CallId: '1' });
+ handlers['callsUpdated']({ CallId: '2' });
+ handlers['callClosed']({ CallId: '3' });
+ });
+
+ // Still inside the debounce window — no refetch yet.
+ expect(mockFetchCalls).not.toHaveBeenCalled();
+
+ act(() => {
+ jest.advanceTimersByTime(2000);
+ });
+
+ // A burst of three events coalesces into a single forced refresh.
+ expect(mockFetchCalls).toHaveBeenCalledTimes(1);
+ expect(mockFetchCalls).toHaveBeenCalledWith(true);
+
+ // Timestamps still update immediately for the map hook.
+ expect(result.current.lastUpdateTimestamps.callAdded).toBeGreaterThan(0);
+ expect(result.current.lastUpdateTimestamps.callClosed).toBeGreaterThan(0);
+ } finally {
+ jest.useRealTimers();
+ }
+ });
+
it('should remove hub-scoped lifecycle listeners before connecting', async () => {
const { result } = renderHook(() => useSignalRStore());
@@ -413,9 +507,7 @@ describe('useSignalRStore', () => {
});
expect(signalRService.connectToHubWithEventingUrl).not.toHaveBeenCalled();
- expect(result.current.error).toEqual(
- new Error('EventingUrl not available in config. Please ensure config is loaded first.')
- );
+ expect(result.current.error).toEqual(new Error('EventingUrl not available in config. Please ensure config is loaded first.'));
});
it('should register no-op location handlers that do not write to the store', async () => {
diff --git a/src/stores/signalr/signalr-store.ts b/src/stores/signalr/signalr-store.ts
index 994c4d17..1e66bd1d 100644
--- a/src/stores/signalr/signalr-store.ts
+++ b/src/stores/signalr/signalr-store.ts
@@ -7,6 +7,7 @@ import { SignalRService, signalRService } from '@/services/signalr.service';
import { useCoreStore } from '../app/core-store';
import { useIncidentCommandStore } from '../calls/incident-command-store';
+import { useCallsStore } from '../calls/store';
import { useChatStore } from '../chat/store';
import { FeatureFlagKeys, featureFlagsStore } from '../feature-flags/store';
import { securityStore } from '../security/store';
@@ -195,6 +196,27 @@ function extractCommandCallId(message: unknown): string | undefined {
return undefined;
}
+// A dispatch fan-out raises several call events in quick succession; debounce so a
+// burst coalesces into a single calls-list refresh. The per-event timestamps still
+// update immediately for consumers (e.g. the map hook) that key off them.
+const CALLS_REFRESH_DEBOUNCE_MS = 2000;
+let callsRefreshTimer: ReturnType | null = null;
+
+function scheduleCallsRefresh(): void {
+ if (callsRefreshTimer) {
+ clearTimeout(callsRefreshTimer);
+ }
+ callsRefreshTimer = setTimeout(() => {
+ callsRefreshTimer = null;
+ useCallsStore
+ .getState()
+ .fetchCalls(true)
+ .catch((error: unknown) => {
+ logger.warn({ message: 'Failed to refresh calls after SignalR call event', context: { error } });
+ });
+ }, CALLS_REFRESH_DEBOUNCE_MS);
+}
+
/** Update-hub events that carry a per-event timestamp for targeted refetches. */
export const UPDATE_HUB_EVENTS = [
'personnelStatusUpdated',
@@ -315,15 +337,11 @@ export const useSignalRStore = create((set, get) => ({
const updateEvents = [...UPDATE_HUB_EVENTS, 'onConnected', updateHubDisconnected, updateHubReconnecting, updateHubReconnected];
updateEvents.forEach((event) => signalRService.removeAllListeners(event));
- // Connect to the eventing hub
- await signalRService.connectToHubWithEventingUrl({
- name: Env.CHANNEL_HUB_NAME,
- eventingUrl: eventingUrl,
- hubName: Env.CHANNEL_HUB_NAME,
- methods: [...UPDATE_HUB_EVENTS, 'onConnected'],
- });
-
- await joinDepartmentGroup();
+ // Register every listener BEFORE starting the connection: the server raises
+ // onConnected as soon as the transport is up, so registering afterwards can
+ // drop the event and leave isUpdateHubConnected stuck at false. The
+ // removeAllListeners sweep above guards against duplicate registration when
+ // connectUpdateHub runs again.
// Connection lifecycle: clear the connected flag when the hub drops so
// connectUpdateHub() can recover, and re-join the department group +
@@ -430,9 +448,21 @@ export const useSignalRStore = create((set, get) => ({
signalRService.on('personnelStatusUpdated', recordEvent('personnelStatusUpdated'));
signalRService.on('personnelStaffingUpdated', recordEvent('personnelStaffingUpdated'));
- signalRService.on('callsUpdated', recordEvent('callsUpdated'));
- signalRService.on('callAdded', recordEvent('callAdded'));
- signalRService.on('callClosed', recordEvent('callClosed'));
+
+ // Call events also refresh the calls list itself (debounced) — the
+ // timestamps alone only drive the map hook, not the list data.
+ signalRService.on('callsUpdated', (message) => {
+ recordEvent('callsUpdated')(message);
+ scheduleCallsRefresh();
+ });
+ signalRService.on('callAdded', (message) => {
+ recordEvent('callAdded')(message);
+ scheduleCallsRefresh();
+ });
+ signalRService.on('callClosed', (message) => {
+ recordEvent('callClosed')(message);
+ scheduleCallsRefresh();
+ });
// unitStatusUpdated additionally keeps its raw payload for the status hook
signalRService.on('unitStatusUpdated', (message) => {
@@ -492,12 +522,20 @@ export const useSignalRStore = create((set, get) => ({
});
set({ isUpdateHubConnected: true, error: null });
});
+
+ // Connect to the eventing hub
+ await signalRService.connectToHubWithEventingUrl({
+ name: Env.CHANNEL_HUB_NAME,
+ eventingUrl: eventingUrl,
+ hubName: Env.CHANNEL_HUB_NAME,
+ methods: [...UPDATE_HUB_EVENTS, 'onConnected'],
+ });
+
+ await joinDepartmentGroup();
} catch (error) {
const err = error instanceof Error ? error : new Error('Unknown error occurred');
- logger.warn({
- message: 'Failed to connect to SignalR hubs',
- context: { error: err },
- });
+ // The service already logged the connect failure with hub context; logging
+ // here again would double-report the same transient failure.
set({ error: err });
}
},
@@ -545,13 +583,9 @@ export const useSignalRStore = create((set, get) => ({
const geoEvents = ['onPersonnelLocationUpdated', 'onUnitLocationUpdated', 'onGeolocationConnect', geoHubDisconnected];
geoEvents.forEach((event) => signalRService.removeAllListeners(event));
- // Connect to the geolocation hub
- await signalRService.connectToHubWithEventingUrl({
- name: Env.REALTIME_GEO_HUB_NAME,
- eventingUrl: eventingUrl,
- hubName: Env.REALTIME_GEO_HUB_NAME,
- methods: ['onPersonnelLocationUpdated', 'onUnitLocationUpdated', 'onGeolocationConnect'],
- });
+ // Register listeners BEFORE starting the connection so an early
+ // onGeolocationConnect from the server cannot be dropped, which would
+ // leave isGeolocationHubConnected stuck at false.
// NOTE: no per-message store writes here. Geolocation messages fire per
// unit per location cycle and nothing in the app consumes them — writing
@@ -571,12 +605,18 @@ export const useSignalRStore = create((set, get) => ({
});
set({ isGeolocationHubConnected: true, error: null });
});
+
+ // Connect to the geolocation hub
+ await signalRService.connectToHubWithEventingUrl({
+ name: Env.REALTIME_GEO_HUB_NAME,
+ eventingUrl: eventingUrl,
+ hubName: Env.REALTIME_GEO_HUB_NAME,
+ methods: ['onPersonnelLocationUpdated', 'onUnitLocationUpdated', 'onGeolocationConnect'],
+ });
} catch (error) {
const err = error instanceof Error ? error : new Error('Unknown error occurred');
- logger.warn({
- message: 'Failed to connect to SignalR hubs',
- context: { error: err },
- });
+ // The service already logged the connect failure with hub context; logging
+ // here again would double-report the same transient failure.
set({ error: err });
}
},
diff --git a/src/translations/ar.json b/src/translations/ar.json
index a04c3daa..2971db58 100644
--- a/src/translations/ar.json
+++ b/src/translations/ar.json
@@ -8,6 +8,7 @@
}
},
"app": {
+ "initialization_failed": "تعذر تحميل بيانات التطبيق. يرجى التحقق من اتصالك والمحاولة مرة أخرى.",
"title": "وحدة ريسغريد"
},
"audio_streams": {
@@ -36,6 +37,7 @@
"applied": "مطبق",
"audio": "صوت",
"audioActive": "الصوت نشط",
+ "audio_capable": "يدعم الصوت",
"audio_device": "سماعة BT",
"audio_output": "مخرج الصوت",
"availableDevices": "الأجهزة المتاحة",
@@ -47,6 +49,8 @@
"connect": "اتصال",
"connected": "متصل",
"connectionError": "خطأ في الاتصال",
+ "connection_error_message": "تعذر الاتصال بالجهاز",
+ "connection_error_title": "فشل الاتصال",
"current_selection": "الاختيار الحالي",
"device_disconnected": "تم فصل الجهاز",
"disconnect": "قطع الاتصال",
@@ -104,10 +108,12 @@
"loading": "جاري التحميل...",
"no_images": "لا توجد صور متاحة",
"no_images_description": "أضف صورًا إلى مكالمتك للمساعدة في التوثيق والتواصل",
+ "not_signed_in": "يجب تسجيل الدخول لرفع الصور",
"select_from_gallery": "اختر من المعرض",
"take_photo": "التقط صورة",
"title": "صور المكالمة",
- "upload": "رفع"
+ "upload": "رفع",
+ "upload_error": "خطأ في رفع الصورة"
},
"callNotes": {
"addNote": "إضافة ملاحظة",
@@ -241,15 +247,24 @@
"destination_poi": "Destination POI",
"destination_poi_none": "No destination selected",
"directions": "الاتجاهات",
+ "directions_to_destination": "الاتجاهات إلى الوجهة",
"dispatch_to": "إرسال إلى",
"dispatch_to_everyone": "إرسال إلى جميع الموظفين المتاحين",
"edit_call": "تعديل المكالمة",
"edit_call_description": "تحديث معلومات المكالمة",
+ "errors": {
+ "load_failed": "فشل تحميل المكالمات"
+ },
"everyone": "الجميع",
"field_policy_loading": "لا يزال يتم تحميل إعدادات البلاغات في قسمك، يرجى المحاولة مرة أخرى بعد لحظات",
"files": {
+ "error": "خطأ في الحصول على الملفات",
+ "file_name": "اسم الملف",
"no_files": "لا توجد ملفات متاحة",
"no_files_description": "لم تتم إضافة أي ملفات إلى هذه المكالمة بعد",
+ "open_error": "خطأ في فتح الملف",
+ "share_error": "خطأ في مشاركة الملف",
+ "sharing_unavailable": "المشاركة غير متاحة على هذا الجهاز",
"title": "ملفات المكالمة"
},
"geocoding_error": "فشل البحث عن العنوان، يرجى المحاولة مرة أخرى",
@@ -308,6 +323,7 @@
"users": "المستخدمين",
"viewNotes": "ملاحظات",
"view_details": "عرض التفاصيل",
+ "view_on_map": "عرض على الخريطة",
"what3words": "what3words",
"what3words_found": "تم العثور على عنوان what3words وتم تحديث الموقع",
"what3words_geocoding_error": "فشل في البحث عن عنوان what3words، يرجى المحاولة مرة أخرى",
@@ -433,6 +449,7 @@
"add": "إضافة",
"back": "رجوع",
"cancel": "إلغاء",
+ "clear_search": "مسح البحث",
"close": "إغلاق",
"confirm": "تأكيد",
"confirm_location": "تأكيد الموقع",
@@ -454,6 +471,7 @@
"no_address_found": "لم يتم العثور على عنوان",
"no_location": "لا توجد بيانات موقع متاحة",
"no_results_found": "لم يتم العثور على نتائج",
+ "no_unit": "لا توجد وحدة",
"no_unit_selected": "لم يتم اختيار وحدة",
"nothingToDisplay": "لا يوجد شيء للعرض في الوقت الحالي",
"of": "من",
@@ -476,6 +494,7 @@
"tap_map_to_select": "انقر على الخريطة لتحديد الموقع",
"tryAgainLater": "يرجى المحاولة مرة أخرى لاحقًا",
"unknown": "غير معروف",
+ "unknown_error": "حدث خطأ غير معروف",
"upload": "رفع",
"uploading": "جاري الرفع..."
},
@@ -555,6 +574,8 @@
"threads": "Threads",
"title": "جهات الاتصال",
"twitter": "تويتر",
+ "unknown_company": "شركة غير معروفة",
+ "unknown_person": "شخص غير معروف",
"visibility": "الرؤية",
"website": "الموقع الإلكتروني",
"zip": "الرمز البريدي"
@@ -659,6 +680,7 @@
"message": "يرجى التحقق من اسم المستخدم وكلمة المرور والمحاولة مرة أخرى.",
"title": "فشل تسجيل الدخول"
},
+ "hide_password": "إخفاء كلمة المرور",
"login": "تسجيل الدخول",
"login_button": "تسجيل الدخول",
"login_button_description": "قم بتسجيل الدخول إلى حسابك للمتابعة",
@@ -667,8 +689,8 @@
"login_button_success": "تم تسجيل الدخول بنجاح",
"or_sign_in_with_password": "أو تسجيل الدخول بكلمة المرور",
"password": "كلمة المرور",
- "password_incorrect": "كانت كلمة المرور غير صحيحة",
"password_placeholder": "أدخل كلمة المرور الخاصة بك",
+ "show_password": "إظهار كلمة المرور",
"sso_button": "تسجيل الدخول عبر SSO",
"sso_department": "SSO مقدم من {{name}}",
"sso_department_id": "معرف القسم (اختياري)",
@@ -689,6 +711,7 @@
"call_set_as_current": "تم تعيين المكالمة كمكالمة حالية",
"failed_to_open_maps": "فشل في فتح تطبيق الخرائط",
"failed_to_set_current_call": "فشل في تعيين المكالمة كمكالمة حالية",
+ "lock_map": "تثبيت الخريطة على الوحدة",
"no_location_for_routing": "لا توجد بيانات موقع متاحة للتوجيه",
"pin_address": "Address",
"pin_color": "لون الدبوس",
@@ -696,6 +719,7 @@
"pin_type": "POI Type",
"recenter_map": "إعادة توسيط الخريطة",
"set_as_current_call": "تعيين كمكالمة حالية",
+ "unlock_map": "إلغاء تثبيت الخريطة",
"view_call_details": "عرض تفاصيل المكالمة",
"view_poi_details": "View POI Details"
},
@@ -750,6 +774,24 @@
"search": "البحث في الملاحظات...",
"title": "الملاحظات"
},
+ "notifications": {
+ "confirm_delete_message_count": "هل أنت متأكد من حذف {{count}} إشعارات؟ لا يمكن التراجع عن هذا الإجراء.",
+ "confirm_delete_message_one": "هل أنت متأكد من حذف هذا الإشعار؟ لا يمكن التراجع عن هذا الإجراء.",
+ "confirm_delete_title": "تأكيد الحذف",
+ "delete_selected": "حذف الإشعارات المحددة",
+ "deselect_all": "إلغاء تحديد الكل",
+ "empty": "لا توجد تحديثات",
+ "enter_selection_mode": "تحديد الإشعارات",
+ "open": "فتح الإشعارات",
+ "remove_failed_count": "فشل حذف الإشعارات",
+ "remove_failed_one": "فشل حذف الإشعار",
+ "removed_count": "تم حذف {{count}} إشعارات",
+ "removed_one": "تم حذف الإشعار",
+ "select_all": "تحديد الكل",
+ "selected_count": "تم تحديد {{count}}",
+ "title": "الإشعارات",
+ "unable_to_load": "تعذر تحميل الإشعارات"
+ },
"onboarding": {
"message": "مرحبًا بك في تطبيق Resgrid Unit"
},
@@ -782,12 +824,23 @@
"view_call": "عرض المكالمة"
},
"roles": {
+ "assignedElsewhere": "في دور آخر",
+ "clearAssignment": "مسح الإسناد الحالي",
"modal": {
"title": "تعيينات أدوار الوحدة"
},
+ "noUsersFound": "لم يتم العثور على مستخدمين",
+ "save_error": "خطأ في حفظ إسناد الأدوار",
+ "saved_successfully": "تم حفظ إسناد الأدوار بنجاح",
+ "searchUsers": "ابحث بالاسم أو المجموعة...",
"selectUser": "اختر المستخدم",
+ "selectUserDescription": "اختر شخصًا لهذا الدور",
+ "selectUserForRole": "إسناد: {{role}}",
+ "selectUserLabel": "تحديد {{name}}",
"status": "{{active}} من {{total}} أدوار نشطة",
+ "tapToAssign": "اضغط لإسناد مستخدم إلى {{role}}",
"tap_to_manage": "انقر لإدارة الأدوار",
+ "title": "إسناد أدوار الوحدة",
"unassigned": "غير معين"
},
"routes": {
@@ -929,6 +982,11 @@
"total_distance": "المسافة الإجمالية",
"total_duration": "المدة الإجمالية",
"traffic_delay": "{{time}} تأخير بسبب حركة المرور",
+ "traffic_heavy": "ازدحام كثيف",
+ "traffic_light": "ازدحام خفيف",
+ "traffic_moderate": "ازدحام معتدل",
+ "traffic_no_data": "لا توجد بيانات مرور",
+ "traffic_severe": "ازدحام شديد",
"try_different_search": "Try a different search term",
"type": "النوع",
"unassigned": "غير معين",
diff --git a/src/translations/de.json b/src/translations/de.json
index 1443f553..a6e7b2f7 100644
--- a/src/translations/de.json
+++ b/src/translations/de.json
@@ -8,6 +8,7 @@
}
},
"app": {
+ "initialization_failed": "App-Daten konnten nicht geladen werden. Bitte prüfen Sie Ihre Verbindung und versuchen Sie es erneut.",
"title": "Resgrid Unit"
},
"audio_streams": {
@@ -36,6 +37,7 @@
"applied": "Angewendet",
"audio": "Audio",
"audioActive": "Audio aktiv",
+ "audio_capable": "Audiofähig",
"audio_device": "BT-Handset",
"audio_output": "Audioausgabe",
"availableDevices": "Verfügbare Geräte",
@@ -47,6 +49,8 @@
"connect": "Verbinden",
"connected": "Verbunden",
"connectionError": "Verbindungsfehler",
+ "connection_error_message": "Verbindung zum Gerät nicht möglich",
+ "connection_error_title": "Verbindung fehlgeschlagen",
"current_selection": "Aktuelle Auswahl",
"device_disconnected": "Gerät getrennt",
"disconnect": "Trennen",
@@ -104,10 +108,12 @@
"loading": "Laden...",
"no_images": "Keine Bilder verfügbar",
"no_images_description": "Fügen Sie Ihrem Anruf Bilder hinzu, um die Dokumentation und Kommunikation zu erleichtern",
+ "not_signed_in": "Sie müssen angemeldet sein, um Bilder hochzuladen",
"select_from_gallery": "Aus Galerie auswählen",
"take_photo": "Foto aufnehmen",
"title": "Anrufbilder",
- "upload": "Hochladen"
+ "upload": "Hochladen",
+ "upload_error": "Fehler beim Hochladen des Bildes"
},
"callNotes": {
"addNote": "Notiz hinzufügen",
@@ -241,15 +247,24 @@
"destination_poi": "Destination POI",
"destination_poi_none": "No destination selected",
"directions": "Wegbeschreibung",
+ "directions_to_destination": "Route zum Ziel",
"dispatch_to": "Disponieren an",
"dispatch_to_everyone": "An alle verfügbaren Einsatzkräfte disponieren",
"edit_call": "Anruf bearbeiten",
"edit_call_description": "Anrufinformationen aktualisieren",
+ "errors": {
+ "load_failed": "Anrufe konnten nicht geladen werden"
+ },
"everyone": "Alle",
"field_policy_loading": "Die Einsatzeinstellungen Ihrer Abteilung werden noch geladen, bitte versuchen Sie es gleich erneut",
"files": {
+ "error": "Fehler beim Abrufen der Dateien",
+ "file_name": "Dateiname",
"no_files": "Keine Dateien verfügbar",
"no_files_description": "Diesem Anruf wurden noch keine Dateien hinzugefügt",
+ "open_error": "Fehler beim Öffnen der Datei",
+ "share_error": "Fehler beim Teilen der Datei",
+ "sharing_unavailable": "Teilen ist auf diesem Gerät nicht verfügbar",
"title": "Anrufdateien"
},
"geocoding_error": "Adresssuche fehlgeschlagen, bitte erneut versuchen",
@@ -308,6 +323,7 @@
"users": "Benutzer",
"viewNotes": "Notizen",
"view_details": "Details anzeigen",
+ "view_on_map": "Auf Karte anzeigen",
"what3words": "what3words",
"what3words_found": "what3words-Adresse gefunden und Standort aktualisiert",
"what3words_geocoding_error": "Suche nach what3words-Adresse fehlgeschlagen, bitte erneut versuchen",
@@ -433,6 +449,7 @@
"add": "Hinzufügen",
"back": "Zurück",
"cancel": "Abbrechen",
+ "clear_search": "Suche löschen",
"close": "Schließen",
"confirm": "Bestätigen",
"confirm_location": "Standort bestätigen",
@@ -454,6 +471,7 @@
"no_address_found": "Keine Adresse gefunden",
"no_location": "Keine Standortdaten verfügbar",
"no_results_found": "Keine Ergebnisse gefunden",
+ "no_unit": "Keine Einheit",
"no_unit_selected": "Keine Einheit ausgewählt",
"nothingToDisplay": "Im Moment gibt es nichts anzuzeigen",
"of": "von",
@@ -476,6 +494,7 @@
"tap_map_to_select": "Auf die Karte tippen, um einen Standort auszuwählen",
"tryAgainLater": "Bitte später erneut versuchen",
"unknown": "Unbekannt",
+ "unknown_error": "Ein unbekannter Fehler ist aufgetreten",
"upload": "Hochladen",
"uploading": "Hochladen..."
},
@@ -555,6 +574,8 @@
"threads": "Threads",
"title": "Kontakte",
"twitter": "Twitter",
+ "unknown_company": "Unbekanntes Unternehmen",
+ "unknown_person": "Unbekannte Person",
"visibility": "Sichtbarkeit",
"website": "Website",
"zip": "Postleitzahl"
@@ -659,6 +680,7 @@
"message": "Bitte Benutzername und Passwort überprüfen und erneut versuchen.",
"title": "Anmeldung fehlgeschlagen"
},
+ "hide_password": "Passwort verbergen",
"login": "Anmelden",
"login_button": "Anmelden",
"login_button_description": "Bei Ihrem Konto anmelden, um fortzufahren",
@@ -667,8 +689,8 @@
"login_button_success": "Erfolgreich angemeldet",
"or_sign_in_with_password": "oder mit Passwort anmelden",
"password": "Passwort",
- "password_incorrect": "Passwort war falsch",
"password_placeholder": "Passwort eingeben",
+ "show_password": "Passwort anzeigen",
"sso_button": "Mit SSO anmelden",
"sso_department": "SSO bereitgestellt von {{name}}",
"sso_department_id": "Abteilungs-ID (optional)",
@@ -689,6 +711,7 @@
"call_set_as_current": "Anruf als aktuellen Anruf gesetzt",
"failed_to_open_maps": "Kartenanwendung konnte nicht geöffnet werden",
"failed_to_set_current_call": "Anruf konnte nicht als aktueller Anruf gesetzt werden",
+ "lock_map": "Karte an Einheit binden",
"no_location_for_routing": "Keine Standortdaten für die Navigation verfügbar",
"pin_address": "Address",
"pin_color": "Pin-Farbe",
@@ -696,6 +719,7 @@
"pin_type": "POI Type",
"recenter_map": "Karte neu zentrieren",
"set_as_current_call": "Als aktuellen Anruf setzen",
+ "unlock_map": "Karte entsperren",
"view_call_details": "Anrufdetails anzeigen",
"view_poi_details": "View POI Details"
},
@@ -750,6 +774,24 @@
"search": "Notizen suchen...",
"title": "Notizen"
},
+ "notifications": {
+ "confirm_delete_message_count": "Möchten Sie {{count}} Benachrichtigungen wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.",
+ "confirm_delete_message_one": "Möchten Sie diese Benachrichtigung wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.",
+ "confirm_delete_title": "Löschen bestätigen",
+ "delete_selected": "Ausgewählte Benachrichtigungen löschen",
+ "deselect_all": "Auswahl aufheben",
+ "empty": "Keine Neuigkeiten vorhanden",
+ "enter_selection_mode": "Benachrichtigungen auswählen",
+ "open": "Benachrichtigungen öffnen",
+ "remove_failed_count": "Benachrichtigungen konnten nicht gelöscht werden",
+ "remove_failed_one": "Benachrichtigung konnte nicht gelöscht werden",
+ "removed_count": "{{count}} Benachrichtigungen gelöscht",
+ "removed_one": "Benachrichtigung gelöscht",
+ "select_all": "Alle auswählen",
+ "selected_count": "{{count}} ausgewählt",
+ "title": "Benachrichtigungen",
+ "unable_to_load": "Benachrichtigungen konnten nicht geladen werden"
+ },
"onboarding": {
"message": "Willkommen bei Resgrid Unit app site"
},
@@ -782,12 +824,23 @@
"view_call": "Anruf anzeigen"
},
"roles": {
+ "assignedElsewhere": "In einer anderen Rolle",
+ "clearAssignment": "Aktuelle Zuweisung entfernen",
"modal": {
"title": "Einheitsrollenzuweisungen"
},
+ "noUsersFound": "Keine Benutzer gefunden",
+ "save_error": "Fehler beim Speichern der Rollenzuweisungen",
+ "saved_successfully": "Rollenzuweisungen gespeichert",
+ "searchUsers": "Nach Name oder Gruppe suchen...",
"selectUser": "Benutzer auswählen",
+ "selectUserDescription": "Wählen Sie eine Person für diese Rolle",
+ "selectUserForRole": "Zuweisen: {{role}}",
+ "selectUserLabel": "{{name}} auswählen",
"status": "{{active}} von {{total}} Rollen",
+ "tapToAssign": "Tippen, um {{role}} einen Benutzer zuzuweisen",
"tap_to_manage": "Tippen, um Rollen zu verwalten",
+ "title": "Rollenzuweisung der Einheit",
"unassigned": "Nicht zugewiesen"
},
"routes": {
@@ -929,6 +982,11 @@
"total_distance": "Gesamtentfernung",
"total_duration": "Gesamtdauer",
"traffic_delay": "{{time}} Verzögerung durch Verkehr",
+ "traffic_heavy": "Dichter Verkehr",
+ "traffic_light": "Wenig Verkehr",
+ "traffic_moderate": "Mäßiger Verkehr",
+ "traffic_no_data": "Keine Verkehrsdaten",
+ "traffic_severe": "Sehr dichter Verkehr",
"try_different_search": "Try a different search term",
"type": "Typ",
"unassigned": "Nicht zugewiesen",
diff --git a/src/translations/el.json b/src/translations/el.json
index ffa9ff43..5fd8959a 100644
--- a/src/translations/el.json
+++ b/src/translations/el.json
@@ -8,6 +8,7 @@
}
},
"app": {
+ "initialization_failed": "Δεν ήταν δυνατή η φόρτωση των δεδομένων της εφαρμογής. Ελέγξτε τη σύνδεσή σας και δοκιμάστε ξανά.",
"title": "Resgrid Unit"
},
"audio_streams": {
@@ -36,6 +37,7 @@
"applied": "Εφαρμόστηκε",
"audio": "Ήχος",
"audioActive": "Ήχος Ενεργός",
+ "audio_capable": "Υποστηρίζει ήχο",
"audio_device": "Ακουστικό BT",
"audio_output": "Έξοδος Ήχου",
"availableDevices": "Διαθέσιμες Συσκευές",
@@ -47,6 +49,8 @@
"connect": "Σύνδεση",
"connected": "Συνδεδεμένη",
"connectionError": "Σφάλμα Σύνδεσης",
+ "connection_error_message": "Δεν ήταν δυνατή η σύνδεση με τη συσκευή",
+ "connection_error_title": "Η σύνδεση απέτυχε",
"current_selection": "Τρέχουσα Επιλογή",
"device_disconnected": "Η συσκευή αποσυνδέθηκε",
"disconnect": "Αποσύνδεση",
@@ -104,10 +108,12 @@
"loading": "Φόρτωση...",
"no_images": "Δεν υπάρχουν διαθέσιμες εικόνες",
"no_images_description": "Προσθέστε εικόνες στην κλήση σας για να βοηθήσετε στην τεκμηρίωση και την επικοινωνία",
+ "not_signed_in": "Πρέπει να συνδεθείτε για να μεταφορτώσετε εικόνες",
"select_from_gallery": "Επιλογή από τη Συλλογή",
"take_photo": "Λήψη Φωτογραφίας",
"title": "Εικόνες Κλήσης",
- "upload": "Μεταφόρτωση"
+ "upload": "Μεταφόρτωση",
+ "upload_error": "Σφάλμα κατά τη μεταφόρτωση της εικόνας"
},
"callNotes": {
"addNote": "Προσθήκη Σημείωσης",
@@ -241,15 +247,24 @@
"destination_poi": "Σημείο Ενδιαφέροντος Προορισμού",
"destination_poi_none": "Δεν επιλέχθηκε προορισμός",
"directions": "Οδηγίες",
+ "directions_to_destination": "Οδηγίες προς τον προορισμό",
"dispatch_to": "Αποστολή Προς",
"dispatch_to_everyone": "Αποστολή σε όλο το διαθέσιμο προσωπικό",
"edit_call": "Επεξεργασία Κλήσης",
"edit_call_description": "Ενημέρωση πληροφοριών κλήσης",
+ "errors": {
+ "load_failed": "Η φόρτωση των κλήσεων απέτυχε"
+ },
"everyone": "Όλοι",
"field_policy_loading": "Οι ρυθμίσεις κλήσεων του τμήματός σας φορτώνονται ακόμη, δοκιμάστε ξανά σε λίγο",
"files": {
+ "error": "Σφάλμα κατά τη λήψη των αρχείων",
+ "file_name": "Όνομα αρχείου",
"no_files": "Δεν υπάρχουν διαθέσιμα αρχεία",
"no_files_description": "Δεν έχουν προστεθεί ακόμη αρχεία σε αυτή την κλήση",
+ "open_error": "Σφάλμα κατά το άνοιγμα του αρχείου",
+ "share_error": "Σφάλμα κατά την κοινοποίηση του αρχείου",
+ "sharing_unavailable": "Η κοινοποίηση δεν είναι διαθέσιμη σε αυτήν τη συσκευή",
"title": "Αρχεία Κλήσης"
},
"geocoding_error": "Αποτυχία αναζήτησης διεύθυνσης, δοκιμάστε ξανά",
@@ -308,6 +323,7 @@
"users": "Χρήστες",
"viewNotes": "Σημειώσεις",
"view_details": "Προβολή Λεπτομερειών",
+ "view_on_map": "Προβολή στον χάρτη",
"what3words": "what3words",
"what3words_found": "Η διεύθυνση what3words βρέθηκε και η τοποθεσία ενημερώθηκε",
"what3words_geocoding_error": "Αποτυχία αναζήτησης διεύθυνσης what3words, δοκιμάστε ξανά",
@@ -433,6 +449,7 @@
"add": "Προσθήκη",
"back": "Πίσω",
"cancel": "Ακύρωση",
+ "clear_search": "Καθαρισμός αναζήτησης",
"close": "Κλείσιμο",
"confirm": "Επιβεβαίωση",
"confirm_location": "Επιβεβαίωση Τοποθεσίας",
@@ -454,6 +471,7 @@
"no_address_found": "Δεν βρέθηκε διεύθυνση",
"no_location": "Δεν υπάρχουν διαθέσιμα δεδομένα τοποθεσίας",
"no_results_found": "Δεν βρέθηκαν αποτελέσματα",
+ "no_unit": "Καμία μονάδα",
"no_unit_selected": "Δεν Επιλέχθηκε Μονάδα",
"nothingToDisplay": "Δεν υπάρχει τίποτα να εμφανιστεί αυτή τη στιγμή",
"of": "από",
@@ -476,6 +494,7 @@
"tap_map_to_select": "Πατήστε στον χάρτη για να επιλέξετε τοποθεσία",
"tryAgainLater": "Δοκιμάστε ξανά αργότερα",
"unknown": "Άγνωστο",
+ "unknown_error": "Παρουσιάστηκε άγνωστο σφάλμα",
"upload": "Μεταφόρτωση",
"uploading": "Μεταφόρτωση..."
},
@@ -555,6 +574,8 @@
"threads": "Threads",
"title": "Επαφές",
"twitter": "Twitter",
+ "unknown_company": "Άγνωστη εταιρεία",
+ "unknown_person": "Άγνωστο άτομο",
"visibility": "Ορατότητα",
"website": "Ιστότοπος",
"zip": "Ταχυδρομικός Κώδικας"
@@ -659,6 +680,7 @@
"message": "Ελέγξτε το όνομα χρήστη και τον κωδικό πρόσβασής σας και δοκιμάστε ξανά.",
"title": "Η Σύνδεση Απέτυχε"
},
+ "hide_password": "Απόκρυψη κωδικού",
"login": "Σύνδεση",
"login_button": "Σύνδεση",
"login_button_description": "Συνδεθείτε στον λογαριασμό σας για να συνεχίσετε",
@@ -667,8 +689,8 @@
"login_button_success": "Επιτυχής σύνδεση",
"or_sign_in_with_password": "ή συνδεθείτε με κωδικό πρόσβασης",
"password": "Κωδικός Πρόσβασης",
- "password_incorrect": "Ο κωδικός πρόσβασης ήταν λανθασμένος",
"password_placeholder": "Εισαγάγετε τον κωδικό πρόσβασής σας",
+ "show_password": "Εμφάνιση κωδικού",
"sso_button": "Συνδεθείτε με SSO",
"sso_department": "Το SSO παρέχεται από {{name}}",
"sso_department_id": "Αναγνωριστικό Τμήματος (προαιρετικό)",
@@ -689,6 +711,7 @@
"call_set_as_current": "Η κλήση ορίστηκε ως τρέχουσα κλήση",
"failed_to_open_maps": "Αποτυχία ανοίγματος εφαρμογής χαρτών",
"failed_to_set_current_call": "Αποτυχία ορισμού της κλήσης ως τρέχουσας",
+ "lock_map": "Κλείδωμα χάρτη στη μονάδα",
"no_location_for_routing": "Δεν υπάρχουν διαθέσιμα δεδομένα τοποθεσίας για δρομολόγηση",
"pin_address": "Διεύθυνση",
"pin_color": "Χρώμα Πινέζας",
@@ -696,6 +719,7 @@
"pin_type": "Τύπος Σημείου Ενδιαφέροντος",
"recenter_map": "Επανακεντράρισμα Χάρτη",
"set_as_current_call": "Ορισμός ως Τρέχουσα Κλήση",
+ "unlock_map": "Ξεκλείδωμα χάρτη",
"view_call_details": "Προβολή Λεπτομερειών Κλήσης",
"view_poi_details": "Προβολή Λεπτομερειών Σημείου Ενδιαφέροντος"
},
@@ -750,6 +774,24 @@
"search": "Αναζήτηση σημειώσεων...",
"title": "Σημειώσεις"
},
+ "notifications": {
+ "confirm_delete_message_count": "Είστε βέβαιοι ότι θέλετε να διαγράψετε {{count}} ειδοποιήσεις; Η ενέργεια δεν είναι αναστρέψιμη.",
+ "confirm_delete_message_one": "Είστε βέβαιοι ότι θέλετε να διαγράψετε αυτήν την ειδοποίηση; Η ενέργεια δεν είναι αναστρέψιμη.",
+ "confirm_delete_title": "Επιβεβαίωση διαγραφής",
+ "delete_selected": "Διαγραφή επιλεγμένων ειδοποιήσεων",
+ "deselect_all": "Αποεπιλογή όλων",
+ "empty": "Δεν υπάρχουν ενημερώσεις",
+ "enter_selection_mode": "Επιλογή ειδοποιήσεων",
+ "open": "Άνοιγμα ειδοποιήσεων",
+ "remove_failed_count": "Η διαγραφή των ειδοποιήσεων απέτυχε",
+ "remove_failed_one": "Η διαγραφή της ειδοποίησης απέτυχε",
+ "removed_count": "Διαγράφηκαν {{count}} ειδοποιήσεις",
+ "removed_one": "Η ειδοποίηση διαγράφηκε",
+ "select_all": "Επιλογή όλων",
+ "selected_count": "{{count}} επιλεγμένες",
+ "title": "Ειδοποιήσεις",
+ "unable_to_load": "Δεν ήταν δυνατή η φόρτωση των ειδοποιήσεων"
+ },
"onboarding": {
"message": "Καλώς ήρθατε στον ιστότοπο της εφαρμογής Resgrid Unit"
},
@@ -782,12 +824,23 @@
"view_call": "Προβολή Κλήσης"
},
"roles": {
+ "assignedElsewhere": "Σε άλλον ρόλο",
+ "clearAssignment": "Καθαρισμός της τρέχουσας ανάθεσης",
"modal": {
"title": "Αναθέσεις Ρόλων Μονάδας"
},
+ "noUsersFound": "Δεν βρέθηκαν χρήστες",
+ "save_error": "Σφάλμα κατά την αποθήκευση των αναθέσεων ρόλων",
+ "saved_successfully": "Οι αναθέσεις ρόλων αποθηκεύτηκαν με επιτυχία",
+ "searchUsers": "Αναζήτηση με όνομα ή ομάδα...",
"selectUser": "Επιλογή χρήστη",
+ "selectUserDescription": "Επιλέξτε ένα άτομο για αυτόν τον ρόλο",
+ "selectUserForRole": "Ανάθεση: {{role}}",
+ "selectUserLabel": "Επιλογή {{name}}",
"status": "{{active}} από {{total}} Ρόλους",
+ "tapToAssign": "Πατήστε για ανάθεση χρήστη στον ρόλο {{role}}",
"tap_to_manage": "Πατήστε για διαχείριση ρόλων",
+ "title": "Αναθέσεις ρόλων μονάδας",
"unassigned": "Χωρίς ανάθεση"
},
"routes": {
@@ -929,6 +982,11 @@
"total_distance": "Συνολική Απόσταση",
"total_duration": "Συνολική Διάρκεια",
"traffic_delay": "Καθυστέρηση {{time}} λόγω κυκλοφορίας",
+ "traffic_heavy": "Πυκνή κυκλοφορία",
+ "traffic_light": "Ελαφριά κυκλοφορία",
+ "traffic_moderate": "Μέτρια κυκλοφορία",
+ "traffic_no_data": "Δεν υπάρχουν δεδομένα κυκλοφορίας",
+ "traffic_severe": "Πολύ πυκνή κυκλοφορία",
"try_different_search": "Δοκιμάστε διαφορετικό όρο αναζήτησης",
"type": "Τύπος",
"unassigned": "Χωρίς ανάθεση",
diff --git a/src/translations/en.json b/src/translations/en.json
index 535b7393..bc0f2746 100644
--- a/src/translations/en.json
+++ b/src/translations/en.json
@@ -8,6 +8,7 @@
}
},
"app": {
+ "initialization_failed": "Unable to load app data. Please check your connection and try again.",
"title": "Resgrid Unit"
},
"audio_streams": {
@@ -36,6 +37,7 @@
"applied": "Applied",
"audio": "Audio",
"audioActive": "Audio Active",
+ "audio_capable": "Audio capable",
"audio_device": "BT Handset",
"audio_output": "Audio Output",
"availableDevices": "Available Devices",
@@ -47,6 +49,8 @@
"connect": "Connect",
"connected": "Connected",
"connectionError": "Connection Error",
+ "connection_error_message": "Could not connect to device",
+ "connection_error_title": "Connection Failed",
"current_selection": "Current Selection",
"device_disconnected": "Device disconnected",
"disconnect": "Disconnect",
@@ -104,10 +108,12 @@
"loading": "Loading...",
"no_images": "No images available",
"no_images_description": "Add images to your call to help with documentation and communication",
+ "not_signed_in": "You must be signed in to upload images",
"select_from_gallery": "Select from Gallery",
"take_photo": "Take Photo",
"title": "Call Images",
- "upload": "Upload"
+ "upload": "Upload",
+ "upload_error": "Error uploading image"
},
"callNotes": {
"addNote": "Add Note",
@@ -241,15 +247,24 @@
"destination_poi": "Destination POI",
"destination_poi_none": "No destination selected",
"directions": "Directions",
+ "directions_to_destination": "Directions to destination",
"dispatch_to": "Dispatch To",
"dispatch_to_everyone": "Dispatch to all available personnel",
"edit_call": "Edit Call",
"edit_call_description": "Update call information",
+ "errors": {
+ "load_failed": "Failed to load calls"
+ },
"everyone": "Everyone",
"field_policy_loading": "Still loading your department's call settings, please try again in a moment",
"files": {
+ "error": "Error getting files",
+ "file_name": "File Name",
"no_files": "No files available",
"no_files_description": "No files have been added to this call yet",
+ "open_error": "Error opening file",
+ "share_error": "Error sharing file",
+ "sharing_unavailable": "Sharing is not available on this device",
"title": "Call Files"
},
"geocoding_error": "Failed to search for address, please try again",
@@ -308,6 +323,7 @@
"users": "Users",
"viewNotes": "Notes",
"view_details": "View Details",
+ "view_on_map": "View on map",
"what3words": "what3words",
"what3words_found": "what3words address found and location updated",
"what3words_geocoding_error": "Failed to search for what3words address, please try again",
@@ -433,6 +449,7 @@
"add": "Add",
"back": "Back",
"cancel": "Cancel",
+ "clear_search": "Clear search",
"close": "Close",
"confirm": "Confirm",
"confirm_location": "Confirm Location",
@@ -454,6 +471,7 @@
"no_address_found": "No address found",
"no_location": "No location data available",
"no_results_found": "No results found",
+ "no_unit": "No Unit",
"no_unit_selected": "No Unit Selected",
"nothingToDisplay": "There's nothing to display at the moment",
"of": "of",
@@ -476,6 +494,7 @@
"tap_map_to_select": "Tap on the map to select a location",
"tryAgainLater": "Please try again later",
"unknown": "Unknown",
+ "unknown_error": "Unknown error occurred",
"upload": "Upload",
"uploading": "Uploading..."
},
@@ -555,6 +574,8 @@
"threads": "Threads",
"title": "Contacts",
"twitter": "Twitter",
+ "unknown_company": "Unknown Company",
+ "unknown_person": "Unknown Person",
"visibility": "Visibility",
"website": "Website",
"zip": "Zip Code"
@@ -659,6 +680,7 @@
"message": "Please check your username and password and try again.",
"title": "Login Failed"
},
+ "hide_password": "Hide password",
"login": "Login",
"login_button": "Sign In",
"login_button_description": "Login to your account to continue",
@@ -667,8 +689,8 @@
"login_button_success": "Logged in successfully",
"or_sign_in_with_password": "or sign in with password",
"password": "Password",
- "password_incorrect": "Password was incorrect",
"password_placeholder": "Enter your password",
+ "show_password": "Show password",
"sso_button": "Sign in with SSO",
"sso_department": "SSO provided by {{name}}",
"sso_department_id": "Department ID (optional)",
@@ -689,6 +711,7 @@
"call_set_as_current": "Call set as current call",
"failed_to_open_maps": "Failed to open maps application",
"failed_to_set_current_call": "Failed to set call as current call",
+ "lock_map": "Lock map to unit",
"no_location_for_routing": "No location data available for routing",
"pin_address": "Address",
"pin_color": "Pin Color",
@@ -696,6 +719,7 @@
"pin_type": "POI Type",
"recenter_map": "Recenter Map",
"set_as_current_call": "Set as Current Call",
+ "unlock_map": "Unlock map",
"view_call_details": "View Call Details",
"view_poi_details": "View POI Details"
},
@@ -750,6 +774,24 @@
"search": "Search notes...",
"title": "Notes"
},
+ "notifications": {
+ "confirm_delete_message_count": "Are you sure you want to delete {{count}} notifications? This action cannot be undone.",
+ "confirm_delete_message_one": "Are you sure you want to delete this notification? This action cannot be undone.",
+ "confirm_delete_title": "Confirm Delete",
+ "delete_selected": "Delete selected notifications",
+ "deselect_all": "Deselect All",
+ "empty": "No updates available",
+ "enter_selection_mode": "Select notifications",
+ "open": "Open notifications",
+ "remove_failed_count": "Failed to remove notifications",
+ "remove_failed_one": "Failed to remove notification",
+ "removed_count": "{{count}} notifications removed",
+ "removed_one": "Notification removed",
+ "select_all": "Select All",
+ "selected_count": "{{count}} selected",
+ "title": "Notifications",
+ "unable_to_load": "Unable to load notifications"
+ },
"onboarding": {
"message": "Welcome to Resgrid Unit app site"
},
@@ -782,12 +824,23 @@
"view_call": "View Call"
},
"roles": {
+ "assignedElsewhere": "In another role",
+ "clearAssignment": "Clear the current assignment",
"modal": {
"title": "Unit Role Assignments"
},
+ "noUsersFound": "No users found",
+ "save_error": "Error saving role assignments",
+ "saved_successfully": "Role assignments saved successfully",
+ "searchUsers": "Search by name or group...",
"selectUser": "Select user",
+ "selectUserDescription": "Choose a person to fill this role",
+ "selectUserForRole": "Assign: {{role}}",
+ "selectUserLabel": "Select {{name}}",
"status": "{{active}} of {{total}} Roles",
+ "tapToAssign": "Tap to assign user to {{role}}",
"tap_to_manage": "Tap to manage roles",
+ "title": "Unit Role Assignments",
"unassigned": "Unassigned"
},
"routes": {
@@ -929,6 +982,11 @@
"total_distance": "Total Distance",
"total_duration": "Total Duration",
"traffic_delay": "{{time}} delay due to traffic",
+ "traffic_heavy": "Heavy traffic",
+ "traffic_light": "Light traffic",
+ "traffic_moderate": "Moderate traffic",
+ "traffic_no_data": "No traffic data",
+ "traffic_severe": "Severe traffic",
"try_different_search": "Try a different search term",
"type": "Type",
"unassigned": "Unassigned",
diff --git a/src/translations/es.json b/src/translations/es.json
index 536733f5..909ff5c2 100644
--- a/src/translations/es.json
+++ b/src/translations/es.json
@@ -8,6 +8,7 @@
}
},
"app": {
+ "initialization_failed": "No se pudieron cargar los datos de la aplicación. Comprueba tu conexión e inténtalo de nuevo.",
"title": "Resgrid Unit"
},
"audio_streams": {
@@ -36,6 +37,7 @@
"applied": "Aplicado",
"audio": "Audio",
"audioActive": "Audio Activo",
+ "audio_capable": "Compatible con audio",
"audio_device": "Auricular BT",
"audio_output": "Salida de audio",
"availableDevices": "Dispositivos Disponibles",
@@ -47,6 +49,8 @@
"connect": "Conectar",
"connected": "Conectado",
"connectionError": "Error de Conexión",
+ "connection_error_message": "No se pudo conectar con el dispositivo",
+ "connection_error_title": "Error de conexión",
"current_selection": "Selección Actual",
"device_disconnected": "Dispositivo desconectado",
"disconnect": "Desconectar",
@@ -104,10 +108,12 @@
"loading": "Cargando...",
"no_images": "No hay imágenes disponibles",
"no_images_description": "Añade imágenes a tu llamada para ayudar con la documentación y comunicación",
+ "not_signed_in": "Debes iniciar sesión para subir imágenes",
"select_from_gallery": "Seleccionar de la galería",
"take_photo": "Tomar foto",
"title": "Imágenes de la llamada",
- "upload": "Subir"
+ "upload": "Subir",
+ "upload_error": "Error al subir la imagen"
},
"callNotes": {
"addNote": "Añadir nota",
@@ -241,15 +247,24 @@
"destination_poi": "Destination POI",
"destination_poi_none": "No destination selected",
"directions": "Direcciones",
+ "directions_to_destination": "Indicaciones al destino",
"dispatch_to": "Despachar A",
"dispatch_to_everyone": "Despachar a todo el personal disponible",
"edit_call": "Editar llamada",
"edit_call_description": "Actualizar información de la llamada",
+ "errors": {
+ "load_failed": "Error al cargar las llamadas"
+ },
"everyone": "Todos",
"field_policy_loading": "Aún se están cargando los ajustes de avisos de tu departamento, inténtalo de nuevo en un momento",
"files": {
+ "error": "Error al obtener los archivos",
+ "file_name": "Nombre del archivo",
"no_files": "No hay archivos disponibles",
"no_files_description": "No se han agregado archivos a esta llamada aún",
+ "open_error": "Error al abrir el archivo",
+ "share_error": "Error al compartir el archivo",
+ "sharing_unavailable": "Compartir no está disponible en este dispositivo",
"title": "Archivos de llamada"
},
"geocoding_error": "Error al buscar la dirección, por favor inténtelo de nuevo",
@@ -308,6 +323,7 @@
"users": "Usuarios",
"viewNotes": "Notas",
"view_details": "Ver detalles",
+ "view_on_map": "Ver en el mapa",
"what3words": "what3words",
"what3words_found": "Dirección what3words encontrada y ubicación actualizada",
"what3words_geocoding_error": "Error al buscar dirección what3words, intente nuevamente",
@@ -433,6 +449,7 @@
"add": "Añadir",
"back": "Atrás",
"cancel": "Cancelar",
+ "clear_search": "Borrar búsqueda",
"close": "Cerrar",
"confirm": "Confirmar",
"confirm_location": "Confirmar ubicación",
@@ -454,6 +471,7 @@
"no_address_found": "No se encontró dirección",
"no_location": "No hay datos de ubicación disponibles",
"no_results_found": "No se encontraron resultados",
+ "no_unit": "Sin unidad",
"no_unit_selected": "Ninguna unidad seleccionada",
"nothingToDisplay": "No hay nada que mostrar en este momento",
"of": "de",
@@ -476,6 +494,7 @@
"tap_map_to_select": "Toca el mapa para seleccionar una ubicación",
"tryAgainLater": "Por favor, inténtelo de nuevo más tarde",
"unknown": "Desconocido",
+ "unknown_error": "Se produjo un error desconocido",
"upload": "Subir",
"uploading": "Subiendo..."
},
@@ -555,6 +574,8 @@
"threads": "Threads",
"title": "Contactos",
"twitter": "Twitter",
+ "unknown_company": "Empresa desconocida",
+ "unknown_person": "Persona desconocida",
"visibility": "Visibilidad",
"website": "Sitio web",
"zip": "Código postal"
@@ -659,6 +680,7 @@
"message": "Por favor, comprueba tu nombre de usuario y contraseña e inténtalo de nuevo.",
"title": "Inicio de sesión fallido"
},
+ "hide_password": "Ocultar contraseña",
"login": "Iniciar sesión",
"login_button": "Iniciar sesión",
"login_button_description": "Inicia sesión en tu cuenta para continuar",
@@ -667,8 +689,8 @@
"login_button_success": "Sesión iniciada con éxito",
"or_sign_in_with_password": "o inicia sesión con contraseña",
"password": "Contraseña",
- "password_incorrect": "La contraseña era incorrecta",
"password_placeholder": "Introduce tu contraseña",
+ "show_password": "Mostrar contraseña",
"sso_button": "Iniciar sesión con SSO",
"sso_department": "SSO proporcionado por {{name}}",
"sso_department_id": "ID de departamento (opcional)",
@@ -689,6 +711,7 @@
"call_set_as_current": "Llamada establecida como llamada actual",
"failed_to_open_maps": "Error al abrir la aplicación de mapas",
"failed_to_set_current_call": "Error al establecer la llamada como llamada actual",
+ "lock_map": "Bloquear el mapa en la unidad",
"no_location_for_routing": "No hay datos de ubicación disponibles para el enrutamiento",
"pin_address": "Address",
"pin_color": "Color del pin",
@@ -696,6 +719,7 @@
"pin_type": "POI Type",
"recenter_map": "Recentrar mapa",
"set_as_current_call": "Establecer como llamada actual",
+ "unlock_map": "Desbloquear el mapa",
"view_call_details": "Ver detalles de la llamada",
"view_poi_details": "View POI Details"
},
@@ -750,6 +774,24 @@
"search": "Buscar notas...",
"title": "Notas"
},
+ "notifications": {
+ "confirm_delete_message_count": "¿Seguro que quieres eliminar {{count}} notificaciones? Esta acción no se puede deshacer.",
+ "confirm_delete_message_one": "¿Seguro que quieres eliminar esta notificación? Esta acción no se puede deshacer.",
+ "confirm_delete_title": "Confirmar eliminación",
+ "delete_selected": "Eliminar las notificaciones seleccionadas",
+ "deselect_all": "Deseleccionar todo",
+ "empty": "No hay novedades",
+ "enter_selection_mode": "Seleccionar notificaciones",
+ "open": "Abrir notificaciones",
+ "remove_failed_count": "No se pudieron eliminar las notificaciones",
+ "remove_failed_one": "No se pudo eliminar la notificación",
+ "removed_count": "{{count}} notificaciones eliminadas",
+ "removed_one": "Notificación eliminada",
+ "select_all": "Seleccionar todo",
+ "selected_count": "{{count}} seleccionadas",
+ "title": "Notificaciones",
+ "unable_to_load": "No se pudieron cargar las notificaciones"
+ },
"onboarding": {
"message": "Bienvenido al sitio de la aplicación Resgrid Unit"
},
@@ -782,12 +824,23 @@
"view_call": "Ver Llamada"
},
"roles": {
+ "assignedElsewhere": "En otra función",
+ "clearAssignment": "Borrar la asignación actual",
"modal": {
"title": "Asignaciones de roles de unidad"
},
+ "noUsersFound": "No se encontraron usuarios",
+ "save_error": "Error al guardar las asignaciones de funciones",
+ "saved_successfully": "Asignaciones de funciones guardadas correctamente",
+ "searchUsers": "Buscar por nombre o grupo...",
"selectUser": "Seleccionar usuario",
+ "selectUserDescription": "Elige a una persona para esta función",
+ "selectUserForRole": "Asignar: {{role}}",
+ "selectUserLabel": "Seleccionar a {{name}}",
"status": "{{active}} de {{total}} roles activos",
+ "tapToAssign": "Toca para asignar un usuario a {{role}}",
"tap_to_manage": "Toca para gestionar roles",
+ "title": "Asignación de funciones de la unidad",
"unassigned": "Sin asignar"
},
"routes": {
@@ -929,6 +982,11 @@
"total_distance": "Distancia Total",
"total_duration": "Duración Total",
"traffic_delay": "{{time}} de retraso por el tráfico",
+ "traffic_heavy": "Tráfico denso",
+ "traffic_light": "Tráfico fluido",
+ "traffic_moderate": "Tráfico moderado",
+ "traffic_no_data": "Sin datos de tráfico",
+ "traffic_severe": "Tráfico muy denso",
"try_different_search": "Try a different search term",
"type": "Tipo",
"unassigned": "Sin asignar",
diff --git a/src/translations/fr.json b/src/translations/fr.json
index 10796541..a5e493d7 100644
--- a/src/translations/fr.json
+++ b/src/translations/fr.json
@@ -8,6 +8,7 @@
}
},
"app": {
+ "initialization_failed": "Impossible de charger les données de l'application. Vérifiez votre connexion et réessayez.",
"title": "Resgrid Unit"
},
"audio_streams": {
@@ -36,6 +37,7 @@
"applied": "Appliqué",
"audio": "Audio",
"audioActive": "Audio actif",
+ "audio_capable": "Compatible audio",
"audio_device": "Combiné BT",
"audio_output": "Sortie audio",
"availableDevices": "Appareils disponibles",
@@ -47,6 +49,8 @@
"connect": "Connecter",
"connected": "Connecté",
"connectionError": "Erreur de connexion",
+ "connection_error_message": "Impossible de se connecter à l'appareil",
+ "connection_error_title": "Échec de la connexion",
"current_selection": "Sélection actuelle",
"device_disconnected": "Appareil déconnecté",
"disconnect": "Déconnecter",
@@ -104,10 +108,12 @@
"loading": "Chargement...",
"no_images": "Aucune image disponible",
"no_images_description": "Ajoutez des images à votre appel pour aider à la documentation et à la communication",
+ "not_signed_in": "Vous devez être connecté pour envoyer des images",
"select_from_gallery": "Sélectionner depuis la galerie",
"take_photo": "Prendre une photo",
"title": "Images de l'appel",
- "upload": "Télécharger"
+ "upload": "Télécharger",
+ "upload_error": "Erreur lors de l'envoi de l'image"
},
"callNotes": {
"addNote": "Ajouter une note",
@@ -241,15 +247,24 @@
"destination_poi": "Destination POI",
"destination_poi_none": "No destination selected",
"directions": "Itinéraire",
+ "directions_to_destination": "Itinéraire vers la destination",
"dispatch_to": "Envoyer à",
"dispatch_to_everyone": "Envoyer à tout le personnel disponible",
"edit_call": "Modifier l'appel",
"edit_call_description": "Mettre à jour les informations de l'appel",
+ "errors": {
+ "load_failed": "Échec du chargement des appels"
+ },
"everyone": "Tout le monde",
"field_policy_loading": "Les paramètres d'appel de votre département sont encore en cours de chargement, veuillez réessayer dans un instant",
"files": {
+ "error": "Erreur lors de la récupération des fichiers",
+ "file_name": "Nom du fichier",
"no_files": "Aucun fichier disponible",
"no_files_description": "Aucun fichier n'a encore été ajouté à cet appel",
+ "open_error": "Erreur lors de l'ouverture du fichier",
+ "share_error": "Erreur lors du partage du fichier",
+ "sharing_unavailable": "Le partage n'est pas disponible sur cet appareil",
"title": "Fichiers de l'appel"
},
"geocoding_error": "Échec de la recherche d'adresse, veuillez réessayer",
@@ -308,6 +323,7 @@
"users": "Utilisateurs",
"viewNotes": "Notes",
"view_details": "Voir les détails",
+ "view_on_map": "Voir sur la carte",
"what3words": "what3words",
"what3words_found": "Adresse what3words trouvée et localisation mise à jour",
"what3words_geocoding_error": "Échec de la recherche de l'adresse what3words, veuillez réessayer",
@@ -433,6 +449,7 @@
"add": "Ajouter",
"back": "Retour",
"cancel": "Annuler",
+ "clear_search": "Effacer la recherche",
"close": "Fermer",
"confirm": "Confirmer",
"confirm_location": "Confirmer la localisation",
@@ -454,6 +471,7 @@
"no_address_found": "Aucune adresse trouvée",
"no_location": "Aucune donnée de localisation disponible",
"no_results_found": "Aucun résultat trouvé",
+ "no_unit": "Aucune unité",
"no_unit_selected": "Aucune unité sélectionnée",
"nothingToDisplay": "Rien à afficher pour le moment",
"of": "de",
@@ -476,6 +494,7 @@
"tap_map_to_select": "Appuyez sur la carte pour sélectionner une localisation",
"tryAgainLater": "Veuillez réessayer plus tard",
"unknown": "Inconnu",
+ "unknown_error": "Une erreur inconnue s'est produite",
"upload": "Télécharger",
"uploading": "Téléchargement en cours..."
},
@@ -555,6 +574,8 @@
"threads": "Threads",
"title": "Contacts",
"twitter": "Twitter",
+ "unknown_company": "Entreprise inconnue",
+ "unknown_person": "Personne inconnue",
"visibility": "Visibilité",
"website": "Site web",
"zip": "Code postal"
@@ -659,6 +680,7 @@
"message": "Veuillez vérifier votre nom d'utilisateur et votre mot de passe et réessayer.",
"title": "Échec de la connexion"
},
+ "hide_password": "Masquer le mot de passe",
"login": "Connexion",
"login_button": "Se connecter",
"login_button_description": "Connectez-vous à votre compte pour continuer",
@@ -667,8 +689,8 @@
"login_button_success": "Connexion réussie",
"or_sign_in_with_password": "ou se connecter avec un mot de passe",
"password": "Mot de passe",
- "password_incorrect": "Le mot de passe était incorrect",
"password_placeholder": "Saisir votre mot de passe",
+ "show_password": "Afficher le mot de passe",
"sso_button": "Se connecter avec SSO",
"sso_department": "SSO fourni par {{name}}",
"sso_department_id": "ID du département (optionnel)",
@@ -689,6 +711,7 @@
"call_set_as_current": "Appel défini comme appel actuel",
"failed_to_open_maps": "Échec de l'ouverture de l'application de cartes",
"failed_to_set_current_call": "Échec de la définition de l'appel comme actuel",
+ "lock_map": "Verrouiller la carte sur l'unité",
"no_location_for_routing": "Aucune donnée de localisation disponible pour la navigation",
"pin_address": "Address",
"pin_color": "Couleur de l'épingle",
@@ -696,6 +719,7 @@
"pin_type": "POI Type",
"recenter_map": "Recentrer la carte",
"set_as_current_call": "Définir comme appel actuel",
+ "unlock_map": "Déverrouiller la carte",
"view_call_details": "Voir les détails de l'appel",
"view_poi_details": "View POI Details"
},
@@ -750,6 +774,24 @@
"search": "Rechercher des notes...",
"title": "Notes"
},
+ "notifications": {
+ "confirm_delete_message_count": "Voulez-vous vraiment supprimer {{count}} notifications ? Cette action est irréversible.",
+ "confirm_delete_message_one": "Voulez-vous vraiment supprimer cette notification ? Cette action est irréversible.",
+ "confirm_delete_title": "Confirmer la suppression",
+ "delete_selected": "Supprimer les notifications sélectionnées",
+ "deselect_all": "Tout désélectionner",
+ "empty": "Aucune mise à jour disponible",
+ "enter_selection_mode": "Sélectionner des notifications",
+ "open": "Ouvrir les notifications",
+ "remove_failed_count": "Échec de la suppression des notifications",
+ "remove_failed_one": "Échec de la suppression de la notification",
+ "removed_count": "{{count}} notifications supprimées",
+ "removed_one": "Notification supprimée",
+ "select_all": "Tout sélectionner",
+ "selected_count": "{{count}} sélectionnées",
+ "title": "Notifications",
+ "unable_to_load": "Impossible de charger les notifications"
+ },
"onboarding": {
"message": "Bienvenue sur l'application Resgrid Unit"
},
@@ -782,12 +824,23 @@
"view_call": "Voir l'appel"
},
"roles": {
+ "assignedElsewhere": "Dans un autre rôle",
+ "clearAssignment": "Effacer l'attribution actuelle",
"modal": {
"title": "Attributions de rôles d'unité"
},
+ "noUsersFound": "Aucun utilisateur trouvé",
+ "save_error": "Erreur lors de l'enregistrement des attributions de rôles",
+ "saved_successfully": "Attributions de rôles enregistrées",
+ "searchUsers": "Rechercher par nom ou groupe...",
"selectUser": "Sélectionner un utilisateur",
+ "selectUserDescription": "Choisissez une personne pour ce rôle",
+ "selectUserForRole": "Attribuer : {{role}}",
+ "selectUserLabel": "Sélectionner {{name}}",
"status": "{{active}} sur {{total}} rôles",
+ "tapToAssign": "Appuyez pour attribuer un utilisateur à {{role}}",
"tap_to_manage": "Appuyer pour gérer les rôles",
+ "title": "Attribution des rôles de l'unité",
"unassigned": "Non attribué"
},
"routes": {
@@ -929,6 +982,11 @@
"total_distance": "Distance totale",
"total_duration": "Durée totale",
"traffic_delay": "{{time}} de retard dû au trafic",
+ "traffic_heavy": "Trafic dense",
+ "traffic_light": "Trafic fluide",
+ "traffic_moderate": "Trafic modéré",
+ "traffic_no_data": "Aucune donnée de trafic",
+ "traffic_severe": "Trafic très dense",
"try_different_search": "Try a different search term",
"type": "Type",
"unassigned": "Non assigné",
diff --git a/src/translations/it.json b/src/translations/it.json
index 21437135..bfd140ae 100644
--- a/src/translations/it.json
+++ b/src/translations/it.json
@@ -8,6 +8,7 @@
}
},
"app": {
+ "initialization_failed": "Impossibile caricare i dati dell'app. Controlla la connessione e riprova.",
"title": "Resgrid Unit"
},
"audio_streams": {
@@ -36,6 +37,7 @@
"applied": "Applicato",
"audio": "Audio",
"audioActive": "Audio attivo",
+ "audio_capable": "Compatibile con audio",
"audio_device": "Auricolare BT",
"audio_output": "Uscita audio",
"availableDevices": "Dispositivi disponibili",
@@ -47,6 +49,8 @@
"connect": "Connetti",
"connected": "Connesso",
"connectionError": "Errore di connessione",
+ "connection_error_message": "Impossibile connettersi al dispositivo",
+ "connection_error_title": "Connessione non riuscita",
"current_selection": "Selezione corrente",
"device_disconnected": "Dispositivo disconnesso",
"disconnect": "Disconnetti",
@@ -104,10 +108,12 @@
"loading": "Caricamento...",
"no_images": "Nessuna immagine disponibile",
"no_images_description": "Aggiungi immagini alla tua chiamata per facilitare la documentazione e la comunicazione",
+ "not_signed_in": "Devi aver effettuato l'accesso per caricare immagini",
"select_from_gallery": "Seleziona dalla galleria",
"take_photo": "Scatta foto",
"title": "Immagini chiamata",
- "upload": "Carica"
+ "upload": "Carica",
+ "upload_error": "Errore durante il caricamento dell'immagine"
},
"callNotes": {
"addNote": "Aggiungi nota",
@@ -241,15 +247,24 @@
"destination_poi": "Destination POI",
"destination_poi_none": "No destination selected",
"directions": "Indicazioni",
+ "directions_to_destination": "Indicazioni per la destinazione",
"dispatch_to": "Invia a",
"dispatch_to_everyone": "Invia a tutto il personale disponibile",
"edit_call": "Modifica chiamata",
"edit_call_description": "Aggiorna le informazioni della chiamata",
+ "errors": {
+ "load_failed": "Impossibile caricare le chiamate"
+ },
"everyone": "Tutti",
"field_policy_loading": "Le impostazioni delle chiamate del tuo dipartimento sono ancora in caricamento, riprova tra un momento",
"files": {
+ "error": "Errore durante il recupero dei file",
+ "file_name": "Nome del file",
"no_files": "Nessun file disponibile",
"no_files_description": "Nessun file aggiunto a questa chiamata",
+ "open_error": "Errore durante l'apertura del file",
+ "share_error": "Errore durante la condivisione del file",
+ "sharing_unavailable": "La condivisione non è disponibile su questo dispositivo",
"title": "File chiamata"
},
"geocoding_error": "Ricerca indirizzo fallita, riprovare",
@@ -308,6 +323,7 @@
"users": "Utenti",
"viewNotes": "Note",
"view_details": "Visualizza dettagli",
+ "view_on_map": "Visualizza sulla mappa",
"what3words": "what3words",
"what3words_found": "Indirizzo what3words trovato e posizione aggiornata",
"what3words_geocoding_error": "Ricerca indirizzo what3words fallita, riprovare",
@@ -433,6 +449,7 @@
"add": "Aggiungi",
"back": "Indietro",
"cancel": "Annulla",
+ "clear_search": "Cancella ricerca",
"close": "Chiudi",
"confirm": "Conferma",
"confirm_location": "Conferma posizione",
@@ -454,6 +471,7 @@
"no_address_found": "Nessun indirizzo trovato",
"no_location": "Nessun dato di posizione disponibile",
"no_results_found": "Nessun risultato trovato",
+ "no_unit": "Nessuna unità",
"no_unit_selected": "Nessuna unità selezionata",
"nothingToDisplay": "Non c'è nulla da visualizzare al momento",
"of": "di",
@@ -476,6 +494,7 @@
"tap_map_to_select": "Tocca la mappa per selezionare una posizione",
"tryAgainLater": "Riprova più tardi",
"unknown": "Sconosciuto",
+ "unknown_error": "Si è verificato un errore sconosciuto",
"upload": "Carica",
"uploading": "Caricamento..."
},
@@ -555,6 +574,8 @@
"threads": "Threads",
"title": "Contatti",
"twitter": "Twitter",
+ "unknown_company": "Azienda sconosciuta",
+ "unknown_person": "Persona sconosciuta",
"visibility": "Visibilità",
"website": "Sito web",
"zip": "CAP"
@@ -659,6 +680,7 @@
"message": "Verificare nome utente e password e riprovare.",
"title": "Accesso fallito"
},
+ "hide_password": "Nascondi password",
"login": "Accedi",
"login_button": "Accedi",
"login_button_description": "Accedi al tuo account per continuare",
@@ -667,8 +689,8 @@
"login_button_success": "Accesso effettuato con successo",
"or_sign_in_with_password": "o accedi con password",
"password": "Password",
- "password_incorrect": "La password non è corretta",
"password_placeholder": "Inserisci la tua password",
+ "show_password": "Mostra password",
"sso_button": "Accedi con SSO",
"sso_department": "SSO fornito da {{name}}",
"sso_department_id": "ID dipartimento (opzionale)",
@@ -689,6 +711,7 @@
"call_set_as_current": "Chiamata impostata come chiamata corrente",
"failed_to_open_maps": "Impossibile aprire l'applicazione mappe",
"failed_to_set_current_call": "Impossibile impostare la chiamata come corrente",
+ "lock_map": "Blocca la mappa sull'unità",
"no_location_for_routing": "Nessun dato di posizione disponibile per la navigazione",
"pin_address": "Address",
"pin_color": "Colore pin",
@@ -696,6 +719,7 @@
"pin_type": "POI Type",
"recenter_map": "Ricentra mappa",
"set_as_current_call": "Imposta come chiamata corrente",
+ "unlock_map": "Sblocca la mappa",
"view_call_details": "Visualizza dettagli chiamata",
"view_poi_details": "View POI Details"
},
@@ -750,6 +774,24 @@
"search": "Cerca note...",
"title": "Note"
},
+ "notifications": {
+ "confirm_delete_message_count": "Vuoi davvero eliminare {{count}} notifiche? Questa azione non può essere annullata.",
+ "confirm_delete_message_one": "Vuoi davvero eliminare questa notifica? Questa azione non può essere annullata.",
+ "confirm_delete_title": "Conferma l'eliminazione",
+ "delete_selected": "Elimina le notifiche selezionate",
+ "deselect_all": "Deseleziona tutto",
+ "empty": "Nessun aggiornamento disponibile",
+ "enter_selection_mode": "Seleziona notifiche",
+ "open": "Apri le notifiche",
+ "remove_failed_count": "Impossibile eliminare le notifiche",
+ "remove_failed_one": "Impossibile eliminare la notifica",
+ "removed_count": "{{count}} notifiche eliminate",
+ "removed_one": "Notifica eliminata",
+ "select_all": "Seleziona tutto",
+ "selected_count": "{{count}} selezionate",
+ "title": "Notifiche",
+ "unable_to_load": "Impossibile caricare le notifiche"
+ },
"onboarding": {
"message": "Benvenuto nell'app Resgrid Unit"
},
@@ -782,12 +824,23 @@
"view_call": "Visualizza chiamata"
},
"roles": {
+ "assignedElsewhere": "In un altro ruolo",
+ "clearAssignment": "Rimuovi l'assegnazione attuale",
"modal": {
"title": "Assegnazioni ruoli unità"
},
+ "noUsersFound": "Nessun utente trovato",
+ "save_error": "Errore durante il salvataggio delle assegnazioni dei ruoli",
+ "saved_successfully": "Assegnazioni dei ruoli salvate",
+ "searchUsers": "Cerca per nome o gruppo...",
"selectUser": "Seleziona utente",
+ "selectUserDescription": "Scegli una persona per questo ruolo",
+ "selectUserForRole": "Assegna: {{role}}",
+ "selectUserLabel": "Seleziona {{name}}",
"status": "{{active}} di {{total}} ruoli",
+ "tapToAssign": "Tocca per assegnare un utente a {{role}}",
"tap_to_manage": "Tocca per gestire i ruoli",
+ "title": "Assegnazione dei ruoli dell'unità",
"unassigned": "Non assegnato"
},
"routes": {
@@ -929,6 +982,11 @@
"total_distance": "Distanza totale",
"total_duration": "Durata totale",
"traffic_delay": "{{time}} di ritardo a causa del traffico",
+ "traffic_heavy": "Traffico intenso",
+ "traffic_light": "Traffico scorrevole",
+ "traffic_moderate": "Traffico moderato",
+ "traffic_no_data": "Nessun dato sul traffico",
+ "traffic_severe": "Traffico molto intenso",
"try_different_search": "Try a different search term",
"type": "Tipo",
"unassigned": "Non assegnato",
diff --git a/src/translations/pl.json b/src/translations/pl.json
index f3893beb..16203e90 100644
--- a/src/translations/pl.json
+++ b/src/translations/pl.json
@@ -8,6 +8,7 @@
}
},
"app": {
+ "initialization_failed": "Nie można załadować danych aplikacji. Sprawdź połączenie i spróbuj ponownie.",
"title": "Resgrid Unit"
},
"audio_streams": {
@@ -36,6 +37,7 @@
"applied": "Zastosowano",
"audio": "Audio",
"audioActive": "Audio aktywne",
+ "audio_capable": "Obsługuje dźwięk",
"audio_device": "Słuchawka BT",
"audio_output": "Wyjście audio",
"availableDevices": "Dostępne urządzenia",
@@ -47,6 +49,8 @@
"connect": "Połącz",
"connected": "Połączono",
"connectionError": "Błąd połączenia",
+ "connection_error_message": "Nie można połączyć się z urządzeniem",
+ "connection_error_title": "Połączenie nieudane",
"current_selection": "Bieżący wybór",
"device_disconnected": "Urządzenie rozłączone",
"disconnect": "Rozłącz",
@@ -104,10 +108,12 @@
"loading": "Ładowanie...",
"no_images": "Brak dostępnych zdjęć",
"no_images_description": "Dodaj zdjęcia do zgłoszenia, aby ułatwić dokumentację i komunikację",
+ "not_signed_in": "Aby przesyłać obrazy, musisz się zalogować",
"select_from_gallery": "Wybierz z galerii",
"take_photo": "Zrób zdjęcie",
"title": "Zdjęcia zgłoszenia",
- "upload": "Prześlij"
+ "upload": "Prześlij",
+ "upload_error": "Błąd podczas przesyłania obrazu"
},
"callNotes": {
"addNote": "Dodaj notatkę",
@@ -241,15 +247,24 @@
"destination_poi": "Destination POI",
"destination_poi_none": "No destination selected",
"directions": "Wskazówki",
+ "directions_to_destination": "Trasa do celu",
"dispatch_to": "Wyślij do",
"dispatch_to_everyone": "Wyślij do wszystkich dostępnych",
"edit_call": "Edytuj zgłoszenie",
"edit_call_description": "Zaktualizuj informacje o zgłoszeniu",
+ "errors": {
+ "load_failed": "Nie udało się załadować zgłoszeń"
+ },
"everyone": "Wszyscy",
"field_policy_loading": "Ustawienia zgłoszeń Twojej jednostki są jeszcze wczytywane, spróbuj ponownie za chwilę",
"files": {
+ "error": "Błąd podczas pobierania plików",
+ "file_name": "Nazwa pliku",
"no_files": "Brak dostępnych plików",
"no_files_description": "Do tego zgłoszenia nie dodano jeszcze żadnych plików",
+ "open_error": "Błąd podczas otwierania pliku",
+ "share_error": "Błąd podczas udostępniania pliku",
+ "sharing_unavailable": "Udostępnianie nie jest dostępne na tym urządzeniu",
"title": "Pliki zgłoszenia"
},
"geocoding_error": "Wyszukiwanie adresu nie powiodło się, spróbuj ponownie",
@@ -308,6 +323,7 @@
"users": "Użytkownicy",
"viewNotes": "Notatki",
"view_details": "Wyświetl szczegóły",
+ "view_on_map": "Pokaż na mapie",
"what3words": "what3words",
"what3words_found": "Adres what3words znaleziony i lokalizacja zaktualizowana",
"what3words_geocoding_error": "Wyszukiwanie adresu what3words nie powiodło się, spróbuj ponownie",
@@ -433,6 +449,7 @@
"add": "Dodaj",
"back": "Wstecz",
"cancel": "Anuluj",
+ "clear_search": "Wyczyść wyszukiwanie",
"close": "Zamknij",
"confirm": "Potwierdź",
"confirm_location": "Potwierdź lokalizację",
@@ -454,6 +471,7 @@
"no_address_found": "Nie znaleziono adresu",
"no_location": "Brak danych lokalizacji",
"no_results_found": "Nie znaleziono wyników",
+ "no_unit": "Brak jednostki",
"no_unit_selected": "Nie wybrano jednostki",
"nothingToDisplay": "Nie ma nic do wyświetlenia w tej chwili",
"of": "z",
@@ -476,6 +494,7 @@
"tap_map_to_select": "Dotknij mapy, aby wybrać lokalizację",
"tryAgainLater": "Spróbuj ponownie później",
"unknown": "Nieznany",
+ "unknown_error": "Wystąpił nieznany błąd",
"upload": "Prześlij",
"uploading": "Przesyłanie..."
},
@@ -555,6 +574,8 @@
"threads": "Threads",
"title": "Kontakty",
"twitter": "Twitter",
+ "unknown_company": "Nieznana firma",
+ "unknown_person": "Nieznana osoba",
"visibility": "Widoczność",
"website": "Strona internetowa",
"zip": "Kod pocztowy"
@@ -659,6 +680,7 @@
"message": "Sprawdź nazwę użytkownika i hasło i spróbuj ponownie.",
"title": "Logowanie nieudane"
},
+ "hide_password": "Ukryj hasło",
"login": "Zaloguj się",
"login_button": "Zaloguj się",
"login_button_description": "Zaloguj się do swojego konta, aby kontynuować",
@@ -667,8 +689,8 @@
"login_button_success": "Zalogowano pomyślnie",
"or_sign_in_with_password": "lub zaloguj się hasłem",
"password": "Hasło",
- "password_incorrect": "Hasło jest nieprawidłowe",
"password_placeholder": "Wpisz hasło",
+ "show_password": "Pokaż hasło",
"sso_button": "Zaloguj się przez SSO",
"sso_department": "SSO zapewnione przez {{name}}",
"sso_department_id": "ID działu (opcjonalnie)",
@@ -689,6 +711,7 @@
"call_set_as_current": "Zgłoszenie ustawione jako bieżące",
"failed_to_open_maps": "Nie udało się otworzyć aplikacji map",
"failed_to_set_current_call": "Nie udało się ustawić zgłoszenia jako bieżące",
+ "lock_map": "Zablokuj mapę na jednostce",
"no_location_for_routing": "Brak danych lokalizacji do nawigacji",
"pin_address": "Address",
"pin_color": "Kolor pinezki",
@@ -696,6 +719,7 @@
"pin_type": "POI Type",
"recenter_map": "Wyśrodkuj mapę",
"set_as_current_call": "Ustaw jako bieżące zgłoszenie",
+ "unlock_map": "Odblokuj mapę",
"view_call_details": "Wyświetl szczegóły zgłoszenia",
"view_poi_details": "View POI Details"
},
@@ -750,6 +774,24 @@
"search": "Szukaj notatek...",
"title": "Notatki"
},
+ "notifications": {
+ "confirm_delete_message_count": "Czy na pewno chcesz usunąć powiadomienia w liczbie {{count}}? Tej operacji nie można cofnąć.",
+ "confirm_delete_message_one": "Czy na pewno chcesz usunąć to powiadomienie? Tej operacji nie można cofnąć.",
+ "confirm_delete_title": "Potwierdź usunięcie",
+ "delete_selected": "Usuń zaznaczone powiadomienia",
+ "deselect_all": "Odznacz wszystko",
+ "empty": "Brak nowych informacji",
+ "enter_selection_mode": "Zaznacz powiadomienia",
+ "open": "Otwórz powiadomienia",
+ "remove_failed_count": "Nie udało się usunąć powiadomień",
+ "remove_failed_one": "Nie udało się usunąć powiadomienia",
+ "removed_count": "Usunięto powiadomienia: {{count}}",
+ "removed_one": "Powiadomienie usunięte",
+ "select_all": "Zaznacz wszystko",
+ "selected_count": "Zaznaczono: {{count}}",
+ "title": "Powiadomienia",
+ "unable_to_load": "Nie można załadować powiadomień"
+ },
"onboarding": {
"message": "Witamy w aplikacji Resgrid Unit"
},
@@ -782,12 +824,23 @@
"view_call": "Wyświetl zgłoszenie"
},
"roles": {
+ "assignedElsewhere": "W innej funkcji",
+ "clearAssignment": "Wyczyść bieżący przydział",
"modal": {
"title": "Przypisania ról jednostki"
},
+ "noUsersFound": "Nie znaleziono użytkowników",
+ "save_error": "Błąd podczas zapisywania przydziału funkcji",
+ "saved_successfully": "Zapisano przydział funkcji",
+ "searchUsers": "Szukaj według nazwiska lub grupy...",
"selectUser": "Wybierz użytkownika",
+ "selectUserDescription": "Wybierz osobę do tej funkcji",
+ "selectUserForRole": "Przypisz: {{role}}",
+ "selectUserLabel": "Wybierz: {{name}}",
"status": "{{active}} z {{total}} ról",
+ "tapToAssign": "Dotknij, aby przypisać użytkownika do: {{role}}",
"tap_to_manage": "Dotknij, aby zarządzać rolami",
+ "title": "Przydział funkcji w jednostce",
"unassigned": "Nieprzypisany"
},
"routes": {
@@ -929,6 +982,11 @@
"total_distance": "Całkowita odległość",
"total_duration": "Całkowity czas",
"traffic_delay": "{{time}} opóźnienia z powodu ruchu drogowego",
+ "traffic_heavy": "Duży ruch",
+ "traffic_light": "Mały ruch",
+ "traffic_moderate": "Umiarkowany ruch",
+ "traffic_no_data": "Brak danych o ruchu",
+ "traffic_severe": "Bardzo duży ruch",
"try_different_search": "Try a different search term",
"type": "Typ",
"unassigned": "Nieprzypisany",
diff --git a/src/translations/sv.json b/src/translations/sv.json
index 9e2762f9..a33cb8be 100644
--- a/src/translations/sv.json
+++ b/src/translations/sv.json
@@ -8,6 +8,7 @@
}
},
"app": {
+ "initialization_failed": "Det gick inte att läsa in appdata. Kontrollera din anslutning och försök igen.",
"title": "Resgrid Unit"
},
"audio_streams": {
@@ -36,6 +37,7 @@
"applied": "Tillämpad",
"audio": "Ljud",
"audioActive": "Ljud aktivt",
+ "audio_capable": "Ljudkapabel",
"audio_device": "BT-handset",
"audio_output": "Ljudutgång",
"availableDevices": "Tillgängliga enheter",
@@ -47,6 +49,8 @@
"connect": "Anslut",
"connected": "Ansluten",
"connectionError": "Anslutningsfel",
+ "connection_error_message": "Det gick inte att ansluta till enheten",
+ "connection_error_title": "Anslutningen misslyckades",
"current_selection": "Aktuellt val",
"device_disconnected": "Enhet frånkopplad",
"disconnect": "Koppla från",
@@ -104,10 +108,12 @@
"loading": "Laddar...",
"no_images": "Inga bilder tillgängliga",
"no_images_description": "Lägg till bilder i ditt samtal för att hjälpa med dokumentation och kommunikation",
+ "not_signed_in": "Du måste vara inloggad för att ladda upp bilder",
"select_from_gallery": "Välj från galleriet",
"take_photo": "Ta foto",
"title": "Samtalsbilder",
- "upload": "Ladda upp"
+ "upload": "Ladda upp",
+ "upload_error": "Det gick inte att ladda upp bilden"
},
"callNotes": {
"addNote": "Lägg till anteckning",
@@ -241,15 +247,24 @@
"destination_poi": "Destination POI",
"destination_poi_none": "No destination selected",
"directions": "Vägbeskrivning",
+ "directions_to_destination": "Vägbeskrivning till destinationen",
"dispatch_to": "Skicka till",
"dispatch_to_everyone": "Skicka till all tillgänglig personal",
"edit_call": "Redigera samtal",
"edit_call_description": "Uppdatera samtalsinformation",
+ "errors": {
+ "load_failed": "Det gick inte att läsa in samtalen"
+ },
"everyone": "Alla",
"field_policy_loading": "Din avdelnings samtalsinställningar laddas fortfarande, försök igen om ett ögonblick",
"files": {
+ "error": "Det gick inte att hämta filerna",
+ "file_name": "Filnamn",
"no_files": "Inga filer tillgängliga",
"no_files_description": "Inga filer har lagts till i detta samtal ännu",
+ "open_error": "Det gick inte att öppna filen",
+ "share_error": "Det gick inte att dela filen",
+ "sharing_unavailable": "Delning är inte tillgängligt på den här enheten",
"title": "Samtalsfiler"
},
"geocoding_error": "Det gick inte att söka efter adressen, försök igen",
@@ -308,6 +323,7 @@
"users": "Användare",
"viewNotes": "Anteckningar",
"view_details": "Visa detaljer",
+ "view_on_map": "Visa på kartan",
"what3words": "what3words",
"what3words_found": "what3words-adress hittades och platsen uppdaterades",
"what3words_geocoding_error": "Det gick inte att söka efter what3words-adress, försök igen",
@@ -433,6 +449,7 @@
"add": "Lägg till",
"back": "Tillbaka",
"cancel": "Avbryt",
+ "clear_search": "Rensa sökning",
"close": "Stäng",
"confirm": "Bekräfta",
"confirm_location": "Bekräfta plats",
@@ -454,6 +471,7 @@
"no_address_found": "Ingen adress hittades",
"no_location": "Ingen platsdata tillgänglig",
"no_results_found": "Inga resultat hittades",
+ "no_unit": "Ingen enhet",
"no_unit_selected": "Ingen enhet vald",
"nothingToDisplay": "Det finns inget att visa för tillfället",
"of": "av",
@@ -476,6 +494,7 @@
"tap_map_to_select": "Tryck på kartan för att välja en plats",
"tryAgainLater": "Försök igen senare",
"unknown": "Okänd",
+ "unknown_error": "Ett okänt fel uppstod",
"upload": "Ladda upp",
"uploading": "Laddar upp..."
},
@@ -555,6 +574,8 @@
"threads": "Threads",
"title": "Kontakter",
"twitter": "Twitter",
+ "unknown_company": "Okänt företag",
+ "unknown_person": "Okänd person",
"visibility": "Synlighet",
"website": "Webbplats",
"zip": "Postnummer"
@@ -659,6 +680,7 @@
"message": "Kontrollera ditt användarnamn och lösenord och försök igen.",
"title": "Inloggningen misslyckades"
},
+ "hide_password": "Dölj lösenord",
"login": "Logga in",
"login_button": "Logga in",
"login_button_description": "Logga in på ditt konto för att fortsätta",
@@ -667,8 +689,8 @@
"login_button_success": "Inloggningen lyckades",
"or_sign_in_with_password": "eller logga in med lösenord",
"password": "Lösenord",
- "password_incorrect": "Lösenordet var felaktigt",
"password_placeholder": "Ange ditt lösenord",
+ "show_password": "Visa lösenord",
"sso_button": "Logga in med SSO",
"sso_department": "SSO tillhandahållet av {{name}}",
"sso_department_id": "Avdelnings-ID (valfritt)",
@@ -689,6 +711,7 @@
"call_set_as_current": "Samtal angett som aktuellt samtal",
"failed_to_open_maps": "Det gick inte att öppna kartappen",
"failed_to_set_current_call": "Det gick inte att ange samtalet som aktuellt",
+ "lock_map": "Lås kartan till enheten",
"no_location_for_routing": "Ingen platsdata tillgänglig för navigering",
"pin_address": "Address",
"pin_color": "Nålfärg",
@@ -696,6 +719,7 @@
"pin_type": "POI Type",
"recenter_map": "Centrera kartan",
"set_as_current_call": "Ange som aktuellt samtal",
+ "unlock_map": "Lås upp kartan",
"view_call_details": "Visa samtalsdetaljer",
"view_poi_details": "View POI Details"
},
@@ -750,6 +774,24 @@
"search": "Sök anteckningar...",
"title": "Anteckningar"
},
+ "notifications": {
+ "confirm_delete_message_count": "Är du säker på att du vill ta bort {{count}} aviseringar? Åtgärden kan inte ångras.",
+ "confirm_delete_message_one": "Är du säker på att du vill ta bort den här aviseringen? Åtgärden kan inte ångras.",
+ "confirm_delete_title": "Bekräfta borttagning",
+ "delete_selected": "Ta bort markerade aviseringar",
+ "deselect_all": "Avmarkera alla",
+ "empty": "Inga uppdateringar tillgängliga",
+ "enter_selection_mode": "Markera aviseringar",
+ "open": "Öppna aviseringar",
+ "remove_failed_count": "Det gick inte att ta bort aviseringarna",
+ "remove_failed_one": "Det gick inte att ta bort aviseringen",
+ "removed_count": "{{count}} aviseringar har tagits bort",
+ "removed_one": "Aviseringen har tagits bort",
+ "select_all": "Markera alla",
+ "selected_count": "{{count}} markerade",
+ "title": "Aviseringar",
+ "unable_to_load": "Det gick inte att läsa in aviseringarna"
+ },
"onboarding": {
"message": "Välkommen till Resgrid Unit app site"
},
@@ -782,12 +824,23 @@
"view_call": "Visa samtal"
},
"roles": {
+ "assignedElsewhere": "I en annan roll",
+ "clearAssignment": "Rensa den aktuella tilldelningen",
"modal": {
"title": "Enhetsrolltilldelningar"
},
+ "noUsersFound": "Inga användare hittades",
+ "save_error": "Det gick inte att spara rolltilldelningarna",
+ "saved_successfully": "Rolltilldelningarna har sparats",
+ "searchUsers": "Sök efter namn eller grupp...",
"selectUser": "Välj användare",
+ "selectUserDescription": "Välj en person för den här rollen",
+ "selectUserForRole": "Tilldela: {{role}}",
+ "selectUserLabel": "Välj {{name}}",
"status": "{{active}} av {{total}} roller",
+ "tapToAssign": "Tryck för att tilldela en användare till {{role}}",
"tap_to_manage": "Tryck för att hantera roller",
+ "title": "Rolltilldelning för enheten",
"unassigned": "Otilldelad"
},
"routes": {
@@ -929,6 +982,11 @@
"total_distance": "Totalt avstånd",
"total_duration": "Total varaktighet",
"traffic_delay": "{{time}} försening på grund av trafik",
+ "traffic_heavy": "Tät trafik",
+ "traffic_light": "Lite trafik",
+ "traffic_moderate": "Måttlig trafik",
+ "traffic_no_data": "Inga trafikdata",
+ "traffic_severe": "Mycket tät trafik",
"try_different_search": "Try a different search term",
"type": "Typ",
"unassigned": "Ej tilldelad",
diff --git a/src/translations/uk.json b/src/translations/uk.json
index 4520041f..10412853 100644
--- a/src/translations/uk.json
+++ b/src/translations/uk.json
@@ -8,6 +8,7 @@
}
},
"app": {
+ "initialization_failed": "Не вдалося завантажити дані застосунку. Перевірте з'єднання та повторіть спробу.",
"title": "Resgrid Unit"
},
"audio_streams": {
@@ -36,6 +37,7 @@
"applied": "Застосовано",
"audio": "Аудіо",
"audioActive": "Аудіо активне",
+ "audio_capable": "Підтримує аудіо",
"audio_device": "BT-гарнітура",
"audio_output": "Виведення звуку",
"availableDevices": "Доступні пристрої",
@@ -47,6 +49,8 @@
"connect": "Підключити",
"connected": "Підключено",
"connectionError": "Помилка підключення",
+ "connection_error_message": "Не вдалося під'єднатися до пристрою",
+ "connection_error_title": "Не вдалося з'єднатися",
"current_selection": "Поточний вибір",
"device_disconnected": "Пристрій відключено",
"disconnect": "Відключити",
@@ -104,10 +108,12 @@
"loading": "Завантаження...",
"no_images": "Немає доступних зображень",
"no_images_description": "Додайте зображення до виклику для документування та комунікації",
+ "not_signed_in": "Щоб завантажувати зображення, потрібно увійти",
"select_from_gallery": "Вибрати з галереї",
"take_photo": "Зробити фото",
"title": "Зображення виклику",
- "upload": "Завантажити"
+ "upload": "Завантажити",
+ "upload_error": "Помилка завантаження зображення"
},
"callNotes": {
"addNote": "Додати примітку",
@@ -241,15 +247,24 @@
"destination_poi": "Destination POI",
"destination_poi_none": "No destination selected",
"directions": "Маршрут",
+ "directions_to_destination": "Маршрут до призначення",
"dispatch_to": "Відправити до",
"dispatch_to_everyone": "Відправити до всього доступного персоналу",
"edit_call": "Редагувати виклик",
"edit_call_description": "Оновити інформацію про виклик",
+ "errors": {
+ "load_failed": "Не вдалося завантажити виклики"
+ },
"everyone": "Всі",
"field_policy_loading": "Налаштування викликів вашого відділу ще завантажуються, спробуйте ще раз за мить",
"files": {
+ "error": "Помилка отримання файлів",
+ "file_name": "Назва файлу",
"no_files": "Немає доступних файлів",
"no_files_description": "До цього виклику ще не додано файлів",
+ "open_error": "Помилка відкриття файлу",
+ "share_error": "Помилка надсилання файлу",
+ "sharing_unavailable": "Надсилання недоступне на цьому пристрої",
"title": "Файли виклику"
},
"geocoding_error": "Пошук адреси не вдався, спробуйте ще раз",
@@ -308,6 +323,7 @@
"users": "Користувачі",
"viewNotes": "Примітки",
"view_details": "Переглянути деталі",
+ "view_on_map": "Показати на карті",
"what3words": "what3words",
"what3words_found": "Адресу what3words знайдено та місцезнаходження оновлено",
"what3words_geocoding_error": "Пошук адреси what3words не вдався, спробуйте ще раз",
@@ -433,6 +449,7 @@
"add": "Додати",
"back": "Назад",
"cancel": "Скасувати",
+ "clear_search": "Очистити пошук",
"close": "Закрити",
"confirm": "Підтвердити",
"confirm_location": "Підтвердити місцезнаходження",
@@ -454,6 +471,7 @@
"no_address_found": "Адресу не знайдено",
"no_location": "Немає даних про місцезнаходження",
"no_results_found": "Результатів не знайдено",
+ "no_unit": "Немає підрозділу",
"no_unit_selected": "Підрозділ не вибрано",
"nothingToDisplay": "Наразі нічого не відображати",
"of": "з",
@@ -476,6 +494,7 @@
"tap_map_to_select": "Торкніться карти для вибору місцезнаходження",
"tryAgainLater": "Спробуйте ще раз пізніше",
"unknown": "Невідомо",
+ "unknown_error": "Сталася невідома помилка",
"upload": "Завантажити",
"uploading": "Завантаження..."
},
@@ -555,6 +574,8 @@
"threads": "Threads",
"title": "Контакти",
"twitter": "Twitter",
+ "unknown_company": "Невідома компанія",
+ "unknown_person": "Невідома особа",
"visibility": "Видимість",
"website": "Веб-сайт",
"zip": "Поштовий індекс"
@@ -659,6 +680,7 @@
"message": "Перевірте ім'я користувача та пароль і спробуйте ще раз.",
"title": "Помилка входу"
},
+ "hide_password": "Приховати пароль",
"login": "Увійти",
"login_button": "Увійти",
"login_button_description": "Увійдіть до свого облікового запису для продовження",
@@ -667,8 +689,8 @@
"login_button_success": "Вхід виконано успішно",
"or_sign_in_with_password": "або увійдіть з паролем",
"password": "Пароль",
- "password_incorrect": "Пароль невірний",
"password_placeholder": "Введіть пароль",
+ "show_password": "Показати пароль",
"sso_button": "Увійти через SSO",
"sso_department": "SSO надано {{name}}",
"sso_department_id": "ID підрозділу (необов'язково)",
@@ -689,6 +711,7 @@
"call_set_as_current": "Виклик встановлено як поточний",
"failed_to_open_maps": "Не вдалося відкрити додаток карт",
"failed_to_set_current_call": "Не вдалося встановити виклик як поточний",
+ "lock_map": "Закріпити карту на підрозділі",
"no_location_for_routing": "Немає даних про місцезнаходження для навігації",
"pin_address": "Address",
"pin_color": "Колір мітки",
@@ -696,6 +719,7 @@
"pin_type": "POI Type",
"recenter_map": "Відцентрувати карту",
"set_as_current_call": "Встановити як поточний виклик",
+ "unlock_map": "Відкріпити карту",
"view_call_details": "Переглянути деталі виклику",
"view_poi_details": "View POI Details"
},
@@ -750,6 +774,24 @@
"search": "Пошук приміток...",
"title": "Примітки"
},
+ "notifications": {
+ "confirm_delete_message_count": "Ви впевнені, що хочете видалити сповіщення ({{count}})? Цю дію не можна скасувати.",
+ "confirm_delete_message_one": "Ви впевнені, що хочете видалити це сповіщення? Цю дію не можна скасувати.",
+ "confirm_delete_title": "Підтвердити видалення",
+ "delete_selected": "Видалити вибрані сповіщення",
+ "deselect_all": "Зняти вибір",
+ "empty": "Немає оновлень",
+ "enter_selection_mode": "Вибрати сповіщення",
+ "open": "Відкрити сповіщення",
+ "remove_failed_count": "Не вдалося видалити сповіщення",
+ "remove_failed_one": "Не вдалося видалити сповіщення",
+ "removed_count": "Видалено сповіщень: {{count}}",
+ "removed_one": "Сповіщення видалено",
+ "select_all": "Вибрати все",
+ "selected_count": "Вибрано: {{count}}",
+ "title": "Сповіщення",
+ "unable_to_load": "Не вдалося завантажити сповіщення"
+ },
"onboarding": {
"message": "Ласкаво просимо до додатку Resgrid Unit"
},
@@ -782,12 +824,23 @@
"view_call": "Переглянути виклик"
},
"roles": {
+ "assignedElsewhere": "В іншій ролі",
+ "clearAssignment": "Очистити поточне призначення",
"modal": {
"title": "Призначення ролей підрозділу"
},
+ "noUsersFound": "Користувачів не знайдено",
+ "save_error": "Помилка збереження призначень ролей",
+ "saved_successfully": "Призначення ролей збережено",
+ "searchUsers": "Пошук за іменем або групою...",
"selectUser": "Вибрати користувача",
+ "selectUserDescription": "Виберіть людину на цю роль",
+ "selectUserForRole": "Призначити: {{role}}",
+ "selectUserLabel": "Вибрати {{name}}",
"status": "{{active}} з {{total}} ролей",
+ "tapToAssign": "Торкніться, щоб призначити користувача на {{role}}",
"tap_to_manage": "Торкніться для управління ролями",
+ "title": "Призначення ролей підрозділу",
"unassigned": "Не призначено"
},
"routes": {
@@ -929,6 +982,11 @@
"total_distance": "Загальна відстань",
"total_duration": "Загальний час",
"traffic_delay": "{{time}} затримки через трафік",
+ "traffic_heavy": "Щільний рух",
+ "traffic_light": "Вільний рух",
+ "traffic_moderate": "Помірний рух",
+ "traffic_no_data": "Немає даних про рух",
+ "traffic_severe": "Дуже щільний рух",
"try_different_search": "Try a different search term",
"type": "Тип",
"unassigned": "Не призначено",
diff --git a/src/utils/__tests__/strip-html.test.ts b/src/utils/__tests__/strip-html.test.ts
new file mode 100644
index 00000000..c394c19e
--- /dev/null
+++ b/src/utils/__tests__/strip-html.test.ts
@@ -0,0 +1,56 @@
+import { stripHtml } from '../strip-html';
+
+describe('stripHtml', () => {
+ it('returns an empty string for null, undefined and empty input', () => {
+ expect(stripHtml(null)).toBe('');
+ expect(stripHtml(undefined)).toBe('');
+ expect(stripHtml('')).toBe('');
+ });
+
+ it('returns plain text unchanged', () => {
+ expect(stripHtml('Structure fire at main street')).toBe('Structure fire at main street');
+ });
+
+ it('strips simple tags', () => {
+ expect(stripHtml('Structure fire
')).toBe('Structure fire');
+ expect(stripHtml('MVA with injuries
')).toBe('MVA with injuries');
+ });
+
+ it('inserts whitespace where tags separated words', () => {
+ expect(stripHtml('Line one
Line two
')).toBe('Line one Line two');
+ expect(stripHtml('First Second')).toBe('First Second');
+ });
+
+ it('decodes common named entities', () => {
+ expect(stripHtml('Smoke & flames
')).toBe('Smoke & flames');
+ expect(stripHtml('A B"C"')).toBe('A B"C"');
+ });
+
+ it('decodes entity-encoded markup before stripping, so encoded tags are removed too', () => {
+ // Matches the sanitizer's decode-then-process order: a field that arrives fully
+ // entity-encoded is real markup, not literal angle-bracket text.
+ expect(stripHtml('Smoke & flames <visible>')).toBe('Smoke & flames');
+ });
+
+ it('decodes numeric entities', () => {
+ expect(stripHtml('Temp – 40°')).toBe('Temp – 40°');
+ expect(stripHtml('AB')).toBe('AB');
+ });
+
+ it('collapses runs of whitespace and trims', () => {
+ expect(stripHtml(' Fire \n\t alarm
')).toBe('Fire alarm');
+ });
+
+ it('removes script and style blocks including their content', () => {
+ expect(stripHtml('Safe
')).toBe('Safe');
+ });
+
+ it('handles entity-encoded markup (no literal tags in the input)', () => {
+ expect(stripHtml('<p>Encoded nature</p>')).toBe('Encoded nature');
+ });
+
+ it('handles attributes and self-closing tags', () => {
+ expect(stripHtml('Link text ')).toBe('Link text');
+ expect(stripHtml(' after')).toBe('after');
+ });
+});
diff --git a/src/utils/strip-html.ts b/src/utils/strip-html.ts
new file mode 100644
index 00000000..f5b3cc10
--- /dev/null
+++ b/src/utils/strip-html.ts
@@ -0,0 +1,25 @@
+import { decodeHtmlEntities, decodeHtmlEntitiesIfEncoded } from './html-entities';
+
+/**
+ * Converts an HTML fragment to plain text for previews (e.g. call Nature inside
+ * list cards): drops script/style blocks, strips tags, decodes common entities
+ * and collapses whitespace to single spaces.
+ *
+ * Rendering these fields through the WebView-backed HtmlRenderer is reserved for
+ * detail screens — a WebView per list row is far too heavy.
+ */
+export const stripHtml = (html: string | null | undefined): string => {
+ if (!html) {
+ return '';
+ }
+
+ // Some fields arrive entity-encoded (`<p>…`) — decode first so the tag
+ // stripper below sees real markup instead of literal `<p>` text.
+ const decodedMarkup = decodeHtmlEntitiesIfEncoded(html);
+
+ const withoutBlocks = decodedMarkup.replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, ' ');
+ const withoutTags = withoutBlocks.replace(/<[^>]*>/g, ' ');
+ const decodedText = decodeHtmlEntities(withoutTags);
+
+ return decodedText.replace(/\s+/g, ' ').trim();
+};
diff --git a/theme-tokens.css b/theme-tokens.css
index 3b21a520..8bbcad96 100644
--- a/theme-tokens.css
+++ b/theme-tokens.css
@@ -692,23 +692,6 @@
--shadow-soft-4: 0px 0px 40px rgba(38, 38, 38, 0.1);
}
-/* Web-only pulse animation for user location marker on map */
-@keyframes pulse-ring {
- 0%, 100% {
- transform: scale(1);
- opacity: 0.3;
- }
- 50% {
- transform: scale(1.2);
- opacity: 0.15;
- }
-}
-
-/* Web-only: applied to the location marker outer ring (replaces Animated.loop) */
-.pulse-ring {
- animation: pulse-ring 2s ease-in-out infinite;
-}
-
/* Web-only skeleton loading animation */
@keyframes skeleton-pulse {
0%, 100% {