diff --git a/.github/workflows/react-native-cicd.yml b/.github/workflows/react-native-cicd.yml index 15ff9545..dca52ae4 100644 --- a/.github/workflows/react-native-cicd.yml +++ b/.github/workflows/react-native-cicd.yml @@ -319,7 +319,13 @@ jobs: # Third pass: Remove any remaining HTML comment lines cleaned_body="$(printf '%s\n' "$cleaned_body" | 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 '' || true) \ + | (grep -v '' || 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 @@ -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..." diff --git a/.vscode/settings.json b/.vscode/settings.json index 88f4c3f2..58f69882 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -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", } diff --git a/src/api/common/client.tsx b/src/api/common/client.tsx index 2eda70c1..e54c26a5 100644 --- a/src/api/common/client.tsx +++ b/src/api/common/client.tsx @@ -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'; @@ -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; diff --git a/src/app/(app)/__tests__/protocols.test.tsx b/src/app/(app)/__tests__/protocols.test.tsx index a74b76f3..859c5713 100644 --- a/src/app/(app)/__tests__/protocols.test.tsx +++ b/src/app/(app)/__tests__/protocols.test.tsx @@ -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 ( - onPress(protocol.Id)}> + onPress(protocol.ProtocolId)}> {protocol.Name} ); @@ -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', @@ -126,7 +126,7 @@ const mockProtocols: CallProtocolsResultData[] = [ Questions: [], }, { - Id: '2', + ProtocolId: '2', DepartmentId: 'dept1', Name: 'Medical Emergency', Code: 'MED001', @@ -144,7 +144,7 @@ const mockProtocols: CallProtocolsResultData[] = [ Questions: [], }, { - Id: '3', + ProtocolId: '3', DepartmentId: 'dept1', Name: 'Hazmat Response', Code: 'HAZ001', @@ -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', diff --git a/src/app/(app)/protocols.tsx b/src/app/(app)/protocols.tsx index f59e64bb..35919dd3 100644 --- a/src/app/(app)/protocols.tsx +++ b/src/app/(app)/protocols.tsx @@ -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; @@ -69,11 +76,13 @@ export default function Protocols() { item.Id || `protocol-${index}`} - renderItem={({ item }) => } + keyExtractor={(item, index) => item.ProtocolId || `protocol-${index}`} + renderItem={({ item }) => } showsVerticalScrollIndicator={false} contentContainerStyle={{ paddingBottom: 100 }} refreshControl={} + extraData={handleProtocolPress} + estimatedItemSize={120} /> ) : ( diff --git a/src/app/(app)/settings.tsx b/src/app/(app)/settings.tsx index c20791c7..b52ea0f3 100644 --- a/src/app/(app)/settings.tsx +++ b/src/app/(app)/settings.tsx @@ -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'; @@ -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'; @@ -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(); @@ -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', @@ -89,7 +118,7 @@ export default function Settings() { setShowServerUrl(true)} textStyle="text-info-600" /> setShowLoginInfo(true)} textStyle="text-info-600" /> setShowUnitSelection(true)} textStyle="text-info-600" /> - + setShowLogoutConfirm(true)} textStyle="text-error-600" /> @@ -122,6 +151,27 @@ export default function Settings() { setShowLoginInfo(false)} onSubmit={handleLoginInfoSubmit} /> setShowServerUrl(false)} /> setShowUnitSelection(false)} /> + + {/* Logout Confirmation Dialog */} + setShowLogoutConfirm(false)}> + + + + {t('settings.logout_confirm_title')} + + + {t('settings.logout_confirm_message')} + + + + + + + ); } diff --git a/src/components/calls/__tests__/call-files-modal.test.tsx b/src/components/calls/__tests__/call-files-modal.test.tsx index d28458e9..ed137d19 100644 --- a/src/components/calls/__tests__/call-files-modal.test.tsx +++ b/src/components/calls/__tests__/call-files-modal.test.tsx @@ -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', @@ -41,6 +42,7 @@ let mockStoreState: any = { isLoadingFiles: false, errorFiles: null, fetchCallFiles: mockFetchCallFiles, + clearFiles: mockClearFiles, }; jest.mock('@/stores/calls/detail-store', () => ({ @@ -304,6 +306,7 @@ describe('CallFilesModal', () => { isLoadingFiles: false, errorFiles: null, fetchCallFiles: mockFetchCallFiles, + clearFiles: mockClearFiles, }; }); @@ -423,6 +426,7 @@ describe('CallFilesModal', () => { isLoadingFiles: true, errorFiles: null, fetchCallFiles: mockFetchCallFiles, + clearFiles: mockClearFiles, }; }); @@ -444,6 +448,7 @@ describe('CallFilesModal', () => { isLoadingFiles: false, errorFiles: 'Network error occurred', fetchCallFiles: mockFetchCallFiles, + clearFiles: mockClearFiles, }; }); @@ -476,6 +481,7 @@ describe('CallFilesModal', () => { isLoadingFiles: false, errorFiles: null, fetchCallFiles: mockFetchCallFiles, + clearFiles: mockClearFiles, }; }); @@ -590,6 +596,7 @@ describe('CallFilesModal', () => { isLoadingFiles: false, errorFiles: null, fetchCallFiles: mockFetchCallFiles, + clearFiles: mockClearFiles, }; }); @@ -623,6 +630,7 @@ describe('CallFilesModal', () => { isLoadingFiles: false, errorFiles: null, fetchCallFiles: mockFetchCallFiles, + clearFiles: mockClearFiles, }; }); diff --git a/src/components/calls/__tests__/close-call-bottom-sheet.test.tsx b/src/components/calls/__tests__/close-call-bottom-sheet.test.tsx index bf420759..e344f53a 100644 --- a/src/components/calls/__tests__/close-call-bottom-sheet.test.tsx +++ b/src/components/calls/__tests__/close-call-bottom-sheet.test.tsx @@ -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 ? {children} : null; + }, + ActionsheetBackdrop: () => null, + ActionsheetContent: ({ children }: any) => { + const { View } = require('react-native'); + return {children}; + }, + ActionsheetDragIndicator: () => null, + ActionsheetDragIndicatorWrapper: ({ children }: any) => { + const { View } = require('react-native'); + return {children}; + }, +})); + +// Mock keyboard aware scroll view +jest.mock('react-native-keyboard-controller', () => ({ + KeyboardAwareScrollView: ({ children }: any) => { + const { View } = require('react-native'); + return {children}; + }, +})); + // Mock UI components jest.mock('@/components/ui/bottom-sheet', () => ({ CustomBottomSheet: ({ children, isOpen }: any) => { diff --git a/src/components/calls/call-files-modal.tsx b/src/components/calls/call-files-modal.tsx index 909551cd..ed3f3da2 100644 --- a/src/components/calls/call-files-modal.tsx +++ b/src/components/calls/call-files-modal.tsx @@ -5,7 +5,7 @@ import * as Sharing from 'expo-sharing'; import { Download, File, X } from 'lucide-react-native'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Alert, Pressable } from 'react-native'; +import { Alert, Pressable, useColorScheme } from 'react-native'; import { ScrollView } from 'react-native-gesture-handler'; import { getCallAttachmentFile } from '@/api/calls/callFiles'; @@ -31,7 +31,8 @@ interface CallFilesModalProps { export const CallFilesModal: React.FC = ({ isOpen, onClose, callId }) => { const { t } = useTranslation(); const { trackEvent } = useAnalytics(); - const { callFiles, isLoadingFiles, errorFiles, fetchCallFiles } = useCallDetailStore(); + const colorScheme = useColorScheme(); + const { callFiles, isLoadingFiles, errorFiles, fetchCallFiles, clearFiles } = useCallDetailStore(); const [downloadingFiles, setDownloadingFiles] = useState>({}); // Bottom sheet ref and snap points @@ -46,7 +47,14 @@ export const CallFilesModal: React.FC = ({ isOpen, onClose, } else { bottomSheetRef.current?.close(); } - }, [isOpen, callId, fetchCallFiles]); + + // Cleanup when modal closes to free memory + return () => { + setDownloadingFiles({}); + // Clear files from store to free memory + clearFiles(); + }; + }, [isOpen, callId, fetchCallFiles, clearFiles]); // Track when call files modal is opened/rendered useEffect(() => { @@ -241,8 +249,8 @@ export const CallFilesModal: React.FC = ({ isOpen, onClose, onChange={handleSheetChanges} backdropComponent={renderBackdrop} enablePanDownToClose={true} - handleIndicatorStyle={{ backgroundColor: '#D1D5DB' }} - backgroundStyle={{ backgroundColor: 'white' }} + handleIndicatorStyle={{ backgroundColor: colorScheme === 'dark' ? '#4B5563' : '#D1D5DB' }} + backgroundStyle={{ backgroundColor: colorScheme === 'dark' ? '#1F2937' : 'white' }} > {/* Fixed Header */} diff --git a/src/components/calls/call-images-modal.tsx b/src/components/calls/call-images-modal.tsx index a25aaa14..079d65fd 100644 --- a/src/components/calls/call-images-modal.tsx +++ b/src/components/calls/call-images-modal.tsx @@ -2,11 +2,12 @@ import * as FileSystem from 'expo-file-system'; import { Image } from 'expo-image'; import * as ImageManipulator from 'expo-image-manipulator'; import * as ImagePicker from 'expo-image-picker'; -import { CameraIcon, ChevronLeftIcon, ChevronRightIcon, ImageIcon, PlusIcon, XIcon } from 'lucide-react-native'; -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { CameraIcon, ChevronLeftIcon, ChevronRightIcon, ImageIcon, PlusIcon, X } from 'lucide-react-native'; +import { useColorScheme } from 'nativewind'; +import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Alert, Dimensions, Platform, StyleSheet, TouchableOpacity, View } from 'react-native'; -import { KeyboardAwareScrollView } from 'react-native-keyboard-controller'; +import { Dimensions, Keyboard, Modal, SafeAreaView, StyleSheet, TouchableOpacity, View } from 'react-native'; +import { KeyboardStickyView } from 'react-native-keyboard-controller'; import { Loading } from '@/components/common/loading'; import ZeroState from '@/components/common/zero-state'; @@ -17,9 +18,9 @@ import { type CallFileResultData } from '@/models/v4/callFiles/callFileResultDat import { useLocationStore } from '@/stores/app/location-store'; import { useCallDetailStore } from '@/stores/calls/detail-store'; -import { Actionsheet, ActionsheetBackdrop, ActionsheetContent, ActionsheetDragIndicator, ActionsheetDragIndicatorWrapper, ActionsheetItem, ActionsheetItemText } from '../ui/actionsheet'; import { Box } from '../ui/box'; import { Button, ButtonIcon, ButtonText } from '../ui/button'; +import { Heading } from '../ui/heading'; import { HStack } from '../ui/hstack'; import { Input, InputField } from '../ui/input'; import { Text } from '../ui/text'; @@ -34,24 +35,14 @@ interface CallImagesModalProps { const { width } = Dimensions.get('window'); -const styles = StyleSheet.create({ - galleryImage: { - height: 256, // h-64 equivalent - width: '100%', - borderRadius: 8, // rounded-lg equivalent - }, - previewImage: { - height: 256, // h-64 equivalent - width: '100%', - borderRadius: 8, // rounded-lg equivalent - }, -}); - const CallImagesModal: React.FC = ({ isOpen, onClose, callId }) => { const { t } = useTranslation(); const { trackEvent } = useAnalytics(); + const { colorScheme } = useColorScheme(); const { latitude, longitude } = useLocationStore(); + const isDark = colorScheme === 'dark'; + const [activeIndex, setActiveIndex] = useState(0); const [isUploading, setIsUploading] = useState(false); const [newImageNote, setNewImageNote] = useState(''); @@ -59,9 +50,9 @@ const CallImagesModal: React.FC = ({ isOpen, onClose, call const [isAddingImage, setIsAddingImage] = useState(false); const [imageErrors, setImageErrors] = useState>(new Set()); const [fullScreenImage, setFullScreenImage] = useState<{ source: any; name?: string } | null>(null); - const flatListRef = useRef(null); // FlashList ref type + const flatListRef = useRef>(null); - const { callImages, isLoadingImages, errorImages, fetchCallImages, uploadCallImage } = useCallDetailStore(); + const { callImages, isLoadingImages, errorImages, fetchCallImages, uploadCallImage, clearImages } = useCallDetailStore(); // Filter out images without proper data or URL const validImages = useMemo(() => { @@ -81,8 +72,20 @@ const CallImagesModal: React.FC = ({ isOpen, onClose, call fetchCallImages(callId); setActiveIndex(0); // Reset active index when opening setImageErrors(new Set()); // Reset image errors + } else { + // Cleanup when modal closes to free memory + setFullScreenImage(null); + setSelectedImageInfo(null); + setImageErrors(new Set()); + // Clear images from store to free memory + clearImages(); } - }, [isOpen, callId, fetchCallImages]); + + // Unmount cleanup + return () => { + clearImages(); + }; + }, [isOpen, callId, fetchCallImages, clearImages]); // Track when call images modal is opened/rendered useEffect(() => { @@ -175,6 +178,7 @@ const CallImagesModal: React.FC = ({ isOpen, onClose, call setSelectedImageInfo(null); setNewImageNote(''); setIsAddingImage(false); + Keyboard.dismiss(); } catch (error) { console.error('Error uploading image:', error); } finally { @@ -182,11 +186,30 @@ const CallImagesModal: React.FC = ({ isOpen, onClose, call } }; + const handleCancelAdd = useCallback(() => { + setIsAddingImage(false); + setSelectedImageInfo(null); + setNewImageNote(''); + Keyboard.dismiss(); + }, []); + + const handleClose = useCallback(() => { + setNewImageNote(''); + setSelectedImageInfo(null); + setIsAddingImage(false); + Keyboard.dismiss(); + onClose(); + }, [onClose]); + const handleImageError = (itemId: string, error: any) => { console.error(`Image failed to load for ${itemId}:`, error); setImageErrors((prev) => new Set([...prev, itemId])); }; + const handleImagePress = useCallback((source: { uri: string }, name?: string) => { + setFullScreenImage({ source, name }); + }, []); + // Reset active index when valid images change const renderImageItem = ({ item, index }: { item: CallFileResultData; index: number }) => { @@ -227,16 +250,7 @@ const CallImagesModal: React.FC = ({ isOpen, onClose, call // At this point, imageSource is guaranteed to be non-null return ( - { - setFullScreenImage({ source: imageSource, name: item.Name }); - }} - testID={`image-${item.Id}-touchable`} - activeOpacity={0.7} - style={{ width: '100%' }} - delayPressIn={0} - delayPressOut={0} - > + handleImagePress(imageSource, item.Name)} testID={`image-${item.Id}-touchable`} activeOpacity={0.7} style={{ width: '100%' }} delayPressIn={0} delayPressOut={0}> = ({ isOpen, onClose, call contentFit="contain" transition={200} pointerEvents="none" + cachePolicy="memory-disk" + recyclingKey={item.Id} onError={() => { handleImageError(item.Id, 'expo-image load error'); }} @@ -323,58 +339,60 @@ const CallImagesModal: React.FC = ({ isOpen, onClose, call }; const renderAddImageContent = () => ( - + <> {/* Scrollable content area */} - - - {t('callImages.add_new')} - { - setIsAddingImage(false); - setSelectedImageInfo(null); - setNewImageNote(''); - }} - > - - - - + {selectedImageInfo ? ( - - + + ) : ( - - - - - {t('callImages.select_from_gallery')} + + + + + {t('callImages.select_from_gallery')} - - - - - {t('callImages.take_photo')} + + + + + {t('callImages.take_photo')} - + )} - - - {/* Fixed bottom section for input and save button */} - {selectedImageInfo && ( - - - - - - - - + + + {/* Fixed bottom section for input and save button - Sticks to keyboard */} + {selectedImageInfo ? ( + + + + {t('callImages.image_note')} + + + + + + + + + + + + ) : ( + + + )} - + ); const renderImageGallery = () => { @@ -432,29 +450,34 @@ const CallImagesModal: React.FC = ({ isOpen, onClose, call return renderImageGallery(); }; + if (!isOpen) { + return null; + } + return ( <> - - - - - - - - - {t('callImages.title')} - {!isAddingImage && !isLoadingImages && ( + + + {/* Header */} + + {isAddingImage ? t('callImages.add_new') : t('callImages.title')} + + {!isAddingImage && !isLoadingImages ? ( - )} + ) : null} + + + + - {renderContent()} - - - + {/* Content */} + {renderContent()} + + {/* Full Screen Image Modal */} setFullScreenImage(null)} imageSource={fullScreenImage?.source || { uri: '' }} imageName={fullScreenImage?.name} /> @@ -462,4 +485,64 @@ const CallImagesModal: React.FC = ({ isOpen, onClose, call ); }; -export default CallImagesModal; +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: 'white', + }, + containerDark: { + backgroundColor: '#1F2937', + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 16, + paddingVertical: 12, + borderBottomWidth: 1, + borderBottomColor: '#E5E7EB', + }, + headerDark: { + borderBottomColor: '#374151', + }, + closeButton: { + padding: 8, + }, + contentContainer: { + flex: 1, + }, + addImageContainer: { + flex: 1, + }, + imageOptionButton: { + padding: 16, + borderRadius: 8, + backgroundColor: '#F3F4F6', + }, + imageOptionButtonDark: { + backgroundColor: '#374151', + }, + footer: { + paddingHorizontal: 16, + paddingVertical: 12, + borderTopWidth: 1, + borderTopColor: '#E5E7EB', + backgroundColor: 'white', + }, + footerDark: { + borderTopColor: '#374151', + backgroundColor: '#1F2937', + }, + galleryImage: { + height: 256, + width: '100%', + borderRadius: 8, + }, + previewImage: { + height: 256, + width: '100%', + borderRadius: 8, + }, +}); + +export default memo(CallImagesModal); diff --git a/src/components/calls/call-notes-modal.tsx b/src/components/calls/call-notes-modal.tsx index 2d0db060..f38c5c09 100644 --- a/src/components/calls/call-notes-modal.tsx +++ b/src/components/calls/call-notes-modal.tsx @@ -1,11 +1,9 @@ -import type { BottomSheetBackdropProps } from '@gorhom/bottom-sheet'; -import BottomSheet, { BottomSheetBackdrop, BottomSheetView } from '@gorhom/bottom-sheet'; import { SearchIcon, X } from 'lucide-react-native'; -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useColorScheme } from 'nativewind'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Keyboard, Platform, useWindowDimensions } from 'react-native'; -import { ScrollView } from 'react-native-gesture-handler'; -import { KeyboardAwareScrollView } from 'react-native-keyboard-controller'; +import { FlatList, Keyboard, Modal, SafeAreaView, StyleSheet, TouchableOpacity, View } from 'react-native'; +import { KeyboardStickyView } from 'react-native-keyboard-controller'; import { useAnalytics } from '@/hooks/use-analytics'; import { useAuthStore } from '@/lib/auth'; @@ -13,18 +11,13 @@ import { useCallDetailStore } from '@/stores/calls/detail-store'; import { Loading } from '../common/loading'; import ZeroState from '../common/zero-state'; -import { FocusAwareStatusBar } from '../ui'; import { Box } from '../ui/box'; import { Button, ButtonText } from '../ui/button'; -import { Divider } from '../ui/divider'; import { Heading } from '../ui/heading'; import { HStack } from '../ui/hstack'; -import { Input } from '../ui/input'; -import { InputSlot } from '../ui/input'; -import { InputField } from '../ui/input'; +import { Input, InputField, InputSlot } from '../ui/input'; import { Text } from '../ui/text'; -import { Textarea } from '../ui/textarea'; -import { TextareaInput } from '../ui/textarea'; +import { Textarea, TextareaInput } from '../ui/textarea'; import { VStack } from '../ui/vstack'; interface CallNotesModalProps { @@ -36,23 +29,18 @@ interface CallNotesModalProps { const CallNotesModal = ({ isOpen, onClose, callId }: CallNotesModalProps) => { const { t } = useTranslation(); const { trackEvent } = useAnalytics(); + const { colorScheme } = useColorScheme(); const [searchQuery, setSearchQuery] = useState(''); const [newNote, setNewNote] = useState(''); const { callNotes, addNote, searchNotes, isNotesLoading, fetchCallNotes } = useCallDetailStore(); const { profile } = useAuthStore(); - const { height } = useWindowDimensions(); - // Bottom sheet ref and snap points - const bottomSheetRef = useRef(null); - const snapPoints = useMemo(() => ['67%'], []); + const isDark = colorScheme === 'dark'; // Fetch call notes when modal opens useEffect(() => { if (isOpen && callId) { fetchCallNotes(callId); - bottomSheetRef.current?.expand(); - } else { - bottomSheetRef.current?.close(); } }, [isOpen, callId, fetchCallNotes]); @@ -68,7 +56,7 @@ const CallNotesModal = ({ isOpen, onClose, callId }: CallNotesModalProps) => { } }, [isOpen, trackEvent, callId, callNotes.length, searchQuery.length, isNotesLoading]); - const filteredNotes = React.useMemo(() => { + const filteredNotes = useMemo(() => { return searchNotes(searchQuery); // eslint-disable-next-line react-hooks/exhaustive-deps }, [searchQuery, searchNotes, callNotes]); @@ -76,7 +64,7 @@ const CallNotesModal = ({ isOpen, onClose, callId }: CallNotesModalProps) => { // Get current user from profile const currentUser = profile?.sub || ''; - const handleAddNote = React.useCallback(async () => { + const handleAddNote = useCallback(async () => { if (newNote.trim()) { try { await addNote(callId, newNote, currentUser, null, null); @@ -88,97 +76,138 @@ const CallNotesModal = ({ isOpen, onClose, callId }: CallNotesModalProps) => { } }, [newNote, callId, currentUser, addNote]); - // Handle sheet changes - const handleSheetChanges = useCallback( - (index: number) => { - if (index === -1) { - onClose(); - } - }, - [onClose] + const handleClose = useCallback(() => { + setNewNote(''); + Keyboard.dismiss(); + onClose(); + }, [onClose]); + + const renderNote = useCallback( + ({ item: note }: { item: (typeof filteredNotes)[0] }) => ( + + {note.Note} + + {note.FullName} + {note.TimestampFormatted} + + + ), + [] ); - // Render backdrop - const renderBackdrop = useCallback((props: BottomSheetBackdropProps) => , []); + if (!isOpen) { + return null; + } return ( - <> -