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;
|
||||||
|
}
|
||||||