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
|
||||
/ios
|
||||
/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 React from 'react';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
|
||||
import { HapticTab } from '@/components/haptic-tab';
|
||||
import { IconSymbol } from '@/components/ui/icon-symbol';
|
||||
import { Colors } from '@/constants/theme';
|
||||
import { AppColors } from '@/constants/theme';
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
|
||||
export default function TabLayout() {
|
||||
const colorScheme = useColorScheme();
|
||||
const isDark = colorScheme === 'dark';
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
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,
|
||||
tabBarButton: HapticTab,
|
||||
}}>
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: 'Home',
|
||||
tabBarIcon: ({ color }) => <IconSymbol size={28} name="house.fill" color={color} />,
|
||||
title: 'Patients',
|
||||
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
|
||||
name="explore"
|
||||
options={{
|
||||
title: 'Explore',
|
||||
tabBarIcon: ({ color }) => <IconSymbol size={28} name="paperplane.fill" color={color} />,
|
||||
href: null,
|
||||
}}
|
||||
/>
|
||||
</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 { Platform, StyleSheet } from 'react-native';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
FlatList,
|
||||
TouchableOpacity,
|
||||
RefreshControl,
|
||||
} from 'react-native';
|
||||
import { router } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { api } from '@/services/api';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
||||
import { FullScreenError } from '@/components/ui/ErrorMessage';
|
||||
import { AppColors, BorderRadius, FontSizes, Spacing } from '@/constants/theme';
|
||||
import type { Patient } from '@/types';
|
||||
|
||||
import { HelloWave } from '@/components/hello-wave';
|
||||
import ParallaxScrollView from '@/components/parallax-scroll-view';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { Link } from 'expo-router';
|
||||
export default function PatientsListScreen() {
|
||||
const { user } = useAuth();
|
||||
const [patients, setPatients] = useState<Patient[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
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 (
|
||||
<ParallaxScrollView
|
||||
headerBackgroundColor={{ light: '#A1CEDC', dark: '#1D3D47' }}
|
||||
headerImage={
|
||||
<Image
|
||||
source={require('@/assets/images/partial-react-logo.png')}
|
||||
style={styles.reactLogo}
|
||||
/>
|
||||
}>
|
||||
<ThemedView style={styles.titleContainer}>
|
||||
<ThemedText type="title">Welcome!</ThemedText>
|
||||
<HelloWave />
|
||||
</ThemedView>
|
||||
<ThemedView style={styles.stepContainer}>
|
||||
<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>
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<View>
|
||||
<Text style={styles.greeting}>
|
||||
Hello, {user?.user_name || 'User'}
|
||||
</Text>
|
||||
<Text style={styles.headerTitle}>Your Patients</Text>
|
||||
</View>
|
||||
<TouchableOpacity style={styles.addButton}>
|
||||
<Ionicons name="add" size={24} color={AppColors.white} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ThemedText>
|
||||
{`Tap the Explore tab to learn more about what's included in this starter app.`}
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
<ThemedView style={styles.stepContainer}>
|
||||
<ThemedText type="subtitle">Step 3: Get a fresh start</ThemedText>
|
||||
<ThemedText>
|
||||
{`When you're ready, run `}
|
||||
<ThemedText type="defaultSemiBold">npm run reset-project</ThemedText> to get a fresh{' '}
|
||||
<ThemedText type="defaultSemiBold">app</ThemedText> directory. This will move the current{' '}
|
||||
<ThemedText type="defaultSemiBold">app</ThemedText> to{' '}
|
||||
<ThemedText type="defaultSemiBold">app-example</ThemedText>.
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
</ParallaxScrollView>
|
||||
{/* Patient List */}
|
||||
<FlatList
|
||||
data={patients}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
renderItem={renderPatientCard}
|
||||
contentContainerStyle={styles.listContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isRefreshing}
|
||||
onRefresh={handleRefresh}
|
||||
tintColor={AppColors.primary}
|
||||
/>
|
||||
}
|
||||
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({
|
||||
titleContainer: {
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: AppColors.surface,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: Spacing.lg,
|
||||
paddingVertical: Spacing.md,
|
||||
backgroundColor: AppColors.background,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: AppColors.border,
|
||||
},
|
||||
greeting: {
|
||||
fontSize: FontSizes.sm,
|
||||
color: AppColors.textSecondary,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: FontSizes.xl,
|
||||
fontWeight: '700',
|
||||
color: AppColors.textPrimary,
|
||||
},
|
||||
addButton: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: BorderRadius.full,
|
||||
backgroundColor: AppColors.primary,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
listContent: {
|
||||
padding: Spacing.md,
|
||||
},
|
||||
patientCard: {
|
||||
backgroundColor: AppColors.background,
|
||||
borderRadius: BorderRadius.lg,
|
||||
padding: Spacing.md,
|
||||
marginBottom: Spacing.md,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 4,
|
||||
elevation: 2,
|
||||
},
|
||||
patientInfo: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
marginBottom: Spacing.md,
|
||||
},
|
||||
stepContainer: {
|
||||
gap: 8,
|
||||
marginBottom: 8,
|
||||
avatarContainer: {
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: BorderRadius.full,
|
||||
backgroundColor: AppColors.primaryLight,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: Spacing.md,
|
||||
},
|
||||
reactLogo: {
|
||||
height: 178,
|
||||
width: 290,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
avatarText: {
|
||||
fontSize: FontSizes.xl,
|
||||
fontWeight: '600',
|
||||
color: AppColors.white,
|
||||
},
|
||||
statusIndicator: {
|
||||
position: 'absolute',
|
||||
bottom: 2,
|
||||
right: 2,
|
||||
width: 14,
|
||||
height: 14,
|
||||
borderRadius: BorderRadius.full,
|
||||
borderWidth: 2,
|
||||
borderColor: AppColors.background,
|
||||
},
|
||||
online: {
|
||||
backgroundColor: AppColors.online,
|
||||
},
|
||||
offline: {
|
||||
backgroundColor: AppColors.offline,
|
||||
},
|
||||
patientDetails: {
|
||||
flex: 1,
|
||||
},
|
||||
patientName: {
|
||||
fontSize: FontSizes.lg,
|
||||
fontWeight: '600',
|
||||
color: AppColors.textPrimary,
|
||||
},
|
||||
patientRelationship: {
|
||||
fontSize: FontSizes.sm,
|
||||
color: AppColors.textSecondary,
|
||||
marginTop: 2,
|
||||
},
|
||||
lastActivity: {
|
||||
fontSize: FontSizes.xs,
|
||||
color: AppColors.textMuted,
|
||||
marginTop: 4,
|
||||
},
|
||||
healthStats: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-around',
|
||||
paddingTop: Spacing.md,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: AppColors.border,
|
||||
},
|
||||
statItem: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
statValue: {
|
||||
fontSize: FontSizes.base,
|
||||
fontWeight: '600',
|
||||
color: AppColors.textPrimary,
|
||||
marginTop: Spacing.xs,
|
||||
},
|
||||
statLabel: {
|
||||
fontSize: FontSizes.xs,
|
||||
color: AppColors.textMuted,
|
||||
},
|
||||
chevron: {
|
||||
position: 'absolute',
|
||||
top: Spacing.md,
|
||||
right: Spacing.md,
|
||||
},
|
||||
emptyContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingTop: Spacing.xxl * 2,
|
||||
},
|
||||
emptyTitle: {
|
||||
fontSize: FontSizes.lg,
|
||||
fontWeight: '600',
|
||||
color: AppColors.textPrimary,
|
||||
marginTop: Spacing.md,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: FontSizes.base,
|
||||
color: AppColors.textSecondary,
|
||||
textAlign: 'center',
|
||||
marginTop: Spacing.xs,
|
||||
},
|
||||
});
|
||||
|
||||
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 { Stack } from 'expo-router';
|
||||
import { Stack, router, useSegments } from 'expo-router';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import * as SplashScreen from 'expo-splash-screen';
|
||||
import 'react-native-reanimated';
|
||||
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
import { AuthProvider, useAuth } from '@/contexts/AuthContext';
|
||||
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
||||
|
||||
export const unstable_settings = {
|
||||
anchor: '(tabs)',
|
||||
};
|
||||
// Prevent auto-hiding splash screen
|
||||
SplashScreen.preventAutoHideAsync();
|
||||
|
||||
export default function RootLayout() {
|
||||
function RootLayoutNav() {
|
||||
const colorScheme = useColorScheme();
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
const segments = useSegments();
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) return;
|
||||
|
||||
// Hide splash screen once we know auth state
|
||||
SplashScreen.hideAsync();
|
||||
|
||||
const inAuthGroup = segments[0] === '(auth)';
|
||||
|
||||
if (!isAuthenticated && !inAuthGroup) {
|
||||
// Redirect to login if not authenticated
|
||||
router.replace('/(auth)/login');
|
||||
} else if (isAuthenticated && inAuthGroup) {
|
||||
// Redirect to main app if authenticated
|
||||
router.replace('/(tabs)');
|
||||
}
|
||||
}, [isAuthenticated, isLoading, segments]);
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingSpinner fullScreen message="Loading..." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
||||
<Stack>
|
||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="(auth)" />
|
||||
<Stack.Screen name="(tabs)" />
|
||||
<Stack.Screen name="patients" />
|
||||
<Stack.Screen name="modal" options={{ presentation: 'modal', title: 'Modal' }} />
|
||||
</Stack>
|
||||
<StatusBar style="auto" />
|
||||
</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.
|
||||
* There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc.
|
||||
* WellNuo Theme Configuration
|
||||
* Colors and typography based on design system
|
||||
*/
|
||||
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
const tintColorLight = '#0a7ea4';
|
||||
// WellNuo Brand Colors
|
||||
export const AppColors = {
|
||||
// Primary
|
||||
primary: '#4A90D9',
|
||||
primaryDark: '#2E5C8A',
|
||||
primaryLight: '#6BA8E8',
|
||||
|
||||
// Status
|
||||
success: '#5AC8A8',
|
||||
warning: '#F5A623',
|
||||
error: '#D0021B',
|
||||
|
||||
// Neutral
|
||||
white: '#FFFFFF',
|
||||
background: '#FFFFFF',
|
||||
surface: '#F5F7FA',
|
||||
border: '#E5E7EB',
|
||||
|
||||
// Text
|
||||
textPrimary: '#333333',
|
||||
textSecondary: '#666666',
|
||||
textMuted: '#999999',
|
||||
textLight: '#FFFFFF',
|
||||
|
||||
// Patient Status
|
||||
online: '#5AC8A8',
|
||||
offline: '#999999',
|
||||
};
|
||||
|
||||
const tintColorLight = AppColors.primary;
|
||||
const tintColorDark = '#fff';
|
||||
|
||||
export const Colors = {
|
||||
light: {
|
||||
text: '#11181C',
|
||||
background: '#fff',
|
||||
text: AppColors.textPrimary,
|
||||
background: AppColors.background,
|
||||
tint: tintColorLight,
|
||||
icon: '#687076',
|
||||
tabIconDefault: '#687076',
|
||||
tabIconSelected: tintColorLight,
|
||||
surface: AppColors.surface,
|
||||
border: AppColors.border,
|
||||
primary: AppColors.primary,
|
||||
error: AppColors.error,
|
||||
success: AppColors.success,
|
||||
},
|
||||
dark: {
|
||||
text: '#ECEDEE',
|
||||
@ -24,18 +58,53 @@ export const Colors = {
|
||||
icon: '#9BA1A6',
|
||||
tabIconDefault: '#9BA1A6',
|
||||
tabIconSelected: tintColorDark,
|
||||
surface: '#1E2022',
|
||||
border: '#2D3135',
|
||||
primary: AppColors.primaryLight,
|
||||
error: '#FF6B6B',
|
||||
success: '#6BD9B9',
|
||||
},
|
||||
};
|
||||
|
||||
export const Spacing = {
|
||||
xs: 4,
|
||||
sm: 8,
|
||||
md: 16,
|
||||
lg: 24,
|
||||
xl: 32,
|
||||
xxl: 48,
|
||||
};
|
||||
|
||||
export const BorderRadius = {
|
||||
sm: 4,
|
||||
md: 8,
|
||||
lg: 12,
|
||||
xl: 16,
|
||||
full: 9999,
|
||||
};
|
||||
|
||||
export const FontSizes = {
|
||||
xs: 12,
|
||||
sm: 14,
|
||||
base: 16,
|
||||
lg: 18,
|
||||
xl: 20,
|
||||
'2xl': 24,
|
||||
'3xl': 30,
|
||||
};
|
||||
|
||||
export const FontWeights = {
|
||||
normal: '400' as const,
|
||||
medium: '500' as const,
|
||||
semibold: '600' as const,
|
||||
bold: '700' as const,
|
||||
};
|
||||
|
||||
export const Fonts = Platform.select({
|
||||
ios: {
|
||||
/** iOS `UIFontDescriptorSystemDesignDefault` */
|
||||
sans: 'system-ui',
|
||||
/** iOS `UIFontDescriptorSystemDesignSerif` */
|
||||
serif: 'ui-serif',
|
||||
/** iOS `UIFontDescriptorSystemDesignRounded` */
|
||||
rounded: 'ui-rounded',
|
||||
/** iOS `UIFontDescriptorSystemDesignMonospaced` */
|
||||
mono: 'ui-monospace',
|
||||
},
|
||||
default: {
|
||||
|
||||
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-linking": "~8.0.10",
|
||||
"expo-router": "~6.0.19",
|
||||
"expo-secure-store": "^15.0.8",
|
||||
"expo-splash-screen": "~31.0.12",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"expo-symbols": "~1.0.8",
|
||||
@ -38,6 +39,7 @@
|
||||
"@types/react": "~19.1.0",
|
||||
"eslint": "^9.25.0",
|
||||
"eslint-config-expo": "~10.0.0",
|
||||
"playwright": "^1.57.0",
|
||||
"typescript": "~5.9.2"
|
||||
}
|
||||
},
|
||||
@ -6443,6 +6445,15 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-secure-store": {
|
||||
"version": "15.0.8",
|
||||
"resolved": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-15.0.8.tgz",
|
||||
"integrity": "sha512-lHnzvRajBu4u+P99+0GEMijQMFCOYpWRO4dWsXSuMt77+THPIGjzNvVKrGSl6mMrLsfVaKL8BpwYZLGlgA+zAw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"expo": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-server": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/expo-server/-/expo-server-1.0.5.tgz",
|
||||
@ -9856,6 +9867,53 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.57.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz",
|
||||
"integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.57.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.57.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz",
|
||||
"integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/plist": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz",
|
||||
|
||||
10
package.json
10
package.json
@ -22,6 +22,7 @@
|
||||
"expo-image": "~3.0.11",
|
||||
"expo-linking": "~8.0.10",
|
||||
"expo-router": "~6.0.19",
|
||||
"expo-secure-store": "^15.0.8",
|
||||
"expo-splash-screen": "~31.0.12",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"expo-symbols": "~1.0.8",
|
||||
@ -31,17 +32,18 @@
|
||||
"react-dom": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-worklets": "0.5.1",
|
||||
"react-native-reanimated": "~4.1.1",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-web": "~0.21.0"
|
||||
"react-native-web": "~0.21.0",
|
||||
"react-native-worklets": "0.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "~19.1.0",
|
||||
"typescript": "~5.9.2",
|
||||
"eslint": "^9.25.0",
|
||||
"eslint-config-expo": "~10.0.0"
|
||||
"eslint-config-expo": "~10.0.0",
|
||||
"playwright": "^1.57.0",
|
||||
"typescript": "~5.9.2"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
|
||||
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