diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index bd883f7..bb7a763 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -10,6 +10,7 @@ android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="false" android:theme="@style/AppTheme" + android:networkSecurityConfig="@xml/network_security_config" android:supportsRtl="true"> + + + + +DICOM Viewer - Fullscreen Mobile Friendly + + + + +
+
+ + + + +
+
+ + + + +
+
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index 1d5d02a..7ca2df2 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png index 1d5d02a..7ca2df2 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png index fed7c12..3786822 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png index fed7c12..3786822 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index 0d78f7e..5d69dbc 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png index 0d78f7e..5d69dbc 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index 8ef759a..33da03c 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png index 8ef759a..33da03c 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index b4c03d7..548e97d 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png index b4c03d7..548e97d 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/playstore.png b/android/app/src/main/res/playstore.png index bbab458..311caef 100644 Binary files a/android/app/src/main/res/playstore.png and b/android/app/src/main/res/playstore.png differ diff --git a/android/app/src/main/res/xml/network_security_config.xml b/android/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..12f2981 --- /dev/null +++ b/android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/assets/dicom/dicom-viewer.html b/app/assets/dicom/dicom-viewer.html index 4e128a1..0f7adf1 100644 --- a/app/assets/dicom/dicom-viewer.html +++ b/app/assets/dicom/dicom-viewer.html @@ -2,376 +2,429 @@ - -DICOM Viewer - Mobile Friendly + +DICOM Viewer - Fullscreen Mobile Friendly -
-

DICOM Viewer

-
- Ready to load DICOM files - Loading... +
+
+ + + -
-
-
- - -
- -
- - - -
- -
-
- -
No images
- +
+
+ + + +
+
+ + + +
+
-
-
-
🩻
-
DICOM Viewer
-
No image loaded
DICOM files will be loaded from parent component
-
-
+ + + + + + @@ -382,345 +435,299 @@ diff --git a/app/assets/dicom/test-dicom-viewer.html b/app/assets/dicom/test-dicom-viewer.html index 0f7c363..84a39b5 100644 --- a/app/assets/dicom/test-dicom-viewer.html +++ b/app/assets/dicom/test-dicom-viewer.html @@ -1,443 +1,96 @@ - + - DICOM Viewer Test + Dummy Page -
-

DICOM Viewer Test

-

Test the DICOM viewer functionality in your browser before using it in React Native.

- -
-

Sample DICOM URLs

-
-
- Sample 1:
- LIDC-IDRI-0001 -
-
- Sample 2:
- LIDC-IDRI-0001 -
-
- Sample 3:
- LIDC-IDRI-0001 -
-
-
+
+

Welcome to the Dummy Page

+

This is just a placeholder website.

+
-
-

Custom DICOM URL

- - -
+ -
-
Ready to load DICOM image
-
-
Click a sample URL above or enter a custom URL to load a DICOM image
-
-
+
+
+

About Us

+

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec ac orci vel nisi gravida feugiat.

+
- +
+

Our Services

+
    +
  • Service One
  • +
  • Service Two
  • +
  • Service Three
  • +
+
-
-
+
+

Contact

+
+
+

+ +
+

+ +
+

+ + +
+
+ - +
+

© 2025 Dummy Company. All rights reserved.

+
diff --git a/app/modules/Auth/redux/authActions.ts b/app/modules/Auth/redux/authActions.ts index d603953..66eab1e 100644 --- a/app/modules/Auth/redux/authActions.ts +++ b/app/modules/Auth/redux/authActions.ts @@ -18,7 +18,6 @@ export const login = createAsyncThunk( async (credentials: { email: string; password: string }, { rejectWithValue }) => { try { const response:any = await authAPI.login(credentials.email, credentials.password,'web'); - console.log('user response',response) if(response.data.message && !response.data.success){ showError(response.data.message) diff --git a/app/modules/Auth/services/authAPI.ts b/app/modules/Auth/services/authAPI.ts index 156e4cc..8f8757b 100644 --- a/app/modules/Auth/services/authAPI.ts +++ b/app/modules/Auth/services/authAPI.ts @@ -40,6 +40,14 @@ export const authAPI = { }, }), + //upload profile photo for onboarding + uploadProfilePhoto: (formData:any, token:string | undefined) => api.post('/api/auth/onboarding/upload-profile-photo', formData, { + headers: { + 'Content-Type': 'multipart/form-data', + ...(token && { 'Authorization': `Bearer ${token}` }), + }, + }), + // Update user profile updateUserProfile: (userId: string, profileData: { first_name: string; diff --git a/app/modules/Dashboard/screens/DashboardScreen.tsx b/app/modules/Dashboard/screens/DashboardScreen.tsx index 07b6c44..a11d9ab 100644 --- a/app/modules/Dashboard/screens/DashboardScreen.tsx +++ b/app/modules/Dashboard/screens/DashboardScreen.tsx @@ -21,6 +21,8 @@ import { DashboardHeader } from '../components/DashboardHeader'; import { BrainPredictionsOverview } from '../components/BrainPredictionsOverview'; import { FeedbackAnalysisPieChart } from '../components/FeedbackAnalysisPieChart'; import { useAIDashboard } from '../hooks/useAIDashboard'; +import { selectUserDisplayName, selectUserFirstName } from '../../Auth/redux/authSelectors'; +import { useAppSelector } from '../../../store/hooks'; /** * DashboardScreenProps Interface @@ -123,7 +125,7 @@ export const DashboardScreen: React.FC = ({ navigation, }) => { // ============================================================================ - // CUSTOM HOOKS + // CUSTOM HOOKS & SELECTORS // ============================================================================ // Use custom hook for AI dashboard functionality @@ -136,6 +138,40 @@ export const DashboardScreen: React.FC = ({ refreshDashboardStatistics } = useAIDashboard(); + // Get user display name from auth state + const userDisplayName = useAppSelector(selectUserFirstName); + + // ============================================================================ + // HELPER FUNCTIONS + // ============================================================================ + + /** + * getPersonalizedGreeting Function + * + * Purpose: Generate a personalized greeting based on time of day and user's display name + * + * @returns Personalized greeting string + */ + const getPersonalizedGreeting = (): string => { + const currentHour = new Date().getHours(); + let timeGreeting = ''; + + // Determine time-based greeting + if (currentHour >= 5 && currentHour < 12) { + timeGreeting = 'Good Morning'; + } else if (currentHour >= 12 && currentHour < 17) { + timeGreeting = 'Good Afternoon'; + } else if (currentHour >= 17 && currentHour < 21) { + timeGreeting = 'Good Evening'; + } else { + timeGreeting = 'Good Evening'; + } + + // Create personalized greeting with fallback + const displayName = userDisplayName || 'Doctor'; + return `${timeGreeting}, Dr. ${displayName}`; + }; + // ============================================================================ // EVENT HANDLERS // ============================================================================ @@ -611,7 +647,7 @@ export const DashboardScreen: React.FC = ({ {/* Dashboard header with title and refresh button */} - AI Analysis Dashboard + {getPersonalizedGreeting()} {dashboardMessage} @@ -761,10 +797,11 @@ const styles = StyleSheet.create({ // Dashboard title styling dashboardTitle: { - fontSize: theme.typography.fontSize.displayLarge, + fontSize: theme.typography.fontSize.displayMedium, fontFamily: theme.typography.fontFamily.bold, color: theme.colors.textPrimary, marginBottom: theme.spacing.xs, + marginTop: theme.spacing.sm, }, // Dashboard subtitle styling diff --git a/app/modules/PatientCare/components/PatientCard.tsx b/app/modules/PatientCare/components/PatientCard.tsx index 8431994..3919873 100644 --- a/app/modules/PatientCare/components/PatientCard.tsx +++ b/app/modules/PatientCare/components/PatientCard.tsx @@ -200,7 +200,6 @@ const PatientCard: React.FC = ({ // ============================================================================ // MAIN RENDER // ============================================================================ - return ( = ({ navigation, const [showFullImage, setShowFullImage] = useState(false); const [activeTab, setActiveTab] = useState<'overview' | 'aiAnalysis' | 'history'>('overview'); + // DICOM Modal state + const [dicomModalVisible, setDicomModalVisible] = useState(false); + const [selectedDicomData, setSelectedDicomData] = useState<{ + dicomUrl: string; + seriesData: SeriesSummary; + prediction?: Prediction; + } | null>(null); + // Navigation state const [selectedSeriesForDetail, setSelectedSeriesForDetail] = useState(null); @@ -222,24 +231,77 @@ const PatientDetailsScreen: React.FC = ({ navigation, }, [fetchPatientData]); /** - * Handle Image Press + * Handle DICOM Image Press * - * Purpose: Open full-screen image viewer for selected series + * Purpose: Open DICOM viewer modal for selected series * * @param seriesIndex - Index of the series */ const handleImagePress = useCallback((seriesIndex: number) => { - setSelectedImageIndex(seriesIndex); - setShowFullImage(true); - }, []); + if (!patientData || !patientData.series_summary[seriesIndex]) return; + + const series = patientData.series_summary[seriesIndex]; + const seriesPredictions = patientData.predictions_by_series[series.series_num] || []; + const firstPrediction = seriesPredictions[0]; + + if (firstPrediction?.preview) { + const dicomUrl = API_CONFIG.BASE_URL +'/api/dicom'+ firstPrediction.file_path; + console.log('dicomUrl', dicomUrl); + setSelectedDicomData({ + dicomUrl, + seriesData: series, + prediction: firstPrediction, + }); + setDicomModalVisible(true); + } else { + Alert.alert( + 'No DICOM Available', + 'No DICOM image is available for this series.', + [{ text: 'OK' }] + ); + } + }, [patientData]); /** - * Handle Close Image Viewer + * Handle Open DICOM Modal * - * Purpose: Close full-screen image viewer + * Purpose: Open DICOM modal with specific series and prediction data + * + * @param series - Series data + * @param prediction - Optional prediction data */ - const handleCloseImageViewer = useCallback(() => { - setShowFullImage(false); + const handleOpenDicomModal = useCallback((series: SeriesSummary, prediction?: Prediction) => { + if (!patientData) return; + + const seriesPredictions = patientData.predictions_by_series[series.series_num] || []; + const targetPrediction = prediction || seriesPredictions[0]; + + if (targetPrediction?.preview) { + const dicomUrl = API_CONFIG.DICOM_BASE_URL + targetPrediction.preview; + + setSelectedDicomData({ + dicomUrl, + seriesData: series, + prediction: targetPrediction, + }); + setDicomModalVisible(true); + } else { + Alert.alert( + 'No DICOM Available', + 'No DICOM image is available for this series.', + [{ text: 'OK' }] + ); + } + }, [patientData]); + + /** + * Handle Close DICOM Modal + * + * Purpose: Close DICOM viewer modal and reset state + */ + const handleCloseDicomModal = useCallback(() => { + setDicomModalVisible(false); + setSelectedDicomData(null); }, []); /** @@ -696,7 +758,7 @@ const PatientDetailsScreen: React.FC = ({ navigation, const hasPredictions = seriesPredictions.length > 0; return ( - + {/* Series Header */} @@ -784,7 +846,7 @@ const PatientDetailsScreen: React.FC = ({ navigation, {hasPredictions ? ( seriesPredictions.map((prediction) => ( - + {prediction.prediction.label} = ({ navigation, {activeTab === 'history' && renderHistoryTab()} - + {/* DICOM Viewer Modal */} + {selectedDicomData && ( + + )} ); diff --git a/app/modules/PatientCare/screens/SeriesDetailScreen.tsx b/app/modules/PatientCare/screens/SeriesDetailScreen.tsx index c72f19a..5f3af3b 100644 --- a/app/modules/PatientCare/screens/SeriesDetailScreen.tsx +++ b/app/modules/PatientCare/screens/SeriesDetailScreen.tsx @@ -33,6 +33,7 @@ import { theme } from '../../../theme/theme'; import { useAppDispatch, useAppSelector } from '../../../store/hooks'; import Icon from 'react-native-vector-icons/Feather'; import { SafeAreaView } from 'react-native-safe-area-context'; +import { DicomViewerModal } from '../../../shared/components'; // Import types and API import { patientAPI } from '../services/patientAPI'; @@ -151,6 +152,14 @@ const SeriesDetailScreen: React.FC = ({ navigation, rou // Track newly added feedback for visual indication const [newFeedbackIds, setNewFeedbackIds] = useState>(new Set()); + + // DICOM Modal state + const [dicomModalVisible, setDicomModalVisible] = useState(false); + const [selectedDicomData, setSelectedDicomData] = useState<{ + dicomUrl: string; + prediction: Prediction; + imageIndex: number; + } | null>(null); // ============================================================================ // EFFECTS @@ -218,6 +227,44 @@ const SeriesDetailScreen: React.FC = ({ navigation, rou navigation.goBack(); }, [navigation]); + /** + * Handle DICOM Image Press + * + * Purpose: Open DICOM viewer modal for selected prediction image + * + * @param prediction - Prediction data containing DICOM file path + * @param imageIndex - Index of the image in the series + */ + const handleDicomImagePress = useCallback((prediction: Prediction, imageIndex: number) => { + if (prediction?.file_path) { + const dicomUrl = API_CONFIG.BASE_URL + '/api/dicom' + prediction.file_path; + console.log('DICOM URL:', dicomUrl); + + setSelectedDicomData({ + dicomUrl, + prediction, + imageIndex, + }); + setDicomModalVisible(true); + } else { + Alert.alert( + 'No DICOM Available', + 'No DICOM file path is available for this image.', + [{ text: 'OK' }] + ); + } + }, []); + + /** + * Handle Close DICOM Modal + * + * Purpose: Close DICOM viewer modal and reset state + */ + const handleCloseDicomModal = useCallback(() => { + setDicomModalVisible(false); + setSelectedDicomData(null); + }, []); + /** * Handle Refresh * @@ -1337,11 +1384,22 @@ const SeriesDetailScreen: React.FC = ({ navigation, rou {predictions.map((prediction: Prediction, index: number) => ( {prediction.preview ? ( - + handleDicomImagePress(prediction, index)} + activeOpacity={0.7} + > + + {/* Overlay to indicate clickable */} + + + View DICOM + + ) : ( @@ -1704,6 +1762,19 @@ const SeriesDetailScreen: React.FC = ({ navigation, rou )} + + {/* DICOM Viewer Modal */} + {selectedDicomData && ( + + )} + ); }; @@ -1934,11 +2005,34 @@ const styles = StyleSheet.create({ alignItems: 'center', marginRight: theme.spacing.md, }, + imageClickable: { + position: 'relative', + marginBottom: theme.spacing.xs, + borderRadius: 8, + overflow: 'hidden', + }, seriesImage: { width: 120, height: 120, borderRadius: 8, - marginBottom: theme.spacing.xs, + }, + imageOverlay: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: 'rgba(0, 0, 0, 0.8)', + justifyContent: 'center', + alignItems: 'center', + opacity: 1, + }, + imageOverlayText: { + color: theme.colors.background, + fontSize: 10, + fontFamily: theme.typography.fontFamily.bold, + marginTop: 4, + textAlign: 'center', }, noImagePlaceholder: { width: 120, diff --git a/app/modules/Settings/screens/SettingsScreen.tsx b/app/modules/Settings/screens/SettingsScreen.tsx index ba9df8d..75d0eda 100644 --- a/app/modules/Settings/screens/SettingsScreen.tsx +++ b/app/modules/Settings/screens/SettingsScreen.tsx @@ -14,6 +14,11 @@ import { Alert, RefreshControl, Image, + TouchableOpacity, + ActivityIndicator, + ActionSheetIOS, + Platform, + PermissionsAndroid, } from 'react-native'; import { theme } from '../../../theme/theme'; import { @@ -27,6 +32,7 @@ import { ProfileCard } from '../components/ProfileCard'; import { CustomModal } from '../../../shared/components'; import { useAppDispatch, useAppSelector } from '../../../store/hooks'; import { logoutUser } from '../../Auth/redux/authActions'; +import { updateUserProfile } from '../../Auth/redux/authSlice'; import { selectUser, selectUserDisplayName, @@ -37,6 +43,14 @@ import { selectDashboardSettings } from '../../Auth/redux/authSelectors'; import { API_CONFIG } from '../../../shared/utils'; +import { authAPI } from '../../Auth/services/authAPI'; +import { + launchImageLibrary, + launchCamera, + ImagePickerResponse, + MediaType +} from 'react-native-image-picker'; +import Icon from 'react-native-vector-icons/Feather'; /** * SettingsScreenProps Interface @@ -84,6 +98,19 @@ export const SettingsScreen: React.FC = ({ // UI state const [refreshing, setRefreshing] = useState(false); + // Profile photo state + const [uploadingPhoto, setUploadingPhoto] = useState(false); + const [tempProfilePhoto, setTempProfilePhoto] = useState(null); + + // Upload response interface + interface UploadPhotoResponse { + success: boolean; + message?: string; + data?: { + profile_photo_url: string; + }; + } + // Modal state const [modalVisible, setModalVisible] = useState(false); const [modalConfig, setModalConfig] = useState({ @@ -197,10 +224,345 @@ export const SettingsScreen: React.FC = ({ setSettingsSections(generateSettingsSections()); }, [user, dashboardSettings]); + // ============================================================================ + // PERMISSION HANDLERS + // ============================================================================ + + /** + * requestCameraPermission Function + * + * Purpose: Request camera permission for Android devices + * + * @returns Promise - Whether permission was granted + */ + const requestCameraPermission = async (): Promise => { + if (Platform.OS === 'android') { + try { + const granted = await PermissionsAndroid.request( + PermissionsAndroid.PERMISSIONS.CAMERA, + { + title: 'Camera Permission', + message: 'This app needs camera permission to capture profile photos.', + buttonNeutral: 'Ask Me Later', + buttonNegative: 'Cancel', + buttonPositive: 'OK', + } + ); + return granted === PermissionsAndroid.RESULTS.GRANTED; + } catch (err) { + console.warn('Camera permission error:', err); + return false; + } + } + return true; // iOS permissions are handled via Info.plist + }; + // ============================================================================ // EVENT HANDLERS // ============================================================================ + /** + * handleProfilePhotoUpdate Function + * + * Purpose: Show action sheet with camera and gallery options for profile photo update + * + * Flow: + * 1. Show action sheet with camera and gallery options + * 2. Handle user selection + * 3. Launch appropriate image picker + */ + const handleProfilePhotoUpdate = () => { + if (Platform.OS === 'ios') { + ActionSheetIOS.showActionSheetWithOptions( + { + options: ['Cancel', 'Take Photo', 'Choose from Gallery'], + cancelButtonIndex: 0, + userInterfaceStyle: 'light', + }, + (buttonIndex) => { + if (buttonIndex === 1) { + handleCameraCapture(); + } else if (buttonIndex === 2) { + handleGallerySelection(); + } + } + ); + } else { + // For Android, show custom action sheet or Alert + Alert.alert( + 'Update Profile Photo', + 'Choose an option', + [ + { text: 'Cancel', style: 'cancel' }, + { text: 'Take Photo', onPress: handleCameraCapture }, + { text: 'Choose from Gallery', onPress: handleGallerySelection }, + ] + ); + } + }; + + /** + * handleCameraCapture Function + * + * Purpose: Launch camera to capture new profile photo + * + * Flow: + * 1. Check camera permissions + * 2. Launch camera with callback + * 3. Validate captured image + * 4. Upload to server + * 5. Update local state + */ + const handleCameraCapture = async () => { + try { + // Check camera permission first + const hasPermission = await requestCameraPermission(); + + if (!hasPermission) { + setModalConfig({ + title: 'Permission Required', + message: 'Camera permission is required to capture profile photos.', + type: 'error', + onConfirm: () => {}, + showCancel: false, + icon: 'camera', + }); + setModalVisible(true); + return; + } + + // Launch camera with callback + const options = { + mediaType: 'photo' as MediaType, + quality: 0.8 as const, + maxWidth: 800, + maxHeight: 800, + saveToPhotos: false, + includeBase64: false, + }; + + launchCamera(options, (response: ImagePickerResponse) => { + try { + // Handle user cancellation + if (response.didCancel) { + return; + } + + // Handle errors + if (response.errorMessage) { + throw new Error(response.errorMessage); + } + + // Validate response and assets + if (!response.assets || response.assets.length === 0) { + throw new Error('No image captured'); + } + + const asset = response.assets[0]; + if (!asset.uri) { + throw new Error('Invalid image data'); + } + + // Validate file size (max 5MB) + if (asset.fileSize && asset.fileSize > 5 * 1024 * 1024) { + throw new Error('Image size must be less than 5MB'); + } + + // Set temporary photo for preview + setTempProfilePhoto(asset.uri); + + // Upload the captured photo + uploadProfilePhoto(asset.uri); + + } catch (error) { + console.error('Camera capture processing error:', error); + setModalConfig({ + title: 'Camera Error', + message: error instanceof Error ? error.message : 'Failed to capture photo', + type: 'error', + onConfirm: () => {}, + showCancel: false, + icon: 'alert-circle', + }); + setModalVisible(true); + } + }); + + } catch (error) { + console.error('Camera launch error:', error); + setModalConfig({ + title: 'Error', + message: 'Failed to launch camera. Please try again.', + type: 'error', + onConfirm: () => {}, + showCancel: false, + icon: 'alert-circle', + }); + setModalVisible(true); + } + }; + + /** + * handleGallerySelection Function + * + * Purpose: Launch gallery to select existing profile photo + * + * Flow: + * 1. Launch image picker with callback + * 2. Validate selected image + * 3. Upload to server + * 4. Update local state + */ + const handleGallerySelection = () => { + try { + // Launch image picker with callback + const options = { + mediaType: 'photo' as MediaType, + quality: 0.8 as const, + maxWidth: 800, + maxHeight: 800, + includeBase64: false, + }; + + launchImageLibrary(options, (response: ImagePickerResponse) => { + try { + // Handle user cancellation + if (response.didCancel) { + return; + } + + // Handle errors + if (response.errorMessage) { + throw new Error(response.errorMessage); + } + + // Validate response and assets + if (!response.assets || response.assets.length === 0) { + throw new Error('No image selected'); + } + + const asset = response.assets[0]; + if (!asset.uri) { + throw new Error('Invalid image data'); + } + + // Validate file size (max 5MB) + if (asset.fileSize && asset.fileSize > 5 * 1024 * 1024) { + throw new Error('Image size must be less than 5MB'); + } + + // Set temporary photo for preview + setTempProfilePhoto(asset.uri); + + // Upload the selected photo + uploadProfilePhoto(asset.uri); + + } catch (error) { + console.error('Gallery selection processing error:', error); + setModalConfig({ + title: 'Gallery Error', + message: error instanceof Error ? error.message : 'Failed to select photo', + type: 'error', + onConfirm: () => {}, + showCancel: false, + icon: 'alert-circle', + }); + setModalVisible(true); + } + }); + + } catch (error) { + console.error('Gallery launch error:', error); + setModalConfig({ + title: 'Error', + message: 'Failed to open gallery. Please try again.', + type: 'error', + onConfirm: () => {}, + showCancel: false, + icon: 'alert-circle', + }); + setModalVisible(true); + } + }; + + /** + * uploadProfilePhoto Function + * + * Purpose: Upload selected profile photo to server + * + * @param imageUri - URI of the selected image + */ + const uploadProfilePhoto = async (imageUri: string) => { + try { + setUploadingPhoto(true); + + // Create form data + const formData = new FormData(); + formData.append('profile_photo', { + uri: imageUri, + type: 'image/jpeg', + name: 'profile_photo.jpg', + } as any); + + // Get user token from Redux + const token = user?.access_token; + + if (!token) { + throw new Error('Authentication token not found'); + } + + // Upload using authAPI + const response = await authAPI.uploadProfilePhoto(formData, token); + + // Type the response properly + const responseData = response.data as UploadPhotoResponse; + + if (responseData.success) { + // Update local state with new photo + setTempProfilePhoto(null); + + // Update Redux state with new profile photo URL + if (responseData.data?.profile_photo_url) { + console.log('Updating user profile with new photo URL:', responseData.data.profile_photo_url); + dispatch(updateUserProfile({ + self_url: responseData.data.profile_photo_url + })); + console.log('Redux state updated successfully'); + } + + // Show success message + setModalConfig({ + title: 'Success', + message: responseData.message || 'Profile photo updated successfully!', + type: 'success', + icon: 'check-circle', + onConfirm: () => { + // Optional: Refresh if needed, but Redux update should be enough + // handleRefresh(); + }, + showCancel: false, + }); + setModalVisible(true); + } else { + throw new Error(responseData.message || 'Upload failed'); + } + + } catch (error) { + console.error('Error uploading photo:', error); + setModalConfig({ + title: 'Upload Failed', + message: error instanceof Error ? error.message : 'Failed to upload profile photo', + type: 'error', + icon: 'alert-circle', + onConfirm: () => {}, + showCancel: false, + }); + setModalVisible(true); + } finally { + setUploadingPhoto(false); + } + }; + /** * handleRefresh Function * @@ -333,7 +695,7 @@ export const SettingsScreen: React.FC = ({ }; -console.log('user', user) + // ============================================================================ // MAIN RENDER // ============================================================================ @@ -361,10 +723,20 @@ console.log('user', user) {user && ( - - {user.profile_photo_url ? ( + + {tempProfilePhoto ? ( + ) : user.self_url ? ( + @@ -375,7 +747,22 @@ console.log('user', user) )} - + + {/* Edit icon overlay */} + + + + + {/* Loading indicator */} + {uploadingPhoto && ( + + + + )} + @@ -389,12 +776,12 @@ console.log('user', user) )} {/* Settings sections */} - {settingsSections.map((section) => ( - - ))} + {settingsSections.map((section, index) => + React.createElement(SettingsSectionComponent, { + key: `${section.id}-${index}`, + section: section + }) + )} {/* Bottom spacing for tab bar */} @@ -522,6 +909,33 @@ const styles = StyleSheet.create({ color: theme.colors.primary, }, + // Edit icon overlay for profile photo update + editIconOverlay: { + position: 'absolute', + bottom: 0, + right: 0, + backgroundColor: theme.colors.primary, + borderRadius: 12, + width: 24, + height: 24, + justifyContent: 'center', + alignItems: 'center', + borderWidth: 2, + borderColor: theme.colors.background, + }, + + // Uploading overlay with loading indicator + uploadingOverlay: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: 'rgba(0,0,0,0.5)', + borderRadius: 30, + }, }); diff --git a/app/shared/components/DicomViewer.tsx b/app/shared/components/DicomViewer.tsx index 9d0580a..b6ffb9a 100644 --- a/app/shared/components/DicomViewer.tsx +++ b/app/shared/components/DicomViewer.tsx @@ -14,7 +14,6 @@ interface DicomViewerProps { dicomUrl: string; onError?: (error: string) => void; onLoad?: () => void; - debugMode?: boolean; } // Interface for WebView reference @@ -23,40 +22,24 @@ interface WebViewRef { reload: () => void; } -export default function DicomViewer({ dicomUrl, onError, onLoad, debugMode = false }: DicomViewerProps): React.ReactElement { +export default function DicomViewer({ dicomUrl, onError, onLoad }: DicomViewerProps): React.ReactElement { const webViewRef = useRef(null); - const [isLoading, setIsLoading] = useState(true); const [hasError, setHasError] = useState(false); - const [debugInfo, setDebugInfo] = useState([]); const [webViewReady, setWebViewReady] = useState(false); - // Debug logging function - const debugLog = (message: string) => { - if (debugMode) { - const timestamp = new Date().toLocaleTimeString(); - const logMessage = `[${timestamp}] ${message}`; - console.log(logMessage); - setDebugInfo(prev => [...prev.slice(-9), logMessage]); // Keep last 10 messages - } - }; + // Handle WebView load events const handleLoadStart = () => { - debugLog('WebView load started'); - setIsLoading(true); setHasError(false); }; const handleLoadEnd = () => { - debugLog('WebView load ended'); - setIsLoading(false); setWebViewReady(true); onLoad?.(); }; const handleError = (error: any) => { - debugLog(`WebView error: ${JSON.stringify(error)}`); - setIsLoading(false); setHasError(true); onError?.(error?.nativeEvent?.description || 'Failed to load DICOM viewer'); }; @@ -64,13 +47,11 @@ export default function DicomViewer({ dicomUrl, onError, onLoad, debugMode = fal const handleMessage = (event: WebViewMessageEvent) => { try { const message = event.nativeEvent.data; - debugLog(`Message from WebView: ${message}`); // Try to parse JSON message if (typeof message === 'string') { try { const parsedMessage = JSON.parse(message); - debugLog(`Parsed message: ${JSON.stringify(parsedMessage)}`); if (parsedMessage.type === 'error') { setHasError(true); @@ -79,26 +60,23 @@ export default function DicomViewer({ dicomUrl, onError, onLoad, debugMode = fal setHasError(false); } } catch (parseError) { - debugLog(`Failed to parse message as JSON: ${parseError}`); + // Failed to parse message as JSON } } } catch (error) { - debugLog(`Error handling WebView message: ${error}`); + // Error handling WebView message } }; // Send DICOM URL to WebView when component mounts or URL changes useEffect(() => { if (webViewRef.current && dicomUrl && webViewReady) { - debugLog(`Sending DICOM URL to WebView: ${dicomUrl}`); - // Wait a bit for WebView to be ready const timer = setTimeout(() => { if (webViewRef.current) { try { // Send the URL directly as a string message webViewRef.current.postMessage(dicomUrl); - debugLog('DICOM URL sent successfully'); // Also try sending as a structured message setTimeout(() => { @@ -108,12 +86,11 @@ export default function DicomViewer({ dicomUrl, onError, onLoad, debugMode = fal data: dicomUrl }); webViewRef.current.postMessage(structuredMessage); - debugLog('Structured DICOM message sent'); } }, 500); } catch (error) { - debugLog(`Failed to send DICOM URL: ${error}`); + // Failed to send DICOM URL } } }, 1000); @@ -124,25 +101,20 @@ export default function DicomViewer({ dicomUrl, onError, onLoad, debugMode = fal // Reload WebView if there's an error const handleRetry = () => { - debugLog('Retrying WebView load'); if (webViewRef.current) { setHasError(false); - setIsLoading(true); setWebViewReady(false); webViewRef.current.reload(); } }; - // Clear debug info - const clearDebugInfo = () => { - setDebugInfo([]); - }; + return ( ( - - - Loading DICOM Viewer... - - )} + mixedContentMode="always" /> {hasError && ( @@ -175,32 +141,7 @@ export default function DicomViewer({ dicomUrl, onError, onLoad, debugMode = fal )} - {debugMode && ( - - - Debug Info - - Clear - - - - {debugInfo.map((info, index) => ( - {info} - ))} - - - - WebView Ready: {webViewReady ? 'Yes' : 'No'} - - - Loading: {isLoading ? 'Yes' : 'No'} - - - Error: {hasError ? 'Yes' : 'No'} - - - - )} + ); } @@ -214,21 +155,7 @@ const styles = StyleSheet.create({ flex: 1, backgroundColor: '#000', }, - loadingContainer: { - position: 'absolute', - top: 0, - left: 0, - right: 0, - bottom: 0, - justifyContent: 'center', - alignItems: 'center', - backgroundColor: '#000', - }, - loadingText: { - color: '#FFF', - marginTop: 16, - fontSize: 16, - }, + errorContainer: { position: 'absolute', top: 0, @@ -265,52 +192,6 @@ const styles = StyleSheet.create({ fontSize: 16, fontWeight: '600', }, - debugContainer: { - position: 'absolute', - top: 10, - right: 10, - backgroundColor: 'rgba(0,0,0,0.9)', - borderRadius: 8, - padding: 10, - maxWidth: 300, - maxHeight: 400, - }, - debugHeader: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - marginBottom: 8, - }, - debugTitle: { - color: '#FFFFFF', - fontSize: 14, - fontWeight: '600', - }, - clearButton: { - color: '#2196F3', - fontSize: 12, - textDecorationLine: 'underline', - }, - debugContent: { - maxHeight: 200, - }, - debugText: { - color: '#FFFFFF', - fontSize: 10, - fontFamily: 'monospace', - marginBottom: 2, - }, - debugStatus: { - marginTop: 8, - paddingTop: 8, - borderTopColor: '#333', - borderTopWidth: 1, - }, - debugStatusText: { - color: '#CCC', - fontSize: 10, - marginBottom: 2, - }, }); /* diff --git a/app/shared/components/DicomViewerModal.example.tsx b/app/shared/components/DicomViewerModal.example.tsx new file mode 100644 index 0000000..81012be --- /dev/null +++ b/app/shared/components/DicomViewerModal.example.tsx @@ -0,0 +1,241 @@ +/* + * File: DicomViewerModal.example.tsx + * Description: Example usage of DicomViewerModal component + * Design & Developed by Tech4Biz Solutions + * Copyright (c) Spurrin Innovations. All rights reserved. + */ + +import React, { useState } from 'react'; +import { View, TouchableOpacity, Text, StyleSheet } from 'react-native'; +import { DicomViewerModal } from './index'; +import { theme } from '../../theme/theme'; + +// ============================================================================ +// EXAMPLE COMPONENT +// ============================================================================ + +/** + * DicomViewerModalExample Component + * + * Purpose: Demonstrates how to use the DicomViewerModal component + * + * Features: + * - Shows how to pass dicomUrl to modal + * - Demonstrates modal state management + * - Example with patient information + * - Error handling examples + */ +export const DicomViewerModalExample: React.FC = () => { + // ============================================================================ + // STATE MANAGEMENT + // ============================================================================ + + const [isModalVisible, setIsModalVisible] = useState(false); + + // Example DICOM URLs (replace with your actual URLs) + const exampleDicomUrl = 'https://example-dicom-server.com/studies/123/series/456/instances/789'; + + // Example patient data + const patientData = { + name: 'John Doe', + studyDescription: 'CT Brain with Contrast', + }; + + // ============================================================================ + // EVENT HANDLERS + // ============================================================================ + + /** + * Open DICOM viewer modal + */ + const openDicomViewer = () => { + setIsModalVisible(true); + }; + + /** + * Close DICOM viewer modal + */ + const closeDicomViewer = () => { + setIsModalVisible(false); + }; + + // ============================================================================ + // RENDER + // ============================================================================ + + return ( + + {/* Trigger Button */} + + View DICOM Image + + + {/* DICOM Viewer Modal */} + + + ); +}; + +// ============================================================================ +// STYLES +// ============================================================================ + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: theme.colors.background, + padding: theme.spacing.lg, + }, + + button: { + backgroundColor: theme.colors.primary, + paddingHorizontal: theme.spacing.xl, + paddingVertical: theme.spacing.md, + borderRadius: theme.borderRadius.medium, + ...theme.shadows.primary, + }, + + buttonText: { + fontSize: theme.typography.fontSize.bodyLarge, + fontFamily: theme.typography.fontFamily.bold, + color: theme.colors.background, + }, +}); + +// ============================================================================ +// USAGE EXAMPLES IN OTHER COMPONENTS +// ============================================================================ + +/* + +// Example 1: Basic Usage in Patient Details Screen +import { DicomViewerModal } from '../../../shared/components'; + +const PatientDetailsExample = () => { + const [showDicom, setShowDicom] = useState(false); + const dicomUrl = patient.scanResults?.dicomUrl; + + return ( + <> + setShowDicom(true)}> + View Scan Results + + + setShowDicom(false)} + patientName={patient.name} + studyDescription={patient.scanResults?.description} + /> + + ); +}; + +// Example 2: Usage with Series Selection +import { DicomViewerModal } from '../../../shared/components'; + +const SeriesListExample = () => { + const [selectedDicom, setSelectedDicom] = useState(null); + const [modalVisible, setModalVisible] = useState(false); + + const openDicom = (dicomUrl: string) => { + setSelectedDicom(dicomUrl); + setModalVisible(true); + }; + + const closeDicom = () => { + setModalVisible(false); + setSelectedDicom(null); + }; + + return ( + <> + {seriesList.map((series) => ( + openDicom(series.dicomUrl)} + > + {series.description} + + ))} + + + + ); +}; + +// Example 3: Usage with Error Handling +import { DicomViewerModal } from '../../../shared/components'; + +const ErrorHandlingExample = () => { + const [dicomModalState, setDicomModalState] = useState({ + visible: false, + url: '', + title: '', + }); + + const openDicomWithValidation = (url: string, title: string) => { + if (!url) { + Alert.alert('Error', 'No DICOM URL available'); + return; + } + + setDicomModalState({ + visible: true, + url, + title, + }); + }; + + const closeDicomModal = () => { + setDicomModalState({ + visible: false, + url: '', + title: '', + }); + }; + + return ( + <> + openDicomWithValidation(scan.url, scan.title)} + > + View Scan + + + + + ); +}; + +*/ + +/* + * End of File: DicomViewerModal.example.tsx + * Design & Developed by Tech4Biz Solutions + * Copyright (c) Spurrin Innovations. All rights reserved. + */ diff --git a/app/shared/components/DicomViewerModal.tsx b/app/shared/components/DicomViewerModal.tsx new file mode 100644 index 0000000..38a6984 --- /dev/null +++ b/app/shared/components/DicomViewerModal.tsx @@ -0,0 +1,344 @@ +/* + * File: DicomViewerModal.tsx + * Description: Reusable modal component for DICOM image viewing + * Design & Developed by Tech4Biz Solutions + * Copyright (c) Spurrin Innovations. All rights reserved. + */ + +import React, { useState } from 'react'; +import { + View, + Text, + StyleSheet, + TouchableOpacity, + Modal, + SafeAreaView, + StatusBar, + Dimensions, + Alert, +} from 'react-native'; +import { theme } from '../../theme/theme'; +import Icon from 'react-native-vector-icons/Feather'; +import DicomViewer from './DicomViewer'; + +// ============================================================================ +// INTERFACES +// ============================================================================ + +/** + * DicomViewerModalProps Interface + * + * Purpose: Defines the props required by the DicomViewerModal component + * + * Props: + * - visible: Whether the modal is visible + * - dicomUrl: URL of the DICOM file to display + * - onClose: Callback function when modal is closed + * - title: Optional title for the modal header + * - patientName: Optional patient name for context + * - studyDescription: Optional study description + */ +interface DicomViewerModalProps { + visible: boolean; + dicomUrl: string; + onClose: () => void; + title?: string; + patientName?: string; + studyDescription?: string; +} + +// ============================================================================ +// DICOM VIEWER MODAL COMPONENT +// ============================================================================ + +/** + * DicomViewerModal Component + * + * Purpose: Provides a full-screen modal for viewing DICOM medical images + * + * Features: + * - Full-screen DICOM image viewing + * - Modal overlay with close functionality + * - Error handling and display + * - Loading states + * - Header with patient/study information + * - Responsive design for different screen sizes + * - Proper medical image viewing environment (dark background) + */ +export const DicomViewerModal: React.FC = ({ + visible, + dicomUrl, + onClose, + title = 'DICOM Viewer', + patientName, + studyDescription, +}) => { + // ============================================================================ + // STATE MANAGEMENT + // ============================================================================ + + const [isLoading, setIsLoading] = useState(false); + const [hasError, setHasError] = useState(false); + + // ============================================================================ + // EVENT HANDLERS + // ============================================================================ + + /** + * Handle DICOM viewer load completion + */ + const handleDicomLoad = () => { + setIsLoading(false); + setHasError(false); + }; + + /** + * Handle DICOM viewer errors + * @param error - Error message from DICOM viewer + */ + const handleDicomError = (error: string) => { + setIsLoading(false); + setHasError(true); + + // Show error alert to user + Alert.alert( + 'DICOM Loading Error', + `Failed to load DICOM file: ${error}`, + [ + { + text: 'Retry', + onPress: () => { + setHasError(false); + setIsLoading(true); + }, + }, + { + text: 'Close', + onPress: onClose, + style: 'cancel', + }, + ] + ); + }; + + /** + * Handle modal close request + */ + const handleClose = () => { + // Reset states when closing + setIsLoading(false); + setHasError(false); + onClose(); + }; + + /** + * Handle back button press (Android) + */ + const handleRequestClose = () => { + handleClose(); + }; + + // ============================================================================ + // RENDER HEADER + // ============================================================================ + + /** + * Render modal header with title and close button + */ + const renderHeader = () => ( + + {/* Title and Patient Info */} + + {title} + {patientName && ( + Patient: {patientName} + )} + {studyDescription && ( + Study: {studyDescription} + )} + + + {/* Close Button */} + + + + + ); + + // ============================================================================ + // RENDER CONTENT + // ============================================================================ + + /** + * Render DICOM viewer content + */ + const renderContent = () => { + if (!dicomUrl) { + return ( + + + No DICOM URL provided + + Close + + + ); + } + + return ( + + + + ); + }; + + // ============================================================================ + // MAIN RENDER + // ============================================================================ + + return ( + + + {/* Set status bar to light content for dark background */} + + + {/* Header */} + {renderHeader()} + + {/* Content */} + {renderContent()} + + + ); +}; + +// ============================================================================ +// STYLES +// ============================================================================ + +const { width: screenWidth, height: screenHeight } = Dimensions.get('window'); + +const styles = StyleSheet.create({ + // Main container + container: { + flex: 1, + backgroundColor: '#000000', + }, + + // Header section + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: theme.spacing.lg, + paddingVertical: theme.spacing.md, + backgroundColor: 'rgba(0, 0, 0, 0.9)', + borderBottomWidth: 1, + borderBottomColor: 'rgba(255, 255, 255, 0.1)', + }, + + // Header content + headerContent: { + flex: 1, + marginRight: theme.spacing.md, + }, + + // Header title + headerTitle: { + fontSize: theme.typography.fontSize.displaySmall, + fontFamily: theme.typography.fontFamily.bold, + color: theme.colors.background, + marginBottom: 2, + }, + + // Header subtitle + headerSubtitle: { + fontSize: theme.typography.fontSize.bodyMedium, + fontFamily: theme.typography.fontFamily.regular, + color: 'rgba(255, 255, 255, 0.8)', + lineHeight: theme.typography.fontSize.bodyMedium * 1.2, + }, + + // Close button + closeButton: { + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: 'rgba(255, 255, 255, 0.1)', + justifyContent: 'center', + alignItems: 'center', + borderWidth: 1, + borderColor: 'rgba(255, 255, 255, 0.2)', + }, + + // DICOM viewer container + viewerContainer: { + flex: 1, + backgroundColor: '#000000', + }, + + // Empty state container + emptyContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: '#000000', + paddingHorizontal: theme.spacing.xl, + }, + + // Empty state text + emptyText: { + fontSize: theme.typography.fontSize.bodyLarge, + fontFamily: theme.typography.fontFamily.medium, + color: theme.colors.textMuted, + textAlign: 'center', + marginTop: theme.spacing.md, + marginBottom: theme.spacing.xl, + }, + + // Secondary close button + closeButtonSecondary: { + backgroundColor: theme.colors.primary, + paddingHorizontal: theme.spacing.xl, + paddingVertical: theme.spacing.md, + borderRadius: theme.borderRadius.medium, + ...theme.shadows.primary, + }, + + // Close button text + closeButtonText: { + fontSize: theme.typography.fontSize.bodyLarge, + fontFamily: theme.typography.fontFamily.bold, + color: theme.colors.background, + }, +}); + +// ============================================================================ +// EXPORT +// ============================================================================ + +export default DicomViewerModal; + +/* + * End of File: DicomViewerModal.tsx + * Design & Developed by Tech4Biz Solutions + * Copyright (c) Spurrin Innovations. All rights reserved. + */ diff --git a/app/shared/components/index.ts b/app/shared/components/index.ts index c126ee9..5e1638a 100644 --- a/app/shared/components/index.ts +++ b/app/shared/components/index.ts @@ -18,6 +18,9 @@ export { ComingSoonScreen } from './ComingSoonScreen'; // DICOM Viewer Component export { default as DicomViewer } from './DicomViewer'; +// DICOM Viewer Modal Component +export { default as DicomViewerModal } from './DicomViewerModal'; + // DICOM Viewer Test Component export { default as DicomViewerTest } from './DicomViewerTest'; diff --git a/app/shared/types/auth.ts b/app/shared/types/auth.ts index 21db44d..930f66c 100644 --- a/app/shared/types/auth.ts +++ b/app/shared/types/auth.ts @@ -44,6 +44,7 @@ export interface User { onboarding_message: string; access_token: string; platform: 'app'|'web'; + self_url:string | null; } export type UserRole =