WellNuo Lite v1.0.0 - simplified version for App Store review
- Removed voice input features - Simplified profile page (only legal links and logout) - Chat with AI context working - Auto-select first beneficiary - Dashboard WebView intact 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
44
.gitignore
vendored
Normal file
@ -0,0 +1,44 @@
|
||||
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# Expo
|
||||
.expo/
|
||||
dist/
|
||||
web-build/
|
||||
expo-env.d.ts
|
||||
|
||||
# Native
|
||||
.kotlin/
|
||||
*.orig.*
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
|
||||
# Metro
|
||||
.metro-health-check*
|
||||
|
||||
# debug
|
||||
npm-debug.*
|
||||
yarn-debug.*
|
||||
yarn-error.*
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
app-example
|
||||
|
||||
# generated native folders
|
||||
/ios
|
||||
/android
|
||||
.git-credentials
|
||||
1
.vscode/extensions.json
vendored
Normal file
@ -0,0 +1 @@
|
||||
{ "recommendations": ["expo.vscode-expo-tools"] }
|
||||
7
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll": "explicit",
|
||||
"source.organizeImports": "explicit",
|
||||
"source.sortMembers": "explicit"
|
||||
}
|
||||
}
|
||||
50
README.md
Normal file
@ -0,0 +1,50 @@
|
||||
# Welcome to your Expo app 👋
|
||||
|
||||
This is an [Expo](https://expo.dev) project created with [`create-expo-app`](https://www.npmjs.com/package/create-expo-app).
|
||||
|
||||
## Get started
|
||||
|
||||
1. Install dependencies
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
2. Start the app
|
||||
|
||||
```bash
|
||||
npx expo start
|
||||
```
|
||||
|
||||
In the output, you'll find options to open the app in a
|
||||
|
||||
- [development build](https://docs.expo.dev/develop/development-builds/introduction/)
|
||||
- [Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/)
|
||||
- [iOS simulator](https://docs.expo.dev/workflow/ios-simulator/)
|
||||
- [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo
|
||||
|
||||
You can start developing by editing the files inside the **app** directory. This project uses [file-based routing](https://docs.expo.dev/router/introduction).
|
||||
|
||||
## Get a fresh project
|
||||
|
||||
When you're ready, run:
|
||||
|
||||
```bash
|
||||
npm run reset-project
|
||||
```
|
||||
|
||||
This command will move the starter code to the **app-example** directory and create a blank **app** directory where you can start developing.
|
||||
|
||||
## Learn more
|
||||
|
||||
To learn more about developing your project with Expo, look at the following resources:
|
||||
|
||||
- [Expo documentation](https://docs.expo.dev/): Learn fundamentals, or go into advanced topics with our [guides](https://docs.expo.dev/guides).
|
||||
- [Learn Expo tutorial](https://docs.expo.dev/tutorial/introduction/): Follow a step-by-step tutorial where you'll create a project that runs on Android, iOS, and the web.
|
||||
|
||||
## Join the community
|
||||
|
||||
Join our community of developers creating universal apps.
|
||||
|
||||
- [Expo on GitHub](https://github.com/expo/expo): View our open source platform and contribute.
|
||||
- [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions.
|
||||
59
app.json
Normal file
@ -0,0 +1,59 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "WellNuo Lite",
|
||||
"slug": "WellNuoLite",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "wellnuo",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"newArchEnabled": true,
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "com.wellnuo.app",
|
||||
"infoPlist": {
|
||||
"ITSAppUsesNonExemptEncryption": false
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"backgroundColor": "#E6F4FE",
|
||||
"foregroundImage": "./assets/images/android-icon-foreground.png",
|
||||
"backgroundImage": "./assets/images/android-icon-background.png",
|
||||
"monochromeImage": "./assets/images/android-icon-monochrome.png"
|
||||
},
|
||||
"edgeToEdgeEnabled": true,
|
||||
"predictiveBackGestureEnabled": false
|
||||
},
|
||||
"web": {
|
||||
"output": "static",
|
||||
"favicon": "./assets/images/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
[
|
||||
"expo-splash-screen",
|
||||
{
|
||||
"image": "./assets/images/splash-icon.png",
|
||||
"imageWidth": 200,
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff",
|
||||
"dark": {
|
||||
"backgroundColor": "#000000"
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true,
|
||||
"reactCompiler": true
|
||||
},
|
||||
"extra": {
|
||||
"router": {},
|
||||
"eas": {
|
||||
"projectId": "4a77e46d-7b0e-4ace-a385-006b07027234"
|
||||
}
|
||||
},
|
||||
"owner": "kosyakorel1"
|
||||
}
|
||||
}
|
||||
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
@ -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,
|
||||
},
|
||||
});
|
||||
83
app/(tabs)/_layout.tsx
Normal file
@ -0,0 +1,83 @@
|
||||
import { Tabs } from 'expo-router';
|
||||
import React from 'react';
|
||||
import { Feather } from '@expo/vector-icons';
|
||||
|
||||
import { HapticTab } from '@/components/haptic-tab';
|
||||
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: AppColors.primary,
|
||||
tabBarInactiveTintColor: isDark ? '#9BA1A6' : '#687076',
|
||||
tabBarStyle: {
|
||||
backgroundColor: isDark ? '#151718' : AppColors.background,
|
||||
borderTopColor: isDark ? '#2D3135' : AppColors.border,
|
||||
height: 85,
|
||||
paddingBottom: 25,
|
||||
paddingTop: 10,
|
||||
},
|
||||
tabBarLabelStyle: {
|
||||
fontSize: 11,
|
||||
fontWeight: '500',
|
||||
},
|
||||
headerShown: false,
|
||||
tabBarButton: HapticTab,
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: 'Dashboard',
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<Feather name="grid" size={22} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
{/* Hide old dashboard - now index shows WebView dashboard */}
|
||||
<Tabs.Screen
|
||||
name="dashboard"
|
||||
options={{
|
||||
href: null,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="chat"
|
||||
options={{
|
||||
title: 'Chat',
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<Feather name="message-circle" size={22} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="profile"
|
||||
options={{
|
||||
title: 'Profile',
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<Feather name="user" size={22} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
{/* Hide explore tab */}
|
||||
<Tabs.Screen
|
||||
name="explore"
|
||||
options={{
|
||||
href: null,
|
||||
}}
|
||||
/>
|
||||
{/* Beneficiaries - hidden from tab bar but keeps tab bar visible */}
|
||||
<Tabs.Screen
|
||||
name="beneficiaries"
|
||||
options={{
|
||||
href: null,
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
336
app/(tabs)/beneficiaries/[id]/dashboard.tsx
Normal file
@ -0,0 +1,336 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { View, Text, StyleSheet, ActivityIndicator, TouchableOpacity } from 'react-native';
|
||||
import { WebView } from 'react-native-webview';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useLocalSearchParams, router } from 'expo-router';
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
import { useBeneficiary } from '@/contexts/BeneficiaryContext';
|
||||
import { AppColors, BorderRadius, FontSizes, Spacing } from '@/constants/theme';
|
||||
import { FullScreenError } from '@/components/ui/ErrorMessage';
|
||||
|
||||
// Start with login page, then redirect to dashboard after auth
|
||||
const LOGIN_URL = 'https://react.eluxnetworks.net/login';
|
||||
const DASHBOARD_URL = 'https://react.eluxnetworks.net/dashboard';
|
||||
|
||||
export default function BeneficiaryDashboardScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const { currentBeneficiary, setCurrentBeneficiary } = useBeneficiary();
|
||||
const webViewRef = useRef<WebView>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [canGoBack, setCanGoBack] = useState(false);
|
||||
const [authToken, setAuthToken] = useState<string | null>(null);
|
||||
const [userName, setUserName] = useState<string | null>(null);
|
||||
const [userId, setUserId] = useState<string | null>(null);
|
||||
const [isTokenLoaded, setIsTokenLoaded] = useState(false);
|
||||
const [webViewUrl, setWebViewUrl] = useState(DASHBOARD_URL);
|
||||
|
||||
const beneficiaryName = currentBeneficiary?.name || 'Dashboard';
|
||||
|
||||
// Load token, username, and userId from SecureStore
|
||||
useEffect(() => {
|
||||
const loadCredentials = async () => {
|
||||
try {
|
||||
const token = await SecureStore.getItemAsync('accessToken');
|
||||
const user = await SecureStore.getItemAsync('userName');
|
||||
const uid = await SecureStore.getItemAsync('userId');
|
||||
setAuthToken(token);
|
||||
setUserName(user);
|
||||
setUserId(uid);
|
||||
console.log('Loaded credentials for WebView:', { hasToken: !!token, user, uid });
|
||||
} catch (err) {
|
||||
console.error('Failed to load credentials:', err);
|
||||
} finally {
|
||||
setIsTokenLoaded(true);
|
||||
}
|
||||
};
|
||||
loadCredentials();
|
||||
}, []);
|
||||
|
||||
// JavaScript to inject token into localStorage before page loads
|
||||
// Web app uses auth2 key with JSON object: {username, token, user_id}
|
||||
const injectedJavaScript = authToken
|
||||
? `
|
||||
(function() {
|
||||
try {
|
||||
// Web app expects auth2 as JSON object with these exact fields
|
||||
var authData = {
|
||||
username: '${userName || ''}',
|
||||
token: '${authToken}',
|
||||
user_id: ${userId || 'null'}
|
||||
};
|
||||
localStorage.setItem('auth2', JSON.stringify(authData));
|
||||
console.log('Auth data injected:', authData.username, 'user_id:', authData.user_id);
|
||||
} catch(e) {
|
||||
console.error('Failed to inject token:', e);
|
||||
}
|
||||
})();
|
||||
true;
|
||||
`
|
||||
: '';
|
||||
|
||||
const handleRefresh = () => {
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
webViewRef.current?.reload();
|
||||
};
|
||||
|
||||
const handleWebViewBack = () => {
|
||||
if (canGoBack) {
|
||||
webViewRef.current?.goBack();
|
||||
}
|
||||
};
|
||||
|
||||
const handleNavigationStateChange = (navState: any) => {
|
||||
setCanGoBack(navState.canGoBack);
|
||||
};
|
||||
|
||||
const handleError = () => {
|
||||
setError('Failed to load dashboard. Please check your internet connection.');
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const handleGoBack = () => {
|
||||
router.back();
|
||||
};
|
||||
|
||||
// Wait for token to load before showing WebView
|
||||
if (!isTokenLoaded) {
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity style={styles.backButton} onPress={handleGoBack}>
|
||||
<Ionicons name="arrow-back" size={24} color={AppColors.textPrimary} />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.headerTitle}>{beneficiaryName}</Text>
|
||||
<View style={styles.placeholder} />
|
||||
</View>
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={AppColors.primary} />
|
||||
<Text style={styles.loadingText}>Preparing dashboard...</Text>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity style={styles.backButton} onPress={handleGoBack}>
|
||||
<Ionicons name="arrow-back" size={24} color={AppColors.textPrimary} />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.headerTitle}>{beneficiaryName}</Text>
|
||||
<View style={styles.placeholder} />
|
||||
</View>
|
||||
<FullScreenError message={error} onRetry={handleRefresh} />
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity style={styles.backButton} onPress={handleGoBack}>
|
||||
<Ionicons name="arrow-back" size={24} color={AppColors.textPrimary} />
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.headerCenter}>
|
||||
{currentBeneficiary && (
|
||||
<View style={styles.avatarSmall}>
|
||||
<Text style={styles.avatarText}>
|
||||
{currentBeneficiary.name.charAt(0).toUpperCase()}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<View>
|
||||
<Text style={styles.headerTitle}>{beneficiaryName}</Text>
|
||||
{currentBeneficiary?.relationship && (
|
||||
<Text style={styles.headerSubtitle}>{currentBeneficiary.relationship}</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.headerActions}>
|
||||
{canGoBack && (
|
||||
<TouchableOpacity style={styles.actionButton} onPress={handleWebViewBack}>
|
||||
<Ionicons name="chevron-back" size={22} color={AppColors.primary} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<TouchableOpacity style={styles.actionButton} onPress={handleRefresh}>
|
||||
<Ionicons name="refresh" size={22} color={AppColors.primary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* WebView */}
|
||||
<View style={styles.webViewContainer}>
|
||||
<WebView
|
||||
ref={webViewRef}
|
||||
source={{ uri: webViewUrl }}
|
||||
style={styles.webView}
|
||||
onLoadStart={() => setIsLoading(true)}
|
||||
onLoadEnd={() => setIsLoading(false)}
|
||||
onError={handleError}
|
||||
onHttpError={handleError}
|
||||
onNavigationStateChange={handleNavigationStateChange}
|
||||
javaScriptEnabled={true}
|
||||
domStorageEnabled={true}
|
||||
startInLoadingState={true}
|
||||
scalesPageToFit={true}
|
||||
allowsBackForwardNavigationGestures={true}
|
||||
// Inject token into localStorage BEFORE content loads
|
||||
injectedJavaScriptBeforeContentLoaded={injectedJavaScript}
|
||||
// Also inject after load in case page reads localStorage late
|
||||
injectedJavaScript={injectedJavaScript}
|
||||
renderLoading={() => (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={AppColors.primary} />
|
||||
<Text style={styles.loadingText}>Loading dashboard...</Text>
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
|
||||
{isLoading && (
|
||||
<View style={styles.loadingOverlay}>
|
||||
<ActivityIndicator size="large" color={AppColors.primary} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Bottom Quick Actions */}
|
||||
<View style={styles.bottomBar}>
|
||||
<TouchableOpacity
|
||||
style={styles.quickAction}
|
||||
onPress={() => {
|
||||
if (currentBeneficiary) {
|
||||
setCurrentBeneficiary(currentBeneficiary);
|
||||
}
|
||||
router.push('/(tabs)/chat');
|
||||
}}
|
||||
>
|
||||
<Ionicons name="chatbubble-ellipses" size={24} color={AppColors.primary} />
|
||||
<Text style={styles.quickActionText}>Ask Julia</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.quickAction}
|
||||
onPress={() => router.push(`./`)}
|
||||
>
|
||||
<Ionicons name="person" size={24} color={AppColors.primary} />
|
||||
<Text style={styles.quickActionText}>Details</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: AppColors.background,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: Spacing.md,
|
||||
paddingVertical: Spacing.sm,
|
||||
backgroundColor: AppColors.background,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: AppColors.border,
|
||||
},
|
||||
backButton: {
|
||||
padding: Spacing.xs,
|
||||
},
|
||||
headerCenter: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginLeft: Spacing.sm,
|
||||
},
|
||||
avatarSmall: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: BorderRadius.full,
|
||||
backgroundColor: AppColors.primaryLight,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: Spacing.sm,
|
||||
},
|
||||
avatarText: {
|
||||
fontSize: FontSizes.base,
|
||||
fontWeight: '600',
|
||||
color: AppColors.white,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: FontSizes.lg,
|
||||
fontWeight: '700',
|
||||
color: AppColors.textPrimary,
|
||||
},
|
||||
headerSubtitle: {
|
||||
fontSize: FontSizes.xs,
|
||||
color: AppColors.textSecondary,
|
||||
},
|
||||
headerActions: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
actionButton: {
|
||||
padding: Spacing.xs,
|
||||
marginLeft: Spacing.xs,
|
||||
},
|
||||
placeholder: {
|
||||
width: 32,
|
||||
},
|
||||
webViewContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
webView: {
|
||||
flex: 1,
|
||||
},
|
||||
loadingContainer: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: AppColors.background,
|
||||
},
|
||||
loadingOverlay: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(255,255,255,0.8)',
|
||||
},
|
||||
loadingText: {
|
||||
marginTop: Spacing.md,
|
||||
fontSize: FontSizes.base,
|
||||
color: AppColors.textSecondary,
|
||||
},
|
||||
bottomBar: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-around',
|
||||
paddingVertical: Spacing.sm,
|
||||
paddingHorizontal: Spacing.lg,
|
||||
backgroundColor: AppColors.background,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: AppColors.border,
|
||||
},
|
||||
quickAction: {
|
||||
alignItems: 'center',
|
||||
padding: Spacing.sm,
|
||||
},
|
||||
quickActionText: {
|
||||
fontSize: FontSizes.xs,
|
||||
color: AppColors.primary,
|
||||
marginTop: Spacing.xs,
|
||||
},
|
||||
});
|
||||
386
app/(tabs)/beneficiaries/[id]/index.tsx
Normal file
@ -0,0 +1,386 @@
|
||||
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 { useBeneficiary } from '@/contexts/BeneficiaryContext';
|
||||
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 { Beneficiary } from '@/types';
|
||||
|
||||
export default function BeneficiaryDetailScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const { setCurrentBeneficiary } = useBeneficiary();
|
||||
const [beneficiary, setBeneficiary] = useState<Beneficiary | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadBeneficiary = useCallback(async (showLoading = true) => {
|
||||
if (!id) return;
|
||||
|
||||
if (showLoading) setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await api.getBeneficiary(parseInt(id, 10));
|
||||
|
||||
if (response.ok && response.data) {
|
||||
setBeneficiary(response.data);
|
||||
} else {
|
||||
setError(response.error?.message || 'Failed to load beneficiary');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
loadBeneficiary();
|
||||
}, [loadBeneficiary]);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
setIsRefreshing(true);
|
||||
loadBeneficiary(false);
|
||||
}, [loadBeneficiary]);
|
||||
|
||||
const handleChatPress = () => {
|
||||
// Set current beneficiary in context before navigating to chat
|
||||
// This allows the chat to include beneficiary context in AI questions
|
||||
if (beneficiary) {
|
||||
setCurrentBeneficiary(beneficiary);
|
||||
}
|
||||
router.push('/(tabs)/chat');
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingSpinner fullScreen message="Loading beneficiary data..." />;
|
||||
}
|
||||
|
||||
if (error || !beneficiary) {
|
||||
return (
|
||||
<FullScreenError
|
||||
message={error || 'Beneficiary not found'}
|
||||
onRetry={() => loadBeneficiary()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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}>{beneficiary.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}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{/* Beneficiary Info Card */}
|
||||
<View style={styles.infoCard}>
|
||||
<View style={styles.avatarContainer}>
|
||||
<Text style={styles.avatarText}>
|
||||
{beneficiary.name.charAt(0).toUpperCase()}
|
||||
</Text>
|
||||
<View
|
||||
style={[
|
||||
styles.statusBadge,
|
||||
beneficiary.status === 'online' ? styles.onlineBadge : styles.offlineBadge,
|
||||
]}
|
||||
>
|
||||
<Text style={styles.statusText}>
|
||||
{beneficiary.status === 'online' ? 'Online' : 'Offline'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text style={styles.beneficiaryName}>{beneficiary.name}</Text>
|
||||
<Text style={styles.relationship}>{beneficiary.relationship}</Text>
|
||||
<Text style={styles.lastSeen}>
|
||||
Last activity: {beneficiary.last_activity}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Sensor Stats */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Sensor Overview</Text>
|
||||
|
||||
<View style={styles.statsGrid}>
|
||||
<View style={styles.statCard}>
|
||||
<View style={[styles.statIcon, { backgroundColor: beneficiary.sensor_data?.motion_detected ? '#D1FAE5' : '#F3F4F6' }]}>
|
||||
<Ionicons
|
||||
name={beneficiary.sensor_data?.motion_detected ? "walk" : "walk-outline"}
|
||||
size={24}
|
||||
color={beneficiary.sensor_data?.motion_detected ? AppColors.success : AppColors.textMuted}
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.statValue}>
|
||||
{beneficiary.sensor_data?.motion_detected ? 'Active' : 'Inactive'}
|
||||
</Text>
|
||||
<Text style={styles.statLabel}>Motion</Text>
|
||||
<Text style={styles.statUnit}>{beneficiary.sensor_data?.last_motion || '--'}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.statCard}>
|
||||
<View style={[styles.statIcon, { backgroundColor: beneficiary.sensor_data?.door_status === 'open' ? '#FEF3C7' : '#DBEAFE' }]}>
|
||||
<Ionicons
|
||||
name={beneficiary.sensor_data?.door_status === 'open' ? "enter-outline" : "home-outline"}
|
||||
size={24}
|
||||
color={beneficiary.sensor_data?.door_status === 'open' ? AppColors.warning : AppColors.primary}
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.statValue}>
|
||||
{beneficiary.sensor_data?.door_status === 'open' ? 'Open' : 'Closed'}
|
||||
</Text>
|
||||
<Text style={styles.statLabel}>Door Status</Text>
|
||||
<Text style={styles.statUnit}>Main entrance</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.statCard}>
|
||||
<View style={[styles.statIcon, { backgroundColor: '#E0E7FF' }]}>
|
||||
<Ionicons name="thermometer-outline" size={24} color={AppColors.primaryDark} />
|
||||
</View>
|
||||
<Text style={styles.statValue}>
|
||||
{beneficiary.sensor_data?.temperature || '--'}°C
|
||||
</Text>
|
||||
<Text style={styles.statLabel}>Temperature</Text>
|
||||
<Text style={styles.statUnit}>{beneficiary.sensor_data?.humidity || '--'}% humidity</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="analytics" size={24} color="#9333EA" />
|
||||
</View>
|
||||
<Text style={styles.actionLabel}>Activity Report</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,
|
||||
},
|
||||
beneficiaryName: {
|
||||
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,
|
||||
},
|
||||
});
|
||||
16
app/(tabs)/beneficiaries/_layout.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import { Stack } from 'expo-router';
|
||||
import { AppColors } from '@/constants/theme';
|
||||
|
||||
export default function BeneficiariesLayout() {
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
contentStyle: { backgroundColor: AppColors.background },
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="[id]/index" />
|
||||
<Stack.Screen name="[id]/dashboard" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
672
app/(tabs)/chat.tsx
Normal file
@ -0,0 +1,672 @@
|
||||
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
FlatList,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
Alert,
|
||||
Modal,
|
||||
ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
import { api } from '@/services/api';
|
||||
import { useBeneficiary } from '@/contexts/BeneficiaryContext';
|
||||
import { AppColors, BorderRadius, FontSizes, Spacing } from '@/constants/theme';
|
||||
import type { Message, Beneficiary } from '@/types';
|
||||
|
||||
const API_URL = 'https://eluxnetworks.net/function/well-api/api';
|
||||
|
||||
export default function ChatScreen() {
|
||||
const { currentBeneficiary, setCurrentBeneficiary, getBeneficiaryContext } = useBeneficiary();
|
||||
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);
|
||||
|
||||
// Beneficiary picker state
|
||||
const [showBeneficiaryPicker, setShowBeneficiaryPicker] = useState(false);
|
||||
const [beneficiaries, setBeneficiaries] = useState<Beneficiary[]>([]);
|
||||
const [loadingBeneficiaries, setLoadingBeneficiaries] = useState(false);
|
||||
|
||||
// Load beneficiaries when picker opens
|
||||
const loadBeneficiaries = useCallback(async () => {
|
||||
setLoadingBeneficiaries(true);
|
||||
try {
|
||||
const response = await api.getAllBeneficiaries();
|
||||
if (response.ok && response.data) {
|
||||
setBeneficiaries(response.data);
|
||||
return response.data;
|
||||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('Failed to load beneficiaries:', error);
|
||||
return [];
|
||||
} finally {
|
||||
setLoadingBeneficiaries(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Auto-select first beneficiary on mount if none selected
|
||||
useEffect(() => {
|
||||
const autoSelectBeneficiary = async () => {
|
||||
if (!currentBeneficiary) {
|
||||
const loaded = await loadBeneficiaries();
|
||||
if (loaded.length > 0) {
|
||||
setCurrentBeneficiary(loaded[0]);
|
||||
console.log('Auto-selected first beneficiary:', loaded[0].name);
|
||||
}
|
||||
}
|
||||
};
|
||||
autoSelectBeneficiary();
|
||||
}, []);
|
||||
|
||||
const openBeneficiaryPicker = useCallback(() => {
|
||||
setShowBeneficiaryPicker(true);
|
||||
loadBeneficiaries();
|
||||
}, [loadBeneficiaries]);
|
||||
|
||||
const selectBeneficiary = useCallback((beneficiary: Beneficiary) => {
|
||||
setCurrentBeneficiary(beneficiary);
|
||||
setShowBeneficiaryPicker(false);
|
||||
}, [setCurrentBeneficiary]);
|
||||
|
||||
// Fetch activity data for context
|
||||
const getActivityContext = async (token: string, userName: string, deploymentId: string): Promise<string> => {
|
||||
try {
|
||||
const response = await fetch(API_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
function: 'activities_report_details',
|
||||
user_name: userName,
|
||||
token: token,
|
||||
deployment_id: deploymentId,
|
||||
filter: '0',
|
||||
}).toString(),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!data.chart_data || data.chart_data.length === 0) return '';
|
||||
|
||||
const weeklyData = data.chart_data.find((d: any) => d.name === 'Weekly');
|
||||
if (!weeklyData) return '';
|
||||
|
||||
const lines: string[] = [];
|
||||
if (data.alert_text) lines.push(`Alert status: ${data.alert_text}`);
|
||||
|
||||
const todayStats: string[] = [];
|
||||
for (const room of weeklyData.rooms) {
|
||||
const todayData = room.data[room.data.length - 1];
|
||||
if (todayData && todayData.hours > 0) {
|
||||
todayStats.push(`${room.name}: ${todayData.hours.toFixed(1)} hours (${todayData.events} events)`);
|
||||
}
|
||||
}
|
||||
if (todayStats.length > 0) lines.push(`Today's activity: ${todayStats.join(', ')}`);
|
||||
|
||||
const weeklyStats: string[] = [];
|
||||
for (const room of weeklyData.rooms) {
|
||||
const totalHours = room.data.reduce((sum: number, d: any) => sum + d.hours, 0);
|
||||
if (totalHours > 0) {
|
||||
weeklyStats.push(`${room.name}: ${totalHours.toFixed(1)} hours total this week`);
|
||||
}
|
||||
}
|
||||
if (weeklyStats.length > 0) lines.push(`Weekly summary: ${weeklyStats.join(', ')}`);
|
||||
|
||||
return lines.join('. ');
|
||||
} catch (error) {
|
||||
console.log('Failed to fetch activity context:', error);
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch dashboard data as fallback context
|
||||
const getDashboardContext = async (token: string, userName: string, deploymentId: string): Promise<string> => {
|
||||
try {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
const response = await fetch(API_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
function: 'dashboard_single',
|
||||
user_name: userName,
|
||||
token: token,
|
||||
deployment_id: deploymentId,
|
||||
date: today,
|
||||
}).toString(),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!data.result_list || data.result_list.length === 0) return '';
|
||||
|
||||
const info = data.result_list[0];
|
||||
const lines: string[] = [];
|
||||
|
||||
if (info.wellness_descriptor) lines.push(`Current wellness: ${info.wellness_descriptor}`);
|
||||
if (info.wellness_score_percent) lines.push(`Wellness score: ${info.wellness_score_percent}%`);
|
||||
if (info.last_location) lines.push(`Last seen in: ${info.last_location}`);
|
||||
if (info.last_detected_time) lines.push(`Last activity: ${info.last_detected_time}`);
|
||||
if (info.sleep_hours) lines.push(`Sleep hours: ${info.sleep_hours}`);
|
||||
if (info.temperature) lines.push(`Temperature: ${info.temperature}${info.units === 'F' ? '°F' : '°C'}`);
|
||||
|
||||
return lines.join('. ');
|
||||
} catch (error) {
|
||||
console.log('Failed to fetch dashboard context:', error);
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
// Send message with full context
|
||||
const sendWithContext = async (question: string): Promise<string> => {
|
||||
const token = await SecureStore.getItemAsync('accessToken');
|
||||
const userName = await SecureStore.getItemAsync('userName');
|
||||
|
||||
if (!token || !userName) throw new Error('Please log in');
|
||||
if (!currentBeneficiary?.id) throw new Error('Please select a beneficiary');
|
||||
|
||||
const beneficiaryName = currentBeneficiary.name || 'the patient';
|
||||
const deploymentId = currentBeneficiary.id.toString();
|
||||
|
||||
// Get activity context (primary source)
|
||||
let activityContext = await getActivityContext(token, userName, deploymentId);
|
||||
|
||||
// If activity context is empty, try dashboard context as fallback
|
||||
if (!activityContext) {
|
||||
activityContext = await getDashboardContext(token, userName, deploymentId);
|
||||
}
|
||||
|
||||
// Build the question with embedded context
|
||||
let enhancedQuestion: string;
|
||||
if (activityContext) {
|
||||
enhancedQuestion = `You are a caring assistant helping monitor ${beneficiaryName}'s wellbeing.
|
||||
|
||||
Here is the current data about ${beneficiaryName}:
|
||||
${activityContext}
|
||||
|
||||
Based on this data, please answer the following question: ${question}`;
|
||||
} else {
|
||||
enhancedQuestion = `You are a caring assistant helping monitor ${beneficiaryName}'s wellbeing. Please answer: ${question}`;
|
||||
}
|
||||
|
||||
// Call API
|
||||
const requestBody = new URLSearchParams({
|
||||
function: 'voice_ask',
|
||||
clientId: '001',
|
||||
user_name: userName,
|
||||
token: token,
|
||||
question: enhancedQuestion,
|
||||
deployment_id: deploymentId,
|
||||
context: activityContext || '',
|
||||
}).toString();
|
||||
|
||||
const response = await fetch(API_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: requestBody,
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.ok && data.response?.body) {
|
||||
return data.response.body;
|
||||
} else if (data.status === '401 Unauthorized') {
|
||||
throw new Error('Session expired. Please log in again.');
|
||||
} else {
|
||||
throw new Error('Could not get response');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
const trimmedInput = input.trim();
|
||||
if (!trimmedInput || isSending) return;
|
||||
|
||||
// Require beneficiary to be selected
|
||||
if (!currentBeneficiary?.id) {
|
||||
Alert.alert(
|
||||
'Select Beneficiary',
|
||||
'Please select a beneficiary using the icon in the top right corner.',
|
||||
[{ text: 'OK' }]
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const userMessage: Message = {
|
||||
id: Date.now().toString(),
|
||||
role: 'user',
|
||||
content: trimmedInput,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, userMessage]);
|
||||
setInput('');
|
||||
setIsSending(true);
|
||||
|
||||
try {
|
||||
const aiResponse = await sendWithContext(trimmedInput);
|
||||
|
||||
const assistantMessage: Message = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
role: 'assistant',
|
||||
content: aiResponse,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
setMessages((prev) => [...prev, assistantMessage]);
|
||||
} catch (error) {
|
||||
const errorMessage: Message = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
role: 'assistant',
|
||||
content: `Sorry, I encountered an error: ${error instanceof Error ? error.message : 'Unknown error'}. Please try again.`,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
setMessages((prev) => [...prev, errorMessage]);
|
||||
} finally {
|
||||
setIsSending(false);
|
||||
}
|
||||
}, [input, isSending, currentBeneficiary]);
|
||||
|
||||
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...'
|
||||
: currentBeneficiary
|
||||
? `About ${currentBeneficiary.name}`
|
||||
: 'Online'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<TouchableOpacity style={styles.headerButton} onPress={openBeneficiaryPicker}>
|
||||
<Ionicons name="people-outline" size={24} color={AppColors.primary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Beneficiary Picker Modal */}
|
||||
<Modal
|
||||
visible={showBeneficiaryPicker}
|
||||
transparent
|
||||
animationType="slide"
|
||||
onRequestClose={() => setShowBeneficiaryPicker(false)}
|
||||
>
|
||||
<View style={styles.modalOverlay}>
|
||||
<View style={styles.modalContent}>
|
||||
<View style={styles.modalHeader}>
|
||||
<Text style={styles.modalTitle}>Select Beneficiary</Text>
|
||||
<TouchableOpacity onPress={() => setShowBeneficiaryPicker(false)}>
|
||||
<Ionicons name="close" size={24} color={AppColors.textPrimary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{loadingBeneficiaries ? (
|
||||
<View style={styles.modalLoading}>
|
||||
<ActivityIndicator size="large" color={AppColors.primary} />
|
||||
<Text style={styles.loadingText}>Loading beneficiaries...</Text>
|
||||
</View>
|
||||
) : beneficiaries.length === 0 ? (
|
||||
<View style={styles.modalEmpty}>
|
||||
<Text style={styles.emptyText}>No beneficiaries found</Text>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={beneficiaries}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
renderItem={({ item }) => (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.beneficiaryItem,
|
||||
currentBeneficiary?.id === item.id && styles.beneficiaryItemSelected,
|
||||
]}
|
||||
onPress={() => selectBeneficiary(item)}
|
||||
>
|
||||
<View style={styles.beneficiaryAvatar}>
|
||||
<Text style={styles.beneficiaryAvatarText}>
|
||||
{item.name.split(' ').map(n => n[0]).join('').slice(0, 2)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.beneficiaryInfo}>
|
||||
<Text style={styles.beneficiaryName}>{item.name}</Text>
|
||||
{item.email && (
|
||||
<Text style={styles.beneficiaryEmail}>{item.email}</Text>
|
||||
)}
|
||||
</View>
|
||||
{currentBeneficiary?.id === item.id && (
|
||||
<Ionicons name="checkmark-circle" size={24} color={AppColors.success} />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
style={styles.beneficiaryList}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
|
||||
{/* 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,
|
||||
},
|
||||
// Modal styles
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
modalContent: {
|
||||
backgroundColor: AppColors.background,
|
||||
borderTopLeftRadius: BorderRadius.xl,
|
||||
borderTopRightRadius: BorderRadius.xl,
|
||||
maxHeight: '70%',
|
||||
paddingBottom: Spacing.xl,
|
||||
},
|
||||
modalHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: Spacing.md,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: AppColors.border,
|
||||
},
|
||||
modalTitle: {
|
||||
fontSize: FontSizes.lg,
|
||||
fontWeight: '600',
|
||||
color: AppColors.textPrimary,
|
||||
},
|
||||
modalLoading: {
|
||||
padding: Spacing.xl,
|
||||
alignItems: 'center',
|
||||
},
|
||||
loadingText: {
|
||||
marginTop: Spacing.md,
|
||||
fontSize: FontSizes.base,
|
||||
color: AppColors.textSecondary,
|
||||
},
|
||||
modalEmpty: {
|
||||
padding: Spacing.xl,
|
||||
alignItems: 'center',
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: FontSizes.base,
|
||||
color: AppColors.textSecondary,
|
||||
},
|
||||
beneficiaryList: {
|
||||
paddingHorizontal: Spacing.md,
|
||||
},
|
||||
beneficiaryItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: Spacing.md,
|
||||
backgroundColor: AppColors.surface,
|
||||
borderRadius: BorderRadius.md,
|
||||
marginTop: Spacing.sm,
|
||||
},
|
||||
beneficiaryItemSelected: {
|
||||
backgroundColor: AppColors.primaryLight || '#E3F2FD',
|
||||
borderWidth: 1,
|
||||
borderColor: AppColors.primary,
|
||||
},
|
||||
beneficiaryAvatar: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: BorderRadius.full,
|
||||
backgroundColor: AppColors.primary,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: Spacing.md,
|
||||
},
|
||||
beneficiaryAvatarText: {
|
||||
fontSize: FontSizes.base,
|
||||
fontWeight: '600',
|
||||
color: AppColors.white,
|
||||
},
|
||||
beneficiaryInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
beneficiaryName: {
|
||||
fontSize: FontSizes.base,
|
||||
fontWeight: '500',
|
||||
color: AppColors.textPrimary,
|
||||
},
|
||||
beneficiaryEmail: {
|
||||
fontSize: FontSizes.sm,
|
||||
color: AppColors.textSecondary,
|
||||
marginTop: 2,
|
||||
},
|
||||
});
|
||||
162
app/(tabs)/dashboard.tsx
Normal file
@ -0,0 +1,162 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { View, Text, StyleSheet, ActivityIndicator, TouchableOpacity } from 'react-native';
|
||||
import { WebView } from 'react-native-webview';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
import { AppColors, FontSizes, Spacing } from '@/constants/theme';
|
||||
import { FullScreenError } from '@/components/ui/ErrorMessage';
|
||||
|
||||
const DASHBOARD_URL = 'https://react.eluxnetworks.net/dashboard';
|
||||
|
||||
export default function DashboardScreen() {
|
||||
const webViewRef = useRef<WebView>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [canGoBack, setCanGoBack] = useState(false);
|
||||
|
||||
const handleRefresh = () => {
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
webViewRef.current?.reload();
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
if (canGoBack) {
|
||||
webViewRef.current?.goBack();
|
||||
}
|
||||
};
|
||||
|
||||
const handleNavigationStateChange = (navState: any) => {
|
||||
setCanGoBack(navState.canGoBack);
|
||||
};
|
||||
|
||||
const handleError = () => {
|
||||
setError('Failed to load dashboard. Please check your internet connection.');
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.headerTitle}>Dashboard</Text>
|
||||
</View>
|
||||
<FullScreenError message={error} onRetry={handleRefresh} />
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
{canGoBack && (
|
||||
<TouchableOpacity style={styles.backButton} onPress={handleBack}>
|
||||
<Ionicons name="arrow-back" size={24} color={AppColors.textPrimary} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<Text style={[styles.headerTitle, canGoBack && styles.headerTitleWithBack]}>
|
||||
Dashboard
|
||||
</Text>
|
||||
<TouchableOpacity style={styles.refreshButton} onPress={handleRefresh}>
|
||||
<Ionicons name="refresh" size={22} color={AppColors.primary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* WebView */}
|
||||
<View style={styles.webViewContainer}>
|
||||
<WebView
|
||||
ref={webViewRef}
|
||||
source={{ uri: DASHBOARD_URL }}
|
||||
style={styles.webView}
|
||||
onLoadStart={() => setIsLoading(true)}
|
||||
onLoadEnd={() => setIsLoading(false)}
|
||||
onError={handleError}
|
||||
onHttpError={handleError}
|
||||
onNavigationStateChange={handleNavigationStateChange}
|
||||
javaScriptEnabled={true}
|
||||
domStorageEnabled={true}
|
||||
startInLoadingState={true}
|
||||
scalesPageToFit={true}
|
||||
allowsBackForwardNavigationGestures={true}
|
||||
renderLoading={() => (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={AppColors.primary} />
|
||||
<Text style={styles.loadingText}>Loading dashboard...</Text>
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
|
||||
{isLoading && (
|
||||
<View style={styles.loadingOverlay}>
|
||||
<ActivityIndicator size="large" color={AppColors.primary} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: AppColors.background,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: Spacing.md,
|
||||
paddingVertical: Spacing.sm,
|
||||
backgroundColor: AppColors.background,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: AppColors.border,
|
||||
},
|
||||
backButton: {
|
||||
padding: Spacing.xs,
|
||||
marginRight: Spacing.sm,
|
||||
},
|
||||
headerTitle: {
|
||||
flex: 1,
|
||||
fontSize: FontSizes.xl,
|
||||
fontWeight: '700',
|
||||
color: AppColors.textPrimary,
|
||||
},
|
||||
headerTitleWithBack: {
|
||||
marginLeft: 0,
|
||||
},
|
||||
refreshButton: {
|
||||
padding: Spacing.xs,
|
||||
},
|
||||
webViewContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
webView: {
|
||||
flex: 1,
|
||||
},
|
||||
loadingContainer: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: AppColors.background,
|
||||
},
|
||||
loadingOverlay: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(255,255,255,0.8)',
|
||||
},
|
||||
loadingText: {
|
||||
marginTop: Spacing.md,
|
||||
fontSize: FontSizes.base,
|
||||
color: AppColors.textSecondary,
|
||||
},
|
||||
});
|
||||
112
app/(tabs)/explore.tsx
Normal file
@ -0,0 +1,112 @@
|
||||
import { Image } from 'expo-image';
|
||||
import { Platform, StyleSheet } from 'react-native';
|
||||
|
||||
import { Collapsible } from '@/components/ui/collapsible';
|
||||
import { ExternalLink } from '@/components/external-link';
|
||||
import ParallaxScrollView from '@/components/parallax-scroll-view';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { IconSymbol } from '@/components/ui/icon-symbol';
|
||||
import { Fonts } from '@/constants/theme';
|
||||
|
||||
export default function TabTwoScreen() {
|
||||
return (
|
||||
<ParallaxScrollView
|
||||
headerBackgroundColor={{ light: '#D0D0D0', dark: '#353636' }}
|
||||
headerImage={
|
||||
<IconSymbol
|
||||
size={310}
|
||||
color="#808080"
|
||||
name="chevron.left.forwardslash.chevron.right"
|
||||
style={styles.headerImage}
|
||||
/>
|
||||
}>
|
||||
<ThemedView style={styles.titleContainer}>
|
||||
<ThemedText
|
||||
type="title"
|
||||
style={{
|
||||
fontFamily: Fonts.rounded,
|
||||
}}>
|
||||
Explore
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
<ThemedText>This app includes example code to help you get started.</ThemedText>
|
||||
<Collapsible title="File-based routing">
|
||||
<ThemedText>
|
||||
This app has two screens:{' '}
|
||||
<ThemedText type="defaultSemiBold">app/(tabs)/index.tsx</ThemedText> and{' '}
|
||||
<ThemedText type="defaultSemiBold">app/(tabs)/explore.tsx</ThemedText>
|
||||
</ThemedText>
|
||||
<ThemedText>
|
||||
The layout file in <ThemedText type="defaultSemiBold">app/(tabs)/_layout.tsx</ThemedText>{' '}
|
||||
sets up the tab navigator.
|
||||
</ThemedText>
|
||||
<ExternalLink href="https://docs.expo.dev/router/introduction">
|
||||
<ThemedText type="link">Learn more</ThemedText>
|
||||
</ExternalLink>
|
||||
</Collapsible>
|
||||
<Collapsible title="Android, iOS, and web support">
|
||||
<ThemedText>
|
||||
You can open this project on Android, iOS, and the web. To open the web version, press{' '}
|
||||
<ThemedText type="defaultSemiBold">w</ThemedText> in the terminal running this project.
|
||||
</ThemedText>
|
||||
</Collapsible>
|
||||
<Collapsible title="Images">
|
||||
<ThemedText>
|
||||
For static images, you can use the <ThemedText type="defaultSemiBold">@2x</ThemedText> and{' '}
|
||||
<ThemedText type="defaultSemiBold">@3x</ThemedText> suffixes to provide files for
|
||||
different screen densities
|
||||
</ThemedText>
|
||||
<Image
|
||||
source={require('@/assets/images/react-logo.png')}
|
||||
style={{ width: 100, height: 100, alignSelf: 'center' }}
|
||||
/>
|
||||
<ExternalLink href="https://reactnative.dev/docs/images">
|
||||
<ThemedText type="link">Learn more</ThemedText>
|
||||
</ExternalLink>
|
||||
</Collapsible>
|
||||
<Collapsible title="Light and dark mode components">
|
||||
<ThemedText>
|
||||
This template has light and dark mode support. The{' '}
|
||||
<ThemedText type="defaultSemiBold">useColorScheme()</ThemedText> hook lets you inspect
|
||||
what the user's current color scheme is, and so you can adjust UI colors accordingly.
|
||||
</ThemedText>
|
||||
<ExternalLink href="https://docs.expo.dev/develop/user-interface/color-themes/">
|
||||
<ThemedText type="link">Learn more</ThemedText>
|
||||
</ExternalLink>
|
||||
</Collapsible>
|
||||
<Collapsible title="Animations">
|
||||
<ThemedText>
|
||||
This template includes an example of an animated component. The{' '}
|
||||
<ThemedText type="defaultSemiBold">components/HelloWave.tsx</ThemedText> component uses
|
||||
the powerful{' '}
|
||||
<ThemedText type="defaultSemiBold" style={{ fontFamily: Fonts.mono }}>
|
||||
react-native-reanimated
|
||||
</ThemedText>{' '}
|
||||
library to create a waving hand animation.
|
||||
</ThemedText>
|
||||
{Platform.select({
|
||||
ios: (
|
||||
<ThemedText>
|
||||
The <ThemedText type="defaultSemiBold">components/ParallaxScrollView.tsx</ThemedText>{' '}
|
||||
component provides a parallax effect for the header image.
|
||||
</ThemedText>
|
||||
),
|
||||
})}
|
||||
</Collapsible>
|
||||
</ParallaxScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
headerImage: {
|
||||
color: '#808080',
|
||||
bottom: -90,
|
||||
left: -35,
|
||||
position: 'absolute',
|
||||
},
|
||||
titleContainer: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
},
|
||||
});
|
||||
279
app/(tabs)/index.tsx
Normal file
@ -0,0 +1,279 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { View, Text, StyleSheet, ActivityIndicator, TouchableOpacity } from 'react-native';
|
||||
import { WebView } from 'react-native-webview';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { AppColors, FontSizes, Spacing } from '@/constants/theme';
|
||||
|
||||
const DASHBOARD_URL = 'https://react.eluxnetworks.net/dashboard';
|
||||
|
||||
export default function HomeScreen() {
|
||||
const { user } = useAuth();
|
||||
const webViewRef = useRef<WebView>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [canGoBack, setCanGoBack] = useState(false);
|
||||
const [authToken, setAuthToken] = useState<string | null>(null);
|
||||
const [userName, setUserName] = useState<string | null>(null);
|
||||
const [userId, setUserId] = useState<string | null>(null);
|
||||
const [isTokenLoaded, setIsTokenLoaded] = useState(false);
|
||||
|
||||
// Load credentials from SecureStore
|
||||
useEffect(() => {
|
||||
const loadCredentials = async () => {
|
||||
try {
|
||||
const token = await SecureStore.getItemAsync('accessToken');
|
||||
const user = await SecureStore.getItemAsync('userName');
|
||||
const uid = await SecureStore.getItemAsync('userId');
|
||||
setAuthToken(token);
|
||||
setUserName(user);
|
||||
setUserId(uid);
|
||||
console.log('Home: Loaded credentials for WebView:', { hasToken: !!token, user, uid });
|
||||
} catch (err) {
|
||||
console.error('Failed to load credentials:', err);
|
||||
} finally {
|
||||
setIsTokenLoaded(true);
|
||||
}
|
||||
};
|
||||
loadCredentials();
|
||||
}, []);
|
||||
|
||||
// JavaScript to inject auth token into localStorage
|
||||
// Web app expects auth2 as JSON: {username, token, user_id}
|
||||
const injectedJavaScript = authToken
|
||||
? `
|
||||
(function() {
|
||||
try {
|
||||
var authData = {
|
||||
username: '${userName || ''}',
|
||||
token: '${authToken}',
|
||||
user_id: ${userId || 'null'}
|
||||
};
|
||||
localStorage.setItem('auth2', JSON.stringify(authData));
|
||||
console.log('Auth injected:', authData.username);
|
||||
} catch(e) {
|
||||
console.error('Failed to inject token:', e);
|
||||
}
|
||||
})();
|
||||
true;
|
||||
`
|
||||
: '';
|
||||
|
||||
const handleRefresh = () => {
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
webViewRef.current?.reload();
|
||||
};
|
||||
|
||||
const handleWebViewBack = () => {
|
||||
if (canGoBack) {
|
||||
webViewRef.current?.goBack();
|
||||
}
|
||||
};
|
||||
|
||||
const handleNavigationStateChange = (navState: any) => {
|
||||
setCanGoBack(navState.canGoBack);
|
||||
};
|
||||
|
||||
const handleError = () => {
|
||||
setError('Failed to load dashboard. Please check your internet connection.');
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
// Wait for token to load
|
||||
if (!isTokenLoaded) {
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
<View style={styles.header}>
|
||||
<View>
|
||||
<Text style={styles.greeting}>Hello, {user?.user_name || 'User'}</Text>
|
||||
<Text style={styles.headerTitle}>Dashboard</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={AppColors.primary} />
|
||||
<Text style={styles.loadingText}>Preparing dashboard...</Text>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
<View style={styles.header}>
|
||||
<View>
|
||||
<Text style={styles.greeting}>Hello, {user?.user_name || 'User'}</Text>
|
||||
<Text style={styles.headerTitle}>Dashboard</Text>
|
||||
</View>
|
||||
<TouchableOpacity style={styles.refreshButton} onPress={handleRefresh}>
|
||||
<Ionicons name="refresh" size={22} color={AppColors.primary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View style={styles.errorContainer}>
|
||||
<Ionicons name="cloud-offline-outline" size={64} color={AppColors.textMuted} />
|
||||
<Text style={styles.errorTitle}>Connection Error</Text>
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
<TouchableOpacity style={styles.retryButton} onPress={handleRefresh}>
|
||||
<Text style={styles.retryButtonText}>Try Again</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<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}>Dashboard</Text>
|
||||
</View>
|
||||
<View style={styles.headerActions}>
|
||||
{canGoBack && (
|
||||
<TouchableOpacity style={styles.actionButton} onPress={handleWebViewBack}>
|
||||
<Ionicons name="chevron-back" size={22} color={AppColors.primary} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<TouchableOpacity style={styles.actionButton} onPress={handleRefresh}>
|
||||
<Ionicons name="refresh" size={22} color={AppColors.primary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* WebView Dashboard */}
|
||||
<View style={styles.webViewContainer}>
|
||||
<WebView
|
||||
ref={webViewRef}
|
||||
source={{ uri: DASHBOARD_URL }}
|
||||
style={styles.webView}
|
||||
onLoadStart={() => setIsLoading(true)}
|
||||
onLoadEnd={() => setIsLoading(false)}
|
||||
onError={handleError}
|
||||
onHttpError={handleError}
|
||||
onNavigationStateChange={handleNavigationStateChange}
|
||||
javaScriptEnabled={true}
|
||||
domStorageEnabled={true}
|
||||
startInLoadingState={true}
|
||||
scalesPageToFit={true}
|
||||
allowsBackForwardNavigationGestures={true}
|
||||
injectedJavaScriptBeforeContentLoaded={injectedJavaScript}
|
||||
injectedJavaScript={injectedJavaScript}
|
||||
renderLoading={() => (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={AppColors.primary} />
|
||||
<Text style={styles.loadingText}>Loading dashboard...</Text>
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
|
||||
{isLoading && (
|
||||
<View style={styles.loadingOverlay}>
|
||||
<ActivityIndicator size="large" color={AppColors.primary} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: AppColors.background,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
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,
|
||||
},
|
||||
headerActions: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
actionButton: {
|
||||
padding: Spacing.xs,
|
||||
marginLeft: Spacing.xs,
|
||||
},
|
||||
refreshButton: {
|
||||
padding: Spacing.xs,
|
||||
},
|
||||
webViewContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
webView: {
|
||||
flex: 1,
|
||||
},
|
||||
loadingContainer: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: AppColors.background,
|
||||
},
|
||||
loadingOverlay: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(255,255,255,0.8)',
|
||||
},
|
||||
loadingText: {
|
||||
marginTop: Spacing.md,
|
||||
fontSize: FontSizes.base,
|
||||
color: AppColors.textSecondary,
|
||||
},
|
||||
errorContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: Spacing.xl,
|
||||
},
|
||||
errorTitle: {
|
||||
fontSize: FontSizes.lg,
|
||||
fontWeight: '600',
|
||||
color: AppColors.textPrimary,
|
||||
marginTop: Spacing.md,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: FontSizes.base,
|
||||
color: AppColors.textSecondary,
|
||||
textAlign: 'center',
|
||||
marginTop: Spacing.xs,
|
||||
},
|
||||
retryButton: {
|
||||
marginTop: Spacing.lg,
|
||||
paddingHorizontal: Spacing.xl,
|
||||
paddingVertical: Spacing.md,
|
||||
backgroundColor: AppColors.primary,
|
||||
borderRadius: 8,
|
||||
},
|
||||
retryButtonText: {
|
||||
color: AppColors.white,
|
||||
fontSize: FontSizes.base,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
253
app/(tabs)/profile.tsx
Normal file
@ -0,0 +1,253 @@
|
||||
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>
|
||||
</View>
|
||||
|
||||
{/* Legal - Required for App Store */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Legal</Text>
|
||||
<View style={styles.menuCard}>
|
||||
<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,
|
||||
},
|
||||
});
|
||||
62
app/_layout.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
import { useEffect } from 'react';
|
||||
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
|
||||
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 { BeneficiaryProvider } from '@/contexts/BeneficiaryContext';
|
||||
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
||||
|
||||
// Prevent auto-hiding splash screen
|
||||
SplashScreen.preventAutoHideAsync();
|
||||
|
||||
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 screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="(auth)" />
|
||||
<Stack.Screen name="(tabs)" />
|
||||
<Stack.Screen name="modal" options={{ presentation: 'modal', title: 'Modal' }} />
|
||||
</Stack>
|
||||
<StatusBar style="auto" />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RootLayout() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<BeneficiaryProvider>
|
||||
<RootLayoutNav />
|
||||
</BeneficiaryProvider>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
29
app/modal.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import { Link } from 'expo-router';
|
||||
import { StyleSheet } from 'react-native';
|
||||
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
|
||||
export default function ModalScreen() {
|
||||
return (
|
||||
<ThemedView style={styles.container}>
|
||||
<ThemedText type="title">This is a modal</ThemedText>
|
||||
<Link href="/" dismissTo style={styles.link}>
|
||||
<ThemedText type="link">Go to home screen</ThemedText>
|
||||
</Link>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
link: {
|
||||
marginTop: 15,
|
||||
paddingVertical: 15,
|
||||
},
|
||||
});
|
||||
BIN
assets/images/android-icon-background.png
Normal file
|
After Width: | Height: | Size: 6.4 KiB |
BIN
assets/images/android-icon-foreground.png
Normal file
|
After Width: | Height: | Size: 135 KiB |
BIN
assets/images/android-icon-monochrome.png
Normal file
|
After Width: | Height: | Size: 114 KiB |
BIN
assets/images/favicon.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
assets/images/icon.png
Normal file
|
After Width: | Height: | Size: 131 KiB |
BIN
assets/images/partial-react-logo.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
assets/images/react-logo.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
assets/images/react-logo@2x.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
assets/images/react-logo@3x.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
assets/images/splash-icon.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
assets/logo.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
25
components/external-link.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import { Href, Link } from 'expo-router';
|
||||
import { openBrowserAsync, WebBrowserPresentationStyle } from 'expo-web-browser';
|
||||
import { type ComponentProps } from 'react';
|
||||
|
||||
type Props = Omit<ComponentProps<typeof Link>, 'href'> & { href: Href & string };
|
||||
|
||||
export function ExternalLink({ href, ...rest }: Props) {
|
||||
return (
|
||||
<Link
|
||||
target="_blank"
|
||||
{...rest}
|
||||
href={href}
|
||||
onPress={async (event) => {
|
||||
if (process.env.EXPO_OS !== 'web') {
|
||||
// Prevent the default behavior of linking to the default browser on native.
|
||||
event.preventDefault();
|
||||
// Open the link in an in-app browser.
|
||||
await openBrowserAsync(href, {
|
||||
presentationStyle: WebBrowserPresentationStyle.AUTOMATIC,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
18
components/haptic-tab.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import { BottomTabBarButtonProps } from '@react-navigation/bottom-tabs';
|
||||
import { PlatformPressable } from '@react-navigation/elements';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
|
||||
export function HapticTab(props: BottomTabBarButtonProps) {
|
||||
return (
|
||||
<PlatformPressable
|
||||
{...props}
|
||||
onPressIn={(ev) => {
|
||||
if (process.env.EXPO_OS === 'ios') {
|
||||
// Add a soft haptic feedback when pressing down on the tabs.
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
}
|
||||
props.onPressIn?.(ev);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
19
components/hello-wave.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import Animated from 'react-native-reanimated';
|
||||
|
||||
export function HelloWave() {
|
||||
return (
|
||||
<Animated.Text
|
||||
style={{
|
||||
fontSize: 28,
|
||||
lineHeight: 32,
|
||||
marginTop: -6,
|
||||
animationName: {
|
||||
'50%': { transform: [{ rotate: '25deg' }] },
|
||||
},
|
||||
animationIterationCount: 4,
|
||||
animationDuration: '300ms',
|
||||
}}>
|
||||
👋
|
||||
</Animated.Text>
|
||||
);
|
||||
}
|
||||
79
components/parallax-scroll-view.tsx
Normal file
@ -0,0 +1,79 @@
|
||||
import type { PropsWithChildren, ReactElement } from 'react';
|
||||
import { StyleSheet } from 'react-native';
|
||||
import Animated, {
|
||||
interpolate,
|
||||
useAnimatedRef,
|
||||
useAnimatedStyle,
|
||||
useScrollOffset,
|
||||
} from 'react-native-reanimated';
|
||||
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
import { useThemeColor } from '@/hooks/use-theme-color';
|
||||
|
||||
const HEADER_HEIGHT = 250;
|
||||
|
||||
type Props = PropsWithChildren<{
|
||||
headerImage: ReactElement;
|
||||
headerBackgroundColor: { dark: string; light: string };
|
||||
}>;
|
||||
|
||||
export default function ParallaxScrollView({
|
||||
children,
|
||||
headerImage,
|
||||
headerBackgroundColor,
|
||||
}: Props) {
|
||||
const backgroundColor = useThemeColor({}, 'background');
|
||||
const colorScheme = useColorScheme() ?? 'light';
|
||||
const scrollRef = useAnimatedRef<Animated.ScrollView>();
|
||||
const scrollOffset = useScrollOffset(scrollRef);
|
||||
const headerAnimatedStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
transform: [
|
||||
{
|
||||
translateY: interpolate(
|
||||
scrollOffset.value,
|
||||
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
|
||||
[-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75]
|
||||
),
|
||||
},
|
||||
{
|
||||
scale: interpolate(scrollOffset.value, [-HEADER_HEIGHT, 0, HEADER_HEIGHT], [2, 1, 1]),
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<Animated.ScrollView
|
||||
ref={scrollRef}
|
||||
style={{ backgroundColor, flex: 1 }}
|
||||
scrollEventThrottle={16}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.header,
|
||||
{ backgroundColor: headerBackgroundColor[colorScheme] },
|
||||
headerAnimatedStyle,
|
||||
]}>
|
||||
{headerImage}
|
||||
</Animated.View>
|
||||
<ThemedView style={styles.content}>{children}</ThemedView>
|
||||
</Animated.ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
height: HEADER_HEIGHT,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: 32,
|
||||
gap: 16,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
});
|
||||
60
components/themed-text.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
import { StyleSheet, Text, type TextProps } from 'react-native';
|
||||
|
||||
import { useThemeColor } from '@/hooks/use-theme-color';
|
||||
|
||||
export type ThemedTextProps = TextProps & {
|
||||
lightColor?: string;
|
||||
darkColor?: string;
|
||||
type?: 'default' | 'title' | 'defaultSemiBold' | 'subtitle' | 'link';
|
||||
};
|
||||
|
||||
export function ThemedText({
|
||||
style,
|
||||
lightColor,
|
||||
darkColor,
|
||||
type = 'default',
|
||||
...rest
|
||||
}: ThemedTextProps) {
|
||||
const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text');
|
||||
|
||||
return (
|
||||
<Text
|
||||
style={[
|
||||
{ color },
|
||||
type === 'default' ? styles.default : undefined,
|
||||
type === 'title' ? styles.title : undefined,
|
||||
type === 'defaultSemiBold' ? styles.defaultSemiBold : undefined,
|
||||
type === 'subtitle' ? styles.subtitle : undefined,
|
||||
type === 'link' ? styles.link : undefined,
|
||||
style,
|
||||
]}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
default: {
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
},
|
||||
defaultSemiBold: {
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
fontWeight: '600',
|
||||
},
|
||||
title: {
|
||||
fontSize: 32,
|
||||
fontWeight: 'bold',
|
||||
lineHeight: 32,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
link: {
|
||||
lineHeight: 30,
|
||||
fontSize: 16,
|
||||
color: '#0a7ea4',
|
||||
},
|
||||
});
|
||||
14
components/themed-view.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
import { View, type ViewProps } from 'react-native';
|
||||
|
||||
import { useThemeColor } from '@/hooks/use-theme-color';
|
||||
|
||||
export type ThemedViewProps = ViewProps & {
|
||||
lightColor?: string;
|
||||
darkColor?: string;
|
||||
};
|
||||
|
||||
export function ThemedView({ style, lightColor, darkColor, ...otherProps }: ThemedViewProps) {
|
||||
const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background');
|
||||
|
||||
return <View style={[{ backgroundColor }, style]} {...otherProps} />;
|
||||
}
|
||||
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
@ -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
@ -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
@ -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',
|
||||
},
|
||||
});
|
||||
45
components/ui/collapsible.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import { PropsWithChildren, useState } from 'react';
|
||||
import { StyleSheet, TouchableOpacity } from 'react-native';
|
||||
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { IconSymbol } from '@/components/ui/icon-symbol';
|
||||
import { Colors } from '@/constants/theme';
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
|
||||
export function Collapsible({ children, title }: PropsWithChildren & { title: string }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const theme = useColorScheme() ?? 'light';
|
||||
|
||||
return (
|
||||
<ThemedView>
|
||||
<TouchableOpacity
|
||||
style={styles.heading}
|
||||
onPress={() => setIsOpen((value) => !value)}
|
||||
activeOpacity={0.8}>
|
||||
<IconSymbol
|
||||
name="chevron.right"
|
||||
size={18}
|
||||
weight="medium"
|
||||
color={theme === 'light' ? Colors.light.icon : Colors.dark.icon}
|
||||
style={{ transform: [{ rotate: isOpen ? '90deg' : '0deg' }] }}
|
||||
/>
|
||||
|
||||
<ThemedText type="defaultSemiBold">{title}</ThemedText>
|
||||
</TouchableOpacity>
|
||||
{isOpen && <ThemedView style={styles.content}>{children}</ThemedView>}
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
heading: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
},
|
||||
content: {
|
||||
marginTop: 6,
|
||||
marginLeft: 24,
|
||||
},
|
||||
});
|
||||
32
components/ui/icon-symbol.ios.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import { SymbolView, SymbolViewProps, SymbolWeight } from 'expo-symbols';
|
||||
import { StyleProp, ViewStyle } from 'react-native';
|
||||
|
||||
export function IconSymbol({
|
||||
name,
|
||||
size = 24,
|
||||
color,
|
||||
style,
|
||||
weight = 'regular',
|
||||
}: {
|
||||
name: SymbolViewProps['name'];
|
||||
size?: number;
|
||||
color: string;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
weight?: SymbolWeight;
|
||||
}) {
|
||||
return (
|
||||
<SymbolView
|
||||
weight={weight}
|
||||
tintColor={color}
|
||||
resizeMode="scaleAspectFit"
|
||||
name={name}
|
||||
style={[
|
||||
{
|
||||
width: size,
|
||||
height: size,
|
||||
},
|
||||
style,
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
41
components/ui/icon-symbol.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
// Fallback for using MaterialIcons on Android and web.
|
||||
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import { SymbolWeight, SymbolViewProps } from 'expo-symbols';
|
||||
import { ComponentProps } from 'react';
|
||||
import { OpaqueColorValue, type StyleProp, type TextStyle } from 'react-native';
|
||||
|
||||
type IconMapping = Record<SymbolViewProps['name'], ComponentProps<typeof MaterialIcons>['name']>;
|
||||
type IconSymbolName = keyof typeof MAPPING;
|
||||
|
||||
/**
|
||||
* Add your SF Symbols to Material Icons mappings here.
|
||||
* - see Material Icons in the [Icons Directory](https://icons.expo.fyi).
|
||||
* - see SF Symbols in the [SF Symbols](https://developer.apple.com/sf-symbols/) app.
|
||||
*/
|
||||
const MAPPING = {
|
||||
'house.fill': 'home',
|
||||
'paperplane.fill': 'send',
|
||||
'chevron.left.forwardslash.chevron.right': 'code',
|
||||
'chevron.right': 'chevron-right',
|
||||
} as IconMapping;
|
||||
|
||||
/**
|
||||
* An icon component that uses native SF Symbols on iOS, and Material Icons on Android and web.
|
||||
* This ensures a consistent look across platforms, and optimal resource usage.
|
||||
* Icon `name`s are based on SF Symbols and require manual mapping to Material Icons.
|
||||
*/
|
||||
export function IconSymbol({
|
||||
name,
|
||||
size = 24,
|
||||
color,
|
||||
style,
|
||||
}: {
|
||||
name: IconSymbolName;
|
||||
size?: number;
|
||||
color: string | OpaqueColorValue;
|
||||
style?: StyleProp<TextStyle>;
|
||||
weight?: SymbolWeight;
|
||||
}) {
|
||||
return <MaterialIcons color={color} size={size} name={MAPPING[name]} style={style} />;
|
||||
}
|
||||
122
constants/theme.ts
Normal file
@ -0,0 +1,122 @@
|
||||
/**
|
||||
* WellNuo Theme Configuration
|
||||
* Colors and typography based on design system
|
||||
*/
|
||||
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
// 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: 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',
|
||||
background: '#151718',
|
||||
tint: tintColorDark,
|
||||
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: {
|
||||
sans: 'system-ui',
|
||||
serif: 'ui-serif',
|
||||
rounded: 'ui-rounded',
|
||||
mono: 'ui-monospace',
|
||||
},
|
||||
default: {
|
||||
sans: 'normal',
|
||||
serif: 'serif',
|
||||
rounded: 'normal',
|
||||
mono: 'monospace',
|
||||
},
|
||||
web: {
|
||||
sans: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
|
||||
serif: "Georgia, 'Times New Roman', serif",
|
||||
rounded: "'SF Pro Rounded', 'Hiragino Maru Gothic ProN', Meiryo, 'MS PGothic', sans-serif",
|
||||
mono: "SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
|
||||
},
|
||||
});
|
||||
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;
|
||||
}
|
||||
86
contexts/BeneficiaryContext.tsx
Normal file
@ -0,0 +1,86 @@
|
||||
import React, { createContext, useContext, useState, useCallback } from 'react';
|
||||
import type { Beneficiary } from '@/types';
|
||||
|
||||
interface BeneficiaryContextType {
|
||||
currentBeneficiary: Beneficiary | null;
|
||||
setCurrentBeneficiary: (beneficiary: Beneficiary | null) => void;
|
||||
clearCurrentBeneficiary: () => void;
|
||||
// Helper to format beneficiary context for AI
|
||||
getBeneficiaryContext: () => string;
|
||||
}
|
||||
|
||||
const BeneficiaryContext = createContext<BeneficiaryContextType | undefined>(undefined);
|
||||
|
||||
export function BeneficiaryProvider({ children }: { children: React.ReactNode }) {
|
||||
const [currentBeneficiary, setCurrentBeneficiary] = useState<Beneficiary | null>(null);
|
||||
|
||||
const clearCurrentBeneficiary = useCallback(() => {
|
||||
setCurrentBeneficiary(null);
|
||||
}, []);
|
||||
|
||||
const getBeneficiaryContext = useCallback(() => {
|
||||
if (!currentBeneficiary) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const parts = [`[Context: Asking about ${currentBeneficiary.name}`];
|
||||
|
||||
if (currentBeneficiary.relationship) {
|
||||
parts.push(`(${currentBeneficiary.relationship})`);
|
||||
}
|
||||
|
||||
if (currentBeneficiary.sensor_data) {
|
||||
const sensor = currentBeneficiary.sensor_data;
|
||||
const sensorInfo: string[] = [];
|
||||
|
||||
if (sensor.motion_detected !== undefined) {
|
||||
sensorInfo.push(`motion: ${sensor.motion_detected ? 'active' : 'inactive'}`);
|
||||
}
|
||||
if (sensor.last_motion) {
|
||||
sensorInfo.push(`last motion: ${sensor.last_motion}`);
|
||||
}
|
||||
if (sensor.door_status) {
|
||||
sensorInfo.push(`door: ${sensor.door_status}`);
|
||||
}
|
||||
if (sensor.temperature !== undefined) {
|
||||
sensorInfo.push(`temp: ${sensor.temperature}°C`);
|
||||
}
|
||||
if (sensor.humidity !== undefined) {
|
||||
sensorInfo.push(`humidity: ${sensor.humidity}%`);
|
||||
}
|
||||
|
||||
if (sensorInfo.length > 0) {
|
||||
parts.push(`| Sensors: ${sensorInfo.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (currentBeneficiary.last_activity) {
|
||||
parts.push(`| Last activity: ${currentBeneficiary.last_activity}`);
|
||||
}
|
||||
|
||||
parts.push(']');
|
||||
|
||||
return parts.join(' ');
|
||||
}, [currentBeneficiary]);
|
||||
|
||||
return (
|
||||
<BeneficiaryContext.Provider
|
||||
value={{
|
||||
currentBeneficiary,
|
||||
setCurrentBeneficiary,
|
||||
clearCurrentBeneficiary,
|
||||
getBeneficiaryContext,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</BeneficiaryContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useBeneficiary() {
|
||||
const context = useContext(BeneficiaryContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useBeneficiary must be used within a BeneficiaryProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
29
eas.json
Normal file
@ -0,0 +1,29 @@
|
||||
{
|
||||
"cli": {
|
||||
"version": ">= 5.0.0",
|
||||
"appVersionSource": "remote"
|
||||
},
|
||||
"build": {
|
||||
"development": {
|
||||
"developmentClient": true,
|
||||
"distribution": "internal"
|
||||
},
|
||||
"preview": {
|
||||
"distribution": "internal"
|
||||
},
|
||||
"production": {
|
||||
"autoIncrement": true
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
"production": {
|
||||
"ios": {
|
||||
"ascAppId": "6756594148",
|
||||
"appleTeamId": "UHLZD54ULZ",
|
||||
"ascApiKeyIssuerId": "dcac5647-0710-4764-affd-2d3270bf49d4",
|
||||
"ascApiKeyId": "GA9C2GRPHS",
|
||||
"ascApiKeyPath": "./AuthKey_GA9C2GRPHS.p8"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
10
eslint.config.js
Normal file
@ -0,0 +1,10 @@
|
||||
// https://docs.expo.dev/guides/using-eslint/
|
||||
const { defineConfig } = require('eslint/config');
|
||||
const expoConfig = require('eslint-config-expo/flat');
|
||||
|
||||
module.exports = defineConfig([
|
||||
expoConfig,
|
||||
{
|
||||
ignores: ['dist/*'],
|
||||
},
|
||||
]);
|
||||
1
hooks/use-color-scheme.ts
Normal file
@ -0,0 +1 @@
|
||||
export { useColorScheme } from 'react-native';
|
||||
21
hooks/use-color-scheme.web.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useColorScheme as useRNColorScheme } from 'react-native';
|
||||
|
||||
/**
|
||||
* To support static rendering, this value needs to be re-calculated on the client side for web
|
||||
*/
|
||||
export function useColorScheme() {
|
||||
const [hasHydrated, setHasHydrated] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setHasHydrated(true);
|
||||
}, []);
|
||||
|
||||
const colorScheme = useRNColorScheme();
|
||||
|
||||
if (hasHydrated) {
|
||||
return colorScheme;
|
||||
}
|
||||
|
||||
return 'light';
|
||||
}
|
||||
21
hooks/use-theme-color.ts
Normal file
@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Learn more about light and dark modes:
|
||||
* https://docs.expo.dev/guides/color-schemes/
|
||||
*/
|
||||
|
||||
import { Colors } from '@/constants/theme';
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
|
||||
export function useThemeColor(
|
||||
props: { light?: string; dark?: string },
|
||||
colorName: keyof typeof Colors.light & keyof typeof Colors.dark
|
||||
) {
|
||||
const theme = useColorScheme() ?? 'light';
|
||||
const colorFromProps = props[theme];
|
||||
|
||||
if (colorFromProps) {
|
||||
return colorFromProps;
|
||||
} else {
|
||||
return Colors[theme][colorName];
|
||||
}
|
||||
}
|
||||
13440
package-lock.json
generated
Normal file
51
package.json
Normal file
@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "wellnuo",
|
||||
"main": "expo-router/entry",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"reset-project": "node ./scripts/reset-project.js",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"web": "expo start --web",
|
||||
"lint": "expo lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^15.0.3",
|
||||
"@react-navigation/bottom-tabs": "^7.4.0",
|
||||
"@react-navigation/elements": "^2.6.3",
|
||||
"@react-navigation/native": "^7.1.8",
|
||||
"expo": "~54.0.29",
|
||||
"expo-constants": "~18.0.12",
|
||||
"expo-font": "~14.0.10",
|
||||
"expo-haptics": "~15.0.8",
|
||||
"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",
|
||||
"expo-system-ui": "~6.0.9",
|
||||
"expo-web-browser": "~15.0.10",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"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-webview": "^13.16.0",
|
||||
"react-native-worklets": "0.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "~19.1.0",
|
||||
"eslint": "^9.25.0",
|
||||
"eslint-config-expo": "~10.0.0",
|
||||
"playwright": "^1.57.0",
|
||||
"sharp": "^0.34.5",
|
||||
"typescript": "~5.9.2"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
112
scripts/reset-project.js
Executable file
@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* This script is used to reset the project to a blank state.
|
||||
* It deletes or moves the /app, /components, /hooks, /scripts, and /constants directories to /app-example based on user input and creates a new /app directory with an index.tsx and _layout.tsx file.
|
||||
* You can remove the `reset-project` script from package.json and safely delete this file after running it.
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const readline = require("readline");
|
||||
|
||||
const root = process.cwd();
|
||||
const oldDirs = ["app", "components", "hooks", "constants", "scripts"];
|
||||
const exampleDir = "app-example";
|
||||
const newAppDir = "app";
|
||||
const exampleDirPath = path.join(root, exampleDir);
|
||||
|
||||
const indexContent = `import { Text, View } from "react-native";
|
||||
|
||||
export default function Index() {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Text>Edit app/index.tsx to edit this screen.</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
`;
|
||||
|
||||
const layoutContent = `import { Stack } from "expo-router";
|
||||
|
||||
export default function RootLayout() {
|
||||
return <Stack />;
|
||||
}
|
||||
`;
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
const moveDirectories = async (userInput) => {
|
||||
try {
|
||||
if (userInput === "y") {
|
||||
// Create the app-example directory
|
||||
await fs.promises.mkdir(exampleDirPath, { recursive: true });
|
||||
console.log(`📁 /${exampleDir} directory created.`);
|
||||
}
|
||||
|
||||
// Move old directories to new app-example directory or delete them
|
||||
for (const dir of oldDirs) {
|
||||
const oldDirPath = path.join(root, dir);
|
||||
if (fs.existsSync(oldDirPath)) {
|
||||
if (userInput === "y") {
|
||||
const newDirPath = path.join(root, exampleDir, dir);
|
||||
await fs.promises.rename(oldDirPath, newDirPath);
|
||||
console.log(`➡️ /${dir} moved to /${exampleDir}/${dir}.`);
|
||||
} else {
|
||||
await fs.promises.rm(oldDirPath, { recursive: true, force: true });
|
||||
console.log(`❌ /${dir} deleted.`);
|
||||
}
|
||||
} else {
|
||||
console.log(`➡️ /${dir} does not exist, skipping.`);
|
||||
}
|
||||
}
|
||||
|
||||
// Create new /app directory
|
||||
const newAppDirPath = path.join(root, newAppDir);
|
||||
await fs.promises.mkdir(newAppDirPath, { recursive: true });
|
||||
console.log("\n📁 New /app directory created.");
|
||||
|
||||
// Create index.tsx
|
||||
const indexPath = path.join(newAppDirPath, "index.tsx");
|
||||
await fs.promises.writeFile(indexPath, indexContent);
|
||||
console.log("📄 app/index.tsx created.");
|
||||
|
||||
// Create _layout.tsx
|
||||
const layoutPath = path.join(newAppDirPath, "_layout.tsx");
|
||||
await fs.promises.writeFile(layoutPath, layoutContent);
|
||||
console.log("📄 app/_layout.tsx created.");
|
||||
|
||||
console.log("\n✅ Project reset complete. Next steps:");
|
||||
console.log(
|
||||
`1. Run \`npx expo start\` to start a development server.\n2. Edit app/index.tsx to edit the main screen.${
|
||||
userInput === "y"
|
||||
? `\n3. Delete the /${exampleDir} directory when you're done referencing it.`
|
||||
: ""
|
||||
}`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`❌ Error during script execution: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
rl.question(
|
||||
"Do you want to move existing files to /app-example instead of deleting them? (Y/n): ",
|
||||
(answer) => {
|
||||
const userInput = answer.trim().toLowerCase() || "y";
|
||||
if (userInput === "y" || userInput === "n") {
|
||||
moveDirectories(userInput).finally(() => rl.close());
|
||||
} else {
|
||||
console.log("❌ Invalid input. Please enter 'Y' or 'N'.");
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
);
|
||||
234
services/api.ts
Normal file
@ -0,0 +1,234 @@
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
import type { AuthResponse, ChatResponse, Beneficiary, 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;
|
||||
}
|
||||
}
|
||||
|
||||
// Beneficiaries (elderly people being monitored)
|
||||
async getBeneficiaries(): Promise<ApiResponse<{ beneficiaries: Beneficiary[] }>> {
|
||||
const token = await this.getToken();
|
||||
if (!token) {
|
||||
return { ok: false, error: { message: 'Not authenticated', code: 'UNAUTHORIZED' } };
|
||||
}
|
||||
|
||||
// Note: Using mock data since API structure is not fully documented
|
||||
// Replace with actual API call when available
|
||||
const mockBeneficiaries: Beneficiary[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Julia Smith',
|
||||
status: 'online',
|
||||
relationship: 'Mother',
|
||||
last_activity: '2 min ago',
|
||||
sensor_data: {
|
||||
motion_detected: true,
|
||||
last_motion: '2 min ago',
|
||||
door_status: 'closed',
|
||||
temperature: 22,
|
||||
humidity: 45,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Robert Johnson',
|
||||
status: 'offline',
|
||||
relationship: 'Father',
|
||||
last_activity: '1 hour ago',
|
||||
sensor_data: {
|
||||
motion_detected: false,
|
||||
last_motion: '1 hour ago',
|
||||
door_status: 'closed',
|
||||
temperature: 21,
|
||||
humidity: 50,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return { data: { beneficiaries: mockBeneficiaries }, ok: true };
|
||||
}
|
||||
|
||||
async getBeneficiary(id: number): Promise<ApiResponse<Beneficiary>> {
|
||||
const response = await this.getBeneficiaries();
|
||||
if (!response.ok || !response.data) {
|
||||
return { ok: false, error: response.error };
|
||||
}
|
||||
|
||||
const beneficiary = response.data.beneficiaries.find((b) => b.id === id);
|
||||
if (!beneficiary) {
|
||||
return { ok: false, error: { message: 'Beneficiary not found', code: 'NOT_FOUND' } };
|
||||
}
|
||||
|
||||
return { data: beneficiary, ok: true };
|
||||
}
|
||||
|
||||
// Get all beneficiaries using deployments_list API (real data)
|
||||
async getAllBeneficiaries(): Promise<ApiResponse<Beneficiary[]>> {
|
||||
const token = await this.getToken();
|
||||
const userName = await this.getUserName();
|
||||
|
||||
if (!token || !userName) {
|
||||
return { ok: false, error: { message: 'Not authenticated', code: 'UNAUTHORIZED' } };
|
||||
}
|
||||
|
||||
const response = await this.makeRequest<{ result_list: Array<{
|
||||
deployment_id: number;
|
||||
email: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
}> }>({
|
||||
function: 'deployments_list',
|
||||
user_name: userName,
|
||||
token: token,
|
||||
first: '0',
|
||||
last: '100',
|
||||
});
|
||||
|
||||
if (!response.ok || !response.data?.result_list) {
|
||||
return { ok: false, error: response.error || { message: 'Failed to get beneficiaries' } };
|
||||
}
|
||||
|
||||
const beneficiaries: Beneficiary[] = response.data.result_list.map(item => ({
|
||||
id: item.deployment_id,
|
||||
name: `${item.first_name} ${item.last_name}`.trim(),
|
||||
status: 'offline' as const,
|
||||
email: item.email,
|
||||
}));
|
||||
|
||||
return { data: beneficiaries, 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();
|
||||
17
tsconfig.json
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".expo/types/**/*.ts",
|
||||
"expo-env.d.ts"
|
||||
]
|
||||
}
|
||||
72
types/index.ts
Normal file
@ -0,0 +1,72 @@
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Beneficiary Types (elderly people being monitored)
|
||||
export interface Beneficiary {
|
||||
id: number;
|
||||
name: string;
|
||||
avatar?: string;
|
||||
device_id?: string;
|
||||
status: 'online' | 'offline';
|
||||
relationship?: string;
|
||||
last_activity?: string;
|
||||
sensor_data?: SensorData;
|
||||
}
|
||||
|
||||
export interface SensorData {
|
||||
motion_detected?: boolean;
|
||||
last_motion?: string;
|
||||
door_status?: 'open' | 'closed';
|
||||
temperature?: number;
|
||||
humidity?: 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;
|
||||
}
|
||||