diff --git a/src/app/call/[id].tsx b/src/app/call/[id].tsx
index 9110620..517add5 100644
--- a/src/app/call/[id].tsx
+++ b/src/app/call/[id].tsx
@@ -1,7 +1,6 @@
import { format, isValid } from 'date-fns';
import { Stack, useLocalSearchParams, useRouter } from 'expo-router';
import { ClockIcon, FileTextIcon, ImageIcon, InfoIcon, LoaderIcon, MessageSquareIcon, PaperclipIcon, RouteIcon, TimerIcon, UserIcon, UsersIcon, VideoIcon } from 'lucide-react-native';
-import { useColorScheme } from 'nativewind';
import React, { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ScrollView, StyleSheet, useWindowDimensions, View } from 'react-native';
@@ -12,6 +11,7 @@ import { CheckInTabContent } from '@/components/check-in-timers/check-in-tab-con
import { MessageCommanderSheet } from '@/components/command/message-commander-sheet';
import { ReopenCommandSheet } from '@/components/command/reopen-command-sheet';
import { StartCommandSheet } from '@/components/command/start-command-sheet';
+import { HeaderBackButton } from '@/components/common/header-back-button';
import { Loading } from '@/components/common/loading';
import ZeroState from '@/components/common/zero-state';
// Import a static map component instead of react-native-maps
@@ -84,9 +84,6 @@ export default function CallDetail() {
const stopPolling = useCheckInTimerStore((state) => state.stopPolling);
const resetTimers = useCheckInTimerStore((state) => state.reset);
- const { colorScheme } = useColorScheme();
- const textColor = colorScheme === 'dark' ? '#FFFFFF' : '#000000';
-
// Get current user location from the location store
const userLatitude = useLocationStore((state) => state.latitude);
const userLongitude = useLocationStore((state) => state.longitude);
@@ -288,6 +285,7 @@ export default function CallDetail() {
options={{
title: t('call_detail.title'),
headerShown: true,
+ headerLeft: () => ,
headerRight: HeaderRightMenu,
headerBackTitle: '',
}}
@@ -307,6 +305,7 @@ export default function CallDetail() {
options={{
title: t('call_detail.title'),
headerShown: true,
+ headerLeft: () => ,
headerRight: HeaderRightMenu,
headerBackTitle: '',
}}
@@ -328,6 +327,7 @@ export default function CallDetail() {
options={{
title: t('call_detail.title'),
headerShown: true,
+ headerLeft: () => ,
headerBackTitle: '',
}}
/>
@@ -352,7 +352,7 @@ export default function CallDetail() {
title: t('call_detail.tabs.info'),
icon: ,
content: (
-
+
{t('call_detail.priority')}
@@ -425,7 +425,7 @@ export default function CallDetail() {
{callExtraData?.Protocols && callExtraData.Protocols.length > 0 ? (
{callExtraData.Protocols.map((protocol, index) => (
-
+
{protocol.Name}
{protocol.Description}
@@ -449,7 +449,7 @@ export default function CallDetail() {
{callExtraData?.Dispatches && callExtraData.Dispatches.length > 0 ? (
{callExtraData.Dispatches.map((dispatched, index) => (
-
+
{dispatched.Name}
@@ -527,13 +527,14 @@ export default function CallDetail() {
options={{
title: t('call_detail.title'),
headerShown: true,
+ headerLeft: () => ,
headerRight: HeaderRightMenu,
headerBackTitle: '',
}}
/>
-
+
{/* Header */}
-
+
{call.Name} ({call.Number})
@@ -572,13 +573,13 @@ export default function CallDetail() {
{/* Map - only show when valid coordinates exist */}
{coordinates.latitude !== null && coordinates.longitude !== null ? (
-
+
) : null}
{/* Action Buttons */}
-
+
{/* Tabs */}
-
+
diff --git a/src/app/call/[id]/edit.tsx b/src/app/call/[id]/edit.tsx
index 8a79ef5..065218a 100644
--- a/src/app/call/[id]/edit.tsx
+++ b/src/app/call/[id]/edit.tsx
@@ -11,6 +11,7 @@ import * as z from 'zod';
import { DestinationPoiSelector } from '@/components/calls/destination-poi-selector';
import { DispatchSelectionModal } from '@/components/calls/dispatch-selection-modal';
+import { HeaderBackButton } from '@/components/common/header-back-button';
import { Loading } from '@/components/common/loading';
import FullScreenLocationPicker from '@/components/maps/full-screen-location-picker';
import LocationPicker from '@/components/maps/location-picker';
@@ -417,6 +418,7 @@ export default function EditCall() {
options={{
title: t('calls.edit_call'),
headerShown: true,
+ headerLeft: () => router.back()} />,
headerBackTitle: '',
}}
/>
@@ -432,6 +434,7 @@ export default function EditCall() {
options={{
title: t('calls.edit_call'),
headerShown: true,
+ headerLeft: () => router.back()} />,
headerBackTitle: '',
}}
/>
@@ -450,15 +453,16 @@ export default function EditCall() {
options={{
title: t('calls.edit_call'),
headerShown: true,
+ headerLeft: () => router.back()} />,
headerBackTitle: '',
}}
/>
-
+
{t('calls.edit_call_description')}
-
+
{t('calls.name')}
@@ -480,7 +484,7 @@ export default function EditCall() {
-
+
{t('calls.nature')}
@@ -502,7 +506,7 @@ export default function EditCall() {
-
+
{t('calls.priority')}
@@ -535,7 +539,7 @@ export default function EditCall() {
-
+
{t('calls.type')}
@@ -568,7 +572,7 @@ export default function EditCall() {
-
+
{t('calls.note')}
@@ -585,7 +589,7 @@ export default function EditCall() {
-
+
{t('calls.call_location')}
{/* Address Field */}
@@ -637,7 +641,7 @@ export default function EditCall() {
/>
-
+
{t('calls.contact_name')}
@@ -654,7 +658,7 @@ export default function EditCall() {
-
+
{t('calls.contact_info')}
@@ -671,7 +675,7 @@ export default function EditCall() {
-
+
{t('calls.dispatch_to')}
-
+
diff --git a/src/components/calls/dispatch-selection-modal.tsx b/src/components/calls/dispatch-selection-modal.tsx
index d329063..5763d9c 100644
--- a/src/components/calls/dispatch-selection-modal.tsx
+++ b/src/components/calls/dispatch-selection-modal.tsx
@@ -2,9 +2,10 @@ import { CheckIcon, SearchIcon, UsersIcon, X } from 'lucide-react-native';
import { useColorScheme } from 'nativewind';
import React, { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
-import { ScrollView, TouchableOpacity, View } from 'react-native';
+import { ScrollView, TouchableOpacity } from 'react-native';
import { Loading } from '@/components/common/loading';
+import { Actionsheet, ActionsheetBackdrop, ActionsheetContent, ActionsheetDragIndicator, ActionsheetDragIndicatorWrapper } from '@/components/ui/actionsheet';
import { Box } from '@/components/ui/box';
import { Button, ButtonText } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
@@ -24,7 +25,7 @@ interface DispatchSelectionModalProps {
export const DispatchSelectionModal: React.FC = ({ isVisible, onClose, onConfirm, initialSelection }) => {
const { t } = useTranslation();
const { colorScheme } = useColorScheme();
- const { data, selection, isLoading, error, searchQuery, fetchDispatchData, setSelection, toggleEveryone, toggleUser, toggleGroup, toggleRole, toggleUnit, setSearchQuery, clearSelection, getFilteredData } =
+ const { selection, isLoading, error, searchQuery, fetchDispatchData, setSelection, toggleEveryone, toggleUser, toggleGroup, toggleRole, toggleUnit, setSearchQuery, clearSelection, getFilteredData } =
useDispatchStore();
const filteredData = getFilteredData();
@@ -53,194 +54,177 @@ export const DispatchSelectionModal: React.FC = ({
return selection.users.length + selection.groups.length + selection.roles.length + selection.units.length;
};
- if (!isVisible) return null;
-
return (
-
- {/* Header */}
-
-
-
- {t('calls.select_dispatch_recipients')}
+
+
+
+
+
+
+
+ {/* Header */}
+
+
+
+
+ {t('calls.select_dispatch_recipients')}
+
+
+
+
+
-
-
-
-
-
- {/* Search */}
-
-
-
-
-
-
-
- {/* Content */}
- {isLoading ? (
-
- ) : error ? (
-
- {error}
+
+ {/* Search */}
+
+
+
+
+
- ) : (
-
- {/* 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}
-
-
-
-
- ))}
-
- )}
-
- {/* 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}
-
-
-
-
- ))}
-
- )}
-
- {/* 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}
-
-
-
-
- ))}
-
- )}
-
- {/* 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}
-
-
-
-
- ))}
-
- )}
-
- {/* No Results */}
- {searchQuery && filteredData.users.length === 0 && filteredData.groups.length === 0 && filteredData.roles.length === 0 && filteredData.units.length === 0 && (
-
- {t('common.no_results_found')}
-
- )}
-
- )}
-
- {/* Footer */}
-
-
- {getSelectionCount()} {t('calls.selected')}
-
-
-
- {t('common.cancel')}
-
-
- {t('common.confirm')}
-
-
-
-
+
+ {/* Content */}
+ {isLoading ? (
+
+
+
+ ) : error ? (
+
+ {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')}
+
+
+
+ {t('common.cancel')}
+
+
+ {t('common.confirm')}
+
+
+
+
+
);
};
diff --git a/src/components/common/header-back-button.tsx b/src/components/common/header-back-button.tsx
new file mode 100644
index 0000000..caee154
--- /dev/null
+++ b/src/components/common/header-back-button.tsx
@@ -0,0 +1,37 @@
+import { ArrowLeftIcon } from 'lucide-react-native';
+import { useColorScheme } from 'nativewind';
+import React from 'react';
+import { StyleSheet, View } from 'react-native';
+
+import { Pressable } from '@/components/ui/';
+
+interface HeaderBackButtonProps {
+ onPress: () => void;
+}
+
+// Fixed 40x40 plain View wrapper: iOS 26 liquid glass headers stretch flexible-width
+// header subviews (react-native-screens 4.16). Gluestack/NativeWind styles can land a
+// frame after the first native commit, so the glass capsule caches a stretched
+// constraint on re-entry — the wrapper guarantees a fixed frame from the first commit.
+// collapsable={false} keeps the native view (and its frame) from being optimized away.
+export const HeaderBackButton: React.FC = ({ onPress }) => {
+ const { colorScheme } = useColorScheme();
+
+ return (
+
+
+ {/* lucide icons draw with `stroke="currentColor"`, which react-native-svg resolves
+ to black — a `className` text colour never reaches them. Pass `color` instead,
+ or the arrow is invisible against the dark header. */}
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ button: {
+ width: 40,
+ height: 40,
+ },
+});
diff --git a/src/hooks/use-keyboard-height.ts b/src/hooks/use-keyboard-height.ts
index 00ecac3..1cc6c6a 100644
--- a/src/hooks/use-keyboard-height.ts
+++ b/src/hooks/use-keyboard-height.ts
@@ -4,12 +4,15 @@ import { Keyboard, Platform } from 'react-native';
/**
* Current soft-keyboard height in dp, or 0 when it is closed.
*
- * Bottom sheets render inside a native `Modal`, which owns its own window.
- * react-native-keyboard-controller's inset animations are bound to the main window,
- * so `KeyboardAvoidingView`/`KeyboardAwareScrollView` never move sheet content and the
- * keyboard sits on top of it. React Native's own `Keyboard` events are dispatched
- * regardless of which window is focused, so they still describe the keyboard correctly
- * inside a sheet — use them to size the gap the sheet needs to leave.
+ * Bottom sheets render inside a native `Modal`, which owns its own window. The modal
+ * window itself never resizes or pans for the keyboard on either platform, so the sheet
+ * must leave the gap itself — pad `ActionsheetContent` by this height and the
+ * content-sized, bottom-anchored sheet slides up out from under the keyboard.
+ *
+ * IMPORTANT: this must be the ONLY keyboard compensation inside a sheet. Do NOT nest a
+ * `KeyboardAwareScrollView`/`KeyboardAvoidingView` in sheet content: keyboard events are
+ * window-agnostic on both platforms, so those components still fire inside the modal and
+ * shift the content a second keyboard-height, pushing it out of the visible sheet.
*
* iOS gets the `Will` events so the sheet moves with the keyboard animation; Android only
* reports usable frames on `Did`.
diff --git a/src/stores/signalr/signalr-store.ts b/src/stores/signalr/signalr-store.ts
index 04a47f2..3cca6c7 100644
--- a/src/stores/signalr/signalr-store.ts
+++ b/src/stores/signalr/signalr-store.ts
@@ -44,6 +44,14 @@ const UPDATE_REJOIN_RETRY_MS = 5000;
const UPDATE_REJOIN_MAX_ATTEMPTS = 3;
let updateRejoinTimer: ReturnType | null = null;
let updateRejoinAttempts = 0;
+// Stamps each rejoin with the connection lifecycle that started it. Teardown (explicit
+// disconnect or a dropped transport) bumps the generation, so an invoke still in flight
+// against the old connection completes as a no-op instead of restoring the connected
+// flag, resyncing boards, or scheduling retries after the connection is gone.
+let updateConnectionGeneration = 0;
+// The rejoin in flight for the current generation, so overlapping reconnect events share
+// one announce instead of racing each other's retry budget.
+let updateRejoinOperation: { generation: number; promise: Promise } | null = null;
function stopUpdateRejoinRetry(): void {
if (updateRejoinTimer) {
@@ -487,32 +495,54 @@ export const useSignalRStore = create((set, get) => ({
* close does — so the flag has to be cleared here or connectUpdateHub()'s already-connected
* guard would block every later repair.
*/
- const rejoinDepartmentGroup = () => {
+ const runUpdateRejoin = async (generation: number): Promise => {
const departmentId = parseInt(securityStore.getState().rights?.DepartmentId ?? '0');
- signalRService
- .invoke(Env.CHANNEL_HUB_NAME, 'connect', departmentId)
- .then(() => {
+ try {
+ await signalRService.invoke(Env.CHANNEL_HUB_NAME, 'connect', departmentId);
+ // A completion from a torn-down connection must not restore state or resync.
+ if (generation !== updateConnectionGeneration) {
+ return;
+ }
+ stopUpdateRejoinRetry();
+ updateRejoinAttempts = 0;
+ set({ isUpdateHubConnected: true, error: null });
+ logger.info({ message: 'Re-announced to update hub after reconnect; resyncing command boards', context: { departmentId } });
+ debouncedFullSync();
+ } catch (error) {
+ // A stale failure must not schedule retries against a connection that is gone.
+ if (generation !== updateConnectionGeneration) {
+ return;
+ }
+ updateRejoinAttempts += 1;
+ logger.warn({ message: 'Failed to re-announce to update hub after reconnect', context: { error, attempt: updateRejoinAttempts, maxAttempts: UPDATE_REJOIN_MAX_ATTEMPTS } });
+ set({ isUpdateHubConnected: false });
+
+ if (updateRejoinAttempts < UPDATE_REJOIN_MAX_ATTEMPTS) {
stopUpdateRejoinRetry();
- updateRejoinAttempts = 0;
- set({ isUpdateHubConnected: true, error: null });
- logger.info({ message: 'Re-announced to update hub after reconnect; resyncing command boards', context: { departmentId } });
- debouncedFullSync();
- })
- .catch((error) => {
- updateRejoinAttempts += 1;
- logger.warn({ message: 'Failed to re-announce to update hub after reconnect', context: { error, attempt: updateRejoinAttempts, maxAttempts: UPDATE_REJOIN_MAX_ATTEMPTS } });
- set({ isUpdateHubConnected: false });
-
- if (updateRejoinAttempts < UPDATE_REJOIN_MAX_ATTEMPTS) {
- stopUpdateRejoinRetry();
- updateRejoinTimer = setTimeout(() => {
- updateRejoinTimer = null;
- rejoinDepartmentGroup();
- }, UPDATE_REJOIN_RETRY_MS);
- } else {
- logger.error({ message: 'Giving up re-announcing to update hub; the next connectUpdateHub will rebuild the session', context: { attempts: updateRejoinAttempts } });
- }
- });
+ updateRejoinTimer = setTimeout(() => {
+ updateRejoinTimer = null;
+ rejoinDepartmentGroup();
+ }, UPDATE_REJOIN_RETRY_MS);
+ } else {
+ logger.error({ message: 'Giving up re-announcing to update hub; the next connectUpdateHub will rebuild the session', context: { attempts: updateRejoinAttempts } });
+ }
+ }
+ };
+
+ const rejoinDepartmentGroup = () => {
+ const generation = updateConnectionGeneration;
+ // Reuse the in-flight rejoin only when it belongs to this connection; an operation
+ // left over from a previous generation is a dead announce that must not absorb the
+ // fresh connection's rejoin.
+ if (updateRejoinOperation && updateRejoinOperation.generation === generation) {
+ return;
+ }
+ const promise = runUpdateRejoin(generation).finally(() => {
+ if (updateRejoinOperation?.promise === promise) {
+ updateRejoinOperation = null;
+ }
+ });
+ updateRejoinOperation = { generation, promise };
};
const onUpdateReconnected = () => {
@@ -525,7 +555,9 @@ export const useSignalRStore = create((set, get) => ({
const updateDisconnected = `${SignalRService.HUB_DISCONNECTED_EVENT}:${Env.CHANNEL_HUB_NAME}`;
const onUpdateDisconnected = () => {
- // A dropped transport supersedes any rejoin still pending against the old connection.
+ // A dropped transport supersedes any rejoin still pending against the old connection —
+ // bumping the generation turns an invoke already in flight into a no-op.
+ updateConnectionGeneration += 1;
stopUpdateRejoinRetry();
updateRejoinAttempts = 0;
// Clearing the flag is what lets connectUpdateHub rebuild the session later; while it stayed
@@ -545,6 +577,9 @@ export const useSignalRStore = create((set, get) => ({
},
disconnectUpdateHub: async () => {
try {
+ // Invalidate any rejoin still in flight so its completion can't restore the
+ // connected flag or resync boards after this teardown.
+ updateConnectionGeneration += 1;
stopUpdateRejoinRetry();
updateRejoinAttempts = 0;
unregisterUpdateHubHandlers();