Implement Auth, Patients List, and Dashboard

Features:
- Login screen with API integration (credentials endpoint)
- SecureStore for token management
- Patients list with health data display
- Patient dashboard with stats and quick actions
- AI Chat screen (voice_ask API integration)
- Profile screen with logout
- Full error handling and loading states
- WellNuo brand colors and UI components

API Integration:
- Base URL: eluxnetworks.net/function/well-api/api
- Auth: function=credentials
- Chat: function=voice_ask

🤖 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 12:58:15 -08:00
parent 6af55cbd8d
commit a804a82512
20 changed files with 2634 additions and 111 deletions

1
.gitignore vendored
View File

@ -41,3 +41,4 @@ app-example
# generated native folders # generated native folders
/ios /ios
/android /android
.git-credentials

15
app/(auth)/_layout.tsx Normal file
View File

@ -0,0 +1,15 @@
import { Stack } from 'expo-router';
import { AppColors } from '@/constants/theme';
export default function AuthLayout() {
return (
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: AppColors.background },
}}
>
<Stack.Screen name="login" />
</Stack>
);
}

210
app/(auth)/login.tsx Normal file
View File

@ -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<string | null>(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 (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<ScrollView
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{/* Logo / Header */}
<View style={styles.header}>
<View style={styles.logoContainer}>
<Text style={styles.logoText}>WellNuo</Text>
</View>
<Text style={styles.title}>Welcome Back</Text>
<Text style={styles.subtitle}>Sign in to continue monitoring your loved ones</Text>
</View>
{/* Form */}
<View style={styles.form}>
{displayError && (
<ErrorMessage
message={displayError}
onDismiss={() => {
clearError();
setValidationError(null);
}}
/>
)}
<Input
label="Username"
placeholder="Enter your username"
leftIcon="person-outline"
value={username}
onChangeText={(text) => {
setUsername(text);
setValidationError(null);
}}
autoCapitalize="none"
autoCorrect={false}
editable={!isLoading}
/>
<Input
label="Password"
placeholder="Enter your password"
leftIcon="lock-closed-outline"
secureTextEntry
value={password}
onChangeText={(text) => {
setPassword(text);
setValidationError(null);
}}
editable={!isLoading}
onSubmitEditing={handleLogin}
returnKeyType="done"
/>
<TouchableOpacity style={styles.forgotPassword}>
<Text style={styles.forgotPasswordText}>Forgot Password?</Text>
</TouchableOpacity>
<Button
title="Sign In"
onPress={handleLogin}
loading={isLoading}
fullWidth
size="lg"
/>
</View>
{/* Footer */}
<View style={styles.footer}>
<Text style={styles.footerText}>Don't have an account? </Text>
<TouchableOpacity>
<Text style={styles.footerLink}>Create Account</Text>
</TouchableOpacity>
</View>
{/* Version Info */}
<Text style={styles.version}>WellNuo v1.0.0</Text>
</ScrollView>
</KeyboardAvoidingView>
);
}
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,
},
});

View File

@ -1,33 +1,60 @@
import { Tabs } from 'expo-router'; import { Tabs } from 'expo-router';
import React from 'react'; import React from 'react';
import { Ionicons } from '@expo/vector-icons';
import { HapticTab } from '@/components/haptic-tab'; import { HapticTab } from '@/components/haptic-tab';
import { IconSymbol } from '@/components/ui/icon-symbol'; import { AppColors } from '@/constants/theme';
import { Colors } from '@/constants/theme';
import { useColorScheme } from '@/hooks/use-color-scheme'; import { useColorScheme } from '@/hooks/use-color-scheme';
export default function TabLayout() { export default function TabLayout() {
const colorScheme = useColorScheme(); const colorScheme = useColorScheme();
const isDark = colorScheme === 'dark';
return ( return (
<Tabs <Tabs
screenOptions={{ screenOptions={{
tabBarActiveTintColor: Colors[colorScheme ?? 'light'].tint, tabBarActiveTintColor: AppColors.primary,
tabBarInactiveTintColor: isDark ? '#9BA1A6' : '#687076',
tabBarStyle: {
backgroundColor: isDark ? '#151718' : AppColors.background,
borderTopColor: isDark ? '#2D3135' : AppColors.border,
},
headerShown: false, headerShown: false,
tabBarButton: HapticTab, tabBarButton: HapticTab,
}}> }}
>
<Tabs.Screen <Tabs.Screen
name="index" name="index"
options={{ options={{
title: 'Home', title: 'Patients',
tabBarIcon: ({ color }) => <IconSymbol size={28} name="house.fill" color={color} />, tabBarIcon: ({ color, size }) => (
<Ionicons name="people" size={size} color={color} />
),
}} }}
/> />
<Tabs.Screen
name="chat"
options={{
title: 'Chat',
tabBarIcon: ({ color, size }) => (
<Ionicons name="chatbubble-ellipses" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="profile"
options={{
title: 'Profile',
tabBarIcon: ({ color, size }) => (
<Ionicons name="person" size={size} color={color} />
),
}}
/>
{/* Hide explore tab */}
<Tabs.Screen <Tabs.Screen
name="explore" name="explore"
options={{ options={{
title: 'Explore', href: null,
tabBarIcon: ({ color }) => <IconSymbol size={28} name="paperplane.fill" color={color} />,
}} }}
/> />
</Tabs> </Tabs>

321
app/(tabs)/chat.tsx Normal file
View File

@ -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<Message[]>([
{
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<FlatList>(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 (
<View
style={[
styles.messageContainer,
isUser ? styles.userMessageContainer : styles.assistantMessageContainer,
]}
>
{!isUser && (
<View style={styles.avatarContainer}>
<Text style={styles.avatarText}>J</Text>
</View>
)}
<View
style={[
styles.messageBubble,
isUser ? styles.userBubble : styles.assistantBubble,
]}
>
<Text
style={[
styles.messageText,
isUser ? styles.userMessageText : styles.assistantMessageText,
]}
>
{item.content}
</Text>
<Text style={[styles.timestamp, isUser && styles.userTimestamp]}>
{item.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</Text>
</View>
</View>
);
};
return (
<SafeAreaView style={styles.container} edges={['top']}>
{/* Header */}
<View style={styles.header}>
<View style={styles.headerInfo}>
<View style={styles.headerAvatar}>
<Text style={styles.headerAvatarText}>J</Text>
</View>
<View>
<Text style={styles.headerTitle}>Julia AI</Text>
<Text style={styles.headerSubtitle}>
{isSending ? 'Typing...' : 'Online'}
</Text>
</View>
</View>
<TouchableOpacity style={styles.headerButton}>
<Ionicons name="ellipsis-vertical" size={24} color={AppColors.textPrimary} />
</TouchableOpacity>
</View>
{/* Messages */}
<KeyboardAvoidingView
style={styles.chatContainer}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
keyboardVerticalOffset={Platform.OS === 'ios' ? 90 : 0}
>
<FlatList
ref={flatListRef}
data={messages}
keyExtractor={(item) => item.id}
renderItem={renderMessage}
contentContainerStyle={styles.messagesList}
showsVerticalScrollIndicator={false}
onContentSizeChange={() => flatListRef.current?.scrollToEnd({ animated: true })}
/>
{/* Input */}
<View style={styles.inputContainer}>
<TextInput
style={styles.input}
placeholder="Type a message..."
placeholderTextColor={AppColors.textMuted}
value={input}
onChangeText={setInput}
multiline
maxLength={1000}
editable={!isSending}
onSubmitEditing={handleSend}
/>
<TouchableOpacity
style={[styles.sendButton, (!input.trim() || isSending) && styles.sendButtonDisabled]}
onPress={handleSend}
disabled={!input.trim() || isSending}
>
<Ionicons
name={isSending ? 'hourglass' : 'send'}
size={20}
color={input.trim() && !isSending ? AppColors.white : AppColors.textMuted}
/>
</TouchableOpacity>
</View>
</KeyboardAvoidingView>
</SafeAreaView>
);
}
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,
},
});

View File

@ -1,98 +1,311 @@
import { Image } from 'expo-image'; import React, { useState, useEffect, useCallback } from 'react';
import { Platform, StyleSheet } from 'react-native'; 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'; export default function PatientsListScreen() {
import ParallaxScrollView from '@/components/parallax-scroll-view'; const { user } = useAuth();
import { ThemedText } from '@/components/themed-text'; const [patients, setPatients] = useState<Patient[]>([]);
import { ThemedView } from '@/components/themed-view'; const [isLoading, setIsLoading] = useState(true);
import { Link } from 'expo-router'; const [isRefreshing, setIsRefreshing] = useState(false);
const [error, setError] = useState<string | null>(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 }) => (
<TouchableOpacity
style={styles.patientCard}
onPress={() => handlePatientPress(item)}
activeOpacity={0.7}
>
<View style={styles.patientInfo}>
<View style={styles.avatarContainer}>
<Text style={styles.avatarText}>
{item.name.charAt(0).toUpperCase()}
</Text>
<View
style={[
styles.statusIndicator,
item.status === 'online' ? styles.online : styles.offline,
]}
/>
</View>
<View style={styles.patientDetails}>
<Text style={styles.patientName}>{item.name}</Text>
<Text style={styles.patientRelationship}>{item.relationship}</Text>
<Text style={styles.lastActivity}>
<Ionicons name="time-outline" size={12} color={AppColors.textMuted} />{' '}
{item.last_activity}
</Text>
</View>
</View>
{item.health_data && (
<View style={styles.healthStats}>
<View style={styles.statItem}>
<Ionicons name="heart-outline" size={16} color={AppColors.error} />
<Text style={styles.statValue}>{item.health_data.heart_rate}</Text>
<Text style={styles.statLabel}>BPM</Text>
</View>
<View style={styles.statItem}>
<Ionicons name="footsteps-outline" size={16} color={AppColors.primary} />
<Text style={styles.statValue}>{item.health_data.steps?.toLocaleString()}</Text>
<Text style={styles.statLabel}>Steps</Text>
</View>
<View style={styles.statItem}>
<Ionicons name="moon-outline" size={16} color={AppColors.primaryDark} />
<Text style={styles.statValue}>{item.health_data.sleep_hours}h</Text>
<Text style={styles.statLabel}>Sleep</Text>
</View>
</View>
)}
<Ionicons
name="chevron-forward"
size={20}
color={AppColors.textMuted}
style={styles.chevron}
/>
</TouchableOpacity>
);
if (isLoading) {
return <LoadingSpinner fullScreen message="Loading patients..." />;
}
if (error) {
return <FullScreenError message={error} onRetry={() => loadPatients()} />;
}
export default function HomeScreen() {
return ( return (
<ParallaxScrollView <SafeAreaView style={styles.container} edges={['top']}>
headerBackgroundColor={{ light: '#A1CEDC', dark: '#1D3D47' }} {/* Header */}
headerImage={ <View style={styles.header}>
<Image <View>
source={require('@/assets/images/partial-react-logo.png')} <Text style={styles.greeting}>
style={styles.reactLogo} Hello, {user?.user_name || 'User'}
/> </Text>
}> <Text style={styles.headerTitle}>Your Patients</Text>
<ThemedView style={styles.titleContainer}> </View>
<ThemedText type="title">Welcome!</ThemedText> <TouchableOpacity style={styles.addButton}>
<HelloWave /> <Ionicons name="add" size={24} color={AppColors.white} />
</ThemedView> </TouchableOpacity>
<ThemedView style={styles.stepContainer}> </View>
<ThemedText type="subtitle">Step 1: Try it</ThemedText>
<ThemedText>
Edit <ThemedText type="defaultSemiBold">app/(tabs)/index.tsx</ThemedText> to see changes.
Press{' '}
<ThemedText type="defaultSemiBold">
{Platform.select({
ios: 'cmd + d',
android: 'cmd + m',
web: 'F12',
})}
</ThemedText>{' '}
to open developer tools.
</ThemedText>
</ThemedView>
<ThemedView style={styles.stepContainer}>
<Link href="/modal">
<Link.Trigger>
<ThemedText type="subtitle">Step 2: Explore</ThemedText>
</Link.Trigger>
<Link.Preview />
<Link.Menu>
<Link.MenuAction title="Action" icon="cube" onPress={() => alert('Action pressed')} />
<Link.MenuAction
title="Share"
icon="square.and.arrow.up"
onPress={() => alert('Share pressed')}
/>
<Link.Menu title="More" icon="ellipsis">
<Link.MenuAction
title="Delete"
icon="trash"
destructive
onPress={() => alert('Delete pressed')}
/>
</Link.Menu>
</Link.Menu>
</Link>
<ThemedText> {/* Patient List */}
{`Tap the Explore tab to learn more about what's included in this starter app.`} <FlatList
</ThemedText> data={patients}
</ThemedView> keyExtractor={(item) => item.id.toString()}
<ThemedView style={styles.stepContainer}> renderItem={renderPatientCard}
<ThemedText type="subtitle">Step 3: Get a fresh start</ThemedText> contentContainerStyle={styles.listContent}
<ThemedText> showsVerticalScrollIndicator={false}
{`When you're ready, run `} refreshControl={
<ThemedText type="defaultSemiBold">npm run reset-project</ThemedText> to get a fresh{' '} <RefreshControl
<ThemedText type="defaultSemiBold">app</ThemedText> directory. This will move the current{' '} refreshing={isRefreshing}
<ThemedText type="defaultSemiBold">app</ThemedText> to{' '} onRefresh={handleRefresh}
<ThemedText type="defaultSemiBold">app-example</ThemedText>. tintColor={AppColors.primary}
</ThemedText> />
</ThemedView> }
</ParallaxScrollView> ListEmptyComponent={
<View style={styles.emptyContainer}>
<Ionicons name="people-outline" size={64} color={AppColors.textMuted} />
<Text style={styles.emptyTitle}>No patients yet</Text>
<Text style={styles.emptyText}>
Add your first patient to start monitoring
</Text>
</View>
}
/>
</SafeAreaView>
); );
} }
const styles = StyleSheet.create({ 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', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
gap: 8, marginBottom: Spacing.md,
}, },
stepContainer: { avatarContainer: {
gap: 8, width: 56,
marginBottom: 8, height: 56,
borderRadius: BorderRadius.full,
backgroundColor: AppColors.primaryLight,
justifyContent: 'center',
alignItems: 'center',
marginRight: Spacing.md,
}, },
reactLogo: { avatarText: {
height: 178, fontSize: FontSizes.xl,
width: 290, fontWeight: '600',
bottom: 0, color: AppColors.white,
left: 0, },
statusIndicator: {
position: 'absolute', 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,
}, },
}); });

314
app/(tabs)/profile.tsx Normal file
View File

@ -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 (
<TouchableOpacity style={styles.menuItem} onPress={onPress}>
<View style={[styles.menuIconContainer, { backgroundColor: iconBgColor }]}>
<Ionicons name={icon} size={20} color={iconColor} />
</View>
<View style={styles.menuTextContainer}>
<Text style={styles.menuTitle}>{title}</Text>
{subtitle && <Text style={styles.menuSubtitle}>{subtitle}</Text>}
</View>
{showChevron && (
<Ionicons name="chevron-forward" size={20} color={AppColors.textMuted} />
)}
</TouchableOpacity>
);
}
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 (
<SafeAreaView style={styles.container} edges={['top']}>
<ScrollView showsVerticalScrollIndicator={false}>
{/* Header */}
<View style={styles.header}>
<Text style={styles.headerTitle}>Profile</Text>
</View>
{/* User Info */}
<View style={styles.userCard}>
<View style={styles.avatarContainer}>
<Text style={styles.avatarText}>
{user?.user_name?.charAt(0).toUpperCase() || 'U'}
</Text>
</View>
<View style={styles.userInfo}>
<Text style={styles.userName}>{user?.user_name || 'User'}</Text>
<Text style={styles.userRole}>
Role: {user?.max_role === 2 ? 'Admin' : 'User'}
</Text>
</View>
<TouchableOpacity style={styles.editButton}>
<Ionicons name="pencil" size={18} color={AppColors.primary} />
</TouchableOpacity>
</View>
{/* Menu Sections */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Account</Text>
<View style={styles.menuCard}>
<MenuItem
icon="person-outline"
title="Edit Profile"
subtitle="Update your personal information"
/>
<View style={styles.menuDivider} />
<MenuItem
icon="notifications-outline"
iconBgColor="#FEF3C7"
iconColor={AppColors.warning}
title="Notifications"
subtitle="Manage notification preferences"
/>
<View style={styles.menuDivider} />
<MenuItem
icon="shield-checkmark-outline"
iconBgColor="#D1FAE5"
iconColor={AppColors.success}
title="Privacy & Security"
subtitle="Password, 2FA, data"
/>
</View>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>Subscription</Text>
<View style={styles.menuCard}>
<MenuItem
icon="diamond-outline"
iconBgColor="#F3E8FF"
iconColor="#9333EA"
title="WellNuo Pro"
subtitle="Upgrade for premium features"
/>
<View style={styles.menuDivider} />
<MenuItem
icon="card-outline"
title="Payment Methods"
subtitle="Manage your payment options"
/>
</View>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>Support</Text>
<View style={styles.menuCard}>
<MenuItem
icon="help-circle-outline"
title="Help Center"
subtitle="FAQs and guides"
/>
<View style={styles.menuDivider} />
<MenuItem
icon="chatbubble-outline"
title="Contact Support"
subtitle="Get help from our team"
/>
<View style={styles.menuDivider} />
<MenuItem
icon="document-text-outline"
title="Terms of Service"
/>
<View style={styles.menuDivider} />
<MenuItem
icon="shield-outline"
title="Privacy Policy"
/>
</View>
</View>
{/* Logout Button */}
<View style={styles.section}>
<TouchableOpacity style={styles.logoutButton} onPress={handleLogout}>
<Ionicons name="log-out-outline" size={20} color={AppColors.error} />
<Text style={styles.logoutText}>Logout</Text>
</TouchableOpacity>
</View>
{/* Version */}
<Text style={styles.version}>WellNuo v1.0.0</Text>
</ScrollView>
</SafeAreaView>
);
}
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,
},
});

View File

@ -1,24 +1,60 @@
import { useEffect } from 'react';
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native'; 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 { StatusBar } from 'expo-status-bar';
import * as SplashScreen from 'expo-splash-screen';
import 'react-native-reanimated'; 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 { LoadingSpinner } from '@/components/ui/LoadingSpinner';
export const unstable_settings = { // Prevent auto-hiding splash screen
anchor: '(tabs)', SplashScreen.preventAutoHideAsync();
};
export default function RootLayout() { function RootLayoutNav() {
const colorScheme = useColorScheme(); 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 <LoadingSpinner fullScreen message="Loading..." />;
}
return ( return (
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}> <ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
<Stack> <Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} /> <Stack.Screen name="(auth)" />
<Stack.Screen name="(tabs)" />
<Stack.Screen name="patients" />
<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" />
</ThemeProvider> </ThemeProvider>
); );
} }
export default function RootLayout() {
return (
<AuthProvider>
<RootLayoutNav />
</AuthProvider>
);
}

371
app/patients/[id]/index.tsx Normal file
View File

@ -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<Patient | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isRefreshing, setIsRefreshing] = useState(false);
const [error, setError] = useState<string | null>(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 <LoadingSpinner fullScreen message="Loading patient data..." />;
}
if (error || !patient) {
return (
<FullScreenError
message={error || 'Patient not found'}
onRetry={() => loadPatient()}
/>
);
}
return (
<SafeAreaView style={styles.container} edges={['top']}>
{/* Header */}
<View style={styles.header}>
<TouchableOpacity
style={styles.backButton}
onPress={() => router.back()}
>
<Ionicons name="arrow-back" size={24} color={AppColors.textPrimary} />
</TouchableOpacity>
<Text style={styles.headerTitle}>{patient.name}</Text>
<TouchableOpacity style={styles.menuButton}>
<Ionicons name="ellipsis-vertical" size={24} color={AppColors.textPrimary} />
</TouchableOpacity>
</View>
<ScrollView
style={styles.content}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
refreshing={isRefreshing}
onRefresh={handleRefresh}
tintColor={AppColors.primary}
/>
}
>
{/* Patient Info Card */}
<View style={styles.infoCard}>
<View style={styles.avatarContainer}>
<Text style={styles.avatarText}>
{patient.name.charAt(0).toUpperCase()}
</Text>
<View
style={[
styles.statusBadge,
patient.status === 'online' ? styles.onlineBadge : styles.offlineBadge,
]}
>
<Text style={styles.statusText}>
{patient.status === 'online' ? 'Online' : 'Offline'}
</Text>
</View>
</View>
<Text style={styles.patientName}>{patient.name}</Text>
<Text style={styles.relationship}>{patient.relationship}</Text>
<Text style={styles.lastSeen}>
Last activity: {patient.last_activity}
</Text>
</View>
{/* Health Stats */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Health Overview</Text>
<View style={styles.statsGrid}>
<View style={styles.statCard}>
<View style={[styles.statIcon, { backgroundColor: '#FEE2E2' }]}>
<Ionicons name="heart" size={24} color={AppColors.error} />
</View>
<Text style={styles.statValue}>
{patient.health_data?.heart_rate || '--'}
</Text>
<Text style={styles.statLabel}>Heart Rate</Text>
<Text style={styles.statUnit}>BPM</Text>
</View>
<View style={styles.statCard}>
<View style={[styles.statIcon, { backgroundColor: '#DBEAFE' }]}>
<Ionicons name="footsteps" size={24} color={AppColors.primary} />
</View>
<Text style={styles.statValue}>
{patient.health_data?.steps?.toLocaleString() || '--'}
</Text>
<Text style={styles.statLabel}>Steps Today</Text>
<Text style={styles.statUnit}>steps</Text>
</View>
<View style={styles.statCard}>
<View style={[styles.statIcon, { backgroundColor: '#E0E7FF' }]}>
<Ionicons name="moon" size={24} color={AppColors.primaryDark} />
</View>
<Text style={styles.statValue}>
{patient.health_data?.sleep_hours || '--'}
</Text>
<Text style={styles.statLabel}>Sleep</Text>
<Text style={styles.statUnit}>hours</Text>
</View>
</View>
</View>
{/* Quick Actions */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Quick Actions</Text>
<View style={styles.actionsGrid}>
<TouchableOpacity style={styles.actionCard} onPress={handleChatPress}>
<View style={[styles.actionIcon, { backgroundColor: '#D1FAE5' }]}>
<Ionicons name="chatbubble-ellipses" size={24} color={AppColors.success} />
</View>
<Text style={styles.actionLabel}>Chat with Julia</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.actionCard}>
<View style={[styles.actionIcon, { backgroundColor: '#FEF3C7' }]}>
<Ionicons name="notifications" size={24} color={AppColors.warning} />
</View>
<Text style={styles.actionLabel}>Set Reminder</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.actionCard}>
<View style={[styles.actionIcon, { backgroundColor: '#DBEAFE' }]}>
<Ionicons name="call" size={24} color={AppColors.primary} />
</View>
<Text style={styles.actionLabel}>Video Call</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.actionCard}>
<View style={[styles.actionIcon, { backgroundColor: '#F3E8FF' }]}>
<Ionicons name="medkit" size={24} color="#9333EA" />
</View>
<Text style={styles.actionLabel}>Medications</Text>
</TouchableOpacity>
</View>
</View>
{/* Chat with Julia Button */}
<View style={styles.chatButtonContainer}>
<Button
title="Chat with Julia AI"
onPress={handleChatPress}
fullWidth
size="lg"
/>
</View>
</ScrollView>
</SafeAreaView>
);
}
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,
},
});

15
app/patients/_layout.tsx Normal file
View File

@ -0,0 +1,15 @@
import { Stack } from 'expo-router';
import { AppColors } from '@/constants/theme';
export default function PatientsLayout() {
return (
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: AppColors.background },
}}
>
<Stack.Screen name="[id]/index" />
</Stack>
);
}

137
components/ui/Button.tsx Normal file
View File

@ -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 (
<TouchableOpacity
style={buttonStyles}
disabled={isDisabled}
activeOpacity={0.7}
{...props}
>
{loading ? (
<ActivityIndicator
color={variant === 'primary' ? AppColors.white : AppColors.primary}
size="small"
/>
) : (
<Text style={textStyles}>{title}</Text>
)}
</TouchableOpacity>
);
}
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,
},
});

View File

@ -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 (
<View style={styles.container}>
<View style={styles.content}>
<Ionicons name="alert-circle" size={24} color={AppColors.error} />
<Text style={styles.message}>{message}</Text>
</View>
<View style={styles.actions}>
{onRetry && (
<TouchableOpacity onPress={onRetry} style={styles.button}>
<Ionicons name="refresh" size={18} color={AppColors.primary} />
<Text style={styles.buttonText}>Retry</Text>
</TouchableOpacity>
)}
{onDismiss && (
<TouchableOpacity onPress={onDismiss} style={styles.dismissButton}>
<Ionicons name="close" size={18} color={AppColors.textMuted} />
</TouchableOpacity>
)}
</View>
</View>
);
}
interface FullScreenErrorProps {
title?: string;
message: string;
onRetry?: () => void;
}
export function FullScreenError({
title = 'Something went wrong',
message,
onRetry
}: FullScreenErrorProps) {
return (
<View style={styles.fullScreenContainer}>
<Ionicons name="cloud-offline-outline" size={64} color={AppColors.textMuted} />
<Text style={styles.fullScreenTitle}>{title}</Text>
<Text style={styles.fullScreenMessage}>{message}</Text>
{onRetry && (
<TouchableOpacity onPress={onRetry} style={styles.retryButton}>
<Ionicons name="refresh" size={20} color={AppColors.white} />
<Text style={styles.retryButtonText}>Try Again</Text>
</TouchableOpacity>
)}
</View>
);
}
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,
},
});

143
components/ui/Input.tsx Normal file
View File

@ -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 (
<View style={styles.container}>
{label && <Text style={styles.label}>{label}</Text>}
<View style={[styles.inputContainer, error && styles.inputError]}>
{leftIcon && (
<Ionicons
name={leftIcon}
size={20}
color={AppColors.textMuted}
style={styles.leftIcon}
/>
)}
<TextInput
style={[
styles.input,
leftIcon && styles.inputWithLeftIcon,
(isPassword || rightIcon) && styles.inputWithRightIcon,
style,
]}
placeholderTextColor={AppColors.textMuted}
secureTextEntry={isPassword && !isPasswordVisible}
{...props}
/>
{isPassword && (
<TouchableOpacity
onPress={handleTogglePassword}
style={styles.rightIconButton}
>
<Ionicons
name={isPasswordVisible ? 'eye-off-outline' : 'eye-outline'}
size={20}
color={AppColors.textMuted}
/>
</TouchableOpacity>
)}
{!isPassword && rightIcon && (
<TouchableOpacity
onPress={onRightIconPress}
style={styles.rightIconButton}
disabled={!onRightIconPress}
>
<Ionicons
name={rightIcon}
size={20}
color={AppColors.textMuted}
/>
</TouchableOpacity>
)}
</View>
{error && <Text style={styles.errorText}>{error}</Text>}
</View>
);
}
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,
},
});

View File

@ -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 = (
<>
<ActivityIndicator size={size} color={color} />
{message && <Text style={styles.message}>{message}</Text>}
</>
);
if (fullScreen) {
return <View style={styles.fullScreen}>{content}</View>;
}
return <View style={styles.container}>{content}</View>;
}
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',
},
});

View File

@ -1,21 +1,55 @@
/** /**
* Below are the colors that are used in the app. The colors are defined in the light and dark mode. * WellNuo Theme Configuration
* 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. * Colors and typography based on design system
*/ */
import { Platform } from 'react-native'; 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'; const tintColorDark = '#fff';
export const Colors = { export const Colors = {
light: { light: {
text: '#11181C', text: AppColors.textPrimary,
background: '#fff', background: AppColors.background,
tint: tintColorLight, tint: tintColorLight,
icon: '#687076', icon: '#687076',
tabIconDefault: '#687076', tabIconDefault: '#687076',
tabIconSelected: tintColorLight, tabIconSelected: tintColorLight,
surface: AppColors.surface,
border: AppColors.border,
primary: AppColors.primary,
error: AppColors.error,
success: AppColors.success,
}, },
dark: { dark: {
text: '#ECEDEE', text: '#ECEDEE',
@ -24,18 +58,53 @@ export const Colors = {
icon: '#9BA1A6', icon: '#9BA1A6',
tabIconDefault: '#9BA1A6', tabIconDefault: '#9BA1A6',
tabIconSelected: tintColorDark, 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({ export const Fonts = Platform.select({
ios: { ios: {
/** iOS `UIFontDescriptorSystemDesignDefault` */
sans: 'system-ui', sans: 'system-ui',
/** iOS `UIFontDescriptorSystemDesignSerif` */
serif: 'ui-serif', serif: 'ui-serif',
/** iOS `UIFontDescriptorSystemDesignRounded` */
rounded: 'ui-rounded', rounded: 'ui-rounded',
/** iOS `UIFontDescriptorSystemDesignMonospaced` */
mono: 'ui-monospace', mono: 'ui-monospace',
}, },
default: { default: {

136
contexts/AuthContext.tsx Normal file
View File

@ -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<boolean>;
logout: () => Promise<void>;
clearError: () => void;
}
const AuthContext = createContext<AuthContextType | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<AuthState>({
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<boolean> => {
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 (
<AuthContext.Provider value={{ ...state, login, logout, clearError }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}

58
package-lock.json generated
View File

@ -19,6 +19,7 @@
"expo-image": "~3.0.11", "expo-image": "~3.0.11",
"expo-linking": "~8.0.10", "expo-linking": "~8.0.10",
"expo-router": "~6.0.19", "expo-router": "~6.0.19",
"expo-secure-store": "^15.0.8",
"expo-splash-screen": "~31.0.12", "expo-splash-screen": "~31.0.12",
"expo-status-bar": "~3.0.9", "expo-status-bar": "~3.0.9",
"expo-symbols": "~1.0.8", "expo-symbols": "~1.0.8",
@ -38,6 +39,7 @@
"@types/react": "~19.1.0", "@types/react": "~19.1.0",
"eslint": "^9.25.0", "eslint": "^9.25.0",
"eslint-config-expo": "~10.0.0", "eslint-config-expo": "~10.0.0",
"playwright": "^1.57.0",
"typescript": "~5.9.2" "typescript": "~5.9.2"
} }
}, },
@ -6443,6 +6445,15 @@
"node": ">=10" "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": { "node_modules/expo-server": {
"version": "1.0.5", "version": "1.0.5",
"resolved": "https://registry.npmjs.org/expo-server/-/expo-server-1.0.5.tgz", "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-1.0.5.tgz",
@ -9856,6 +9867,53 @@
"node": ">= 6" "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": { "node_modules/plist": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz",

View File

@ -22,6 +22,7 @@
"expo-image": "~3.0.11", "expo-image": "~3.0.11",
"expo-linking": "~8.0.10", "expo-linking": "~8.0.10",
"expo-router": "~6.0.19", "expo-router": "~6.0.19",
"expo-secure-store": "^15.0.8",
"expo-splash-screen": "~31.0.12", "expo-splash-screen": "~31.0.12",
"expo-status-bar": "~3.0.9", "expo-status-bar": "~3.0.9",
"expo-symbols": "~1.0.8", "expo-symbols": "~1.0.8",
@ -31,17 +32,18 @@
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-native": "0.81.5", "react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0", "react-native-gesture-handler": "~2.28.0",
"react-native-worklets": "0.5.1",
"react-native-reanimated": "~4.1.1", "react-native-reanimated": "~4.1.1",
"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-worklets": "0.5.1"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "~19.1.0", "@types/react": "~19.1.0",
"typescript": "~5.9.2",
"eslint": "^9.25.0", "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 "private": true
} }

194
services/api.ts Normal file
View File

@ -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<string | null> {
try {
return await SecureStore.getItemAsync('accessToken');
} catch {
return null;
}
}
private async getUserName(): Promise<string | null> {
try {
return await SecureStore.getItemAsync('userName');
} catch {
return null;
}
}
private generateNonce(): string {
return Math.floor(Math.random() * 1000000).toString();
}
private async makeRequest<T>(params: Record<string, string>): Promise<ApiResponse<T>> {
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<ApiResponse<AuthResponse>> {
const response = await this.makeRequest<AuthResponse>({
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<void> {
await SecureStore.deleteItemAsync('accessToken');
await SecureStore.deleteItemAsync('userId');
await SecureStore.deleteItemAsync('userName');
await SecureStore.deleteItemAsync('privileges');
await SecureStore.deleteItemAsync('maxRole');
}
async isAuthenticated(): Promise<boolean> {
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<ApiResponse<{ patients: Patient[] }>> {
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<ApiResponse<Patient>> {
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<ApiResponse<ChatResponse>> {
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<ChatResponse>({
function: 'voice_ask',
clientId: CLIENT_ID,
user_name: userName,
token: token,
question: question,
deployment_id: deploymentId,
});
}
}
export const api = new ApiService();

70
types/index.ts Normal file
View File

@ -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<T> {
data?: T;
error?: ApiError;
ok: boolean;
}