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:
parent
6af55cbd8d
commit
a804a82512
1
.gitignore
vendored
1
.gitignore
vendored
@ -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
15
app/(auth)/_layout.tsx
Normal 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
210
app/(auth)/login.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -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
321
app/(tabs)/chat.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -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
314
app/(tabs)/profile.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -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
371
app/patients/[id]/index.tsx
Normal 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
15
app/patients/_layout.tsx
Normal 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
137
components/ui/Button.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
141
components/ui/ErrorMessage.tsx
Normal file
141
components/ui/ErrorMessage.tsx
Normal 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
143
components/ui/Input.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
50
components/ui/LoadingSpinner.tsx
Normal file
50
components/ui/LoadingSpinner.tsx
Normal 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -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
136
contexts/AuthContext.tsx
Normal 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
58
package-lock.json
generated
@ -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",
|
||||||
|
|||||||
10
package.json
10
package.json
@ -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
194
services/api.ts
Normal 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
70
types/index.ts
Normal 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;
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user