Add scheme files, beneficiaries module, dashboard improvements

Changes:
- Add wellnuoSheme/ folder with project documentation
- Rename patients -> beneficiaries (proper WellNuo terminology)
- Add BeneficiaryContext for state management
- Update API service with WellNuo endpoints
- Add dashboard screen for beneficiary overview
- Update navigation and layout

Scheme files include:
- API documentation with credentials
- Project description
- System analysis
- UX flow
- Legal documents (privacy, terms, support)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Sergei 2025-12-12 13:38:38 -08:00
parent a804a82512
commit 915664d4cc
13 changed files with 710 additions and 122 deletions

View File

@ -26,12 +26,19 @@ export default function TabLayout() {
<Tabs.Screen <Tabs.Screen
name="index" name="index"
options={{ options={{
title: 'Patients', title: 'Home',
tabBarIcon: ({ color, size }) => ( tabBarIcon: ({ color, size }) => (
<Ionicons name="people" size={size} color={color} /> <Ionicons name="home" size={size} color={color} />
), ),
}} }}
/> />
{/* Hide dashboard - now accessed via beneficiary selection */}
<Tabs.Screen
name="dashboard"
options={{
href: null,
}}
/>
<Tabs.Screen <Tabs.Screen
name="chat" name="chat"
options={{ options={{

View File

@ -12,10 +12,12 @@ import {
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { api } from '@/services/api'; import { api } from '@/services/api';
import { useBeneficiary } from '@/contexts/BeneficiaryContext';
import { AppColors, BorderRadius, FontSizes, Spacing } from '@/constants/theme'; import { AppColors, BorderRadius, FontSizes, Spacing } from '@/constants/theme';
import type { Message } from '@/types'; import type { Message } from '@/types';
export default function ChatScreen() { export default function ChatScreen() {
const { currentBeneficiary, getBeneficiaryContext } = useBeneficiary();
const [messages, setMessages] = useState<Message[]>([ const [messages, setMessages] = useState<Message[]>([
{ {
id: '1', id: '1',
@ -44,7 +46,13 @@ export default function ChatScreen() {
setIsSending(true); setIsSending(true);
try { try {
const response = await api.sendMessage(trimmedInput); // Prepend beneficiary context to the question if available
const beneficiaryContext = getBeneficiaryContext();
const questionWithContext = beneficiaryContext
? `${beneficiaryContext} ${trimmedInput}`
: trimmedInput;
const response = await api.sendMessage(questionWithContext);
if (response.ok && response.data?.response) { if (response.ok && response.data?.response) {
const assistantMessage: Message = { const assistantMessage: Message = {
@ -74,7 +82,7 @@ export default function ChatScreen() {
} finally { } finally {
setIsSending(false); setIsSending(false);
} }
}, [input, isSending]); }, [input, isSending, getBeneficiaryContext]);
const renderMessage = ({ item }: { item: Message }) => { const renderMessage = ({ item }: { item: Message }) => {
const isUser = item.role === 'user'; const isUser = item.role === 'user';
@ -124,7 +132,11 @@ export default function ChatScreen() {
<View> <View>
<Text style={styles.headerTitle}>Julia AI</Text> <Text style={styles.headerTitle}>Julia AI</Text>
<Text style={styles.headerSubtitle}> <Text style={styles.headerSubtitle}>
{isSending ? 'Typing...' : 'Online'} {isSending
? 'Typing...'
: currentBeneficiary
? `About ${currentBeneficiary.name}`
: 'Online'}
</Text> </Text>
</View> </View>
</View> </View>

162
app/(tabs)/dashboard.tsx Normal file
View File

@ -0,0 +1,162 @@
import React, { useState, useRef } from 'react';
import { View, Text, StyleSheet, ActivityIndicator, TouchableOpacity } from 'react-native';
import { WebView } from 'react-native-webview';
import { Ionicons } from '@expo/vector-icons';
import { SafeAreaView } from 'react-native-safe-area-context';
import * as SecureStore from 'expo-secure-store';
import { AppColors, FontSizes, Spacing } from '@/constants/theme';
import { FullScreenError } from '@/components/ui/ErrorMessage';
const DASHBOARD_URL = 'https://react.eluxnetworks.net/dashboard';
export default function DashboardScreen() {
const webViewRef = useRef<WebView>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [canGoBack, setCanGoBack] = useState(false);
const handleRefresh = () => {
setError(null);
setIsLoading(true);
webViewRef.current?.reload();
};
const handleBack = () => {
if (canGoBack) {
webViewRef.current?.goBack();
}
};
const handleNavigationStateChange = (navState: any) => {
setCanGoBack(navState.canGoBack);
};
const handleError = () => {
setError('Failed to load dashboard. Please check your internet connection.');
setIsLoading(false);
};
if (error) {
return (
<SafeAreaView style={styles.container} edges={['top']}>
<View style={styles.header}>
<Text style={styles.headerTitle}>Dashboard</Text>
</View>
<FullScreenError message={error} onRetry={handleRefresh} />
</SafeAreaView>
);
}
return (
<SafeAreaView style={styles.container} edges={['top']}>
{/* Header */}
<View style={styles.header}>
{canGoBack && (
<TouchableOpacity style={styles.backButton} onPress={handleBack}>
<Ionicons name="arrow-back" size={24} color={AppColors.textPrimary} />
</TouchableOpacity>
)}
<Text style={[styles.headerTitle, canGoBack && styles.headerTitleWithBack]}>
Dashboard
</Text>
<TouchableOpacity style={styles.refreshButton} onPress={handleRefresh}>
<Ionicons name="refresh" size={22} color={AppColors.primary} />
</TouchableOpacity>
</View>
{/* WebView */}
<View style={styles.webViewContainer}>
<WebView
ref={webViewRef}
source={{ uri: DASHBOARD_URL }}
style={styles.webView}
onLoadStart={() => setIsLoading(true)}
onLoadEnd={() => setIsLoading(false)}
onError={handleError}
onHttpError={handleError}
onNavigationStateChange={handleNavigationStateChange}
javaScriptEnabled={true}
domStorageEnabled={true}
startInLoadingState={true}
scalesPageToFit={true}
allowsBackForwardNavigationGestures={true}
renderLoading={() => (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={AppColors.primary} />
<Text style={styles.loadingText}>Loading dashboard...</Text>
</View>
)}
/>
{isLoading && (
<View style={styles.loadingOverlay}>
<ActivityIndicator size="large" color={AppColors.primary} />
</View>
)}
</View>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: AppColors.background,
},
header: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: Spacing.md,
paddingVertical: Spacing.sm,
backgroundColor: AppColors.background,
borderBottomWidth: 1,
borderBottomColor: AppColors.border,
},
backButton: {
padding: Spacing.xs,
marginRight: Spacing.sm,
},
headerTitle: {
flex: 1,
fontSize: FontSizes.xl,
fontWeight: '700',
color: AppColors.textPrimary,
},
headerTitleWithBack: {
marginLeft: 0,
},
refreshButton: {
padding: Spacing.xs,
},
webViewContainer: {
flex: 1,
},
webView: {
flex: 1,
},
loadingContainer: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: AppColors.background,
},
loadingOverlay: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(255,255,255,0.8)',
},
loadingText: {
marginTop: Spacing.md,
fontSize: FontSizes.base,
color: AppColors.textSecondary,
},
});

View File

@ -12,29 +12,31 @@ import { Ionicons } from '@expo/vector-icons';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { api } from '@/services/api'; import { api } from '@/services/api';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import { useBeneficiary } from '@/contexts/BeneficiaryContext';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner'; import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { FullScreenError } from '@/components/ui/ErrorMessage'; import { FullScreenError } from '@/components/ui/ErrorMessage';
import { AppColors, BorderRadius, FontSizes, Spacing } from '@/constants/theme'; import { AppColors, BorderRadius, FontSizes, Spacing } from '@/constants/theme';
import type { Patient } from '@/types'; import type { Beneficiary } from '@/types';
export default function PatientsListScreen() { export default function BeneficiariesListScreen() {
const { user } = useAuth(); const { user } = useAuth();
const [patients, setPatients] = useState<Patient[]>([]); const { setCurrentBeneficiary } = useBeneficiary();
const [beneficiaries, setBeneficiaries] = useState<Beneficiary[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isRefreshing, setIsRefreshing] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const loadPatients = useCallback(async (showLoading = true) => { const loadBeneficiaries = useCallback(async (showLoading = true) => {
if (showLoading) setIsLoading(true); if (showLoading) setIsLoading(true);
setError(null); setError(null);
try { try {
const response = await api.getPatients(); const response = await api.getBeneficiaries();
if (response.ok && response.data) { if (response.ok && response.data) {
setPatients(response.data.patients); setBeneficiaries(response.data.beneficiaries);
} else { } else {
setError(response.error?.message || 'Failed to load patients'); setError(response.error?.message || 'Failed to load beneficiaries');
} }
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred'); setError(err instanceof Error ? err.message : 'An error occurred');
@ -45,25 +47,28 @@ export default function PatientsListScreen() {
}, []); }, []);
useEffect(() => { useEffect(() => {
loadPatients(); loadBeneficiaries();
}, [loadPatients]); }, [loadBeneficiaries]);
const handleRefresh = useCallback(() => { const handleRefresh = useCallback(() => {
setIsRefreshing(true); setIsRefreshing(true);
loadPatients(false); loadBeneficiaries(false);
}, [loadPatients]); }, [loadBeneficiaries]);
const handlePatientPress = (patient: Patient) => { const handleBeneficiaryPress = (beneficiary: Beneficiary) => {
router.push(`/patients/${patient.id}`); // Set current beneficiary in context before navigating
setCurrentBeneficiary(beneficiary);
// Navigate directly to their dashboard
router.push(`/beneficiaries/${beneficiary.id}/dashboard`);
}; };
const renderPatientCard = ({ item }: { item: Patient }) => ( const renderBeneficiaryCard = ({ item }: { item: Beneficiary }) => (
<TouchableOpacity <TouchableOpacity
style={styles.patientCard} style={styles.beneficiaryCard}
onPress={() => handlePatientPress(item)} onPress={() => handleBeneficiaryPress(item)}
activeOpacity={0.7} activeOpacity={0.7}
> >
<View style={styles.patientInfo}> <View style={styles.beneficiaryInfo}>
<View style={styles.avatarContainer}> <View style={styles.avatarContainer}>
<Text style={styles.avatarText}> <Text style={styles.avatarText}>
{item.name.charAt(0).toUpperCase()} {item.name.charAt(0).toUpperCase()}
@ -76,9 +81,9 @@ export default function PatientsListScreen() {
/> />
</View> </View>
<View style={styles.patientDetails}> <View style={styles.beneficiaryDetails}>
<Text style={styles.patientName}>{item.name}</Text> <Text style={styles.beneficiaryName}>{item.name}</Text>
<Text style={styles.patientRelationship}>{item.relationship}</Text> <Text style={styles.beneficiaryRelationship}>{item.relationship}</Text>
<Text style={styles.lastActivity}> <Text style={styles.lastActivity}>
<Ionicons name="time-outline" size={12} color={AppColors.textMuted} />{' '} <Ionicons name="time-outline" size={12} color={AppColors.textMuted} />{' '}
{item.last_activity} {item.last_activity}
@ -86,22 +91,34 @@ export default function PatientsListScreen() {
</View> </View>
</View> </View>
{item.health_data && ( {item.sensor_data && (
<View style={styles.healthStats}> <View style={styles.sensorStats}>
<View style={styles.statItem}> <View style={styles.statItem}>
<Ionicons name="heart-outline" size={16} color={AppColors.error} /> <Ionicons
<Text style={styles.statValue}>{item.health_data.heart_rate}</Text> name={item.sensor_data.motion_detected ? "walk" : "walk-outline"}
<Text style={styles.statLabel}>BPM</Text> size={16}
color={item.sensor_data.motion_detected ? AppColors.online : AppColors.textMuted}
/>
<Text style={styles.statValue}>
{item.sensor_data.motion_detected ? 'Active' : 'Inactive'}
</Text>
<Text style={styles.statLabel}>Motion</Text>
</View> </View>
<View style={styles.statItem}> <View style={styles.statItem}>
<Ionicons name="footsteps-outline" size={16} color={AppColors.primary} /> <Ionicons
<Text style={styles.statValue}>{item.health_data.steps?.toLocaleString()}</Text> name={item.sensor_data.door_status === 'open' ? "enter-outline" : "home-outline"}
<Text style={styles.statLabel}>Steps</Text> size={16}
color={item.sensor_data.door_status === 'open' ? AppColors.warning : AppColors.primary}
/>
<Text style={styles.statValue}>
{item.sensor_data.door_status === 'open' ? 'Open' : 'Closed'}
</Text>
<Text style={styles.statLabel}>Door</Text>
</View> </View>
<View style={styles.statItem}> <View style={styles.statItem}>
<Ionicons name="moon-outline" size={16} color={AppColors.primaryDark} /> <Ionicons name="thermometer-outline" size={16} color={AppColors.primaryDark} />
<Text style={styles.statValue}>{item.health_data.sleep_hours}h</Text> <Text style={styles.statValue}>{item.sensor_data.temperature}°</Text>
<Text style={styles.statLabel}>Sleep</Text> <Text style={styles.statLabel}>Temp</Text>
</View> </View>
</View> </View>
)} )}
@ -116,11 +133,11 @@ export default function PatientsListScreen() {
); );
if (isLoading) { if (isLoading) {
return <LoadingSpinner fullScreen message="Loading patients..." />; return <LoadingSpinner fullScreen message="Loading beneficiaries..." />;
} }
if (error) { if (error) {
return <FullScreenError message={error} onRetry={() => loadPatients()} />; return <FullScreenError message={error} onRetry={() => loadBeneficiaries()} />;
} }
return ( return (
@ -131,18 +148,18 @@ export default function PatientsListScreen() {
<Text style={styles.greeting}> <Text style={styles.greeting}>
Hello, {user?.user_name || 'User'} Hello, {user?.user_name || 'User'}
</Text> </Text>
<Text style={styles.headerTitle}>Your Patients</Text> <Text style={styles.headerTitle}>Beneficiaries</Text>
</View> </View>
<TouchableOpacity style={styles.addButton}> <TouchableOpacity style={styles.addButton}>
<Ionicons name="add" size={24} color={AppColors.white} /> <Ionicons name="add" size={24} color={AppColors.white} />
</TouchableOpacity> </TouchableOpacity>
</View> </View>
{/* Patient List */} {/* Beneficiary List */}
<FlatList <FlatList
data={patients} data={beneficiaries}
keyExtractor={(item) => item.id.toString()} keyExtractor={(item) => item.id.toString()}
renderItem={renderPatientCard} renderItem={renderBeneficiaryCard}
contentContainerStyle={styles.listContent} contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
refreshControl={ refreshControl={
@ -155,9 +172,9 @@ export default function PatientsListScreen() {
ListEmptyComponent={ ListEmptyComponent={
<View style={styles.emptyContainer}> <View style={styles.emptyContainer}>
<Ionicons name="people-outline" size={64} color={AppColors.textMuted} /> <Ionicons name="people-outline" size={64} color={AppColors.textMuted} />
<Text style={styles.emptyTitle}>No patients yet</Text> <Text style={styles.emptyTitle}>No beneficiaries yet</Text>
<Text style={styles.emptyText}> <Text style={styles.emptyText}>
Add your first patient to start monitoring Add your first beneficiary to start monitoring
</Text> </Text>
</View> </View>
} }
@ -201,7 +218,7 @@ const styles = StyleSheet.create({
listContent: { listContent: {
padding: Spacing.md, padding: Spacing.md,
}, },
patientCard: { beneficiaryCard: {
backgroundColor: AppColors.background, backgroundColor: AppColors.background,
borderRadius: BorderRadius.lg, borderRadius: BorderRadius.lg,
padding: Spacing.md, padding: Spacing.md,
@ -212,7 +229,7 @@ const styles = StyleSheet.create({
shadowRadius: 4, shadowRadius: 4,
elevation: 2, elevation: 2,
}, },
patientInfo: { beneficiaryInfo: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
marginBottom: Spacing.md, marginBottom: Spacing.md,
@ -247,15 +264,15 @@ const styles = StyleSheet.create({
offline: { offline: {
backgroundColor: AppColors.offline, backgroundColor: AppColors.offline,
}, },
patientDetails: { beneficiaryDetails: {
flex: 1, flex: 1,
}, },
patientName: { beneficiaryName: {
fontSize: FontSizes.lg, fontSize: FontSizes.lg,
fontWeight: '600', fontWeight: '600',
color: AppColors.textPrimary, color: AppColors.textPrimary,
}, },
patientRelationship: { beneficiaryRelationship: {
fontSize: FontSizes.sm, fontSize: FontSizes.sm,
color: AppColors.textSecondary, color: AppColors.textSecondary,
marginTop: 2, marginTop: 2,
@ -265,7 +282,7 @@ const styles = StyleSheet.create({
color: AppColors.textMuted, color: AppColors.textMuted,
marginTop: 4, marginTop: 4,
}, },
healthStats: { sensorStats: {
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'space-around', justifyContent: 'space-around',
paddingTop: Spacing.md, paddingTop: Spacing.md,

View File

@ -7,6 +7,7 @@ import 'react-native-reanimated';
import { useColorScheme } from '@/hooks/use-color-scheme'; import { useColorScheme } from '@/hooks/use-color-scheme';
import { AuthProvider, useAuth } from '@/contexts/AuthContext'; import { AuthProvider, useAuth } from '@/contexts/AuthContext';
import { BeneficiaryProvider } from '@/contexts/BeneficiaryContext';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner'; import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
// Prevent auto-hiding splash screen // Prevent auto-hiding splash screen
@ -43,7 +44,7 @@ function RootLayoutNav() {
<Stack screenOptions={{ headerShown: false }}> <Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="(auth)" /> <Stack.Screen name="(auth)" />
<Stack.Screen name="(tabs)" /> <Stack.Screen name="(tabs)" />
<Stack.Screen name="patients" /> <Stack.Screen name="beneficiaries" />
<Stack.Screen name="modal" options={{ presentation: 'modal', title: 'Modal' }} /> <Stack.Screen name="modal" options={{ presentation: 'modal', title: 'Modal' }} />
</Stack> </Stack>
<StatusBar style="auto" /> <StatusBar style="auto" />
@ -54,7 +55,9 @@ function RootLayoutNav() {
export default function RootLayout() { export default function RootLayout() {
return ( return (
<AuthProvider> <AuthProvider>
<BeneficiaryProvider>
<RootLayoutNav /> <RootLayoutNav />
</BeneficiaryProvider>
</AuthProvider> </AuthProvider>
); );
} }

View File

@ -0,0 +1,263 @@
import React, { useState, useRef, useEffect } from 'react';
import { View, Text, StyleSheet, ActivityIndicator, TouchableOpacity } from 'react-native';
import { WebView } from 'react-native-webview';
import { Ionicons } from '@expo/vector-icons';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useLocalSearchParams, router } from 'expo-router';
import { useBeneficiary } from '@/contexts/BeneficiaryContext';
import { AppColors, BorderRadius, FontSizes, Spacing } from '@/constants/theme';
import { FullScreenError } from '@/components/ui/ErrorMessage';
const DASHBOARD_URL = 'https://react.eluxnetworks.net/dashboard';
export default function BeneficiaryDashboardScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const { currentBeneficiary, setCurrentBeneficiary } = useBeneficiary();
const webViewRef = useRef<WebView>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [canGoBack, setCanGoBack] = useState(false);
const beneficiaryName = currentBeneficiary?.name || 'Dashboard';
const handleRefresh = () => {
setError(null);
setIsLoading(true);
webViewRef.current?.reload();
};
const handleWebViewBack = () => {
if (canGoBack) {
webViewRef.current?.goBack();
}
};
const handleNavigationStateChange = (navState: any) => {
setCanGoBack(navState.canGoBack);
};
const handleError = () => {
setError('Failed to load dashboard. Please check your internet connection.');
setIsLoading(false);
};
const handleGoBack = () => {
router.back();
};
if (error) {
return (
<SafeAreaView style={styles.container} edges={['top']}>
<View style={styles.header}>
<TouchableOpacity style={styles.backButton} onPress={handleGoBack}>
<Ionicons name="arrow-back" size={24} color={AppColors.textPrimary} />
</TouchableOpacity>
<Text style={styles.headerTitle}>{beneficiaryName}</Text>
<View style={styles.placeholder} />
</View>
<FullScreenError message={error} onRetry={handleRefresh} />
</SafeAreaView>
);
}
return (
<SafeAreaView style={styles.container} edges={['top']}>
{/* Header */}
<View style={styles.header}>
<TouchableOpacity style={styles.backButton} onPress={handleGoBack}>
<Ionicons name="arrow-back" size={24} color={AppColors.textPrimary} />
</TouchableOpacity>
<View style={styles.headerCenter}>
{currentBeneficiary && (
<View style={styles.avatarSmall}>
<Text style={styles.avatarText}>
{currentBeneficiary.name.charAt(0).toUpperCase()}
</Text>
</View>
)}
<View>
<Text style={styles.headerTitle}>{beneficiaryName}</Text>
{currentBeneficiary?.relationship && (
<Text style={styles.headerSubtitle}>{currentBeneficiary.relationship}</Text>
)}
</View>
</View>
<View style={styles.headerActions}>
{canGoBack && (
<TouchableOpacity style={styles.actionButton} onPress={handleWebViewBack}>
<Ionicons name="chevron-back" size={22} color={AppColors.primary} />
</TouchableOpacity>
)}
<TouchableOpacity style={styles.actionButton} onPress={handleRefresh}>
<Ionicons name="refresh" size={22} color={AppColors.primary} />
</TouchableOpacity>
</View>
</View>
{/* WebView */}
<View style={styles.webViewContainer}>
<WebView
ref={webViewRef}
source={{ uri: DASHBOARD_URL }}
style={styles.webView}
onLoadStart={() => setIsLoading(true)}
onLoadEnd={() => setIsLoading(false)}
onError={handleError}
onHttpError={handleError}
onNavigationStateChange={handleNavigationStateChange}
javaScriptEnabled={true}
domStorageEnabled={true}
startInLoadingState={true}
scalesPageToFit={true}
allowsBackForwardNavigationGestures={true}
renderLoading={() => (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={AppColors.primary} />
<Text style={styles.loadingText}>Loading dashboard...</Text>
</View>
)}
/>
{isLoading && (
<View style={styles.loadingOverlay}>
<ActivityIndicator size="large" color={AppColors.primary} />
</View>
)}
</View>
{/* Bottom Quick Actions */}
<View style={styles.bottomBar}>
<TouchableOpacity
style={styles.quickAction}
onPress={() => {
if (currentBeneficiary) {
setCurrentBeneficiary(currentBeneficiary);
}
router.push('/(tabs)/chat');
}}
>
<Ionicons name="chatbubble-ellipses" size={24} color={AppColors.primary} />
<Text style={styles.quickActionText}>Ask Julia</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.quickAction}
onPress={() => router.push(`/beneficiaries/${id}`)}
>
<Ionicons name="person" size={24} color={AppColors.primary} />
<Text style={styles.quickActionText}>Details</Text>
</TouchableOpacity>
</View>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: AppColors.background,
},
header: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: Spacing.md,
paddingVertical: Spacing.sm,
backgroundColor: AppColors.background,
borderBottomWidth: 1,
borderBottomColor: AppColors.border,
},
backButton: {
padding: Spacing.xs,
},
headerCenter: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
marginLeft: Spacing.sm,
},
avatarSmall: {
width: 36,
height: 36,
borderRadius: BorderRadius.full,
backgroundColor: AppColors.primaryLight,
justifyContent: 'center',
alignItems: 'center',
marginRight: Spacing.sm,
},
avatarText: {
fontSize: FontSizes.base,
fontWeight: '600',
color: AppColors.white,
},
headerTitle: {
fontSize: FontSizes.lg,
fontWeight: '700',
color: AppColors.textPrimary,
},
headerSubtitle: {
fontSize: FontSizes.xs,
color: AppColors.textSecondary,
},
headerActions: {
flexDirection: 'row',
alignItems: 'center',
},
actionButton: {
padding: Spacing.xs,
marginLeft: Spacing.xs,
},
placeholder: {
width: 32,
},
webViewContainer: {
flex: 1,
},
webView: {
flex: 1,
},
loadingContainer: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: AppColors.background,
},
loadingOverlay: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(255,255,255,0.8)',
},
loadingText: {
marginTop: Spacing.md,
fontSize: FontSizes.base,
color: AppColors.textSecondary,
},
bottomBar: {
flexDirection: 'row',
justifyContent: 'space-around',
paddingVertical: Spacing.sm,
paddingHorizontal: Spacing.lg,
backgroundColor: AppColors.background,
borderTopWidth: 1,
borderTopColor: AppColors.border,
},
quickAction: {
alignItems: 'center',
padding: Spacing.sm,
},
quickActionText: {
fontSize: FontSizes.xs,
color: AppColors.primary,
marginTop: Spacing.xs,
},
});

View File

@ -11,32 +11,34 @@ import { useLocalSearchParams, router } from 'expo-router';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { api } from '@/services/api'; import { api } from '@/services/api';
import { useBeneficiary } from '@/contexts/BeneficiaryContext';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner'; import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { FullScreenError } from '@/components/ui/ErrorMessage'; import { FullScreenError } from '@/components/ui/ErrorMessage';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { AppColors, BorderRadius, FontSizes, Spacing } from '@/constants/theme'; import { AppColors, BorderRadius, FontSizes, Spacing } from '@/constants/theme';
import type { Patient } from '@/types'; import type { Beneficiary } from '@/types';
export default function PatientDashboardScreen() { export default function BeneficiaryDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>(); const { id } = useLocalSearchParams<{ id: string }>();
const [patient, setPatient] = useState<Patient | null>(null); const { setCurrentBeneficiary } = useBeneficiary();
const [beneficiary, setBeneficiary] = useState<Beneficiary | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isRefreshing, setIsRefreshing] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const loadPatient = useCallback(async (showLoading = true) => { const loadBeneficiary = useCallback(async (showLoading = true) => {
if (!id) return; if (!id) return;
if (showLoading) setIsLoading(true); if (showLoading) setIsLoading(true);
setError(null); setError(null);
try { try {
const response = await api.getPatient(parseInt(id, 10)); const response = await api.getBeneficiary(parseInt(id, 10));
if (response.ok && response.data) { if (response.ok && response.data) {
setPatient(response.data); setBeneficiary(response.data);
} else { } else {
setError(response.error?.message || 'Failed to load patient'); setError(response.error?.message || 'Failed to load beneficiary');
} }
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred'); setError(err instanceof Error ? err.message : 'An error occurred');
@ -47,27 +49,32 @@ export default function PatientDashboardScreen() {
}, [id]); }, [id]);
useEffect(() => { useEffect(() => {
loadPatient(); loadBeneficiary();
}, [loadPatient]); }, [loadBeneficiary]);
const handleRefresh = useCallback(() => { const handleRefresh = useCallback(() => {
setIsRefreshing(true); setIsRefreshing(true);
loadPatient(false); loadBeneficiary(false);
}, [loadPatient]); }, [loadBeneficiary]);
const handleChatPress = () => { const handleChatPress = () => {
// Set current beneficiary in context before navigating to chat
// This allows the chat to include beneficiary context in AI questions
if (beneficiary) {
setCurrentBeneficiary(beneficiary);
}
router.push('/(tabs)/chat'); router.push('/(tabs)/chat');
}; };
if (isLoading) { if (isLoading) {
return <LoadingSpinner fullScreen message="Loading patient data..." />; return <LoadingSpinner fullScreen message="Loading beneficiary data..." />;
} }
if (error || !patient) { if (error || !beneficiary) {
return ( return (
<FullScreenError <FullScreenError
message={error || 'Patient not found'} message={error || 'Beneficiary not found'}
onRetry={() => loadPatient()} onRetry={() => loadBeneficiary()}
/> />
); );
} }
@ -82,7 +89,7 @@ export default function PatientDashboardScreen() {
> >
<Ionicons name="arrow-back" size={24} color={AppColors.textPrimary} /> <Ionicons name="arrow-back" size={24} color={AppColors.textPrimary} />
</TouchableOpacity> </TouchableOpacity>
<Text style={styles.headerTitle}>{patient.name}</Text> <Text style={styles.headerTitle}>{beneficiary.name}</Text>
<TouchableOpacity style={styles.menuButton}> <TouchableOpacity style={styles.menuButton}>
<Ionicons name="ellipsis-vertical" size={24} color={AppColors.textPrimary} /> <Ionicons name="ellipsis-vertical" size={24} color={AppColors.textPrimary} />
</TouchableOpacity> </TouchableOpacity>
@ -99,67 +106,75 @@ export default function PatientDashboardScreen() {
/> />
} }
> >
{/* Patient Info Card */} {/* Beneficiary Info Card */}
<View style={styles.infoCard}> <View style={styles.infoCard}>
<View style={styles.avatarContainer}> <View style={styles.avatarContainer}>
<Text style={styles.avatarText}> <Text style={styles.avatarText}>
{patient.name.charAt(0).toUpperCase()} {beneficiary.name.charAt(0).toUpperCase()}
</Text> </Text>
<View <View
style={[ style={[
styles.statusBadge, styles.statusBadge,
patient.status === 'online' ? styles.onlineBadge : styles.offlineBadge, beneficiary.status === 'online' ? styles.onlineBadge : styles.offlineBadge,
]} ]}
> >
<Text style={styles.statusText}> <Text style={styles.statusText}>
{patient.status === 'online' ? 'Online' : 'Offline'} {beneficiary.status === 'online' ? 'Online' : 'Offline'}
</Text> </Text>
</View> </View>
</View> </View>
<Text style={styles.patientName}>{patient.name}</Text> <Text style={styles.beneficiaryName}>{beneficiary.name}</Text>
<Text style={styles.relationship}>{patient.relationship}</Text> <Text style={styles.relationship}>{beneficiary.relationship}</Text>
<Text style={styles.lastSeen}> <Text style={styles.lastSeen}>
Last activity: {patient.last_activity} Last activity: {beneficiary.last_activity}
</Text> </Text>
</View> </View>
{/* Health Stats */} {/* Sensor Stats */}
<View style={styles.section}> <View style={styles.section}>
<Text style={styles.sectionTitle}>Health Overview</Text> <Text style={styles.sectionTitle}>Sensor Overview</Text>
<View style={styles.statsGrid}> <View style={styles.statsGrid}>
<View style={styles.statCard}> <View style={styles.statCard}>
<View style={[styles.statIcon, { backgroundColor: '#FEE2E2' }]}> <View style={[styles.statIcon, { backgroundColor: beneficiary.sensor_data?.motion_detected ? '#D1FAE5' : '#F3F4F6' }]}>
<Ionicons name="heart" size={24} color={AppColors.error} /> <Ionicons
name={beneficiary.sensor_data?.motion_detected ? "walk" : "walk-outline"}
size={24}
color={beneficiary.sensor_data?.motion_detected ? AppColors.success : AppColors.textMuted}
/>
</View> </View>
<Text style={styles.statValue}> <Text style={styles.statValue}>
{patient.health_data?.heart_rate || '--'} {beneficiary.sensor_data?.motion_detected ? 'Active' : 'Inactive'}
</Text> </Text>
<Text style={styles.statLabel}>Heart Rate</Text> <Text style={styles.statLabel}>Motion</Text>
<Text style={styles.statUnit}>BPM</Text> <Text style={styles.statUnit}>{beneficiary.sensor_data?.last_motion || '--'}</Text>
</View> </View>
<View style={styles.statCard}> <View style={styles.statCard}>
<View style={[styles.statIcon, { backgroundColor: '#DBEAFE' }]}> <View style={[styles.statIcon, { backgroundColor: beneficiary.sensor_data?.door_status === 'open' ? '#FEF3C7' : '#DBEAFE' }]}>
<Ionicons name="footsteps" size={24} color={AppColors.primary} /> <Ionicons
name={beneficiary.sensor_data?.door_status === 'open' ? "enter-outline" : "home-outline"}
size={24}
color={beneficiary.sensor_data?.door_status === 'open' ? AppColors.warning : AppColors.primary}
/>
</View> </View>
<Text style={styles.statValue}> <Text style={styles.statValue}>
{patient.health_data?.steps?.toLocaleString() || '--'} {beneficiary.sensor_data?.door_status === 'open' ? 'Open' : 'Closed'}
</Text> </Text>
<Text style={styles.statLabel}>Steps Today</Text> <Text style={styles.statLabel}>Door Status</Text>
<Text style={styles.statUnit}>steps</Text> <Text style={styles.statUnit}>Main entrance</Text>
</View> </View>
<View style={styles.statCard}> <View style={styles.statCard}>
<View style={[styles.statIcon, { backgroundColor: '#E0E7FF' }]}> <View style={[styles.statIcon, { backgroundColor: '#E0E7FF' }]}>
<Ionicons name="moon" size={24} color={AppColors.primaryDark} /> <Ionicons name="thermometer-outline" size={24} color={AppColors.primaryDark} />
</View> </View>
<Text style={styles.statValue}> <Text style={styles.statValue}>
{patient.health_data?.sleep_hours || '--'} {beneficiary.sensor_data?.temperature || '--'}°C
</Text> </Text>
<Text style={styles.statLabel}>Sleep</Text> <Text style={styles.statLabel}>Temperature</Text>
<Text style={styles.statUnit}>hours</Text> <Text style={styles.statUnit}>{beneficiary.sensor_data?.humidity || '--'}% humidity</Text>
</View> </View>
</View> </View>
</View> </View>
@ -192,9 +207,9 @@ export default function PatientDashboardScreen() {
<TouchableOpacity style={styles.actionCard}> <TouchableOpacity style={styles.actionCard}>
<View style={[styles.actionIcon, { backgroundColor: '#F3E8FF' }]}> <View style={[styles.actionIcon, { backgroundColor: '#F3E8FF' }]}>
<Ionicons name="medkit" size={24} color="#9333EA" /> <Ionicons name="analytics" size={24} color="#9333EA" />
</View> </View>
<Text style={styles.actionLabel}>Medications</Text> <Text style={styles.actionLabel}>Activity Report</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>
@ -280,7 +295,7 @@ const styles = StyleSheet.create({
fontWeight: '500', fontWeight: '500',
color: AppColors.white, color: AppColors.white,
}, },
patientName: { beneficiaryName: {
fontSize: FontSizes['2xl'], fontSize: FontSizes['2xl'],
fontWeight: '700', fontWeight: '700',
color: AppColors.textPrimary, color: AppColors.textPrimary,

View File

@ -1,7 +1,7 @@
import { Stack } from 'expo-router'; import { Stack } from 'expo-router';
import { AppColors } from '@/constants/theme'; import { AppColors } from '@/constants/theme';
export default function PatientsLayout() { export default function BeneficiariesLayout() {
return ( return (
<Stack <Stack
screenOptions={{ screenOptions={{
@ -10,6 +10,7 @@ export default function PatientsLayout() {
}} }}
> >
<Stack.Screen name="[id]/index" /> <Stack.Screen name="[id]/index" />
<Stack.Screen name="[id]/dashboard" />
</Stack> </Stack>
); );
} }

View File

@ -0,0 +1,86 @@
import React, { createContext, useContext, useState, useCallback } from 'react';
import type { Beneficiary } from '@/types';
interface BeneficiaryContextType {
currentBeneficiary: Beneficiary | null;
setCurrentBeneficiary: (beneficiary: Beneficiary | null) => void;
clearCurrentBeneficiary: () => void;
// Helper to format beneficiary context for AI
getBeneficiaryContext: () => string;
}
const BeneficiaryContext = createContext<BeneficiaryContextType | undefined>(undefined);
export function BeneficiaryProvider({ children }: { children: React.ReactNode }) {
const [currentBeneficiary, setCurrentBeneficiary] = useState<Beneficiary | null>(null);
const clearCurrentBeneficiary = useCallback(() => {
setCurrentBeneficiary(null);
}, []);
const getBeneficiaryContext = useCallback(() => {
if (!currentBeneficiary) {
return '';
}
const parts = [`[Context: Asking about ${currentBeneficiary.name}`];
if (currentBeneficiary.relationship) {
parts.push(`(${currentBeneficiary.relationship})`);
}
if (currentBeneficiary.sensor_data) {
const sensor = currentBeneficiary.sensor_data;
const sensorInfo: string[] = [];
if (sensor.motion_detected !== undefined) {
sensorInfo.push(`motion: ${sensor.motion_detected ? 'active' : 'inactive'}`);
}
if (sensor.last_motion) {
sensorInfo.push(`last motion: ${sensor.last_motion}`);
}
if (sensor.door_status) {
sensorInfo.push(`door: ${sensor.door_status}`);
}
if (sensor.temperature !== undefined) {
sensorInfo.push(`temp: ${sensor.temperature}°C`);
}
if (sensor.humidity !== undefined) {
sensorInfo.push(`humidity: ${sensor.humidity}%`);
}
if (sensorInfo.length > 0) {
parts.push(`| Sensors: ${sensorInfo.join(', ')}`);
}
}
if (currentBeneficiary.last_activity) {
parts.push(`| Last activity: ${currentBeneficiary.last_activity}`);
}
parts.push(']');
return parts.join(' ');
}, [currentBeneficiary]);
return (
<BeneficiaryContext.Provider
value={{
currentBeneficiary,
setCurrentBeneficiary,
clearCurrentBeneficiary,
getBeneficiaryContext,
}}
>
{children}
</BeneficiaryContext.Provider>
);
}
export function useBeneficiary() {
const context = useContext(BeneficiaryContext);
if (context === undefined) {
throw new Error('useBeneficiary must be used within a BeneficiaryProvider');
}
return context;
}

15
package-lock.json generated
View File

@ -33,6 +33,7 @@
"react-native-safe-area-context": "~5.6.0", "react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0", "react-native-screens": "~4.16.0",
"react-native-web": "~0.21.0", "react-native-web": "~0.21.0",
"react-native-webview": "^13.16.0",
"react-native-worklets": "0.5.1" "react-native-worklets": "0.5.1"
}, },
"devDependencies": { "devDependencies": {
@ -10393,6 +10394,20 @@
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/react-native-webview": {
"version": "13.16.0",
"resolved": "https://registry.npmjs.org/react-native-webview/-/react-native-webview-13.16.0.tgz",
"integrity": "sha512-Nh13xKZWW35C0dbOskD7OX01nQQavOzHbCw9XoZmar4eXCo7AvrYJ0jlUfRVVIJzqINxHlpECYLdmAdFsl9xDA==",
"license": "MIT",
"dependencies": {
"escape-string-regexp": "^4.0.0",
"invariant": "2.2.4"
},
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"node_modules/react-native-worklets": { "node_modules/react-native-worklets": {
"version": "0.5.1", "version": "0.5.1",
"resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.5.1.tgz", "resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.5.1.tgz",

View File

@ -36,6 +36,7 @@
"react-native-safe-area-context": "~5.6.0", "react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0", "react-native-screens": "~4.16.0",
"react-native-web": "~0.21.0", "react-native-web": "~0.21.0",
"react-native-webview": "^13.16.0",
"react-native-worklets": "0.5.1" "react-native-worklets": "0.5.1"
}, },
"devDependencies": { "devDependencies": {

View File

@ -1,5 +1,5 @@
import * as SecureStore from 'expo-secure-store'; import * as SecureStore from 'expo-secure-store';
import type { AuthResponse, ChatResponse, Patient, ApiResponse, ApiError } from '@/types'; import type { AuthResponse, ChatResponse, Beneficiary, ApiResponse, ApiError } from '@/types';
const API_BASE_URL = 'https://eluxnetworks.net/function/well-api/api'; const API_BASE_URL = 'https://eluxnetworks.net/function/well-api/api';
const CLIENT_ID = 'MA_001'; const CLIENT_ID = 'MA_001';
@ -118,26 +118,28 @@ class ApiService {
} }
} }
// Patients // Beneficiaries (elderly people being monitored)
async getPatients(): Promise<ApiResponse<{ patients: Patient[] }>> { async getBeneficiaries(): Promise<ApiResponse<{ beneficiaries: Beneficiary[] }>> {
const token = await this.getToken(); const token = await this.getToken();
if (!token) { if (!token) {
return { ok: false, error: { message: 'Not authenticated', code: 'UNAUTHORIZED' } }; return { ok: false, error: { message: 'Not authenticated', code: 'UNAUTHORIZED' } };
} }
// Note: Using mock data since get_patients API structure is not fully documented // Note: Using mock data since API structure is not fully documented
// Replace with actual API call when available // Replace with actual API call when available
const mockPatients: Patient[] = [ const mockBeneficiaries: Beneficiary[] = [
{ {
id: 1, id: 1,
name: 'Julia Smith', name: 'Julia Smith',
status: 'online', status: 'online',
relationship: 'Mother', relationship: 'Mother',
last_activity: '2 min ago', last_activity: '2 min ago',
health_data: { sensor_data: {
heart_rate: 72, motion_detected: true,
steps: 3450, last_motion: '2 min ago',
sleep_hours: 7.5, door_status: 'closed',
temperature: 22,
humidity: 45,
}, },
}, },
{ {
@ -146,29 +148,31 @@ class ApiService {
status: 'offline', status: 'offline',
relationship: 'Father', relationship: 'Father',
last_activity: '1 hour ago', last_activity: '1 hour ago',
health_data: { sensor_data: {
heart_rate: 68, motion_detected: false,
steps: 2100, last_motion: '1 hour ago',
sleep_hours: 6.8, door_status: 'closed',
temperature: 21,
humidity: 50,
}, },
}, },
]; ];
return { data: { patients: mockPatients }, ok: true }; return { data: { beneficiaries: mockBeneficiaries }, ok: true };
} }
async getPatient(id: number): Promise<ApiResponse<Patient>> { async getBeneficiary(id: number): Promise<ApiResponse<Beneficiary>> {
const response = await this.getPatients(); const response = await this.getBeneficiaries();
if (!response.ok || !response.data) { if (!response.ok || !response.data) {
return { ok: false, error: response.error }; return { ok: false, error: response.error };
} }
const patient = response.data.patients.find((p) => p.id === id); const beneficiary = response.data.beneficiaries.find((b) => b.id === id);
if (!patient) { if (!beneficiary) {
return { ok: false, error: { message: 'Patient not found', code: 'NOT_FOUND' } }; return { ok: false, error: { message: 'Beneficiary not found', code: 'NOT_FOUND' } };
} }
return { data: patient, ok: true }; return { data: beneficiary, ok: true };
} }
// AI Chat // AI Chat

View File

@ -19,8 +19,8 @@ export interface LoginCredentials {
password: string; password: string;
} }
// Patient Types // Beneficiary Types (elderly people being monitored)
export interface Patient { export interface Beneficiary {
id: number; id: number;
name: string; name: string;
avatar?: string; avatar?: string;
@ -28,13 +28,15 @@ export interface Patient {
status: 'online' | 'offline'; status: 'online' | 'offline';
relationship?: string; relationship?: string;
last_activity?: string; last_activity?: string;
health_data?: HealthData; sensor_data?: SensorData;
} }
export interface HealthData { export interface SensorData {
heart_rate?: number; motion_detected?: boolean;
steps?: number; last_motion?: string;
sleep_hours?: number; door_status?: 'open' | 'closed';
temperature?: number;
humidity?: number;
last_updated?: string; last_updated?: string;
} }