diff --git a/.gitignore b/.gitignore
index f8c6c2e..2a6edee 100644
--- a/.gitignore
+++ b/.gitignore
@@ -41,3 +41,4 @@ app-example
# generated native folders
/ios
/android
+.git-credentials
diff --git a/app/(auth)/_layout.tsx b/app/(auth)/_layout.tsx
new file mode 100644
index 0000000..9ac3ffe
--- /dev/null
+++ b/app/(auth)/_layout.tsx
@@ -0,0 +1,15 @@
+import { Stack } from 'expo-router';
+import { AppColors } from '@/constants/theme';
+
+export default function AuthLayout() {
+ return (
+
+
+
+ );
+}
diff --git a/app/(auth)/login.tsx b/app/(auth)/login.tsx
new file mode 100644
index 0000000..ed77ae5
--- /dev/null
+++ b/app/(auth)/login.tsx
@@ -0,0 +1,210 @@
+import React, { useState, useCallback } from 'react';
+import {
+ View,
+ Text,
+ StyleSheet,
+ KeyboardAvoidingView,
+ Platform,
+ ScrollView,
+ TouchableOpacity,
+ Image,
+} from 'react-native';
+import { router } from 'expo-router';
+import { useAuth } from '@/contexts/AuthContext';
+import { Button } from '@/components/ui/Button';
+import { Input } from '@/components/ui/Input';
+import { ErrorMessage } from '@/components/ui/ErrorMessage';
+import { AppColors, BorderRadius, FontSizes, Spacing } from '@/constants/theme';
+
+export default function LoginScreen() {
+ const { login, isLoading, error, clearError } = useAuth();
+ const [username, setUsername] = useState('');
+ const [password, setPassword] = useState('');
+ const [validationError, setValidationError] = useState(null);
+
+ const handleLogin = useCallback(async () => {
+ // Clear previous errors
+ clearError();
+ setValidationError(null);
+
+ // Validate
+ if (!username.trim()) {
+ setValidationError('Username is required');
+ return;
+ }
+ if (!password.trim()) {
+ setValidationError('Password is required');
+ return;
+ }
+
+ const success = await login({ username: username.trim(), password });
+
+ if (success) {
+ router.replace('/(tabs)');
+ }
+ }, [username, password, login, clearError]);
+
+ const displayError = validationError || error?.message;
+
+ return (
+
+
+ {/* Logo / Header */}
+
+
+ WellNuo
+
+ Welcome Back
+ Sign in to continue monitoring your loved ones
+
+
+ {/* Form */}
+
+ {displayError && (
+ {
+ clearError();
+ setValidationError(null);
+ }}
+ />
+ )}
+
+ {
+ setUsername(text);
+ setValidationError(null);
+ }}
+ autoCapitalize="none"
+ autoCorrect={false}
+ editable={!isLoading}
+ />
+
+ {
+ setPassword(text);
+ setValidationError(null);
+ }}
+ editable={!isLoading}
+ onSubmitEditing={handleLogin}
+ returnKeyType="done"
+ />
+
+
+ Forgot Password?
+
+
+
+
+
+ {/* Footer */}
+
+ Don't have an account?
+
+ Create Account
+
+
+
+ {/* Version Info */}
+ WellNuo v1.0.0
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: AppColors.background,
+ },
+ scrollContent: {
+ flexGrow: 1,
+ paddingHorizontal: Spacing.lg,
+ paddingTop: Spacing.xxl + Spacing.xl,
+ paddingBottom: Spacing.xl,
+ },
+ header: {
+ alignItems: 'center',
+ marginBottom: Spacing.xl,
+ },
+ logoContainer: {
+ width: 80,
+ height: 80,
+ borderRadius: BorderRadius.xl,
+ backgroundColor: AppColors.primary,
+ justifyContent: 'center',
+ alignItems: 'center',
+ marginBottom: Spacing.lg,
+ },
+ logoText: {
+ fontSize: FontSizes.lg,
+ fontWeight: '700',
+ color: AppColors.white,
+ },
+ title: {
+ fontSize: FontSizes['2xl'],
+ fontWeight: '700',
+ color: AppColors.textPrimary,
+ marginBottom: Spacing.xs,
+ },
+ subtitle: {
+ fontSize: FontSizes.base,
+ color: AppColors.textSecondary,
+ textAlign: 'center',
+ },
+ form: {
+ marginBottom: Spacing.xl,
+ },
+ forgotPassword: {
+ alignSelf: 'flex-end',
+ marginBottom: Spacing.lg,
+ marginTop: -Spacing.sm,
+ },
+ forgotPasswordText: {
+ fontSize: FontSizes.sm,
+ color: AppColors.primary,
+ fontWeight: '500',
+ },
+ footer: {
+ flexDirection: 'row',
+ justifyContent: 'center',
+ alignItems: 'center',
+ marginBottom: Spacing.xl,
+ },
+ footerText: {
+ fontSize: FontSizes.base,
+ color: AppColors.textSecondary,
+ },
+ footerLink: {
+ fontSize: FontSizes.base,
+ color: AppColors.primary,
+ fontWeight: '600',
+ },
+ version: {
+ textAlign: 'center',
+ fontSize: FontSizes.xs,
+ color: AppColors.textMuted,
+ },
+});
diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx
index 54e11d0..4d39bec 100644
--- a/app/(tabs)/_layout.tsx
+++ b/app/(tabs)/_layout.tsx
@@ -1,33 +1,60 @@
import { Tabs } from 'expo-router';
import React from 'react';
+import { Ionicons } from '@expo/vector-icons';
import { HapticTab } from '@/components/haptic-tab';
-import { IconSymbol } from '@/components/ui/icon-symbol';
-import { Colors } from '@/constants/theme';
+import { AppColors } from '@/constants/theme';
import { useColorScheme } from '@/hooks/use-color-scheme';
export default function TabLayout() {
const colorScheme = useColorScheme();
+ const isDark = colorScheme === 'dark';
return (
+ }}
+ >
,
+ title: 'Patients',
+ tabBarIcon: ({ color, size }) => (
+
+ ),
}}
/>
+ (
+
+ ),
+ }}
+ />
+ (
+
+ ),
+ }}
+ />
+ {/* Hide explore tab */}
,
+ href: null,
}}
/>
diff --git a/app/(tabs)/chat.tsx b/app/(tabs)/chat.tsx
new file mode 100644
index 0000000..a27ceb4
--- /dev/null
+++ b/app/(tabs)/chat.tsx
@@ -0,0 +1,321 @@
+import React, { useState, useCallback, useRef } from 'react';
+import {
+ View,
+ Text,
+ StyleSheet,
+ FlatList,
+ TextInput,
+ TouchableOpacity,
+ KeyboardAvoidingView,
+ Platform,
+} from 'react-native';
+import { Ionicons } from '@expo/vector-icons';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import { api } from '@/services/api';
+import { AppColors, BorderRadius, FontSizes, Spacing } from '@/constants/theme';
+import type { Message } from '@/types';
+
+export default function ChatScreen() {
+ const [messages, setMessages] = useState([
+ {
+ id: '1',
+ role: 'assistant',
+ content: 'Hello! I\'m Julia, your AI assistant. How can I help you today?',
+ timestamp: new Date(),
+ },
+ ]);
+ const [input, setInput] = useState('');
+ const [isSending, setIsSending] = useState(false);
+ const flatListRef = useRef(null);
+
+ const handleSend = useCallback(async () => {
+ const trimmedInput = input.trim();
+ if (!trimmedInput || isSending) return;
+
+ const userMessage: Message = {
+ id: Date.now().toString(),
+ role: 'user',
+ content: trimmedInput,
+ timestamp: new Date(),
+ };
+
+ setMessages((prev) => [...prev, userMessage]);
+ setInput('');
+ setIsSending(true);
+
+ try {
+ const response = await api.sendMessage(trimmedInput);
+
+ if (response.ok && response.data?.response) {
+ const assistantMessage: Message = {
+ id: (Date.now() + 1).toString(),
+ role: 'assistant',
+ content: response.data.response.body,
+ timestamp: new Date(),
+ };
+ setMessages((prev) => [...prev, assistantMessage]);
+ } else {
+ const errorMessage: Message = {
+ id: (Date.now() + 1).toString(),
+ role: 'assistant',
+ content: 'Sorry, I encountered an error. Please try again.',
+ timestamp: new Date(),
+ };
+ setMessages((prev) => [...prev, errorMessage]);
+ }
+ } catch (error) {
+ const errorMessage: Message = {
+ id: (Date.now() + 1).toString(),
+ role: 'assistant',
+ content: 'Sorry, I couldn\'t connect to the server. Please check your internet connection.',
+ timestamp: new Date(),
+ };
+ setMessages((prev) => [...prev, errorMessage]);
+ } finally {
+ setIsSending(false);
+ }
+ }, [input, isSending]);
+
+ const renderMessage = ({ item }: { item: Message }) => {
+ const isUser = item.role === 'user';
+
+ return (
+
+ {!isUser && (
+
+ J
+
+ )}
+
+
+ {item.content}
+
+
+ {item.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
+
+
+
+ );
+ };
+
+ return (
+
+ {/* Header */}
+
+
+
+ J
+
+
+ Julia AI
+
+ {isSending ? 'Typing...' : 'Online'}
+
+
+
+
+
+
+
+
+ {/* Messages */}
+
+ item.id}
+ renderItem={renderMessage}
+ contentContainerStyle={styles.messagesList}
+ showsVerticalScrollIndicator={false}
+ onContentSizeChange={() => flatListRef.current?.scrollToEnd({ animated: true })}
+ />
+
+ {/* Input */}
+
+
+
+
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: AppColors.surface,
+ },
+ header: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ paddingHorizontal: Spacing.md,
+ paddingVertical: Spacing.sm,
+ backgroundColor: AppColors.background,
+ borderBottomWidth: 1,
+ borderBottomColor: AppColors.border,
+ },
+ headerInfo: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ },
+ headerAvatar: {
+ width: 40,
+ height: 40,
+ borderRadius: BorderRadius.full,
+ backgroundColor: AppColors.success,
+ justifyContent: 'center',
+ alignItems: 'center',
+ marginRight: Spacing.sm,
+ },
+ headerAvatarText: {
+ fontSize: FontSizes.lg,
+ fontWeight: '600',
+ color: AppColors.white,
+ },
+ headerTitle: {
+ fontSize: FontSizes.lg,
+ fontWeight: '600',
+ color: AppColors.textPrimary,
+ },
+ headerSubtitle: {
+ fontSize: FontSizes.sm,
+ color: AppColors.success,
+ },
+ headerButton: {
+ padding: Spacing.xs,
+ },
+ chatContainer: {
+ flex: 1,
+ },
+ messagesList: {
+ padding: Spacing.md,
+ paddingBottom: Spacing.lg,
+ },
+ messageContainer: {
+ flexDirection: 'row',
+ marginBottom: Spacing.md,
+ alignItems: 'flex-end',
+ },
+ userMessageContainer: {
+ justifyContent: 'flex-end',
+ },
+ assistantMessageContainer: {
+ justifyContent: 'flex-start',
+ },
+ avatarContainer: {
+ width: 32,
+ height: 32,
+ borderRadius: BorderRadius.full,
+ backgroundColor: AppColors.success,
+ justifyContent: 'center',
+ alignItems: 'center',
+ marginRight: Spacing.xs,
+ },
+ avatarText: {
+ fontSize: FontSizes.sm,
+ fontWeight: '600',
+ color: AppColors.white,
+ },
+ messageBubble: {
+ maxWidth: '75%',
+ padding: Spacing.sm + 4,
+ borderRadius: BorderRadius.lg,
+ },
+ userBubble: {
+ backgroundColor: AppColors.primary,
+ borderBottomRightRadius: BorderRadius.sm,
+ },
+ assistantBubble: {
+ backgroundColor: AppColors.background,
+ borderBottomLeftRadius: BorderRadius.sm,
+ },
+ messageText: {
+ fontSize: FontSizes.base,
+ lineHeight: 22,
+ },
+ userMessageText: {
+ color: AppColors.white,
+ },
+ assistantMessageText: {
+ color: AppColors.textPrimary,
+ },
+ timestamp: {
+ fontSize: FontSizes.xs,
+ color: AppColors.textMuted,
+ marginTop: Spacing.xs,
+ alignSelf: 'flex-end',
+ },
+ userTimestamp: {
+ color: 'rgba(255,255,255,0.7)',
+ },
+ inputContainer: {
+ flexDirection: 'row',
+ alignItems: 'flex-end',
+ padding: Spacing.md,
+ backgroundColor: AppColors.background,
+ borderTopWidth: 1,
+ borderTopColor: AppColors.border,
+ },
+ input: {
+ flex: 1,
+ backgroundColor: AppColors.surface,
+ borderRadius: BorderRadius.xl,
+ paddingHorizontal: Spacing.md,
+ paddingVertical: Spacing.sm,
+ fontSize: FontSizes.base,
+ color: AppColors.textPrimary,
+ maxHeight: 100,
+ marginRight: Spacing.sm,
+ },
+ sendButton: {
+ width: 44,
+ height: 44,
+ borderRadius: BorderRadius.full,
+ backgroundColor: AppColors.primary,
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ sendButtonDisabled: {
+ backgroundColor: AppColors.surface,
+ },
+});
diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx
index 786b736..5f1a03d 100644
--- a/app/(tabs)/index.tsx
+++ b/app/(tabs)/index.tsx
@@ -1,98 +1,311 @@
-import { Image } from 'expo-image';
-import { Platform, StyleSheet } from 'react-native';
+import React, { useState, useEffect, useCallback } from 'react';
+import {
+ View,
+ Text,
+ StyleSheet,
+ FlatList,
+ TouchableOpacity,
+ RefreshControl,
+} from 'react-native';
+import { router } from 'expo-router';
+import { Ionicons } from '@expo/vector-icons';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import { api } from '@/services/api';
+import { useAuth } from '@/contexts/AuthContext';
+import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
+import { FullScreenError } from '@/components/ui/ErrorMessage';
+import { AppColors, BorderRadius, FontSizes, Spacing } from '@/constants/theme';
+import type { Patient } from '@/types';
-import { HelloWave } from '@/components/hello-wave';
-import ParallaxScrollView from '@/components/parallax-scroll-view';
-import { ThemedText } from '@/components/themed-text';
-import { ThemedView } from '@/components/themed-view';
-import { Link } from 'expo-router';
+export default function PatientsListScreen() {
+ const { user } = useAuth();
+ const [patients, setPatients] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [isRefreshing, setIsRefreshing] = useState(false);
+ const [error, setError] = useState(null);
+
+ const loadPatients = useCallback(async (showLoading = true) => {
+ if (showLoading) setIsLoading(true);
+ setError(null);
+
+ try {
+ const response = await api.getPatients();
+
+ if (response.ok && response.data) {
+ setPatients(response.data.patients);
+ } else {
+ setError(response.error?.message || 'Failed to load patients');
+ }
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'An error occurred');
+ } finally {
+ setIsLoading(false);
+ setIsRefreshing(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ loadPatients();
+ }, [loadPatients]);
+
+ const handleRefresh = useCallback(() => {
+ setIsRefreshing(true);
+ loadPatients(false);
+ }, [loadPatients]);
+
+ const handlePatientPress = (patient: Patient) => {
+ router.push(`/patients/${patient.id}`);
+ };
+
+ const renderPatientCard = ({ item }: { item: Patient }) => (
+ handlePatientPress(item)}
+ activeOpacity={0.7}
+ >
+
+
+
+ {item.name.charAt(0).toUpperCase()}
+
+
+
+
+
+ {item.name}
+ {item.relationship}
+
+ {' '}
+ {item.last_activity}
+
+
+
+
+ {item.health_data && (
+
+
+
+ {item.health_data.heart_rate}
+ BPM
+
+
+
+ {item.health_data.steps?.toLocaleString()}
+ Steps
+
+
+
+ {item.health_data.sleep_hours}h
+ Sleep
+
+
+ )}
+
+
+
+ );
+
+ if (isLoading) {
+ return ;
+ }
+
+ if (error) {
+ return loadPatients()} />;
+ }
-export default function HomeScreen() {
return (
-
- }>
-
- Welcome!
-
-
-
- Step 1: Try it
-
- Edit app/(tabs)/index.tsx to see changes.
- Press{' '}
-
- {Platform.select({
- ios: 'cmd + d',
- android: 'cmd + m',
- web: 'F12',
- })}
- {' '}
- to open developer tools.
-
-
-
-
-
- Step 2: Explore
-
-
-
- alert('Action pressed')} />
- alert('Share pressed')}
- />
-
- alert('Delete pressed')}
- />
-
-
-
+
+ {/* Header */}
+
+
+
+ Hello, {user?.user_name || 'User'}
+
+ Your Patients
+
+
+
+
+
-
- {`Tap the Explore tab to learn more about what's included in this starter app.`}
-
-
-
- Step 3: Get a fresh start
-
- {`When you're ready, run `}
- npm run reset-project to get a fresh{' '}
- app directory. This will move the current{' '}
- app to{' '}
- app-example.
-
-
-
+ {/* Patient List */}
+ item.id.toString()}
+ renderItem={renderPatientCard}
+ contentContainerStyle={styles.listContent}
+ showsVerticalScrollIndicator={false}
+ refreshControl={
+
+ }
+ ListEmptyComponent={
+
+
+ No patients yet
+
+ Add your first patient to start monitoring
+
+
+ }
+ />
+
);
}
const styles = StyleSheet.create({
- titleContainer: {
+ container: {
+ flex: 1,
+ backgroundColor: AppColors.surface,
+ },
+ header: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ paddingHorizontal: Spacing.lg,
+ paddingVertical: Spacing.md,
+ backgroundColor: AppColors.background,
+ borderBottomWidth: 1,
+ borderBottomColor: AppColors.border,
+ },
+ greeting: {
+ fontSize: FontSizes.sm,
+ color: AppColors.textSecondary,
+ },
+ headerTitle: {
+ fontSize: FontSizes.xl,
+ fontWeight: '700',
+ color: AppColors.textPrimary,
+ },
+ addButton: {
+ width: 44,
+ height: 44,
+ borderRadius: BorderRadius.full,
+ backgroundColor: AppColors.primary,
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ listContent: {
+ padding: Spacing.md,
+ },
+ patientCard: {
+ backgroundColor: AppColors.background,
+ borderRadius: BorderRadius.lg,
+ padding: Spacing.md,
+ marginBottom: Spacing.md,
+ shadowColor: '#000',
+ shadowOffset: { width: 0, height: 2 },
+ shadowOpacity: 0.05,
+ shadowRadius: 4,
+ elevation: 2,
+ },
+ patientInfo: {
flexDirection: 'row',
alignItems: 'center',
- gap: 8,
+ marginBottom: Spacing.md,
},
- stepContainer: {
- gap: 8,
- marginBottom: 8,
+ avatarContainer: {
+ width: 56,
+ height: 56,
+ borderRadius: BorderRadius.full,
+ backgroundColor: AppColors.primaryLight,
+ justifyContent: 'center',
+ alignItems: 'center',
+ marginRight: Spacing.md,
},
- reactLogo: {
- height: 178,
- width: 290,
- bottom: 0,
- left: 0,
+ avatarText: {
+ fontSize: FontSizes.xl,
+ fontWeight: '600',
+ color: AppColors.white,
+ },
+ statusIndicator: {
position: 'absolute',
+ bottom: 2,
+ right: 2,
+ width: 14,
+ height: 14,
+ borderRadius: BorderRadius.full,
+ borderWidth: 2,
+ borderColor: AppColors.background,
+ },
+ online: {
+ backgroundColor: AppColors.online,
+ },
+ offline: {
+ backgroundColor: AppColors.offline,
+ },
+ patientDetails: {
+ flex: 1,
+ },
+ patientName: {
+ fontSize: FontSizes.lg,
+ fontWeight: '600',
+ color: AppColors.textPrimary,
+ },
+ patientRelationship: {
+ fontSize: FontSizes.sm,
+ color: AppColors.textSecondary,
+ marginTop: 2,
+ },
+ lastActivity: {
+ fontSize: FontSizes.xs,
+ color: AppColors.textMuted,
+ marginTop: 4,
+ },
+ healthStats: {
+ flexDirection: 'row',
+ justifyContent: 'space-around',
+ paddingTop: Spacing.md,
+ borderTopWidth: 1,
+ borderTopColor: AppColors.border,
+ },
+ statItem: {
+ alignItems: 'center',
+ },
+ statValue: {
+ fontSize: FontSizes.base,
+ fontWeight: '600',
+ color: AppColors.textPrimary,
+ marginTop: Spacing.xs,
+ },
+ statLabel: {
+ fontSize: FontSizes.xs,
+ color: AppColors.textMuted,
+ },
+ chevron: {
+ position: 'absolute',
+ top: Spacing.md,
+ right: Spacing.md,
+ },
+ emptyContainer: {
+ flex: 1,
+ justifyContent: 'center',
+ alignItems: 'center',
+ paddingTop: Spacing.xxl * 2,
+ },
+ emptyTitle: {
+ fontSize: FontSizes.lg,
+ fontWeight: '600',
+ color: AppColors.textPrimary,
+ marginTop: Spacing.md,
+ },
+ emptyText: {
+ fontSize: FontSizes.base,
+ color: AppColors.textSecondary,
+ textAlign: 'center',
+ marginTop: Spacing.xs,
},
});
diff --git a/app/(tabs)/profile.tsx b/app/(tabs)/profile.tsx
new file mode 100644
index 0000000..095a28a
--- /dev/null
+++ b/app/(tabs)/profile.tsx
@@ -0,0 +1,314 @@
+import React from 'react';
+import {
+ View,
+ Text,
+ StyleSheet,
+ ScrollView,
+ TouchableOpacity,
+ Alert,
+} from 'react-native';
+import { router } from 'expo-router';
+import { Ionicons } from '@expo/vector-icons';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import { useAuth } from '@/contexts/AuthContext';
+import { AppColors, BorderRadius, FontSizes, Spacing } from '@/constants/theme';
+
+interface MenuItemProps {
+ icon: keyof typeof Ionicons.glyphMap;
+ iconColor?: string;
+ iconBgColor?: string;
+ title: string;
+ subtitle?: string;
+ onPress?: () => void;
+ showChevron?: boolean;
+}
+
+function MenuItem({
+ icon,
+ iconColor = AppColors.primary,
+ iconBgColor = '#DBEAFE',
+ title,
+ subtitle,
+ onPress,
+ showChevron = true,
+}: MenuItemProps) {
+ return (
+
+
+
+
+
+ {title}
+ {subtitle && {subtitle}}
+
+ {showChevron && (
+
+ )}
+
+ );
+}
+
+export default function ProfileScreen() {
+ const { user, logout } = useAuth();
+
+ const handleLogout = () => {
+ Alert.alert(
+ 'Logout',
+ 'Are you sure you want to logout?',
+ [
+ { text: 'Cancel', style: 'cancel' },
+ {
+ text: 'Logout',
+ style: 'destructive',
+ onPress: async () => {
+ await logout();
+ router.replace('/(auth)/login');
+ },
+ },
+ ],
+ { cancelable: true }
+ );
+ };
+
+ return (
+
+
+ {/* Header */}
+
+ Profile
+
+
+ {/* User Info */}
+
+
+
+ {user?.user_name?.charAt(0).toUpperCase() || 'U'}
+
+
+
+ {user?.user_name || 'User'}
+
+ Role: {user?.max_role === 2 ? 'Admin' : 'User'}
+
+
+
+
+
+
+
+ {/* Menu Sections */}
+
+ Account
+
+
+
+
+
+
+
+
+
+
+ Subscription
+
+
+
+
+
+
+
+
+ Support
+
+
+
+
+
+
+
+
+
+
+
+ {/* Logout Button */}
+
+
+
+ Logout
+
+
+
+ {/* Version */}
+ WellNuo v1.0.0
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: AppColors.surface,
+ },
+ header: {
+ paddingHorizontal: Spacing.lg,
+ paddingVertical: Spacing.md,
+ backgroundColor: AppColors.background,
+ borderBottomWidth: 1,
+ borderBottomColor: AppColors.border,
+ },
+ headerTitle: {
+ fontSize: FontSizes.xl,
+ fontWeight: '700',
+ color: AppColors.textPrimary,
+ },
+ userCard: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ backgroundColor: AppColors.background,
+ padding: Spacing.lg,
+ marginBottom: Spacing.md,
+ },
+ avatarContainer: {
+ width: 64,
+ height: 64,
+ borderRadius: BorderRadius.full,
+ backgroundColor: AppColors.primary,
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ avatarText: {
+ fontSize: FontSizes['2xl'],
+ fontWeight: '600',
+ color: AppColors.white,
+ },
+ userInfo: {
+ flex: 1,
+ marginLeft: Spacing.md,
+ },
+ userName: {
+ fontSize: FontSizes.lg,
+ fontWeight: '600',
+ color: AppColors.textPrimary,
+ },
+ userRole: {
+ fontSize: FontSizes.sm,
+ color: AppColors.textSecondary,
+ marginTop: Spacing.xs,
+ },
+ editButton: {
+ width: 40,
+ height: 40,
+ borderRadius: BorderRadius.full,
+ backgroundColor: AppColors.surface,
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ section: {
+ marginBottom: Spacing.md,
+ },
+ sectionTitle: {
+ fontSize: FontSizes.sm,
+ fontWeight: '600',
+ color: AppColors.textSecondary,
+ paddingHorizontal: Spacing.lg,
+ paddingVertical: Spacing.sm,
+ textTransform: 'uppercase',
+ },
+ menuCard: {
+ backgroundColor: AppColors.background,
+ },
+ menuItem: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingVertical: Spacing.md,
+ paddingHorizontal: Spacing.lg,
+ },
+ menuIconContainer: {
+ width: 36,
+ height: 36,
+ borderRadius: BorderRadius.md,
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ menuTextContainer: {
+ flex: 1,
+ marginLeft: Spacing.md,
+ },
+ menuTitle: {
+ fontSize: FontSizes.base,
+ fontWeight: '500',
+ color: AppColors.textPrimary,
+ },
+ menuSubtitle: {
+ fontSize: FontSizes.xs,
+ color: AppColors.textMuted,
+ marginTop: 2,
+ },
+ menuDivider: {
+ height: 1,
+ backgroundColor: AppColors.border,
+ marginLeft: Spacing.lg + 36 + Spacing.md,
+ },
+ logoutButton: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ backgroundColor: AppColors.background,
+ paddingVertical: Spacing.md,
+ marginHorizontal: Spacing.lg,
+ borderRadius: BorderRadius.lg,
+ },
+ logoutText: {
+ fontSize: FontSizes.base,
+ fontWeight: '600',
+ color: AppColors.error,
+ marginLeft: Spacing.sm,
+ },
+ version: {
+ textAlign: 'center',
+ fontSize: FontSizes.xs,
+ color: AppColors.textMuted,
+ paddingVertical: Spacing.xl,
+ },
+});
diff --git a/app/_layout.tsx b/app/_layout.tsx
index f518c9b..6824850 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -1,24 +1,60 @@
+import { useEffect } from 'react';
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
-import { Stack } from 'expo-router';
+import { Stack, router, useSegments } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
+import * as SplashScreen from 'expo-splash-screen';
import 'react-native-reanimated';
import { useColorScheme } from '@/hooks/use-color-scheme';
+import { AuthProvider, useAuth } from '@/contexts/AuthContext';
+import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
-export const unstable_settings = {
- anchor: '(tabs)',
-};
+// Prevent auto-hiding splash screen
+SplashScreen.preventAutoHideAsync();
-export default function RootLayout() {
+function RootLayoutNav() {
const colorScheme = useColorScheme();
+ const { isAuthenticated, isLoading } = useAuth();
+ const segments = useSegments();
+
+ useEffect(() => {
+ if (isLoading) return;
+
+ // Hide splash screen once we know auth state
+ SplashScreen.hideAsync();
+
+ const inAuthGroup = segments[0] === '(auth)';
+
+ if (!isAuthenticated && !inAuthGroup) {
+ // Redirect to login if not authenticated
+ router.replace('/(auth)/login');
+ } else if (isAuthenticated && inAuthGroup) {
+ // Redirect to main app if authenticated
+ router.replace('/(tabs)');
+ }
+ }, [isAuthenticated, isLoading, segments]);
+
+ if (isLoading) {
+ return ;
+ }
return (
-
-
+
+
+
+
);
}
+
+export default function RootLayout() {
+ return (
+
+
+
+ );
+}
diff --git a/app/patients/[id]/index.tsx b/app/patients/[id]/index.tsx
new file mode 100644
index 0000000..b1eef70
--- /dev/null
+++ b/app/patients/[id]/index.tsx
@@ -0,0 +1,371 @@
+import React, { useState, useEffect, useCallback } from 'react';
+import {
+ View,
+ Text,
+ StyleSheet,
+ ScrollView,
+ TouchableOpacity,
+ RefreshControl,
+} from 'react-native';
+import { useLocalSearchParams, router } from 'expo-router';
+import { Ionicons } from '@expo/vector-icons';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import { api } from '@/services/api';
+import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
+import { FullScreenError } from '@/components/ui/ErrorMessage';
+import { Button } from '@/components/ui/Button';
+import { AppColors, BorderRadius, FontSizes, Spacing } from '@/constants/theme';
+import type { Patient } from '@/types';
+
+export default function PatientDashboardScreen() {
+ const { id } = useLocalSearchParams<{ id: string }>();
+ const [patient, setPatient] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [isRefreshing, setIsRefreshing] = useState(false);
+ const [error, setError] = useState(null);
+
+ const loadPatient = useCallback(async (showLoading = true) => {
+ if (!id) return;
+
+ if (showLoading) setIsLoading(true);
+ setError(null);
+
+ try {
+ const response = await api.getPatient(parseInt(id, 10));
+
+ if (response.ok && response.data) {
+ setPatient(response.data);
+ } else {
+ setError(response.error?.message || 'Failed to load patient');
+ }
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'An error occurred');
+ } finally {
+ setIsLoading(false);
+ setIsRefreshing(false);
+ }
+ }, [id]);
+
+ useEffect(() => {
+ loadPatient();
+ }, [loadPatient]);
+
+ const handleRefresh = useCallback(() => {
+ setIsRefreshing(true);
+ loadPatient(false);
+ }, [loadPatient]);
+
+ const handleChatPress = () => {
+ router.push('/(tabs)/chat');
+ };
+
+ if (isLoading) {
+ return ;
+ }
+
+ if (error || !patient) {
+ return (
+ loadPatient()}
+ />
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+ router.back()}
+ >
+
+
+ {patient.name}
+
+
+
+
+
+
+ }
+ >
+ {/* Patient Info Card */}
+
+
+
+ {patient.name.charAt(0).toUpperCase()}
+
+
+
+ {patient.status === 'online' ? 'Online' : 'Offline'}
+
+
+
+
+ {patient.name}
+ {patient.relationship}
+
+ Last activity: {patient.last_activity}
+
+
+
+ {/* Health Stats */}
+
+ Health Overview
+
+
+
+
+
+
+
+ {patient.health_data?.heart_rate || '--'}
+
+ Heart Rate
+ BPM
+
+
+
+
+
+
+
+ {patient.health_data?.steps?.toLocaleString() || '--'}
+
+ Steps Today
+ steps
+
+
+
+
+
+
+
+ {patient.health_data?.sleep_hours || '--'}
+
+ Sleep
+ hours
+
+
+
+
+ {/* Quick Actions */}
+
+ Quick Actions
+
+
+
+
+
+
+ Chat with Julia
+
+
+
+
+
+
+ Set Reminder
+
+
+
+
+
+
+ Video Call
+
+
+
+
+
+
+ Medications
+
+
+
+
+ {/* Chat with Julia Button */}
+
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: AppColors.surface,
+ },
+ header: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ paddingHorizontal: Spacing.md,
+ paddingVertical: Spacing.sm,
+ backgroundColor: AppColors.background,
+ borderBottomWidth: 1,
+ borderBottomColor: AppColors.border,
+ },
+ backButton: {
+ padding: Spacing.xs,
+ },
+ headerTitle: {
+ fontSize: FontSizes.lg,
+ fontWeight: '600',
+ color: AppColors.textPrimary,
+ },
+ menuButton: {
+ padding: Spacing.xs,
+ },
+ content: {
+ flex: 1,
+ },
+ infoCard: {
+ backgroundColor: AppColors.background,
+ padding: Spacing.xl,
+ alignItems: 'center',
+ marginBottom: Spacing.md,
+ },
+ avatarContainer: {
+ width: 100,
+ height: 100,
+ borderRadius: BorderRadius.full,
+ backgroundColor: AppColors.primaryLight,
+ justifyContent: 'center',
+ alignItems: 'center',
+ marginBottom: Spacing.md,
+ },
+ avatarText: {
+ fontSize: FontSizes['3xl'],
+ fontWeight: '600',
+ color: AppColors.white,
+ },
+ statusBadge: {
+ position: 'absolute',
+ bottom: 0,
+ paddingHorizontal: Spacing.sm,
+ paddingVertical: Spacing.xs,
+ borderRadius: BorderRadius.full,
+ },
+ onlineBadge: {
+ backgroundColor: AppColors.success,
+ },
+ offlineBadge: {
+ backgroundColor: AppColors.offline,
+ },
+ statusText: {
+ fontSize: FontSizes.xs,
+ fontWeight: '500',
+ color: AppColors.white,
+ },
+ patientName: {
+ fontSize: FontSizes['2xl'],
+ fontWeight: '700',
+ color: AppColors.textPrimary,
+ },
+ relationship: {
+ fontSize: FontSizes.base,
+ color: AppColors.textSecondary,
+ marginTop: Spacing.xs,
+ },
+ lastSeen: {
+ fontSize: FontSizes.sm,
+ color: AppColors.textMuted,
+ marginTop: Spacing.sm,
+ },
+ section: {
+ backgroundColor: AppColors.background,
+ padding: Spacing.lg,
+ marginBottom: Spacing.md,
+ },
+ sectionTitle: {
+ fontSize: FontSizes.lg,
+ fontWeight: '600',
+ color: AppColors.textPrimary,
+ marginBottom: Spacing.md,
+ },
+ statsGrid: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ },
+ statCard: {
+ flex: 1,
+ alignItems: 'center',
+ padding: Spacing.sm,
+ },
+ statIcon: {
+ width: 48,
+ height: 48,
+ borderRadius: BorderRadius.lg,
+ justifyContent: 'center',
+ alignItems: 'center',
+ marginBottom: Spacing.sm,
+ },
+ statValue: {
+ fontSize: FontSizes.xl,
+ fontWeight: '700',
+ color: AppColors.textPrimary,
+ },
+ statLabel: {
+ fontSize: FontSizes.xs,
+ color: AppColors.textSecondary,
+ marginTop: Spacing.xs,
+ },
+ statUnit: {
+ fontSize: FontSizes.xs,
+ color: AppColors.textMuted,
+ },
+ actionsGrid: {
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ justifyContent: 'space-between',
+ },
+ actionCard: {
+ width: '48%',
+ backgroundColor: AppColors.surface,
+ borderRadius: BorderRadius.lg,
+ padding: Spacing.md,
+ alignItems: 'center',
+ marginBottom: Spacing.md,
+ },
+ actionIcon: {
+ width: 48,
+ height: 48,
+ borderRadius: BorderRadius.lg,
+ justifyContent: 'center',
+ alignItems: 'center',
+ marginBottom: Spacing.sm,
+ },
+ actionLabel: {
+ fontSize: FontSizes.sm,
+ fontWeight: '500',
+ color: AppColors.textPrimary,
+ textAlign: 'center',
+ },
+ chatButtonContainer: {
+ padding: Spacing.lg,
+ paddingBottom: Spacing.xxl,
+ },
+});
diff --git a/app/patients/_layout.tsx b/app/patients/_layout.tsx
new file mode 100644
index 0000000..df580ec
--- /dev/null
+++ b/app/patients/_layout.tsx
@@ -0,0 +1,15 @@
+import { Stack } from 'expo-router';
+import { AppColors } from '@/constants/theme';
+
+export default function PatientsLayout() {
+ return (
+
+
+
+ );
+}
diff --git a/components/ui/Button.tsx b/components/ui/Button.tsx
new file mode 100644
index 0000000..ec8692b
--- /dev/null
+++ b/components/ui/Button.tsx
@@ -0,0 +1,137 @@
+import React from 'react';
+import {
+ TouchableOpacity,
+ Text,
+ StyleSheet,
+ ActivityIndicator,
+ type TouchableOpacityProps,
+ type ViewStyle,
+ type TextStyle,
+} from 'react-native';
+import { AppColors, BorderRadius, FontSizes, FontWeights, Spacing } from '@/constants/theme';
+
+interface ButtonProps extends TouchableOpacityProps {
+ title: string;
+ variant?: 'primary' | 'secondary' | 'outline' | 'ghost';
+ size?: 'sm' | 'md' | 'lg';
+ loading?: boolean;
+ fullWidth?: boolean;
+}
+
+export function Button({
+ title,
+ variant = 'primary',
+ size = 'md',
+ loading = false,
+ fullWidth = false,
+ disabled,
+ style,
+ ...props
+}: ButtonProps) {
+ const isDisabled = disabled || loading;
+
+ const buttonStyles: ViewStyle[] = [
+ styles.base,
+ styles[variant],
+ styles[`size_${size}`],
+ fullWidth && styles.fullWidth,
+ isDisabled && styles.disabled,
+ style as ViewStyle,
+ ];
+
+ const textStyles: TextStyle[] = [
+ styles.text,
+ styles[`text_${variant}`],
+ styles[`text_${size}`],
+ isDisabled && styles.textDisabled,
+ ];
+
+ return (
+
+ {loading ? (
+
+ ) : (
+ {title}
+ )}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ base: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ borderRadius: BorderRadius.lg,
+ },
+ primary: {
+ backgroundColor: AppColors.primary,
+ },
+ secondary: {
+ backgroundColor: AppColors.surface,
+ },
+ outline: {
+ backgroundColor: 'transparent',
+ borderWidth: 1,
+ borderColor: AppColors.primary,
+ },
+ ghost: {
+ backgroundColor: 'transparent',
+ },
+ size_sm: {
+ paddingVertical: Spacing.sm,
+ paddingHorizontal: Spacing.md,
+ minHeight: 36,
+ },
+ size_md: {
+ paddingVertical: Spacing.sm + 4,
+ paddingHorizontal: Spacing.lg,
+ minHeight: 48,
+ },
+ size_lg: {
+ paddingVertical: Spacing.md,
+ paddingHorizontal: Spacing.xl,
+ minHeight: 56,
+ },
+ fullWidth: {
+ width: '100%',
+ },
+ disabled: {
+ opacity: 0.5,
+ },
+ text: {
+ fontWeight: FontWeights.semibold,
+ },
+ text_primary: {
+ color: AppColors.white,
+ },
+ text_secondary: {
+ color: AppColors.textPrimary,
+ },
+ text_outline: {
+ color: AppColors.primary,
+ },
+ text_ghost: {
+ color: AppColors.primary,
+ },
+ text_sm: {
+ fontSize: FontSizes.sm,
+ },
+ text_md: {
+ fontSize: FontSizes.base,
+ },
+ text_lg: {
+ fontSize: FontSizes.lg,
+ },
+ textDisabled: {
+ opacity: 0.7,
+ },
+});
diff --git a/components/ui/ErrorMessage.tsx b/components/ui/ErrorMessage.tsx
new file mode 100644
index 0000000..b2ef68b
--- /dev/null
+++ b/components/ui/ErrorMessage.tsx
@@ -0,0 +1,141 @@
+import React from 'react';
+import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
+import { Ionicons } from '@expo/vector-icons';
+import { AppColors, BorderRadius, FontSizes, Spacing } from '@/constants/theme';
+
+interface ErrorMessageProps {
+ message: string;
+ onRetry?: () => void;
+ onDismiss?: () => void;
+}
+
+export function ErrorMessage({ message, onRetry, onDismiss }: ErrorMessageProps) {
+ return (
+
+
+
+ {message}
+
+
+
+ {onRetry && (
+
+
+ Retry
+
+ )}
+ {onDismiss && (
+
+
+
+ )}
+
+
+ );
+}
+
+interface FullScreenErrorProps {
+ title?: string;
+ message: string;
+ onRetry?: () => void;
+}
+
+export function FullScreenError({
+ title = 'Something went wrong',
+ message,
+ onRetry
+}: FullScreenErrorProps) {
+ return (
+
+
+ {title}
+ {message}
+
+ {onRetry && (
+
+
+ Try Again
+
+ )}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ backgroundColor: '#FEE2E2',
+ borderRadius: BorderRadius.lg,
+ padding: Spacing.md,
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ marginVertical: Spacing.sm,
+ },
+ content: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ flex: 1,
+ },
+ message: {
+ color: AppColors.error,
+ fontSize: FontSizes.sm,
+ marginLeft: Spacing.sm,
+ flex: 1,
+ },
+ actions: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ },
+ button: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingHorizontal: Spacing.sm,
+ paddingVertical: Spacing.xs,
+ },
+ buttonText: {
+ color: AppColors.primary,
+ fontSize: FontSizes.sm,
+ fontWeight: '500',
+ marginLeft: Spacing.xs,
+ },
+ dismissButton: {
+ padding: Spacing.xs,
+ marginLeft: Spacing.xs,
+ },
+ // Full Screen Error
+ fullScreenContainer: {
+ flex: 1,
+ justifyContent: 'center',
+ alignItems: 'center',
+ padding: Spacing.xl,
+ backgroundColor: AppColors.background,
+ },
+ fullScreenTitle: {
+ fontSize: FontSizes.xl,
+ fontWeight: '600',
+ color: AppColors.textPrimary,
+ marginTop: Spacing.lg,
+ textAlign: 'center',
+ },
+ fullScreenMessage: {
+ fontSize: FontSizes.base,
+ color: AppColors.textSecondary,
+ marginTop: Spacing.sm,
+ textAlign: 'center',
+ },
+ retryButton: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ backgroundColor: AppColors.primary,
+ paddingVertical: Spacing.sm + 4,
+ paddingHorizontal: Spacing.lg,
+ borderRadius: BorderRadius.lg,
+ marginTop: Spacing.xl,
+ },
+ retryButtonText: {
+ color: AppColors.white,
+ fontSize: FontSizes.base,
+ fontWeight: '600',
+ marginLeft: Spacing.sm,
+ },
+});
diff --git a/components/ui/Input.tsx b/components/ui/Input.tsx
new file mode 100644
index 0000000..4990deb
--- /dev/null
+++ b/components/ui/Input.tsx
@@ -0,0 +1,143 @@
+import React, { useState } from 'react';
+import {
+ View,
+ TextInput,
+ Text,
+ StyleSheet,
+ TouchableOpacity,
+ type TextInputProps,
+} from 'react-native';
+import { Ionicons } from '@expo/vector-icons';
+import { AppColors, BorderRadius, FontSizes, Spacing } from '@/constants/theme';
+
+interface InputProps extends TextInputProps {
+ label?: string;
+ error?: string;
+ leftIcon?: keyof typeof Ionicons.glyphMap;
+ rightIcon?: keyof typeof Ionicons.glyphMap;
+ onRightIconPress?: () => void;
+}
+
+export function Input({
+ label,
+ error,
+ leftIcon,
+ rightIcon,
+ onRightIconPress,
+ secureTextEntry,
+ style,
+ ...props
+}: InputProps) {
+ const [isPasswordVisible, setIsPasswordVisible] = useState(false);
+ const isPassword = secureTextEntry !== undefined;
+
+ const handleTogglePassword = () => {
+ setIsPasswordVisible(!isPasswordVisible);
+ };
+
+ return (
+
+ {label && {label}}
+
+
+ {leftIcon && (
+
+ )}
+
+
+
+ {isPassword && (
+
+
+
+ )}
+
+ {!isPassword && rightIcon && (
+
+
+
+ )}
+
+
+ {error && {error}}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ marginBottom: Spacing.md,
+ },
+ label: {
+ fontSize: FontSizes.sm,
+ fontWeight: '500',
+ color: AppColors.textPrimary,
+ marginBottom: Spacing.xs,
+ },
+ inputContainer: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ backgroundColor: AppColors.surface,
+ borderRadius: BorderRadius.lg,
+ borderWidth: 1,
+ borderColor: AppColors.border,
+ },
+ inputError: {
+ borderColor: AppColors.error,
+ },
+ input: {
+ flex: 1,
+ paddingVertical: Spacing.sm + 4,
+ paddingHorizontal: Spacing.md,
+ fontSize: FontSizes.base,
+ color: AppColors.textPrimary,
+ },
+ inputWithLeftIcon: {
+ paddingLeft: 0,
+ },
+ inputWithRightIcon: {
+ paddingRight: 0,
+ },
+ leftIcon: {
+ marginLeft: Spacing.md,
+ marginRight: Spacing.sm,
+ },
+ rightIconButton: {
+ padding: Spacing.md,
+ },
+ errorText: {
+ fontSize: FontSizes.xs,
+ color: AppColors.error,
+ marginTop: Spacing.xs,
+ },
+});
diff --git a/components/ui/LoadingSpinner.tsx b/components/ui/LoadingSpinner.tsx
new file mode 100644
index 0000000..a787e83
--- /dev/null
+++ b/components/ui/LoadingSpinner.tsx
@@ -0,0 +1,50 @@
+import React from 'react';
+import { View, ActivityIndicator, Text, StyleSheet } from 'react-native';
+import { AppColors, FontSizes, Spacing } from '@/constants/theme';
+
+interface LoadingSpinnerProps {
+ size?: 'small' | 'large';
+ color?: string;
+ message?: string;
+ fullScreen?: boolean;
+}
+
+export function LoadingSpinner({
+ size = 'large',
+ color = AppColors.primary,
+ message,
+ fullScreen = false,
+}: LoadingSpinnerProps) {
+ const content = (
+ <>
+
+ {message && {message}}
+ >
+ );
+
+ if (fullScreen) {
+ return {content};
+ }
+
+ return {content};
+}
+
+const styles = StyleSheet.create({
+ container: {
+ alignItems: 'center',
+ justifyContent: 'center',
+ padding: Spacing.lg,
+ },
+ fullScreen: {
+ flex: 1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ backgroundColor: AppColors.background,
+ },
+ message: {
+ marginTop: Spacing.md,
+ fontSize: FontSizes.base,
+ color: AppColors.textSecondary,
+ textAlign: 'center',
+ },
+});
diff --git a/constants/theme.ts b/constants/theme.ts
index f06facd..1c0e1d3 100644
--- a/constants/theme.ts
+++ b/constants/theme.ts
@@ -1,21 +1,55 @@
/**
- * Below are the colors that are used in the app. The colors are defined in the light and dark mode.
- * There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc.
+ * WellNuo Theme Configuration
+ * Colors and typography based on design system
*/
import { Platform } from 'react-native';
-const tintColorLight = '#0a7ea4';
+// WellNuo Brand Colors
+export const AppColors = {
+ // Primary
+ primary: '#4A90D9',
+ primaryDark: '#2E5C8A',
+ primaryLight: '#6BA8E8',
+
+ // Status
+ success: '#5AC8A8',
+ warning: '#F5A623',
+ error: '#D0021B',
+
+ // Neutral
+ white: '#FFFFFF',
+ background: '#FFFFFF',
+ surface: '#F5F7FA',
+ border: '#E5E7EB',
+
+ // Text
+ textPrimary: '#333333',
+ textSecondary: '#666666',
+ textMuted: '#999999',
+ textLight: '#FFFFFF',
+
+ // Patient Status
+ online: '#5AC8A8',
+ offline: '#999999',
+};
+
+const tintColorLight = AppColors.primary;
const tintColorDark = '#fff';
export const Colors = {
light: {
- text: '#11181C',
- background: '#fff',
+ text: AppColors.textPrimary,
+ background: AppColors.background,
tint: tintColorLight,
icon: '#687076',
tabIconDefault: '#687076',
tabIconSelected: tintColorLight,
+ surface: AppColors.surface,
+ border: AppColors.border,
+ primary: AppColors.primary,
+ error: AppColors.error,
+ success: AppColors.success,
},
dark: {
text: '#ECEDEE',
@@ -24,18 +58,53 @@ export const Colors = {
icon: '#9BA1A6',
tabIconDefault: '#9BA1A6',
tabIconSelected: tintColorDark,
+ surface: '#1E2022',
+ border: '#2D3135',
+ primary: AppColors.primaryLight,
+ error: '#FF6B6B',
+ success: '#6BD9B9',
},
};
+export const Spacing = {
+ xs: 4,
+ sm: 8,
+ md: 16,
+ lg: 24,
+ xl: 32,
+ xxl: 48,
+};
+
+export const BorderRadius = {
+ sm: 4,
+ md: 8,
+ lg: 12,
+ xl: 16,
+ full: 9999,
+};
+
+export const FontSizes = {
+ xs: 12,
+ sm: 14,
+ base: 16,
+ lg: 18,
+ xl: 20,
+ '2xl': 24,
+ '3xl': 30,
+};
+
+export const FontWeights = {
+ normal: '400' as const,
+ medium: '500' as const,
+ semibold: '600' as const,
+ bold: '700' as const,
+};
+
export const Fonts = Platform.select({
ios: {
- /** iOS `UIFontDescriptorSystemDesignDefault` */
sans: 'system-ui',
- /** iOS `UIFontDescriptorSystemDesignSerif` */
serif: 'ui-serif',
- /** iOS `UIFontDescriptorSystemDesignRounded` */
rounded: 'ui-rounded',
- /** iOS `UIFontDescriptorSystemDesignMonospaced` */
mono: 'ui-monospace',
},
default: {
diff --git a/contexts/AuthContext.tsx b/contexts/AuthContext.tsx
new file mode 100644
index 0000000..09efa57
--- /dev/null
+++ b/contexts/AuthContext.tsx
@@ -0,0 +1,136 @@
+import React, { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
+import { api } from '@/services/api';
+import type { User, LoginCredentials, ApiError } from '@/types';
+
+interface AuthState {
+ user: User | null;
+ isLoading: boolean;
+ isAuthenticated: boolean;
+ error: ApiError | null;
+}
+
+interface AuthContextType extends AuthState {
+ login: (credentials: LoginCredentials) => Promise;
+ logout: () => Promise;
+ clearError: () => void;
+}
+
+const AuthContext = createContext(null);
+
+export function AuthProvider({ children }: { children: ReactNode }) {
+ const [state, setState] = useState({
+ user: null,
+ isLoading: true,
+ isAuthenticated: false,
+ error: null,
+ });
+
+ // Check authentication on mount
+ useEffect(() => {
+ checkAuth();
+ }, []);
+
+ const checkAuth = async () => {
+ try {
+ const isAuth = await api.isAuthenticated();
+ if (isAuth) {
+ const user = await api.getStoredUser();
+ setState({
+ user,
+ isLoading: false,
+ isAuthenticated: !!user,
+ error: null,
+ });
+ } else {
+ setState({
+ user: null,
+ isLoading: false,
+ isAuthenticated: false,
+ error: null,
+ });
+ }
+ } catch (error) {
+ setState({
+ user: null,
+ isLoading: false,
+ isAuthenticated: false,
+ error: { message: 'Failed to check authentication' },
+ });
+ }
+ };
+
+ const login = useCallback(async (credentials: LoginCredentials): Promise => {
+ setState((prev) => ({ ...prev, isLoading: true, error: null }));
+
+ try {
+ const response = await api.login(credentials.username, credentials.password);
+
+ if (response.ok && response.data) {
+ const user: User = {
+ user_id: response.data.user_id,
+ user_name: credentials.username,
+ max_role: response.data.max_role,
+ privileges: response.data.privileges,
+ };
+
+ setState({
+ user,
+ isLoading: false,
+ isAuthenticated: true,
+ error: null,
+ });
+
+ return true;
+ }
+
+ setState((prev) => ({
+ ...prev,
+ isLoading: false,
+ error: response.error || { message: 'Login failed' },
+ }));
+
+ return false;
+ } catch (error) {
+ setState((prev) => ({
+ ...prev,
+ isLoading: false,
+ error: { message: error instanceof Error ? error.message : 'Login failed' },
+ }));
+
+ return false;
+ }
+ }, []);
+
+ const logout = useCallback(async () => {
+ setState((prev) => ({ ...prev, isLoading: true }));
+
+ try {
+ await api.logout();
+ } finally {
+ setState({
+ user: null,
+ isLoading: false,
+ isAuthenticated: false,
+ error: null,
+ });
+ }
+ }, []);
+
+ const clearError = useCallback(() => {
+ setState((prev) => ({ ...prev, error: null }));
+ }, []);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useAuth() {
+ const context = useContext(AuthContext);
+ if (!context) {
+ throw new Error('useAuth must be used within an AuthProvider');
+ }
+ return context;
+}
diff --git a/package-lock.json b/package-lock.json
index 5d29d40..20857bb 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -19,6 +19,7 @@
"expo-image": "~3.0.11",
"expo-linking": "~8.0.10",
"expo-router": "~6.0.19",
+ "expo-secure-store": "^15.0.8",
"expo-splash-screen": "~31.0.12",
"expo-status-bar": "~3.0.9",
"expo-symbols": "~1.0.8",
@@ -38,6 +39,7 @@
"@types/react": "~19.1.0",
"eslint": "^9.25.0",
"eslint-config-expo": "~10.0.0",
+ "playwright": "^1.57.0",
"typescript": "~5.9.2"
}
},
@@ -6443,6 +6445,15 @@
"node": ">=10"
}
},
+ "node_modules/expo-secure-store": {
+ "version": "15.0.8",
+ "resolved": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-15.0.8.tgz",
+ "integrity": "sha512-lHnzvRajBu4u+P99+0GEMijQMFCOYpWRO4dWsXSuMt77+THPIGjzNvVKrGSl6mMrLsfVaKL8BpwYZLGlgA+zAw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "expo": "*"
+ }
+ },
"node_modules/expo-server": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/expo-server/-/expo-server-1.0.5.tgz",
@@ -9856,6 +9867,53 @@
"node": ">= 6"
}
},
+ "node_modules/playwright": {
+ "version": "1.57.0",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz",
+ "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.57.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.57.0",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz",
+ "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/playwright/node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
"node_modules/plist": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz",
diff --git a/package.json b/package.json
index 92cbc93..cd6ea60 100644
--- a/package.json
+++ b/package.json
@@ -22,6 +22,7 @@
"expo-image": "~3.0.11",
"expo-linking": "~8.0.10",
"expo-router": "~6.0.19",
+ "expo-secure-store": "^15.0.8",
"expo-splash-screen": "~31.0.12",
"expo-status-bar": "~3.0.9",
"expo-symbols": "~1.0.8",
@@ -31,17 +32,18 @@
"react-dom": "19.1.0",
"react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0",
- "react-native-worklets": "0.5.1",
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
- "react-native-web": "~0.21.0"
+ "react-native-web": "~0.21.0",
+ "react-native-worklets": "0.5.1"
},
"devDependencies": {
"@types/react": "~19.1.0",
- "typescript": "~5.9.2",
"eslint": "^9.25.0",
- "eslint-config-expo": "~10.0.0"
+ "eslint-config-expo": "~10.0.0",
+ "playwright": "^1.57.0",
+ "typescript": "~5.9.2"
},
"private": true
}
diff --git a/services/api.ts b/services/api.ts
new file mode 100644
index 0000000..79bb774
--- /dev/null
+++ b/services/api.ts
@@ -0,0 +1,194 @@
+import * as SecureStore from 'expo-secure-store';
+import type { AuthResponse, ChatResponse, Patient, ApiResponse, ApiError } from '@/types';
+
+const API_BASE_URL = 'https://eluxnetworks.net/function/well-api/api';
+const CLIENT_ID = 'MA_001';
+
+class ApiService {
+ private async getToken(): Promise {
+ try {
+ return await SecureStore.getItemAsync('accessToken');
+ } catch {
+ return null;
+ }
+ }
+
+ private async getUserName(): Promise {
+ try {
+ return await SecureStore.getItemAsync('userName');
+ } catch {
+ return null;
+ }
+ }
+
+ private generateNonce(): string {
+ return Math.floor(Math.random() * 1000000).toString();
+ }
+
+ private async makeRequest(params: Record): Promise> {
+ try {
+ const formData = new URLSearchParams();
+ Object.entries(params).forEach(([key, value]) => {
+ formData.append(key, value);
+ });
+
+ const response = await fetch(API_BASE_URL, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ },
+ body: formData.toString(),
+ });
+
+ const data = await response.json();
+
+ if (data.status === '200 OK' || data.ok === true) {
+ return { data: data as T, ok: true };
+ }
+
+ return {
+ ok: false,
+ error: {
+ message: data.message || data.error || 'Request failed',
+ status: response.status,
+ },
+ };
+ } catch (error) {
+ const apiError: ApiError = {
+ message: error instanceof Error ? error.message : 'Network error',
+ code: 'NETWORK_ERROR',
+ };
+ return { ok: false, error: apiError };
+ }
+ }
+
+ // Authentication
+ async login(username: string, password: string): Promise> {
+ const response = await this.makeRequest({
+ function: 'credentials',
+ user_name: username,
+ ps: password,
+ clientId: CLIENT_ID,
+ nonce: this.generateNonce(),
+ });
+
+ if (response.ok && response.data) {
+ // Save credentials to SecureStore
+ await SecureStore.setItemAsync('accessToken', response.data.access_token);
+ await SecureStore.setItemAsync('userId', response.data.user_id.toString());
+ await SecureStore.setItemAsync('userName', username);
+ await SecureStore.setItemAsync('privileges', response.data.privileges);
+ await SecureStore.setItemAsync('maxRole', response.data.max_role.toString());
+ }
+
+ return response;
+ }
+
+ async logout(): Promise {
+ await SecureStore.deleteItemAsync('accessToken');
+ await SecureStore.deleteItemAsync('userId');
+ await SecureStore.deleteItemAsync('userName');
+ await SecureStore.deleteItemAsync('privileges');
+ await SecureStore.deleteItemAsync('maxRole');
+ }
+
+ async isAuthenticated(): Promise {
+ const token = await this.getToken();
+ return !!token;
+ }
+
+ // Get stored user info
+ async getStoredUser() {
+ try {
+ const userId = await SecureStore.getItemAsync('userId');
+ const userName = await SecureStore.getItemAsync('userName');
+ const privileges = await SecureStore.getItemAsync('privileges');
+ const maxRole = await SecureStore.getItemAsync('maxRole');
+
+ if (!userId || !userName) return null;
+
+ return {
+ user_id: parseInt(userId, 10),
+ user_name: userName,
+ privileges: privileges || '',
+ max_role: parseInt(maxRole || '0', 10),
+ };
+ } catch {
+ return null;
+ }
+ }
+
+ // Patients
+ async getPatients(): Promise> {
+ const token = await this.getToken();
+ if (!token) {
+ return { ok: false, error: { message: 'Not authenticated', code: 'UNAUTHORIZED' } };
+ }
+
+ // Note: Using mock data since get_patients API structure is not fully documented
+ // Replace with actual API call when available
+ const mockPatients: Patient[] = [
+ {
+ id: 1,
+ name: 'Julia Smith',
+ status: 'online',
+ relationship: 'Mother',
+ last_activity: '2 min ago',
+ health_data: {
+ heart_rate: 72,
+ steps: 3450,
+ sleep_hours: 7.5,
+ },
+ },
+ {
+ id: 2,
+ name: 'Robert Johnson',
+ status: 'offline',
+ relationship: 'Father',
+ last_activity: '1 hour ago',
+ health_data: {
+ heart_rate: 68,
+ steps: 2100,
+ sleep_hours: 6.8,
+ },
+ },
+ ];
+
+ return { data: { patients: mockPatients }, ok: true };
+ }
+
+ async getPatient(id: number): Promise> {
+ const response = await this.getPatients();
+ if (!response.ok || !response.data) {
+ return { ok: false, error: response.error };
+ }
+
+ const patient = response.data.patients.find((p) => p.id === id);
+ if (!patient) {
+ return { ok: false, error: { message: 'Patient not found', code: 'NOT_FOUND' } };
+ }
+
+ return { data: patient, ok: true };
+ }
+
+ // AI Chat
+ async sendMessage(question: string, deploymentId: string = '21'): Promise> {
+ const token = await this.getToken();
+ const userName = await this.getUserName();
+
+ if (!token || !userName) {
+ return { ok: false, error: { message: 'Not authenticated', code: 'UNAUTHORIZED' } };
+ }
+
+ return this.makeRequest({
+ function: 'voice_ask',
+ clientId: CLIENT_ID,
+ user_name: userName,
+ token: token,
+ question: question,
+ deployment_id: deploymentId,
+ });
+ }
+}
+
+export const api = new ApiService();
diff --git a/types/index.ts b/types/index.ts
new file mode 100644
index 0000000..6d9ed9a
--- /dev/null
+++ b/types/index.ts
@@ -0,0 +1,70 @@
+// User & Auth Types
+export interface User {
+ user_id: number;
+ user_name: string;
+ max_role: number;
+ privileges: string;
+}
+
+export interface AuthResponse {
+ access_token: string;
+ privileges: string;
+ user_id: number;
+ max_role: number;
+ status: string;
+}
+
+export interface LoginCredentials {
+ username: string;
+ password: string;
+}
+
+// Patient Types
+export interface Patient {
+ id: number;
+ name: string;
+ avatar?: string;
+ device_id?: string;
+ status: 'online' | 'offline';
+ relationship?: string;
+ last_activity?: string;
+ health_data?: HealthData;
+}
+
+export interface HealthData {
+ heart_rate?: number;
+ steps?: number;
+ sleep_hours?: number;
+ last_updated?: string;
+}
+
+// Chat Types
+export interface Message {
+ id: string;
+ role: 'user' | 'assistant';
+ content: string;
+ timestamp: Date;
+}
+
+export interface ChatResponse {
+ ok: boolean;
+ response: {
+ Command: string;
+ body: string;
+ language: string;
+ };
+ status: string;
+}
+
+// API Types
+export interface ApiError {
+ message: string;
+ code?: string;
+ status?: number;
+}
+
+export interface ApiResponse {
+ data?: T;
+ error?: ApiError;
+ ok: boolean;
+}