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 */} - + - + 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')} - - - - - - - + + {/* 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')} + + + + + + + + ); }; 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();