Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions .github/workflows/react-native-cicd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,13 @@ jobs:
# Third pass: Remove any remaining HTML comment lines
cleaned_body="$(printf '%s\n' "$cleaned_body" | sed '/^<!--.*-->$/d' | sed '/^<!--/d' | sed '/^-->$/d')"

# Fourth pass: Trim leading and trailing whitespace/empty lines
# Fourth pass: Remove specific CodeRabbit lines
cleaned_body="$(printf '%s\n' "$cleaned_body" \
| (grep -v '✏️ Tip: You can customize this high-level summary in your review settings\.' || true) \
| (grep -v '<!-- This is an auto-generated comment: release notes by coderabbit.ai -->' || true) \
| (grep -v '<!-- end of auto-generated comment: release notes by coderabbit.ai -->' || true))"

# Fifth pass: Trim leading and trailing whitespace/empty lines
cleaned_body="$(printf '%s\n' "$cleaned_body" | sed '/^$/d' | awk 'NF {p=1} p')"

# Try to extract content under "## Release Notes" heading if it exists
Expand Down Expand Up @@ -429,13 +435,10 @@ jobs:
PAYLOAD=$(jq -n \
--arg version "$VERSION" \
--arg notes "$RELEASE_NOTES" \
--arg buildNumber "${{ github.run_number }}" \
--arg commitSha "${{ github.sha }}" \
--arg buildUrl "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
'{
"version": $version,
"title": ("Release v" + $version),
"content": "$notes",
"content": $notes
}')

echo "Sending release notes to Changerawr..."
Expand Down
20 changes: 20 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,24 @@
"editor.codeActionsOnSave": {

},
"workbench.colorCustomizations": {
"activityBar.activeBackground": "#5372eb",
"activityBar.background": "#5372eb",
"activityBar.foreground": "#e7e7e7",
"activityBar.inactiveForeground": "#e7e7e799",
"activityBarBadge.background": "#8f112a",
"activityBarBadge.foreground": "#e7e7e7",
"commandCenter.border": "#e7e7e799",
"sash.hoverBorder": "#5372eb",
"statusBar.background": "#254de6",
"statusBar.foreground": "#e7e7e7",
"statusBarItem.hoverBackground": "#5372eb",
"statusBarItem.remoteBackground": "#254de6",
"statusBarItem.remoteForeground": "#e7e7e7",
"titleBar.activeBackground": "#254de6",
"titleBar.activeForeground": "#e7e7e7",
"titleBar.inactiveBackground": "#254de699",
"titleBar.inactiveForeground": "#e7e7e799"
},
"peacock.color": "#254de6",
}
26 changes: 19 additions & 7 deletions src/api/common/client.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import axios, { type AxiosError, type AxiosInstance, type InternalAxiosRequestConfig } from 'axios';
import axios, { type AxiosError, type AxiosInstance, type InternalAxiosRequestConfig, isAxiosError } from 'axios';

import { refreshTokenRequest } from '@/lib/auth/api';
import { logger } from '@/lib/logging';
Expand Down Expand Up @@ -98,12 +98,24 @@ axiosInstance.interceptors.response.use(
return axiosInstance(originalRequest);
} catch (refreshError) {
processQueue(refreshError as Error);
// Handle refresh token failure
useAuthStore.getState().logout();
logger.error({
message: 'Token refresh failed',
context: { error: refreshError },
});

// Check if it's a network error vs an invalid refresh token
const isNetworkError = isAxiosError(refreshError) && !refreshError.response;

if (!isNetworkError) {
// Only logout for non-network errors (e.g., invalid refresh token, 400/401 from token endpoint)
logger.error({
message: 'Token refresh failed with non-recoverable error, logging out user',
context: { error: refreshError },
});
useAuthStore.getState().logout();
} else {
logger.warn({
message: 'Token refresh failed due to network error',
context: { error: refreshError },
});
}

return Promise.reject(refreshError);
} finally {
isRefreshing = false;
Expand Down
10 changes: 5 additions & 5 deletions src/app/(app)/__tests__/protocols.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ jest.mock('@/components/protocols/protocol-card', () => ({
ProtocolCard: ({ protocol, onPress }: { protocol: any; onPress: (id: string) => void }) => {
const { Pressable, Text } = require('react-native');
return (
<Pressable testID={`protocol-card-${protocol.Id}`} onPress={() => onPress(protocol.Id)}>
<Pressable testID={`protocol-card-${protocol.ProtocolId}`} onPress={() => onPress(protocol.ProtocolId)}>
<Text>{protocol.Name}</Text>
</Pressable>
);
Expand Down Expand Up @@ -108,7 +108,7 @@ jest.mock('@/stores/protocols/store', () => ({
// Mock protocols test data
const mockProtocols: CallProtocolsResultData[] = [
{
Id: '1',
ProtocolId: '1',
DepartmentId: 'dept1',
Name: 'Fire Emergency Response',
Code: 'FIRE001',
Expand All @@ -126,7 +126,7 @@ const mockProtocols: CallProtocolsResultData[] = [
Questions: [],
},
{
Id: '2',
ProtocolId: '2',
DepartmentId: 'dept1',
Name: 'Medical Emergency',
Code: 'MED001',
Expand All @@ -144,7 +144,7 @@ const mockProtocols: CallProtocolsResultData[] = [
Questions: [],
},
{
Id: '3',
ProtocolId: '3',
DepartmentId: 'dept1',
Name: 'Hazmat Response',
Code: 'HAZ001',
Expand All @@ -162,7 +162,7 @@ const mockProtocols: CallProtocolsResultData[] = [
Questions: [],
},
{
Id: '', // Empty ID to test the keyExtractor fix
ProtocolId: '', // Empty ID to test the keyExtractor fix
DepartmentId: 'dept1',
Name: 'Protocol with Empty ID',
Code: 'EMPTY001',
Expand Down
13 changes: 11 additions & 2 deletions src/app/(app)/protocols.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,13 @@ export default function Protocols() {
setRefreshing(false);
}, [fetchProtocols]);

const handleProtocolPress = React.useCallback(
(id: string) => {
selectProtocol(id);
},
[selectProtocol]
);

const filteredProtocols = React.useMemo(() => {
if (!searchQuery.trim()) return protocols;

Expand Down Expand Up @@ -69,11 +76,13 @@ export default function Protocols() {
<FlatList
testID="protocols-list"
data={filteredProtocols}
keyExtractor={(item, index) => item.Id || `protocol-${index}`}
renderItem={({ item }) => <ProtocolCard protocol={item} onPress={selectProtocol} />}
keyExtractor={(item, index) => item.ProtocolId || `protocol-${index}`}
renderItem={({ item }) => <ProtocolCard protocol={item} onPress={handleProtocolPress} />}
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: 100 }}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />}
extraData={handleProtocolPress}
estimatedItemSize={120}
/>
) : (
<ZeroState icon={FileText} heading={t('protocols.empty')} description={t('protocols.emptyDescription')} />
Expand Down
54 changes: 52 additions & 2 deletions src/app/(app)/settings.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/* eslint-disable react/react-in-jsx-scope */
import { Env } from '@env';
import { useColorScheme } from 'nativewind';
import React, { useEffect } from 'react';
import React, { useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';

import { BackgroundGeolocationItem } from '@/components/settings/background-geolocation-item';
Expand All @@ -15,15 +15,19 @@ import { ThemeItem } from '@/components/settings/theme-item';
import { ToggleItem } from '@/components/settings/toggle-item';
import { UnitSelectionBottomSheet } from '@/components/settings/unit-selection-bottom-sheet';
import { FocusAwareStatusBar, ScrollView } from '@/components/ui';
import { AlertDialog, AlertDialogBackdrop, AlertDialogBody, AlertDialogContent, AlertDialogFooter, AlertDialogHeader } from '@/components/ui/alert-dialog';
import { Box } from '@/components/ui/box';
import { Button, ButtonText } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Heading } from '@/components/ui/heading';
import { Text } from '@/components/ui/text';
import { VStack } from '@/components/ui/vstack';
import { useAnalytics } from '@/hooks/use-analytics';
import { useAuth, useAuthStore } from '@/lib';
import { logger } from '@/lib/logging';
import { getBaseApiUrl } from '@/lib/storage/app';
import { openLinkInBrowser } from '@/lib/utils';
import { clearAllAppData } from '@/services/app-reset.service';
import { useCoreStore } from '@/stores/app/core-store';
import { useUnitsStore } from '@/stores/units/store';

Expand All @@ -36,6 +40,7 @@ export default function Settings() {
const { login, status, isAuthenticated } = useAuth();
const [showServerUrl, setShowServerUrl] = React.useState(false);
const [showUnitSelection, setShowUnitSelection] = React.useState(false);
const [showLogoutConfirm, setShowLogoutConfirm] = React.useState(false);
const activeUnit = useCoreStore((state) => state.activeUnit);
const { units } = useUnitsStore();

Expand All @@ -44,6 +49,30 @@ export default function Settings() {
return activeUnit?.Name || t('common.unknown');
}, [activeUnit, t]);

/**
* Handles logout confirmation - clears all data and signs out
*/
const handleLogoutConfirm = useCallback(async () => {
setShowLogoutConfirm(false);

trackEvent('user_logout_confirmed', {
hadActiveUnit: !!activeUnit,
});

// Clear all app data first using the centralized service
try {
await clearAllAppData();
} catch (error) {
logger.error({
message: 'Error during app data cleanup on logout',
context: { error },
});
}

// Then sign out
await signOut();
}, [signOut, trackEvent, activeUnit]);

const handleLoginInfoSubmit = async (data: { username: string; password: string }) => {
logger.info({
message: 'Updating login info',
Expand Down Expand Up @@ -89,7 +118,7 @@ export default function Settings() {
<Item text={t('settings.server')} value={getBaseApiUrl()} onPress={() => setShowServerUrl(true)} textStyle="text-info-600" />
<Item text={t('settings.login_info')} onPress={() => setShowLoginInfo(true)} textStyle="text-info-600" />
<Item text={t('settings.active_unit')} value={activeUnitName} onPress={() => setShowUnitSelection(true)} textStyle="text-info-600" />
<Item text={t('settings.logout')} onPress={signOut} textStyle="text-error-600" />
<Item text={t('settings.logout')} onPress={() => setShowLogoutConfirm(true)} textStyle="text-error-600" />
</VStack>
</Card>

Expand Down Expand Up @@ -122,6 +151,27 @@ export default function Settings() {
<LoginInfoBottomSheet isOpen={showLoginInfo} onClose={() => setShowLoginInfo(false)} onSubmit={handleLoginInfoSubmit} />
<ServerUrlBottomSheet isOpen={showServerUrl} onClose={() => setShowServerUrl(false)} />
<UnitSelectionBottomSheet isOpen={showUnitSelection} onClose={() => setShowUnitSelection(false)} />

{/* Logout Confirmation Dialog */}
<AlertDialog isOpen={showLogoutConfirm} onClose={() => setShowLogoutConfirm(false)}>
<AlertDialogBackdrop />
<AlertDialogContent>
<AlertDialogHeader>
<Heading size="lg">{t('settings.logout_confirm_title')}</Heading>
</AlertDialogHeader>
<AlertDialogBody>
<Text>{t('settings.logout_confirm_message')}</Text>
</AlertDialogBody>
<AlertDialogFooter>
<Button variant="outline" action="secondary" onPress={() => setShowLogoutConfirm(false)}>
<ButtonText>{t('common.cancel')}</ButtonText>
</Button>
<Button action="negative" onPress={handleLogoutConfirm}>
<ButtonText>{t('settings.logout')}</ButtonText>
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Box>
);
}
8 changes: 8 additions & 0 deletions src/components/calls/__tests__/call-files-modal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { CallFilesModal } from '../call-files-modal';

// Mock the zustand store
const mockFetchCallFiles = jest.fn();
const mockClearFiles = jest.fn();
const defaultMockFiles = [
{
Id: 'file-1',
Expand Down Expand Up @@ -41,6 +42,7 @@ let mockStoreState: any = {
isLoadingFiles: false,
errorFiles: null,
fetchCallFiles: mockFetchCallFiles,
clearFiles: mockClearFiles,
};

jest.mock('@/stores/calls/detail-store', () => ({
Expand Down Expand Up @@ -304,6 +306,7 @@ describe('CallFilesModal', () => {
isLoadingFiles: false,
errorFiles: null,
fetchCallFiles: mockFetchCallFiles,
clearFiles: mockClearFiles,
};
});

Expand Down Expand Up @@ -423,6 +426,7 @@ describe('CallFilesModal', () => {
isLoadingFiles: true,
errorFiles: null,
fetchCallFiles: mockFetchCallFiles,
clearFiles: mockClearFiles,
};
});

Expand All @@ -444,6 +448,7 @@ describe('CallFilesModal', () => {
isLoadingFiles: false,
errorFiles: 'Network error occurred',
fetchCallFiles: mockFetchCallFiles,
clearFiles: mockClearFiles,
};
});

Expand Down Expand Up @@ -476,6 +481,7 @@ describe('CallFilesModal', () => {
isLoadingFiles: false,
errorFiles: null,
fetchCallFiles: mockFetchCallFiles,
clearFiles: mockClearFiles,
};
});

Expand Down Expand Up @@ -590,6 +596,7 @@ describe('CallFilesModal', () => {
isLoadingFiles: false,
errorFiles: null,
fetchCallFiles: mockFetchCallFiles,
clearFiles: mockClearFiles,
};
});

Expand Down Expand Up @@ -623,6 +630,7 @@ describe('CallFilesModal', () => {
isLoadingFiles: false,
errorFiles: null,
fetchCallFiles: mockFetchCallFiles,
clearFiles: mockClearFiles,
};
});

Expand Down
26 changes: 26 additions & 0 deletions src/components/calls/__tests__/close-call-bottom-sheet.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,32 @@ jest.mock('nativewind', () => ({
// Mock cssInterop globally
(global as any).cssInterop = jest.fn();

// Mock actionsheet components
jest.mock('@/components/ui/actionsheet', () => ({
Actionsheet: ({ children, isOpen }: any) => {
const { View } = require('react-native');
return isOpen ? <View testID="actionsheet">{children}</View> : null;
},
ActionsheetBackdrop: () => null,
ActionsheetContent: ({ children }: any) => {
const { View } = require('react-native');
return <View testID="actionsheet-content">{children}</View>;
},
ActionsheetDragIndicator: () => null,
ActionsheetDragIndicatorWrapper: ({ children }: any) => {
const { View } = require('react-native');
return <View>{children}</View>;
},
}));

// Mock keyboard aware scroll view
jest.mock('react-native-keyboard-controller', () => ({
KeyboardAwareScrollView: ({ children }: any) => {
const { View } = require('react-native');
return <View>{children}</View>;
},
}));

// Mock UI components
jest.mock('@/components/ui/bottom-sheet', () => ({
CustomBottomSheet: ({ children, isOpen }: any) => {
Expand Down
Loading
Loading