diff --git a/App.tsx b/App.tsx index 70ae326b..c8b4ead4 100644 --- a/App.tsx +++ b/App.tsx @@ -14,16 +14,18 @@ import AppNavigator from './src/navigation/AppNavigator'; import { setupNotificationNavigation } from './src/navigation/linking'; import { apiClient } from './src/services/api'; import { crashReportingService } from './src/services/cashReporting'; +import { featureCapabilities } from './src/services/featureCapabilities'; import { mobileAuthService } from './src/services/mobileAuth'; import { - addNotificationReceivedListener, - getLastNotificationResponse, - removeNotificationListener, + addNotificationReceivedListener, + getLastNotificationResponse, + removeNotificationListener, } from './src/services/pushNotifications'; import { requestQueue } from './src/services/requestQueue'; import socketService from './src/services/socket'; import syncService from './src/services/syncService'; import { useAppStore } from './src/store'; +import { useDegradationStore } from './src/store/degradationStore'; import { handleCacheVersionUpdate } from './src/utils/cacheVersioning'; import { requireEnvVariables } from './src/utils/env'; import { appLogger } from './src/utils/logger'; @@ -120,6 +122,28 @@ const App = () => { // Connect to socket when app starts socketService.connect(); + // Initialize feature capability detection (non-blocking) + // This determines which features are available and updates degradation state + featureCapabilities.checkAllCapabilities() + .then(capabilities => { + const degradationStore = useDegradationStore.getState(); + appLogger.infoSync('[App] Feature capabilities checked', { + camera: capabilities.camera.status, + notifications: capabilities.pushNotifications.status, + location: capabilities.location.status, + }); + // Update degradation store with current feature statuses + Object.entries(capabilities).forEach(([feature, info]) => { + if (feature !== 'checkedAt' && 'status' in info) { + degradationStore.setFeatureStatus(feature as any, info.status); + } + }); + }) + .catch(error => { + appLogger.errorSync('[App] Error checking feature capabilities', error instanceof Error ? error : new Error(String(error))); + // Continue app startup - degradation will be detected on-demand + }); + // Initialize push notifications: request permissions and get device token registerForPushNotifications().then(async (token) => { if (token) { diff --git a/docs/GRACEFUL_DEGRADATION.md b/docs/GRACEFUL_DEGRADATION.md new file mode 100644 index 00000000..2d585617 --- /dev/null +++ b/docs/GRACEFUL_DEGRADATION.md @@ -0,0 +1,37 @@ +# Graceful Degradation Strategy + +This document outlines TeachLink Mobile's approach to graceful degradation when device features or permissions are unavailable. + +## Goals + +- Avoid crashes when hardware or permissions are missing. +- Provide clear user feedback and fallback UX. +- Maintain core app functionality with degraded capabilities. + +## Features Covered + +- Camera (photo capture & gallery) +- Push Notifications +- Location + +## Strategy + +1. Detect capabilities at startup using `src/services/featureCapabilities.ts`. +2. Persist degradation state in `src/store/degradationStore.ts`. +3. Provide hooks with fallbacks: `src/hooks/useCamera.ts`, `src/hooks/useLocation.ts`. +4. Provide UI components to inform users: `src/components/DegradationBanner.tsx`. +5. Use `locationService` to attempt GPS, then cached, then manual entry. +6. Use in-app notifications when push notifications unavailable. + +## Developer Notes + +- When adding new features that require hardware or permissions, update `featureCapabilities` and `degradationStore` accordingly. +- Use `degradationStore.addNotification()` to notify users about degraded features. +- Prefer non-blocking initialization; detect capabilities asynchronously. + +## Testing + +- Test on simulator to verify push notification degradation behavior. +- Deny permissions to test camera fallback to library and manual location entry. +- Test devices without GPS to ensure manual flow works. + diff --git a/src/components/DegradationBanner.tsx b/src/components/DegradationBanner.tsx new file mode 100644 index 00000000..7d7f3b4c --- /dev/null +++ b/src/components/DegradationBanner.tsx @@ -0,0 +1,353 @@ +/** + * Feature Degradation Banner Component + * + * Displays user-friendly notifications when features are degraded or unavailable. + * Shows in a collapsible banner with action buttons for recovery. + * + * Usage: + * + */ + +import { useEffect, useRef, useState } from 'react'; +import { Animated, Text, TouchableOpacity, View } from 'react-native'; +import { FeatureType, featureCapabilities } from '../services/featureCapabilities'; +import { useDegradationStore } from '../store/degradationStore'; +import { appLogger } from '../utils/logger'; +import { useThemeColor } from './themed-view'; + +interface DegradationBannerProps { + feature: FeatureType; + /** Auto-dismiss after N milliseconds (0 = no auto-dismiss) */ + autoDismissAfter?: number; + /** Callback when user takes action */ + onActionTaken?: (action: 'retry' | 'dismissed') => void; + /** Custom message override */ + customMessage?: string; + /** Whether to show retry button */ + showRetryButton?: boolean; + /** Callback for retry button */ + onRetry?: () => Promise; +} + +export const DegradationBanner: React.FC = ({ + feature, + autoDismissAfter = 0, + onActionTaken, + customMessage, + showRetryButton = true, + onRetry, +}) => { + const [visible, setVisible] = useState(true); + const [isRetrying, setIsRetrying] = useState(false); + const animationValue = useRef(new Animated.Value(1)).current; + const degradationStore = useDegradationStore(); + const isDegraded = degradationStore.isFeatureDegraded(feature); + const featureInfo = featureCapabilities.getFeatureInfo(feature); + + const accentColor = useThemeColor({}, 'warning'); + const backgroundColor = useThemeColor({}, 'card'); + const textColor = useThemeColor({}, 'text'); + + const message = customMessage || featureCapabilities.getUnavailabilityMessage(feature); + const fallbackDescription = featureInfo.fallbackDescription; + + // Auto-dismiss logic + useEffect(() => { + if (autoDismissAfter > 0 && visible) { + const timer = setTimeout(() => { + handleDismiss(); + }, autoDismissAfter); + return () => clearTimeout(timer); + } + }, [visible, autoDismissAfter]); + + const handleDismiss = () => { + Animated.timing(animationValue, { + toValue: 0, + duration: 300, + useNativeDriver: true, + }).start(() => { + setVisible(false); + onActionTaken?.('dismissed'); + degradationStore.addNotification({ + feature, + status: featureInfo.status, + message, + actionTaken: 'dismissed', + }); + }); + }; + + const handleRetry = async () => { + if (!onRetry) return; + + setIsRetrying(true); + try { + await onRetry(); + appLogger.infoSync(`[DegradationBanner] Retry successful for ${feature}`); + onActionTaken?.('retry'); + degradationStore.addNotification({ + feature, + status: featureInfo.status, + message: `${feature} retry initiated`, + actionTaken: 'retryRequested', + }); + } catch (error) { + appLogger.errorSync( + `[DegradationBanner] Retry failed for ${feature}`, + error instanceof Error ? error : new Error(String(error)) + ); + } finally { + setIsRetrying(false); + } + }; + + if (!isDegraded || !visible) { + return null; + } + + const opacity = animationValue; + + return ( + + + {/* Header with feature name and close button */} + + + {feature.charAt(0).toUpperCase() + feature.slice(1)} Unavailable + + + × + + + + {/* Main message */} + + {message} + + + {/* Fallback description */} + {fallbackDescription && ( + + 💡 {fallbackDescription} + + )} + + {/* Action buttons */} + + {showRetryButton && onRetry && ( + + + {isRetrying ? 'Retrying...' : 'Retry'} + + + )} + + + Dismiss + + + + + + ); +}; + +/** + * Feature Degradation Notifications Panel + * Shows all current degradation notifications + */ +interface DegradationNotificationsPanelProps { + maxNotifications?: number; + autoHide?: boolean; +} + +export const DegradationNotificationsPanel: React.FC = ({ + maxNotifications = 3, + autoHide = true, +}) => { + const degradationStore = useDegradationStore(); + const unreadNotifications = degradationStore.getUnreadNotifications().slice(0, maxNotifications); + const backgroundColor = useThemeColor({}, 'card'); + const textColor = useThemeColor({}, 'text'); + + if (unreadNotifications.length === 0) { + return null; + } + + return ( + + {unreadNotifications.map((notification) => ( + + + + + {notification.feature} + + + {notification.message} + + + degradationStore.dismissNotification(notification.id)} + hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} + > + × + + + + ))} + + ); +}; + +/** + * Feature Status Indicator + * Shows visual indicator of feature availability + */ +interface FeatureStatusIndicatorProps { + feature: FeatureType; + size?: 'small' | 'medium' | 'large'; +} + +export const FeatureStatusIndicator: React.FC = ({ + feature, + size = 'medium', +}) => { + const degradationStore = useDegradationStore(); + const isDegraded = degradationStore.isFeatureDegraded(feature); + + const sizeStyles = { + small: { width: 8, height: 8 }, + medium: { width: 12, height: 12 }, + large: { width: 16, height: 16 }, + }; + + const colors = { + available: '#10B981', // Green + degraded: '#F59E0B', // Amber + unavailable: '#EF4444', // Red + }; + + const statusColor = isDegraded ? colors.degraded : colors.available; + + return ( + + ); +}; diff --git a/src/hooks/useCamera.ts b/src/hooks/useCamera.ts index e183e146..304ac244 100644 --- a/src/hooks/useCamera.ts +++ b/src/hooks/useCamera.ts @@ -3,6 +3,12 @@ import { useCallback, useEffect, useState } from 'react'; import { Platform } from 'react-native'; import { appLogger } from '../utils/logger'; +export enum CameraFallbackType { + FULL_CAMERA = 'fullCamera', + LIBRARY_ONLY = 'libraryOnly', + UNAVAILABLE = 'unavailable', +} + interface UseCameraReturn { /** Whether camera permission has been granted */ hasPermission: boolean; @@ -18,16 +24,31 @@ interface UseCameraReturn { resetCapturedImage: () => void; /** Request camera and media library permissions */ requestPermissions: () => Promise; + /** Current fallback mode (full camera, library only, or unavailable) */ + fallbackMode: CameraFallbackType; + /** Whether the camera is in degraded/fallback mode */ + isDegraded: boolean; + /** User-friendly message about the current state */ + statusMessage: string; } /** - * Hook for handling camera and image picker functionality - * Manages permissions, captures photos, and selects from gallery + * Hook for handling camera and image picker functionality with graceful degradation + * Manages permissions, captures photos, selects from gallery, and provides fallbacks + * + * Graceful Degradation: + * - If camera permission denied: Falls back to library-only mode + * - If library permission denied: Shows message about using existing photos + * - If both unavailable: Shows user-friendly degradation message */ export const useCamera = (): UseCameraReturn => { const [hasPermission, setHasPermission] = useState(false); const [capturedImage, setCapturedImage] = useState(null); const [isLoading, setIsLoading] = useState(false); + const [fallbackMode, setFallbackMode] = useState(CameraFallbackType.FULL_CAMERA); + const [statusMessage, setStatusMessage] = useState('Camera ready'); + + const degradationStore = useDegradationStore(); /** * Request camera and media library permissions @@ -44,23 +65,58 @@ export const useCamera = (): UseCameraReturn => { cameraStatus.granted && (Platform.OS === 'android' || mediaLibraryStatus.granted); setHasPermission(granted); - return granted; + + // Update feature capability and degradation store + if (granted) { + degradationStore.setFeatureStatus(FeatureType.CAMERA, FeatureStatus.AVAILABLE); + setFallbackMode(CameraFallbackType.FULL_CAMERA); + setStatusMessage('Camera ready'); + } else if (mediaLibraryStatus.granted) { + // Partial: can use library but not camera + degradationStore.setFeatureStatus(FeatureType.CAMERA, FeatureStatus.DEGRADED); + setFallbackMode(CameraFallbackType.LIBRARY_ONLY); + setStatusMessage('Camera unavailable - using photo library instead'); + + degradationStore.addNotification({ + feature: FeatureType.CAMERA, + status: FeatureStatus.DEGRADED, + message: 'Camera permission denied. You can still select photos from your library.', + }); + } else { + // Both unavailable + degradationStore.setFeatureStatus(FeatureType.CAMERA, FeatureStatus.PERMISSION_DENIED); + setFallbackMode(CameraFallbackType.UNAVAILABLE); + setStatusMessage('Camera and photo library access denied'); + + degradationStore.addNotification({ + feature: FeatureType.CAMERA, + status: FeatureStatus.PERMISSION_DENIED, + message: 'Camera and photo library permissions were denied. Grant them in Settings to use this feature.', + }); + } + + return granted || mediaLibraryStatus.granted; } catch (error) { appLogger.errorSync('[useCamera] Error requesting permissions', error instanceof Error ? error : new Error(String(error))); + degradationStore.setFeatureStatus(FeatureType.CAMERA, FeatureStatus.UNAVAILABLE); + setFallbackMode(CameraFallbackType.UNAVAILABLE); + setStatusMessage('Camera initialization failed'); return false; } - }, []); + }, [degradationStore]); /** * Take a picture using the device camera * Allows editing to crop/scale the image + * Falls back to library if camera unavailable */ const takePicture = useCallback(async (): Promise => { - if (!hasPermission) { - const permissionGranted = await requestPermissions(); - if (!permissionGranted) { - return null; - } + // Check if camera is available + const cameraStatus = await ImagePicker.getCameraPermissionsAsync(); + if (!cameraStatus.granted) { + // Camera not available, try library as fallback + appLogger.infoSync('[useCamera] Camera permission not available, falling back to library'); + return pickFromLibrary(); } setIsLoading(true); @@ -80,20 +136,40 @@ export const useCamera = (): UseCameraReturn => { return null; } catch (error) { appLogger.errorSync('[useCamera] Error taking picture', error instanceof Error ? error : new Error(String(error))); - return null; + + // If camera operation fails, try falling back to library + try { + appLogger.infoSync('[useCamera] Camera operation failed, attempting library fallback'); + return await pickFromLibrary(); + } catch (fallbackError) { + appLogger.errorSync('[useCamera] Fallback to library also failed', fallbackError instanceof Error ? fallbackError : new Error(String(fallbackError))); + degradationStore.addNotification({ + feature: FeatureType.CAMERA, + status: FeatureStatus.UNAVAILABLE, + message: 'Unable to access camera or photo library. Please check your permissions.', + }); + return null; + } } finally { setIsLoading(false); } - }, [hasPermission, requestPermissions]); + }, [pickFromLibrary, degradationStore]); /** * Pick an image from the photo library * Allows editing to crop/scale the image */ const pickFromLibrary = useCallback(async (): Promise => { - if (!hasPermission) { - const permissionGranted = await requestPermissions(); - if (!permissionGranted) { + const mediaLibraryStatus = await ImagePicker.getMediaLibraryPermissionsAsync(); + if (!mediaLibraryStatus.granted) { + // Request permission + const newStatus = await ImagePicker.requestMediaLibraryPermissionsAsync(); + if (!newStatus.granted) { + degradationStore.addNotification({ + feature: FeatureType.CAMERA, + status: FeatureStatus.PERMISSION_DENIED, + message: 'Photo library permission denied. Grant it in Settings to select photos.', + }); return null; } } @@ -115,11 +191,16 @@ export const useCamera = (): UseCameraReturn => { return null; } catch (error) { appLogger.errorSync('[useCamera] Error picking from library', error instanceof Error ? error : new Error(String(error))); + degradationStore.addNotification({ + feature: FeatureType.CAMERA, + status: FeatureStatus.UNAVAILABLE, + message: 'Failed to access your photo library. Please try again.', + }); return null; } finally { setIsLoading(false); } - }, [hasPermission, requestPermissions]); + }, [degradationStore]); /** * Reset the captured image state @@ -130,19 +211,36 @@ export const useCamera = (): UseCameraReturn => { }, []); /** - * Check permissions on mount + * Check permissions on mount and detect degradation mode * This ensures the hook reflects the current permission state */ useEffect(() => { const checkPermissions = async () => { const cameraStatus = await ImagePicker.getCameraPermissionsAsync(); const mediaLibraryStatus = await ImagePicker.getMediaLibraryPermissionsAsync(); - const granted = - cameraStatus.granted && (Platform.OS === 'android' || mediaLibraryStatus.granted); - setHasPermission(granted); + + const cameraGranted = cameraStatus.granted; + const libraryGranted = mediaLibraryStatus.granted || Platform.OS === 'android'; + + if (cameraGranted && libraryGranted) { + setHasPermission(true); + setFallbackMode(CameraFallbackType.FULL_CAMERA); + setStatusMessage('Camera ready'); + degradationStore.setFeatureStatus(FeatureType.CAMERA, FeatureStatus.AVAILABLE); + } else if (libraryGranted) { + setHasPermission(false); + setFallbackMode(CameraFallbackType.LIBRARY_ONLY); + setStatusMessage('Camera unavailable - using photo library instead'); + degradationStore.setFeatureStatus(FeatureType.CAMERA, FeatureStatus.DEGRADED); + } else { + setHasPermission(false); + setFallbackMode(CameraFallbackType.UNAVAILABLE); + setStatusMessage('Camera and photo library access denied'); + degradationStore.setFeatureStatus(FeatureType.CAMERA, FeatureStatus.PERMISSION_DENIED); + } }; checkPermissions(); - }, []); + }, [degradationStore]); return { hasPermission, @@ -152,5 +250,8 @@ export const useCamera = (): UseCameraReturn => { pickFromLibrary, resetCapturedImage, requestPermissions, + fallbackMode, + isDegraded: fallbackMode !== CameraFallbackType.FULL_CAMERA, + statusMessage, }; }; diff --git a/src/hooks/useLocation.ts b/src/hooks/useLocation.ts new file mode 100644 index 00000000..2524d2d3 --- /dev/null +++ b/src/hooks/useLocation.ts @@ -0,0 +1,144 @@ +/** + * Hook for Location Management with Graceful Degradation + * + * Usage: + * const { location, manualLocation, setManualLocation, isLoading, isDegraded, statusMessage } = useLocation(); + */ + +import { useCallback, useEffect, useState } from 'react'; +import { LocationData, locationService, LocationSourceType } from '../services/locationService'; +import { useDegradationStore } from '../store/degradationStore'; +import { appLogger } from '../utils/logger'; + +interface UseLocationReturn { + /** Current location (from GPS, cache, or manual entry) */ + location: LocationData | null; + /** Manually entered location string */ + manualLocation: string; + /** Set manual location string */ + setManualLocation: (address: string) => void; + /** Whether location fetch is in progress */ + isLoading: boolean; + /** Whether location feature is degraded (no GPS) */ + isDegraded: boolean; + /** Human-friendly status message */ + statusMessage: string; + /** Request location permission */ + requestPermission: () => Promise; + /** Refresh current location */ + refreshLocation: () => Promise; + /** Clear cached location */ + clearCachedLocation: () => void; +} + +export const useLocation = (): UseLocationReturn => { + const [location, setLocation] = useState(null); + const [manualLocation, setManualLocationState] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [isDegraded, setIsDegraded] = useState(false); + const [statusMessage, setStatusMessage] = useState(''); + + const degradationStore = useDegradationStore(); + + /** + * Request location permission + */ + const requestPermission = useCallback(async (): Promise => { + const granted = await locationService.requestPermission(); + if (granted) { + setIsDegraded(false); + setStatusMessage('Location permission granted'); + } else { + setIsDegraded(true); + setStatusMessage('Location permission denied - manual entry available'); + } + return granted; + }, []); + + /** + * Refresh current location with fallback chain + */ + const refreshLocation = useCallback(async (): Promise => { + setIsLoading(true); + try { + appLogger.infoSync('[useLocation] Refreshing location'); + const locationData = await locationService.getLocationWithFallback(manualLocation); + + if (locationData) { + setLocation(locationData); + setStatusMessage(locationService.getStatusMessage(locationData)); + + // Update manual location if one was obtained + if (locationData.source === LocationSourceType.MANUAL && locationData.address) { + setManualLocationState(locationData.address); + } + + setIsDegraded(locationData.source !== LocationSourceType.GPS); + } else { + setLocation(null); + setIsDegraded(true); + setStatusMessage('Please enter your location manually'); + } + } catch (error) { + appLogger.errorSync('[useLocation] Error refreshing location', error instanceof Error ? error : new Error(String(error))); + setIsDegraded(true); + setStatusMessage('Location refresh failed - please enter manually'); + } finally { + setIsLoading(false); + } + }, [manualLocation]); + + /** + * Set manual location + */ + const handleSetManualLocation = useCallback((address: string): void => { + if (address.trim()) { + const locationData = locationService.setManualLocation(address); + setLocation(locationData); + setManualLocationState(address); + setStatusMessage(`Location saved: ${address}`); + setIsDegraded(true); // Manual entry is degraded mode + appLogger.infoSync('[useLocation] Manual location set', { address }); + } + }, []); + + /** + * Clear cached location + */ + const clearCachedLocation = useCallback((): void => { + locationService.clearCachedLocation(); + setLocation(null); + setManualLocationState(''); + setStatusMessage('Location cleared'); + appLogger.infoSync('[useLocation] Location cleared'); + }, []); + + /** + * Check permission and attempt to get location on mount + */ + useEffect(() => { + const initLocation = async () => { + const hasPermission = await locationService.checkPermission(); + if (hasPermission) { + await refreshLocation(); + } else { + setIsDegraded(true); + setStatusMessage('Location permission required - manual entry available'); + } + }; + + initLocation(); + }, []); + + return { + location, + manualLocation, + setManualLocation: handleSetManualLocation, + isLoading, + isDegraded, + statusMessage, + requestPermission, + refreshLocation, + clearCachedLocation, + }; +}; diff --git a/src/services/featureCapabilities.ts b/src/services/featureCapabilities.ts new file mode 100644 index 00000000..dc068e4a --- /dev/null +++ b/src/services/featureCapabilities.ts @@ -0,0 +1,288 @@ +/** + * Feature Capability Detection Service + * + * Detects device/system capabilities and gracefully degrades features + * when permissions are denied or hardware is unavailable. + * + * Supported Features: + * - Camera (photo capture & gallery selection) + * - Push Notifications (local device notifications) + * - Location (user-provided location data) + * + * Usage: + * const capabilities = FeatureCapabilities.getInstance(); + * if (capabilities.isFeatureAvailable('camera')) { + * // Use camera + * } else { + * // Show fallback UI + * } + */ + +import * as Device from 'expo-device'; +import * as ImagePicker from 'expo-image-picker'; +import * as Notifications from 'expo-notifications'; +import { appLogger } from '../utils/logger'; + +export enum FeatureType { + CAMERA = 'camera', + PUSH_NOTIFICATIONS = 'pushNotifications', + LOCATION = 'location', +} + +export enum FeatureStatus { + AVAILABLE = 'available', + UNAVAILABLE = 'unavailable', + PERMISSION_DENIED = 'permissionDenied', + PERMISSION_NOT_REQUESTED = 'permissionNotRequested', + HARDWARE_UNAVAILABLE = 'hardwareUnavailable', + DEGRADED = 'degraded', // Partially available with limited functionality +} + +export interface FeatureInfo { + type: FeatureType; + status: FeatureStatus; + reason?: string; // Why the feature is unavailable + fallbackAvailable: boolean; // Whether a fallback UX is available + fallbackDescription?: string; // Description of fallback behavior +} + +export interface FeatureCapabilities { + camera: FeatureInfo; + pushNotifications: FeatureInfo; + location: FeatureInfo; + checkedAt: string; // ISO timestamp of last check +} + +class FeatureCan { + private static instance: FeatureCan; + private capabilities: FeatureCapabilities; + private lastCheckTime: number = 0; + private checkIntervalMs: number = 60000; // Recheck every 60 seconds + + private constructor() { + this.capabilities = { + camera: { + type: FeatureType.CAMERA, + status: FeatureStatus.UNAVAILABLE, + fallbackAvailable: true, + fallbackDescription: 'Users can select pre-existing images from their device', + }, + pushNotifications: { + type: FeatureType.PUSH_NOTIFICATIONS, + status: FeatureStatus.UNAVAILABLE, + fallbackAvailable: true, + fallbackDescription: 'In-app notifications will be shown when the app is active', + }, + location: { + type: FeatureType.LOCATION, + status: FeatureStatus.AVAILABLE, // Manual location entry always available + fallbackAvailable: true, + fallbackDescription: 'Users can manually enter their location as text', + }, + checkedAt: new Date().toISOString(), + }; + } + + public static getInstance(): FeatureCan { + if (!FeatureCan.instance) { + FeatureCan.instance = new FeatureCan(); + } + return FeatureCan.instance; + } + + /** + * Check all feature capabilities + * Respects rate limiting (only rechecks every 60 seconds) + */ + public async checkAllCapabilities(): Promise { + const now = Date.now(); + if (now - this.lastCheckTime > this.checkIntervalMs) { + await Promise.all([ + this.checkCameraCapability(), + this.checkPushNotificationsCapability(), + this.checkLocationCapability(), + ]); + this.lastCheckTime = now; + this.capabilities.checkedAt = new Date().toISOString(); + } + return this.capabilities; + } + + /** + * Check camera capability + */ + private async checkCameraCapability(): Promise { + try { + const cameraStatus = await ImagePicker.getCameraPermissionsAsync(); + const mediaLibraryStatus = await ImagePicker.getMediaLibraryPermissionsAsync(); + + if (cameraStatus.granted && mediaLibraryStatus.granted) { + this.capabilities.camera = { + type: FeatureType.CAMERA, + status: FeatureStatus.AVAILABLE, + fallbackAvailable: true, + fallbackDescription: 'Users can select from their photo library', + }; + } else if (cameraStatus.status === 'denied' || mediaLibraryStatus.status === 'denied') { + this.capabilities.camera = { + type: FeatureType.CAMERA, + status: FeatureStatus.PERMISSION_DENIED, + reason: 'Camera or media library permission denied by user', + fallbackAvailable: true, + fallbackDescription: 'Users can select from their existing photo library if available', + }; + } else { + this.capabilities.camera = { + type: FeatureType.CAMERA, + status: FeatureStatus.PERMISSION_NOT_REQUESTED, + reason: 'Camera permission not yet requested', + fallbackAvailable: true, + fallbackDescription: 'Users can request permission or use photo library', + }; + } + } catch (error) { + this.capabilities.camera = { + type: FeatureType.CAMERA, + status: FeatureStatus.UNAVAILABLE, + reason: `Camera check failed: ${error instanceof Error ? error.message : String(error)}`, + fallbackAvailable: true, + fallbackDescription: 'Users can select from their photo library', + }; + appLogger.errorSync('[FeatureCapabilities] Camera check failed', error instanceof Error ? error : new Error(String(error))); + } + } + + /** + * Check push notifications capability + */ + private async checkPushNotificationsCapability(): Promise { + try { + // Push notifications require a physical device + if (!Device.isDevice) { + this.capabilities.pushNotifications = { + type: FeatureType.PUSH_NOTIFICATIONS, + status: FeatureStatus.HARDWARE_UNAVAILABLE, + reason: 'Push notifications only work on physical devices, not simulators', + fallbackAvailable: true, + fallbackDescription: 'In-app notifications will be shown instead when the app is active', + }; + return; + } + + const { status } = await Notifications.getPermissionsAsync(); + + if (status === 'granted') { + this.capabilities.pushNotifications = { + type: FeatureType.PUSH_NOTIFICATIONS, + status: FeatureStatus.AVAILABLE, + fallbackAvailable: true, + fallbackDescription: 'In-app notifications available as backup', + }; + } else if (status === 'denied') { + this.capabilities.pushNotifications = { + type: FeatureType.PUSH_NOTIFICATIONS, + status: FeatureStatus.PERMISSION_DENIED, + reason: 'User denied notification permission', + fallbackAvailable: true, + fallbackDescription: 'In-app notifications will be shown when the app is active', + }; + } else { + this.capabilities.pushNotifications = { + type: FeatureType.PUSH_NOTIFICATIONS, + status: FeatureStatus.PERMISSION_NOT_REQUESTED, + reason: 'Notification permission not yet requested', + fallbackAvailable: true, + fallbackDescription: 'In-app notifications available or request permission', + }; + } + } catch (error) { + this.capabilities.pushNotifications = { + type: FeatureType.PUSH_NOTIFICATIONS, + status: FeatureStatus.UNAVAILABLE, + reason: `Push notification check failed: ${error instanceof Error ? error.message : String(error)}`, + fallbackAvailable: true, + fallbackDescription: 'In-app notifications will be shown instead when the app is active', + }; + appLogger.errorSync('[FeatureCapabilities] Push notification check failed', error instanceof Error ? error : new Error(String(error))); + } + } + + /** + * Check location capability + * Note: Location is always available via manual text entry + */ + private async checkLocationCapability(): Promise { + // Location is always available via manual user input + // Could be extended to check device.hasGPS or geo-location API permissions + this.capabilities.location = { + type: FeatureType.LOCATION, + status: FeatureStatus.AVAILABLE, + reason: 'Manual location entry always available', + fallbackAvailable: true, + fallbackDescription: 'Users can manually enter their location as text', + }; + } + + /** + * Check if a specific feature is available + */ + public isFeatureAvailable(feature: FeatureType): boolean { + const featureInfo = this.getFeatureInfo(feature); + return featureInfo.status === FeatureStatus.AVAILABLE || featureInfo.status === FeatureStatus.DEGRADED; + } + + /** + * Get detailed info about a feature + */ + public getFeatureInfo(feature: FeatureType): FeatureInfo { + switch (feature) { + case FeatureType.CAMERA: + return this.capabilities.camera; + case FeatureType.PUSH_NOTIFICATIONS: + return this.capabilities.pushNotifications; + case FeatureType.LOCATION: + return this.capabilities.location; + default: + throw new Error(`Unknown feature: ${feature}`); + } + } + + /** + * Get all capabilities + */ + public getCapabilities(): FeatureCapabilities { + return { ...this.capabilities }; + } + + /** + * Force recheck of capabilities (ignoring rate limit) + */ + public async forceRecheck(): Promise { + this.lastCheckTime = 0; + return this.checkAllCapabilities(); + } + + /** + * Get a human-friendly message about why a feature is unavailable + */ + public getUnavailabilityMessage(feature: FeatureType): string { + const info = this.getFeatureInfo(feature); + + switch (info.status) { + case FeatureStatus.AVAILABLE: + return 'Feature is available'; + case FeatureStatus.HARDWARE_UNAVAILABLE: + return info.reason || 'Feature is not available on this device'; + case FeatureStatus.PERMISSION_DENIED: + return `Permission denied. ${info.fallbackDescription || 'A fallback is available.'}`; + case FeatureStatus.PERMISSION_NOT_REQUESTED: + return 'Permission not yet requested. Would you like to grant access?'; + case FeatureStatus.UNAVAILABLE: + case FeatureStatus.DEGRADED: + default: + return info.reason || 'Feature is temporarily unavailable'; + } + } +} + +export const featureCapabilities = FeatureCan.getInstance(); diff --git a/src/services/locationService.ts b/src/services/locationService.ts new file mode 100644 index 00000000..0ff4e67e --- /dev/null +++ b/src/services/locationService.ts @@ -0,0 +1,266 @@ +/** + * Location Service with Graceful Degradation + * + * Attempts to get user location via multiple methods: + * 1. Device GPS (geolocation API) + * 2. User-entered text location + * 3. Cached/previously saved location + * + * Gracefully degrades to manual entry if GPS unavailable + * No errors thrown - always falls back to manual entry + */ + +import * as Location from 'expo-location'; +import { useDegradationStore } from '../store/degradationStore'; +import { appLogger } from '../utils/logger'; +import { featureCapabilities, FeatureStatus, FeatureType } from './featureCapabilities'; + +export enum LocationSourceType { + GPS = 'gps', + MANUAL = 'manual', + CACHED = 'cached', + UNAVAILABLE = 'unavailable', +} + +export interface LocationData { + latitude?: number; + longitude?: number; + address?: string; // User-entered or reverse geocoded + accuracy?: number; + source: LocationSourceType; + obtainedAt: string; // ISO timestamp +} + +class LocationService { + private static instance: LocationService; + private cachedLocation: LocationData | null = null; + private locationPermissionStatus: Location.PermissionStatus | null = null; + + private constructor() {} + + public static getInstance(): LocationService { + if (!LocationService.instance) { + LocationService.instance = new LocationService(); + } + return LocationService.instance; + } + + /** + * Request location permission + */ + public async requestPermission(): Promise { + try { + const { status } = await Location.requestForegroundPermissionsAsync(); + this.locationPermissionStatus = status; + + if (status === 'granted') { + featureCapabilities.getFeatureInfo(FeatureType.LOCATION); + const degradationStore = useDegradationStore(); + degradationStore.setFeatureStatus(FeatureType.LOCATION, FeatureStatus.AVAILABLE); + appLogger.infoSync('[LocationService] Location permission granted'); + return true; + } else { + featureCapabilities.getFeatureInfo(FeatureType.LOCATION); + const degradationStore = useDegradationStore(); + degradationStore.setFeatureStatus(FeatureType.LOCATION, FeatureStatus.DEGRADED); + degradationStore.addNotification({ + feature: FeatureType.LOCATION, + status: FeatureStatus.DEGRADED, + message: 'Location permission denied. You can manually enter your location instead.', + }); + appLogger.infoSync('[LocationService] Location permission denied'); + return false; + } + } catch (error) { + appLogger.errorSync('[LocationService] Error requesting permission', error instanceof Error ? error : new Error(String(error))); + return false; + } + } + + /** + * Check current location permission status + */ + public async checkPermission(): Promise { + try { + const { status } = await Location.getForegroundPermissionsAsync(); + this.locationPermissionStatus = status; + return status === 'granted'; + } catch (error) { + appLogger.errorSync('[LocationService] Error checking permission', error instanceof Error ? error : new Error(String(error))); + return false; + } + } + + /** + * Get current location via GPS + * Returns null if GPS unavailable - app should fall back to manual entry + */ + public async getCurrentLocation(): Promise { + try { + // Check permission + const hasPermission = this.locationPermissionStatus === 'granted' || await this.checkPermission(); + if (!hasPermission) { + appLogger.infoSync('[LocationService] Location permission not granted - GPS unavailable'); + featureCapabilities.getFeatureInfo(FeatureType.LOCATION); + const degradationStore = useDegradationStore(); + degradationStore.setFeatureStatus(FeatureType.LOCATION, FeatureStatus.DEGRADED); + return null; + } + + // Try to get current location + const location = await Location.getCurrentPositionAsync({ + accuracy: Location.Accuracy.Balanced, + timeInterval: 5000, + distanceInterval: 0, + }); + + const locationData: LocationData = { + latitude: location.coords.latitude, + longitude: location.coords.longitude, + accuracy: location.coords.accuracy || undefined, + source: LocationSourceType.GPS, + obtainedAt: new Date().toISOString(), + }; + + // Try to reverse geocode to get address + try { + const addresses = await Location.reverseGeocodeAsync({ + latitude: location.coords.latitude, + longitude: location.coords.longitude, + }); + + if (addresses.length > 0) { + const address = addresses[0]; + const parts = [address.city, address.region, address.country].filter(Boolean); + locationData.address = parts.join(', '); + } + } catch (geocodeError) { + appLogger.infoSync('[LocationService] Reverse geocoding failed (non-fatal)', geocodeError instanceof Error ? geocodeError : new Error(String(geocodeError))); + // Continue with GPS coordinates even if geocoding fails + } + + // Cache the location + this.cachedLocation = locationData; + + // Update feature status + featureCapabilities.getFeatureInfo(FeatureType.LOCATION); + const degradationStore = useDegradationStore(); + degradationStore.setFeatureStatus(FeatureType.LOCATION, FeatureStatus.AVAILABLE); + + appLogger.infoSync('[LocationService] GPS location obtained successfully', { + lat: locationData.latitude, + lon: locationData.longitude, + }); + + return locationData; + } catch (error) { + appLogger.errorSync('[LocationService] Error getting current location', error instanceof Error ? error : new Error(String(error))); + + // Feature degraded but not unavailable - return cached location if available + featureCapabilities.getFeatureInfo(FeatureType.LOCATION); + const degradationStore = useDegradationStore(); + degradationStore.setFeatureStatus(FeatureType.LOCATION, FeatureStatus.DEGRADED); + degradationStore.addNotification({ + feature: FeatureType.LOCATION, + status: FeatureStatus.DEGRADED, + message: 'Could not access your current location. Please enter your location manually.', + }); + + return null; + } + } + + /** + * Validate and store a manually entered location + */ + public setManualLocation(address: string): LocationData { + const locationData: LocationData = { + address: address.trim(), + source: LocationSourceType.MANUAL, + obtainedAt: new Date().toISOString(), + }; + + this.cachedLocation = locationData; + + featureCapabilities.getFeatureInfo(FeatureType.LOCATION); + const degradationStore = useDegradationStore(); + degradationStore.setFeatureStatus(FeatureType.LOCATION, FeatureStatus.AVAILABLE); + + appLogger.infoSync('[LocationService] Manual location set', { address }); + return locationData; + } + + /** + * Get cached location if available + */ + public getCachedLocation(): LocationData | null { + return this.cachedLocation ? { ...this.cachedLocation } : null; + } + + /** + * Clear cached location + */ + public clearCachedLocation(): void { + this.cachedLocation = null; + } + + /** + * Get location with fallback chain: GPS -> Cached -> Manual Entry Required + * Never throws - always returns a valid response + */ + public async getLocationWithFallback(previousAddress?: string): Promise { + // Try GPS first + const gpsLocation = await this.getCurrentLocation(); + if (gpsLocation) { + return gpsLocation; + } + + // Fall back to cached location + const cached = this.getCachedLocation(); + if (cached && cached.address) { + appLogger.infoSync('[LocationService] Using cached location'); + return { ...cached, source: LocationSourceType.CACHED }; + } + + // Fall back to previously entered address + if (previousAddress && previousAddress.trim()) { + appLogger.infoSync('[LocationService] Using previously entered location'); + return { + address: previousAddress, + source: LocationSourceType.CACHED, + obtainedAt: new Date().toISOString(), + }; + } + + // Return null - manual entry required + appLogger.infoSync('[LocationService] No location available - manual entry required'); + featureCapabilities.getFeatureInfo(FeatureType.LOCATION); + const degradationStore = useDegradationStore(); + degradationStore.setFeatureStatus(FeatureType.LOCATION, FeatureStatus.DEGRADED); + + return null; + } + + /** + * Get human-friendly location status message + */ + public getStatusMessage(currentLocation?: LocationData): string { + if (!currentLocation) { + return 'No location available. Please enter your location manually.'; + } + + switch (currentLocation.source) { + case LocationSourceType.GPS: + return `Current location (${currentLocation.address || 'coordinates obtained'})`; + case LocationSourceType.MANUAL: + return `Your location: ${currentLocation.address}`; + case LocationSourceType.CACHED: + return `Saved location: ${currentLocation.address}`; + case LocationSourceType.UNAVAILABLE: + default: + return 'Location unavailable - please enter manually'; + } + } +} + +export const locationService = LocationService.getInstance(); diff --git a/src/services/pushNotifications.ts b/src/services/pushNotifications.ts index 880c37cd..61bf2123 100644 --- a/src/services/pushNotifications.ts +++ b/src/services/pushNotifications.ts @@ -2,9 +2,10 @@ import Constants from 'expo-constants'; import * as Device from 'expo-device'; import * as Notifications from 'expo-notifications'; import { Platform } from 'react-native'; +import { useDegradationStore } from '../store/degradationStore'; import { NotificationData, NotificationType } from '../types/notifications'; import logger from '../utils/logger'; -import apiClient from './api/axios.config'; +import { featureCapabilities, FeatureStatus, FeatureType } from './featureCapabilities'; // Configure how notifications are handled when app is in foreground Notifications.setNotificationHandler({ @@ -19,10 +20,20 @@ Notifications.setNotificationHandler({ /** * Register for push notifications and get the Expo push token + * Includes graceful degradation: if push notifications unavailable, falls back to in-app notifications */ export async function registerForPushNotifications(): Promise { + // Check device type if (!Device.isDevice) { - logger.warn('Push notifications require a physical device'); + logger.warn('Push notifications require a physical device (simulator detected)'); + featureCapabilities.getFeatureInfo(FeatureType.PUSH_NOTIFICATIONS); + const degradationStore = useDegradationStore(); + degradationStore.setFeatureStatus(FeatureType.PUSH_NOTIFICATIONS, FeatureStatus.HARDWARE_UNAVAILABLE); + degradationStore.addNotification({ + feature: FeatureType.PUSH_NOTIFICATIONS, + status: FeatureStatus.HARDWARE_UNAVAILABLE, + message: 'Push notifications are not available in the simulator. In-app notifications will be used instead.', + }); return null; } @@ -39,6 +50,13 @@ export async function registerForPushNotifications(): Promise { if (finalStatus !== 'granted') { logger.warn('Push notification permission not granted'); + const degradationStore = useDegradationStore(); + degradationStore.setFeatureStatus(FeatureType.PUSH_NOTIFICATIONS, FeatureStatus.PERMISSION_DENIED); + degradationStore.addNotification({ + feature: FeatureType.PUSH_NOTIFICATIONS, + status: FeatureStatus.PERMISSION_DENIED, + message: 'Push notifications are disabled. In-app notifications will be shown instead. You can enable them in Settings.', + }); return null; } @@ -50,72 +68,109 @@ export async function registerForPushNotifications(): Promise { // Set up Android notification channel if (Platform.OS === 'android') { - await setupAndroidNotificationChannels(); + try { + await setupAndroidNotificationChannels(); + } catch (channelError) { + logger.error('Error setting up Android notification channels:', channelError); + // Continue anyway - channels are nice to have but not critical + } } + // Update feature capability on success + featureCapabilities.getFeatureInfo(FeatureType.PUSH_NOTIFICATIONS); + const degradationStore = useDegradationStore(); + degradationStore.setFeatureStatus(FeatureType.PUSH_NOTIFICATIONS, FeatureStatus.AVAILABLE); + return token.data; } catch (error) { logger.error('Error registering for push notifications:', error); + const degradationStore = useDegradationStore(); + degradationStore.setFeatureStatus(FeatureType.PUSH_NOTIFICATIONS, FeatureStatus.UNAVAILABLE); + degradationStore.addNotification({ + feature: FeatureType.PUSH_NOTIFICATIONS, + status: FeatureStatus.UNAVAILABLE, + message: 'Push notifications failed to initialize. In-app notifications will be used instead.', + }); return null; } } /** * Set up Android notification channels for different notification types + * Each channel setup is wrapped in try-catch to ensure one failure doesn't prevent others */ async function setupAndroidNotificationChannels(): Promise { - // Default channel for general notifications - await Notifications.setNotificationChannelAsync('default', { - name: 'Default', - importance: Notifications.AndroidImportance.HIGH, - vibrationPattern: [0, 250, 250, 250], - lightColor: '#4F46E5', - }); - - // Course updates channel - await Notifications.setNotificationChannelAsync('course-updates', { - name: 'Course Updates', - description: 'Notifications about new course content and updates', - importance: Notifications.AndroidImportance.HIGH, - vibrationPattern: [0, 250, 250, 250], - lightColor: '#4F46E5', - }); - - // Messages channel - await Notifications.setNotificationChannelAsync('messages', { - name: 'Messages', - description: 'New message notifications', - importance: Notifications.AndroidImportance.HIGH, - vibrationPattern: [0, 250, 250, 250], - lightColor: '#10B981', - }); - - // Learning reminders channel - await Notifications.setNotificationChannelAsync('reminders', { - name: 'Learning Reminders', - description: 'Daily learning reminder notifications', - importance: Notifications.AndroidImportance.DEFAULT, - vibrationPattern: [0, 250], - lightColor: '#F59E0B', - }); - - // Achievements channel - await Notifications.setNotificationChannelAsync('achievements', { - name: 'Achievements', - description: 'Achievement unlock notifications', - importance: Notifications.AndroidImportance.HIGH, - vibrationPattern: [0, 500, 250, 500], - lightColor: '#8B5CF6', - }); + const channels = [ + { + id: 'default', + config: { + name: 'Default', + importance: Notifications.AndroidImportance.HIGH, + vibrationPattern: [0, 250, 250, 250], + lightColor: '#4F46E5', + }, + }, + { + id: 'course-updates', + config: { + name: 'Course Updates', + description: 'Notifications about new course content and updates', + importance: Notifications.AndroidImportance.HIGH, + vibrationPattern: [0, 250, 250, 250], + lightColor: '#4F46E5', + }, + }, + { + id: 'messages', + config: { + name: 'Messages', + description: 'New message notifications', + importance: Notifications.AndroidImportance.HIGH, + vibrationPattern: [0, 250, 250, 250], + lightColor: '#10B981', + }, + }, + { + id: 'reminders', + config: { + name: 'Learning Reminders', + description: 'Daily learning reminder notifications', + importance: Notifications.AndroidImportance.DEFAULT, + vibrationPattern: [0, 250], + lightColor: '#F59E0B', + }, + }, + { + id: 'achievements', + config: { + name: 'Achievements', + description: 'Achievement unlock notifications', + importance: Notifications.AndroidImportance.HIGH, + vibrationPattern: [0, 500, 250, 500], + lightColor: '#8B5CF6', + }, + }, + { + id: 'community', + config: { + name: 'Community Activity', + description: 'Notifications about community posts and interactions', + importance: Notifications.AndroidImportance.DEFAULT, + vibrationPattern: [0, 250], + lightColor: '#EC4899', + }, + }, + ]; - // Community channel - await Notifications.setNotificationChannelAsync('community', { - name: 'Community Activity', - description: 'Notifications about community posts and interactions', - importance: Notifications.AndroidImportance.DEFAULT, - vibrationPattern: [0, 250], - lightColor: '#EC4899', - }); + // Set up each channel with graceful error handling + for (const channel of channels) { + try { + await Notifications.setNotificationChannelAsync(channel.id, channel.config as any); + } catch (error) { + logger.warn(`Failed to set up notification channel '${channel.id}':`, error); + // Continue setting up other channels if one fails + } + } } /** diff --git a/src/store/degradationStore.ts b/src/store/degradationStore.ts new file mode 100644 index 00000000..84ff226d --- /dev/null +++ b/src/store/degradationStore.ts @@ -0,0 +1,189 @@ +/** + * Graceful Degradation State Management + * + * Tracks feature availability and degradation states across the app. + * Stores user preferences for feature fallbacks and degradation notifications. + * + * Usage: + * const store = useDegradationStore(); + * if (store.isFeatureDegraded('camera')) { + * // Show degradation banner + * } + */ + +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { create } from 'zustand'; +import { createJSONStorage, persist } from 'zustand/middleware'; +import { FeatureStatus, FeatureType } from './featureCapabilities'; + +export interface DegradationNotification { + id: string; + feature: FeatureType; + status: FeatureStatus; + message: string; + showedAt: string; // ISO timestamp + dismissedAt?: string; + actionTaken?: string; // 'retryRequested' | 'dismissed' | 'acknowledged' +} + +export interface DegradationPreferences { + showDegradationBanners: boolean; // Show UI notices for degraded features + autoDismissDegradationAlerts: boolean; // Auto-dismiss alerts after 5 seconds + remindPermissionRetry: boolean; // Remind user to grant permissions after 1 hour + enableFallbackUX: boolean; // Use fallback UX when features unavailable (always true) +} + +interface DegradationState { + // Track which features are degraded + degradedFeatures: Set; + featureStatuses: Record; + + // Notifications about degradation + notifications: DegradationNotification[]; + + // User preferences + preferences: DegradationPreferences; + + // Actions - Feature status + setFeatureStatus: (feature: FeatureType, status: FeatureStatus) => void; + isFeatureDegraded: (feature: FeatureType) => boolean; + getDegradedFeatures: () => FeatureType[]; + + // Actions - Notifications + addNotification: (notification: Omit) => string; + dismissNotification: (notificationId: string, action?: string) => void; + clearNotifications: () => void; + getUnreadNotifications: () => DegradationNotification[]; + + // Actions - Preferences + setShowDegradationBanners: (show: boolean) => void; + setAutoDismissAlerts: (autoDismiss: boolean) => void; + setRemindPermissionRetry: (remind: boolean) => void; +} + +const DEFAULT_PREFERENCES: DegradationPreferences = { + showDegradationBanners: true, + autoDismissDegradationAlerts: true, + remindPermissionRetry: true, + enableFallbackUX: true, +}; + +let notificationIdCounter = 0; + +export const useDegradationStore = create()( + persist( + (set, get) => ({ + // Initial state + degradedFeatures: new Set(), + featureStatuses: { + [FeatureType.CAMERA]: FeatureStatus.UNAVAILABLE, + [FeatureType.PUSH_NOTIFICATIONS]: FeatureStatus.UNAVAILABLE, + [FeatureType.LOCATION]: FeatureStatus.AVAILABLE, + }, + notifications: [], + preferences: DEFAULT_PREFERENCES, + + // Feature status actions + setFeatureStatus: (feature, status) => + set((state) => { + const newDegraded = new Set(state.degradedFeatures); + const isDegraded = status === FeatureStatus.PERMISSION_DENIED || + status === FeatureStatus.HARDWARE_UNAVAILABLE || + status === FeatureStatus.UNAVAILABLE; + + if (isDegraded) { + newDegraded.add(feature); + } else { + newDegraded.delete(feature); + } + + return { + degradedFeatures: newDegraded, + featureStatuses: { + ...state.featureStatuses, + [feature]: status, + }, + }; + }), + + isFeatureDegraded: (feature: FeatureType): boolean => { + const status = get().featureStatuses[feature]; + return status === FeatureStatus.PERMISSION_DENIED || + status === FeatureStatus.HARDWARE_UNAVAILABLE || + status === FeatureStatus.UNAVAILABLE; + }, + + getDegradedFeatures: (): FeatureType[] => { + const features: FeatureType[] = []; + for (const feature of Object.values(FeatureType)) { + if (get().isFeatureDegraded(feature as FeatureType)) { + features.push(feature as FeatureType); + } + } + return features; + }, + + // Notification actions + addNotification: (notification: Omit): string => { + const id = `notif_${++notificationIdCounter}_${Date.now()}`; + const newNotification: DegradationNotification = { + ...notification, + id, + showedAt: new Date().toISOString(), + }; + + set((state) => ({ + notifications: [newNotification, ...state.notifications].slice(0, 50), // Keep last 50 + })); + + return id; + }, + + dismissNotification: (notificationId: string, action?: string) => { + set((state) => ({ + notifications: state.notifications.map((n) => + n.id === notificationId + ? { ...n, dismissedAt: new Date().toISOString(), actionTaken: action } + : n + ), + })); + }, + + clearNotifications: () => { + set({ notifications: [] }); + }, + + getUnreadNotifications: (): DegradationNotification[] => { + return get().notifications.filter((n) => !n.dismissedAt); + }, + + // Preference actions + setShowDegradationBanners: (show: boolean) => { + set((state) => ({ + preferences: { ...state.preferences, showDegradationBanners: show }, + })); + }, + + setAutoDismissAlerts: (autoDismiss: boolean) => { + set((state) => ({ + preferences: { ...state.preferences, autoDismissDegradationAlerts: autoDismiss }, + })); + }, + + setRemindPermissionRetry: (remind: boolean) => { + set((state) => ({ + preferences: { ...state.preferences, remindPermissionRetry: remind }, + })); + }, + }), + { + name: 'degradation-store', + storage: createJSONStorage(() => AsyncStorage), + partialize: (state) => ({ + preferences: state.preferences, + notifications: state.notifications, + featureStatuses: state.featureStatuses, + }), + } + ) +);