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,
+ }),
+ }
+ )
+);