Implemented null/undefined handling throughout NavigationController and useNavigationFlow hook to prevent crashes from invalid data: - Added null checks for all profile and beneficiary parameters - Validated beneficiary IDs before navigation (type and value checks) - Added fallback routes when data is invalid or missing - Implemented safe navigation with error handling and logging - Added defensive guards for optional purchaseResult parameter Key improvements: - getRouteAfterLogin: handles null profile, null beneficiaries, invalid IDs - getRouteForBeneficiarySetup: validates beneficiary exists before routing - getRouteAfterAddBeneficiary: validates beneficiary ID type and value - getRouteAfterPurchase: handles null purchaseResult safely - getBeneficiaryRoute: returns fallback route for invalid beneficiaries - navigate hook: wraps router calls in try-catch with validation All methods now gracefully handle edge cases without crashing, logging warnings for debugging while maintaining UX flow. Tests included for all null/undefined scenarios.
440 lines
12 KiB
TypeScript
440 lines
12 KiB
TypeScript
/**
|
|
* NavigationController - Centralized navigation logic for WellNuo app
|
|
*
|
|
* This service provides all routing decisions based on user state,
|
|
* beneficiary data, and equipment status.
|
|
*
|
|
* USAGE:
|
|
* import { NavigationController } from '@/services/NavigationController';
|
|
* const { path, params } = NavigationController.getRouteAfterLogin(profile, beneficiaries);
|
|
* router.replace(path);
|
|
*/
|
|
|
|
import type { Beneficiary, EquipmentStatus } from '@/types';
|
|
|
|
// ==================== ROUTE CONSTANTS ====================
|
|
|
|
export const ROUTES = {
|
|
// Auth Flow
|
|
AUTH: {
|
|
LOGIN: '/(auth)/login',
|
|
VERIFY_OTP: '/(auth)/verify-otp',
|
|
ENTER_NAME: '/(auth)/enter-name',
|
|
ADD_LOVED_ONE: '/(auth)/add-loved-one',
|
|
PURCHASE: '/(auth)/purchase',
|
|
ACTIVATE: '/(auth)/activate',
|
|
WELCOME_BACK: '/(auth)/welcome-back',
|
|
COMPLETE_PROFILE: '/(auth)/complete-profile',
|
|
},
|
|
// Main Tabs
|
|
TABS: {
|
|
DASHBOARD: '/(tabs)/dashboard',
|
|
BENEFICIARIES: '/(tabs)', // index.tsx shows beneficiary list
|
|
CHAT: '/(tabs)/chat',
|
|
VOICE: '/(tabs)/voice',
|
|
PROFILE: '/(tabs)/profile',
|
|
},
|
|
// Beneficiary Management
|
|
BENEFICIARY: {
|
|
ADD: '/(tabs)/beneficiaries/add',
|
|
DETAIL: (id: number) => `/(tabs)/beneficiaries/${id}` as const,
|
|
SUBSCRIPTION: (id: number) => `/(tabs)/beneficiaries/${id}/subscription` as const,
|
|
EQUIPMENT: (id: number) => `/(tabs)/beneficiaries/${id}/equipment` as const,
|
|
SHARE: (id: number) => `/(tabs)/beneficiaries/${id}/share` as const,
|
|
},
|
|
// Profile
|
|
PROFILE: {
|
|
INDEX: '/(tabs)/profile',
|
|
EDIT: '/(tabs)/profile/edit',
|
|
NOTIFICATIONS: '/(tabs)/profile/notifications',
|
|
LANGUAGE: '/(tabs)/profile/language',
|
|
PRIVACY: '/(tabs)/profile/privacy',
|
|
TERMS: '/(tabs)/profile/terms',
|
|
SUPPORT: '/(tabs)/profile/support',
|
|
ABOUT: '/(tabs)/profile/about',
|
|
HELP: '/(tabs)/profile/help',
|
|
},
|
|
} as const;
|
|
|
|
// ==================== TYPE DEFINITIONS ====================
|
|
|
|
export interface NavigationResult {
|
|
path: string;
|
|
params?: Record<string, string | number | boolean>;
|
|
}
|
|
|
|
export interface UserProfile {
|
|
id: number;
|
|
email: string;
|
|
firstName: string | null;
|
|
lastName: string | null;
|
|
phone: string | null;
|
|
}
|
|
|
|
// ==================== NAVIGATION CONTROLLER ====================
|
|
|
|
export const NavigationController = {
|
|
/**
|
|
* Determine where to navigate after successful OTP verification
|
|
*
|
|
* Flow:
|
|
* 1. New user without name → enter-name
|
|
* 2. User without beneficiaries → add-loved-one
|
|
* 3. User has beneficiary without devices → check hasDevices
|
|
* - hasDevices=false → purchase (need to buy equipment)
|
|
* - hasDevices=true but equipment not activated → activate
|
|
* 4. User has active beneficiary → dashboard
|
|
*/
|
|
getRouteAfterLogin(
|
|
profile: UserProfile | null | undefined,
|
|
beneficiaries: Beneficiary[] | null | undefined
|
|
): NavigationResult {
|
|
// Step 1: Check if user has name
|
|
if (!profile?.firstName) {
|
|
return {
|
|
path: ROUTES.AUTH.ENTER_NAME,
|
|
};
|
|
}
|
|
|
|
// Step 2: Check if user has any beneficiaries
|
|
if (!beneficiaries || beneficiaries.length === 0) {
|
|
return {
|
|
path: ROUTES.AUTH.ADD_LOVED_ONE,
|
|
};
|
|
}
|
|
|
|
// Step 3: Multiple beneficiaries - go to list, let user choose
|
|
if (beneficiaries.length > 1) {
|
|
return {
|
|
path: ROUTES.TABS.BENEFICIARIES,
|
|
};
|
|
}
|
|
|
|
// Step 4: Single beneficiary - check if needs setup
|
|
const singleBeneficiary = beneficiaries[0];
|
|
|
|
// Null safety: verify beneficiary exists and has valid id
|
|
if (!singleBeneficiary || typeof singleBeneficiary.id !== 'number') {
|
|
return {
|
|
path: ROUTES.AUTH.ADD_LOVED_ONE,
|
|
};
|
|
}
|
|
|
|
const isActive = singleBeneficiary.hasDevices ||
|
|
singleBeneficiary.equipmentStatus === 'active' ||
|
|
singleBeneficiary.equipmentStatus === 'demo';
|
|
|
|
if (isActive) {
|
|
// Single active beneficiary - go to dashboard
|
|
return {
|
|
path: ROUTES.TABS.DASHBOARD,
|
|
};
|
|
}
|
|
|
|
// Single beneficiary needs setup
|
|
return this.getRouteForBeneficiarySetup(singleBeneficiary);
|
|
},
|
|
|
|
/**
|
|
* Determine where to navigate for beneficiary equipment setup
|
|
*/
|
|
getRouteForBeneficiarySetup(beneficiary: Beneficiary | null | undefined): NavigationResult {
|
|
// Null safety: validate beneficiary exists and has valid id
|
|
if (!beneficiary || typeof beneficiary.id !== 'number') {
|
|
return {
|
|
path: ROUTES.AUTH.ADD_LOVED_ONE,
|
|
};
|
|
}
|
|
|
|
const status = beneficiary.equipmentStatus || 'none';
|
|
|
|
switch (status) {
|
|
case 'none':
|
|
// No equipment ordered - go to purchase
|
|
return {
|
|
path: ROUTES.AUTH.PURCHASE,
|
|
params: { beneficiaryId: beneficiary.id },
|
|
};
|
|
|
|
case 'ordered':
|
|
case 'shipped':
|
|
// Equipment is on the way - can show tracking or wait screen
|
|
// For now, go to equipment status page
|
|
return {
|
|
path: ROUTES.BENEFICIARY.EQUIPMENT(beneficiary.id),
|
|
params: { beneficiaryId: beneficiary.id },
|
|
};
|
|
|
|
case 'delivered':
|
|
// Equipment delivered, needs activation
|
|
return {
|
|
path: ROUTES.AUTH.ACTIVATE,
|
|
params: { beneficiaryId: beneficiary.id },
|
|
};
|
|
|
|
case 'active':
|
|
case 'demo':
|
|
// Already active, go to dashboard
|
|
return {
|
|
path: ROUTES.TABS.DASHBOARD,
|
|
};
|
|
|
|
default:
|
|
// Unknown status - go to dashboard
|
|
return {
|
|
path: ROUTES.TABS.DASHBOARD,
|
|
};
|
|
}
|
|
},
|
|
|
|
/**
|
|
* After creating a new beneficiary, determine next step
|
|
*
|
|
* @param beneficiaryId - ID of newly created beneficiary
|
|
* @param hasExistingDevices - User indicated they already have WellNuo devices
|
|
*/
|
|
getRouteAfterAddBeneficiary(
|
|
beneficiaryId: number | null | undefined,
|
|
hasExistingDevices: boolean
|
|
): NavigationResult {
|
|
// Null safety: validate beneficiary ID
|
|
if (!beneficiaryId || typeof beneficiaryId !== 'number') {
|
|
return {
|
|
path: ROUTES.AUTH.ADD_LOVED_ONE,
|
|
};
|
|
}
|
|
|
|
if (hasExistingDevices) {
|
|
// User has existing devices - go directly to activation
|
|
return {
|
|
path: ROUTES.AUTH.ACTIVATE,
|
|
params: { beneficiaryId },
|
|
};
|
|
}
|
|
|
|
// User needs to purchase equipment
|
|
return {
|
|
path: ROUTES.AUTH.PURCHASE,
|
|
params: { beneficiaryId },
|
|
};
|
|
},
|
|
|
|
/**
|
|
* After successful purchase, navigate to next step
|
|
*/
|
|
getRouteAfterPurchase(
|
|
beneficiaryId: number | null | undefined,
|
|
purchaseResult: { skipToActivate?: boolean; demo?: boolean } | null | undefined
|
|
): NavigationResult {
|
|
// Null safety: validate beneficiary ID
|
|
if (!beneficiaryId || typeof beneficiaryId !== 'number') {
|
|
return {
|
|
path: ROUTES.AUTH.ADD_LOVED_ONE,
|
|
};
|
|
}
|
|
|
|
if (purchaseResult?.demo || purchaseResult?.skipToActivate) {
|
|
// Demo mode or skip - go to activate
|
|
return {
|
|
path: ROUTES.AUTH.ACTIVATE,
|
|
params: purchaseResult.demo
|
|
? { beneficiaryId, demo: purchaseResult.demo }
|
|
: { beneficiaryId },
|
|
};
|
|
}
|
|
|
|
// Normal purchase - wait for equipment delivery
|
|
// Go to equipment tracking page
|
|
return {
|
|
path: ROUTES.BENEFICIARY.EQUIPMENT(beneficiaryId),
|
|
params: { beneficiaryId },
|
|
};
|
|
},
|
|
|
|
/**
|
|
* After successful activation, navigate to beneficiary detail page
|
|
*/
|
|
getRouteAfterActivation(beneficiaryId: number | null | undefined): NavigationResult {
|
|
// Null safety: validate beneficiary ID, fallback to dashboard
|
|
if (!beneficiaryId || typeof beneficiaryId !== 'number') {
|
|
return {
|
|
path: ROUTES.TABS.DASHBOARD,
|
|
};
|
|
}
|
|
|
|
return {
|
|
path: ROUTES.BENEFICIARY.DETAIL(beneficiaryId),
|
|
params: { justActivated: true },
|
|
};
|
|
},
|
|
|
|
/**
|
|
* Get route for returning user (already has account)
|
|
*
|
|
* Used when user logs in with existing account
|
|
*/
|
|
getRouteForReturningUser(
|
|
profile: UserProfile | null | undefined,
|
|
beneficiaries: Beneficiary[] | null | undefined
|
|
): NavigationResult {
|
|
// Same logic as after login, but could add welcome-back screen
|
|
return this.getRouteAfterLogin(profile, beneficiaries);
|
|
},
|
|
|
|
/**
|
|
* Check if user should see onboarding
|
|
*/
|
|
shouldShowOnboarding(
|
|
isNewUser: boolean,
|
|
profile: UserProfile | null
|
|
): boolean {
|
|
return isNewUser || !profile?.firstName;
|
|
},
|
|
|
|
/**
|
|
* Get route for a specific beneficiary action
|
|
*/
|
|
getBeneficiaryRoute(
|
|
beneficiary: Beneficiary | null | undefined,
|
|
action: 'view' | 'subscription' | 'equipment' | 'share'
|
|
): NavigationResult {
|
|
// Null safety: validate beneficiary exists and has valid id
|
|
if (!beneficiary || typeof beneficiary.id !== 'number') {
|
|
return {
|
|
path: ROUTES.TABS.BENEFICIARIES,
|
|
};
|
|
}
|
|
|
|
switch (action) {
|
|
case 'view':
|
|
return { path: ROUTES.BENEFICIARY.DETAIL(beneficiary.id) };
|
|
case 'subscription':
|
|
return { path: ROUTES.BENEFICIARY.SUBSCRIPTION(beneficiary.id) };
|
|
case 'equipment':
|
|
return { path: ROUTES.BENEFICIARY.EQUIPMENT(beneficiary.id) };
|
|
case 'share':
|
|
return { path: ROUTES.BENEFICIARY.SHARE(beneficiary.id) };
|
|
default:
|
|
return { path: ROUTES.BENEFICIARY.DETAIL(beneficiary.id) };
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Determine if beneficiary card should show "Setup Equipment" button
|
|
*/
|
|
shouldShowEquipmentSetup(beneficiary: Beneficiary | null | undefined): boolean {
|
|
// Null safety: return false if beneficiary is invalid
|
|
if (!beneficiary) {
|
|
return false;
|
|
}
|
|
|
|
return !beneficiary.hasDevices &&
|
|
beneficiary.equipmentStatus !== 'active' &&
|
|
beneficiary.equipmentStatus !== 'demo';
|
|
},
|
|
|
|
/**
|
|
* Get call-to-action for beneficiary based on status
|
|
*/
|
|
getBeneficiaryCallToAction(
|
|
beneficiary: Beneficiary | null | undefined
|
|
): { label: string; action: () => NavigationResult } | null {
|
|
// Null safety: return null if beneficiary is invalid
|
|
if (!beneficiary || typeof beneficiary.id !== 'number') {
|
|
return null;
|
|
}
|
|
|
|
const status = beneficiary.equipmentStatus || 'none';
|
|
|
|
if (beneficiary.hasDevices || status === 'active' || status === 'demo') {
|
|
// No CTA needed - beneficiary is active
|
|
return null;
|
|
}
|
|
|
|
switch (status) {
|
|
case 'none':
|
|
return {
|
|
label: 'Setup Equipment',
|
|
action: () => ({
|
|
path: ROUTES.AUTH.PURCHASE,
|
|
params: { beneficiaryId: beneficiary.id },
|
|
}),
|
|
};
|
|
|
|
case 'ordered':
|
|
return {
|
|
label: 'Track Delivery',
|
|
action: () => ({
|
|
path: ROUTES.BENEFICIARY.EQUIPMENT(beneficiary.id),
|
|
}),
|
|
};
|
|
|
|
case 'shipped':
|
|
return {
|
|
label: 'Track Package',
|
|
action: () => ({
|
|
path: ROUTES.BENEFICIARY.EQUIPMENT(beneficiary.id),
|
|
}),
|
|
};
|
|
|
|
case 'delivered':
|
|
return {
|
|
label: 'Activate Equipment',
|
|
action: () => ({
|
|
path: ROUTES.AUTH.ACTIVATE,
|
|
params: { beneficiaryId: beneficiary.id },
|
|
}),
|
|
};
|
|
|
|
default:
|
|
return null;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Get status text for equipment
|
|
*/
|
|
getEquipmentStatusText(status: EquipmentStatus | undefined): string {
|
|
switch (status) {
|
|
case 'none':
|
|
return 'No equipment';
|
|
case 'ordered':
|
|
return 'Equipment ordered';
|
|
case 'shipped':
|
|
return 'In transit';
|
|
case 'delivered':
|
|
return 'Ready to activate';
|
|
case 'active':
|
|
return 'Active';
|
|
case 'demo':
|
|
return 'Demo mode';
|
|
default:
|
|
return 'Unknown';
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Get status color for equipment
|
|
*/
|
|
getEquipmentStatusColor(status: EquipmentStatus | undefined): string {
|
|
switch (status) {
|
|
case 'none':
|
|
return '#888888'; // gray
|
|
case 'ordered':
|
|
return '#FFA500'; // orange
|
|
case 'shipped':
|
|
return '#007AFF'; // blue
|
|
case 'delivered':
|
|
return '#34C759'; // green
|
|
case 'active':
|
|
return '#34C759'; // green
|
|
case 'demo':
|
|
return '#9C27B0'; // purple
|
|
default:
|
|
return '#888888'; // gray
|
|
}
|
|
},
|
|
};
|
|
|
|
export default NavigationController;
|