diff --git a/App.tsx b/App.tsx index 9dc7fed..80cdbc5 100644 --- a/App.tsx +++ b/App.tsx @@ -1,4 +1,268 @@ -import socketService from './src/services/socket'; -import { syncService } from './src/services/syncService'; +import { StatusBar } from 'expo-status-bar'; +import React, { useEffect, useRef } from 'react'; +import { Alert, AppState, AppStateStatus, LogBox } from 'react-native'; + +import StorybookUI from './.rnstorybook'; +import './global.css'; + +import * as Font from 'expo-font'; +import * as SplashScreen from 'expo-splash-screen'; +import { ErrorBoundary } from './src/components/common/ErrorBoundary'; +import { initializeLogging } from './src/config/logging'; +import { AuthProvider, useAdaptiveTheme, useReviewMetrics } from './src/hooks'; +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 { inAppReviewService } from './src/services/inAppReview'; -import { useReviewMetrics } from './src/hooks/useInAppReview'; +import { mobileAuthService } from './src/services/mobileAuth'; +import { + addNotificationReceivedListener, + getLastNotificationResponse, + removeNotificationListener, + registerForPushNotifications, // Added missing native push helpers + registerTokenWithBackend, +} from './src/services/pushNotifications'; +import { requestQueue } from './src/services/requestQueue'; +import socketService from './src/services/socket'; +import { syncService } from './src/services/syncService'; // Fixed naming convention from the merge conflict +import { useAppStore, useNotificationStore } from './src/store'; // Added missing store imports +import { useDegradationStore } from './src/store/degradationStore'; +import { handleCacheVersionUpdate } from './src/utils/cacheVersioning'; +import { requireEnvVariables } from './src/utils/env'; +import { appLogger } from './src/utils/logger'; +import { handleNotificationReceived } from './src/utils/notificationHandlers'; +import { initializeSecureStorage } from './src/services/secureStorage'; // Added missing storage helper mock path + +// Keep the splash screen visible while we fetch resources +SplashScreen.preventAutoHideAsync(); + +// SHOW_STORYBOOK flag based on environment variable +const SHOW_STORYBOOK = process.env.EXPO_PUBLIC_STORYBOOK === 'true'; + +// Centralized structured logging initialized on startup +requireEnvVariables(); + +// Initialize centralized logging on app start +initializeLogging().catch(err => { + console.error('[App] Failed to initialize logging:', err); +}); + +if (__DEV__) { + appLogger.infoSync('Development mode: centralized logger active'); + LogBox.ignoreLogs(['Non-serializable values were found in the navigation state']); +} else { + // Strip all logs except errors in production for performance + console.log = () => {}; + console.info = () => {}; + console.warn = () => {}; + console.debug = () => {}; +} + +const App = () => { + const theme = useAppStore((state) => state.theme); + useAdaptiveTheme(); + // Using imported hook from the merge logic if needed downstream + useReviewMetrics(); + + const appStateRef = useRef(AppState.currentState); + const [appIsReady, setAppIsReady] = React.useState(false); + + useEffect(() => { + async function prepareApp() { + try { + // 1. Load fonts + await Font.loadAsync({ + // You can add custom fonts here later if needed + }); + + // 2. Version-based cache invalidation: clear stale caches on app/data version bump + const appVersion = require('./package.json').version as string; + await handleCacheVersionUpdate(appVersion); + + // 3. Initial data fetch (simulate or add real fetch) + await new Promise(resolve => setTimeout(resolve, 500)); + } catch (e) { + console.warn('Error during app initialization:', e); + } finally { + setAppIsReady(true); + await SplashScreen.hideAsync(); + } + } + + prepareApp(); + }, []); + + const SESSION_REFRESH_WINDOW_MS = 5 * 60 * 1000; + + useEffect(() => { + // Initialize crash reporting at app startup + crashReportingService.init(); + + // Initialize secure storage (Keychain/Keystore) for encrypted token storage + initializeSecureStorage().catch((error) => { + appLogger.errorSync('Failed to initialize secure storage:', error); // Fixed 'logger.error' to 'appLogger.errorSync' + }); + + // Add global handler for unhandled promise rejections + const unhandledRejectionHandler = (reason: any) => { + const error = reason instanceof Error ? reason : new Error(String(reason)); + appLogger.errorSync('Unhandled Promise Rejection', error); + crashReportingService.reportError(error, 'UnhandledPromiseRejection'); + }; + + // Register unhandled rejection listener + if (global.onunhandledrejection === undefined) { + // @ts-ignore - Setting global error handler + global.onunhandledrejection = unhandledRejectionHandler; + } + + // Connect to socket when app starts + socketService.connect(); + + // Initialize feature capability detection (non-blocking) + 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))); + }); + + // Initialize push notifications: request permissions and get device token + registerForPushNotifications().then(async (token) => { + if (token) { + const { setPushToken, setTokenRegistered } = useNotificationStore.getState(); + setPushToken(token); + const registered = await registerTokenWithBackend(token); + setTokenRegistered(registered); + } + }); + + // Start request queue monitoring + requestQueue.startMonitoring(apiClient); + + // Initialize and start sync service for background sync + syncService.startAutoSync(); + + // Initialize In-App Review metrics if applicable + inAppReviewService.init?.(); + + // Set up notification navigation handler + const notificationCleanup = setupNotificationNavigation(); + + // Listen for notifications received while app is foregrounded + const subscription = addNotificationReceivedListener(handleNotificationReceived); + + // Check if app was launched from a notification + getLastNotificationResponse().then(response => { + if (response) { + appLogger.infoSync('App launched from notification', { response }); + } + }); + + // Cleanup on unmount + return () => { + socketService.disconnect(); + syncService.stopAutoSync(); + notificationCleanup(); + removeNotificationListener(subscription); + // Clean up the unhandled rejection handler + // @ts-ignore + global.onunhandledrejection = undefined; + }; + }, []); + + useEffect(() => { + const checkSessionOnForeground = async () => { + const { + isAuthenticated, + refreshToken, + sessionExpiresAt, + setUser, + setTokens, + setSessionExpiringSoon, + logout, + } = useAppStore.getState(); + + if (!isAuthenticated || !refreshToken || !sessionExpiresAt) { + return; + } + + const now = Date.now(); + const msUntilExpiry = sessionExpiresAt - now; + + if (msUntilExpiry <= 0) { + logout(); + Alert.alert('Session expired', 'Your session has expired. Please log in again.'); + return; + } + + if (msUntilExpiry <= SESSION_REFRESH_WINDOW_MS) { + setSessionExpiringSoon(true); + Alert.alert('Session expiring soon', 'Refreshing your session to keep you signed in.'); + + try { + const refreshedSession = await mobileAuthService.refreshSession(); + setUser(refreshedSession.user); + setTokens( + refreshedSession.tokens.accessToken, + refreshedSession.tokens.refreshToken, + refreshedSession.tokens.expiresAt + ); + setSessionExpiringSoon(false); + } catch (error) { + appLogger.errorSync('Failed to refresh session on app foreground', error as Error); + logout(); + Alert.alert('Session expired', 'We could not refresh your session. Please log in again.'); + } + } else { + setSessionExpiringSoon(false); + } + }; + + checkSessionOnForeground(); + + const appStateSubscription = AppState.addEventListener('change', nextAppState => { + const wasInBackground = appStateRef.current.match(/inactive|background/); + const isForegrounded = nextAppState === 'active'; + + if (wasInBackground && isForegrounded) { + void checkSessionOnForeground(); + } + + appStateRef.current = nextAppState; + }); + + return () => { + appStateSubscription.remove(); + }; + }, [SESSION_REFRESH_WINDOW_MS]); + + if (!appIsReady) { + return null; + } + + return ( + + + + + + + ); +}; + +export default SHOW_STORYBOOK ? StorybookUI : App; \ No newline at end of file diff --git a/docs/GRACEFUL_DEGRADATION.md b/docs/GRACEFUL_DEGRADATION.md new file mode 100644 index 0000000..2d58561 --- /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 0000000..7d7f3b4 --- /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 e183e14..304ac24 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 index 13e83d1..1bff640 100644 --- a/src/hooks/useLocation.ts +++ b/src/hooks/useLocation.ts @@ -1,49 +1,158 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +/** + * Hook for Location Management with Graceful Degradation & Batching + * + * Usage: + * const { position, location, manualLocation, setManualLocation, loading, isDegraded, statusMessage, refresh, queryNearby } = useLocation(); + */ -import locationService, { GetPositionOptions, Position } from '../services/location'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import locationService, { + LocationData, + LocationSourceType, + GetPositionOptions, + Position +} from '../services/locationService'; // Standardized service import target +import { useDegradationStore } from '../store/degradationStore'; +import { appLogger } from '../utils/logger'; import { Coordinates, LocationPrecision } from '../utils/geoUtils'; -interface UseLocationState { +interface UseLocationReturn { + /** Native geographic position details (lat, lng, timestamp) */ position: Position | null; + /** High-level location metadata (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 */ loading: boolean; + /** Unexpected runtime errors */ error: unknown | null; + /** Whether location feature is degraded (no GPS / using fallback) */ + isDegraded: boolean; + /** Human-friendly status message */ + statusMessage: string; + /** Request location permission */ + requestPermission: () => Promise; + /** Refresh current location with optional performance overrides */ + refresh: (overrides?: GetPositionOptions) => Promise; + /** Clear cached location tracking data */ + clearCachedLocation: () => void; + /** Run a location-keyed backend query, automatically batched with nearby queries */ + queryNearby: ( + coords: Coordinates, + query: (c: Coordinates) => Promise, + precision?: LocationPrecision + ) => Promise; } -/** - * React wrapper around the location service. - * - * Defaults to `coarse` precision to favour battery life; pass - * `{ precision: 'fine' }` only for flows that genuinely need ~1m accuracy. - * Reads are cached/coalesced by the service, so calling `refresh` from many - * components is cheap. Location-keyed backend queries can be batched with - * `queryNearby`. - */ -export function useLocation(defaultOptions: GetPositionOptions = {}) { - const [state, setState] = useState({ - position: null, - loading: false, - error: null, - }); - const optionsRef = useRef(defaultOptions); - optionsRef.current = defaultOptions; +export const useLocation = (defaultOptions: GetPositionOptions = {}): UseLocationReturn => { + const [position, setPosition] = useState(null); + const [location, setLocation] = useState(null); + const [manualLocation, setManualLocationState] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [isDegraded, setIsDegraded] = useState(false); + const [statusMessage, setStatusMessage] = useState(''); + const degradationStore = useDegradationStore(); + + // Safe persistence for configuration changes across renders without re-triggering hooks + const optionsRef = useRef(defaultOptions); useEffect(() => { - void locationService.hydrate(); + optionsRef.current = defaultOptions; + }, [defaultOptions]); + + /** + * 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; }, []); - const refresh = useCallback(async (overrides?: GetPositionOptions) => { - setState((prev) => ({ ...prev, loading: true, error: null })); + /** + * Refresh current location with performance options and fallback chain + */ + const refresh = useCallback(async (overrides?: GetPositionOptions): Promise => { + setLoading(true); + setError(null); try { - const position = await locationService.getCurrentPosition({ + appLogger.infoSync('[useLocation] Refreshing location position mapping'); + + // Fetch underlying position using main-branch configuration merges + const nativePosition = await locationService.getCurrentPosition({ ...optionsRef.current, ...overrides, }); - setState({ position, loading: false, error: null }); - return position; - } catch (error) { - setState((prev) => ({ ...prev, loading: false, error })); + setPosition(nativePosition); + + // Pass down parameters to downstream graceful degradation engines + const locationData = await locationService.getLocationWithFallback(manualLocation); + + if (locationData) { + setLocation(locationData); + setStatusMessage(locationService.getStatusMessage(locationData)); + + if (locationData.source === LocationSourceType.MANUAL && locationData.address) { + setManualLocationState(locationData.address); + } + + const degradedMode = locationData.source !== LocationSourceType.GPS; + setIsDegraded(degradedMode); + degradationStore.setFeatureStatus('location', degradedMode ? 'degraded' : 'available'); + } else { + setLocation(null); + setIsDegraded(true); + setStatusMessage('Please enter your location manually'); + } + + return nativePosition; + } catch (err) { + appLogger.errorSync('[useLocation] Error refreshing location', err instanceof Error ? err : new Error(String(err))); + setError(err); + setIsDegraded(true); + setStatusMessage('Location refresh failed - please enter manually'); + degradationStore.setFeatureStatus('location', 'degraded'); return null; + } finally { + setLoading(false); } + }, [manualLocation, degradationStore]); + + /** + * 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 fallback triggers a degraded state marker + degradationStore.setFeatureStatus('location', 'degraded'); + appLogger.infoSync('[useLocation] Manual location set', { address }); + } + }, [degradationStore]); + + /** + * Clear cached location + */ + const clearCachedLocation = useCallback((): void => { + locationService.clearCachedLocation(); + setLocation(null); + setPosition(null); + setManualLocationState(''); + setStatusMessage('Location cleared'); + appLogger.infoSync('[useLocation] Location cleared'); }, []); /** @@ -51,7 +160,7 @@ export function useLocation(defaultOptions: GetPositionOptions = {}) { * nearby queries (same precision cell) issued in the same window. */ const queryNearby = useCallback( - ( + ( coords: Coordinates, query: (c: Coordinates) => Promise, precision: LocationPrecision = 'coarse', @@ -59,11 +168,41 @@ export function useLocation(defaultOptions: GetPositionOptions = {}) { [], ); + /** + * Hydrate local system, check permission, and attempt to resolve location on mount + */ + useEffect(() => { + const initLocation = async () => { + // Main branch hydration sequence + await locationService.hydrate(); + + const hasPermission = await locationService.checkPermission(); + if (hasPermission) { + await refresh(); + } else { + setIsDegraded(true); + setStatusMessage('Location permission required - manual entry available'); + degradationStore.setFeatureStatus('location', 'degraded'); + } + }; + + initLocation(); + }, []); + return { - ...state, + position, + location, + manualLocation, + setManualLocation: handleSetManualLocation, + loading, + error, + isDegraded, + statusMessage, + requestPermission, refresh, + clearCachedLocation, queryNearby, }; -} +}; -export default useLocation; +export default useLocation; \ No newline at end of file diff --git a/src/services/featureCapabilities.ts b/src/services/featureCapabilities.ts new file mode 100644 index 0000000..dc068e4 --- /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 0000000..0ff4e67 --- /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 def32f0..c90fa06 100644 --- a/src/services/pushNotifications.ts +++ b/src/services/pushNotifications.ts @@ -2,9 +2,10 @@ import Constants from 'expo-constants'; import { isDevice } 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 using the proper 'isDevice' check from expo-device if (!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.getState(); // Fixed: Accessing Zustand store state cleanly outside a component + 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.getState(); + 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.getState(); + degradationStore.setFeatureStatus(FeatureType.PUSH_NOTIFICATIONS, FeatureStatus.AVAILABLE); + return token.data; } catch (error) { logger.error('Error registering for push notifications:', error); + const degradationStore = useDegradationStore.getState(); + 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 + } + } } /** @@ -148,7 +203,6 @@ export async function registerTokenWithBackend(token: string): Promise // const response = await apiClient.post('/api/notifications/register', { // token, // platform: Platform.OS, - // deviceId: Device.deviceName, // }); // return response.data.success; @@ -260,7 +314,7 @@ export function addNotificationResponseListener( * Remove a notification listener */ export function removeNotificationListener(subscription: Notifications.Subscription): void { - subscription.remove(); // Use the subscription's remove method instead + subscription.remove(); } /** @@ -268,4 +322,4 @@ export function removeNotificationListener(subscription: Notifications.Subscript */ export async function getLastNotificationResponse(): Promise { return await Notifications.getLastNotificationResponseAsync(); -} +} \ No newline at end of file diff --git a/src/store/degradationStore.ts b/src/store/degradationStore.ts new file mode 100644 index 0000000..84ff226 --- /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, + }), + } + ) +);