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>
This commit is contained in:
Sergei 2025-12-24 17:13:13 -08:00
commit 8bc9649146
56 changed files with 17932 additions and 0 deletions

44
.gitignore vendored Normal file
View 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
View File

@ -0,0 +1 @@
{ "recommendations": ["expo.vscode-expo-tools"] }

7
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,7 @@
{
"editor.codeActionsOnSave": {
"source.fixAll": "explicit",
"source.organizeImports": "explicit",
"source.sortMembers": "explicit"
}
}

50
README.md Normal file
View 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
View 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
View File

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

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

@ -0,0 +1,210 @@
import React, { useState, useCallback } from 'react';
import {
View,
Text,
StyleSheet,
KeyboardAvoidingView,
Platform,
ScrollView,
TouchableOpacity,
Image,
} from 'react-native';
import { router } from 'expo-router';
import { useAuth } from '@/contexts/AuthContext';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { ErrorMessage } from '@/components/ui/ErrorMessage';
import { AppColors, BorderRadius, FontSizes, Spacing } from '@/constants/theme';
export default function LoginScreen() {
const { login, isLoading, error, clearError } = useAuth();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [validationError, setValidationError] = useState<string | null>(null);
const handleLogin = useCallback(async () => {
// Clear previous errors
clearError();
setValidationError(null);
// Validate
if (!username.trim()) {
setValidationError('Username is required');
return;
}
if (!password.trim()) {
setValidationError('Password is required');
return;
}
const success = await login({ username: username.trim(), password });
if (success) {
router.replace('/(tabs)');
}
}, [username, password, login, clearError]);
const displayError = validationError || error?.message;
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<ScrollView
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{/* Logo / Header */}
<View style={styles.header}>
<View style={styles.logoContainer}>
<Text style={styles.logoText}>WellNuo</Text>
</View>
<Text style={styles.title}>Welcome Back</Text>
<Text style={styles.subtitle}>Sign in to continue monitoring your loved ones</Text>
</View>
{/* Form */}
<View style={styles.form}>
{displayError && (
<ErrorMessage
message={displayError}
onDismiss={() => {
clearError();
setValidationError(null);
}}
/>
)}
<Input
label="Username"
placeholder="Enter your username"
leftIcon="person-outline"
value={username}
onChangeText={(text) => {
setUsername(text);
setValidationError(null);
}}
autoCapitalize="none"
autoCorrect={false}
editable={!isLoading}
/>
<Input
label="Password"
placeholder="Enter your password"
leftIcon="lock-closed-outline"
secureTextEntry
value={password}
onChangeText={(text) => {
setPassword(text);
setValidationError(null);
}}
editable={!isLoading}
onSubmitEditing={handleLogin}
returnKeyType="done"
/>
<TouchableOpacity style={styles.forgotPassword}>
<Text style={styles.forgotPasswordText}>Forgot Password?</Text>
</TouchableOpacity>
<Button
title="Sign In"
onPress={handleLogin}
loading={isLoading}
fullWidth
size="lg"
/>
</View>
{/* Footer */}
<View style={styles.footer}>
<Text style={styles.footerText}>Don't have an account? </Text>
<TouchableOpacity>
<Text style={styles.footerLink}>Create Account</Text>
</TouchableOpacity>
</View>
{/* Version Info */}
<Text style={styles.version}>WellNuo v1.0.0</Text>
</ScrollView>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: AppColors.background,
},
scrollContent: {
flexGrow: 1,
paddingHorizontal: Spacing.lg,
paddingTop: Spacing.xxl + Spacing.xl,
paddingBottom: Spacing.xl,
},
header: {
alignItems: 'center',
marginBottom: Spacing.xl,
},
logoContainer: {
width: 80,
height: 80,
borderRadius: BorderRadius.xl,
backgroundColor: AppColors.primary,
justifyContent: 'center',
alignItems: 'center',
marginBottom: Spacing.lg,
},
logoText: {
fontSize: FontSizes.lg,
fontWeight: '700',
color: AppColors.white,
},
title: {
fontSize: FontSizes['2xl'],
fontWeight: '700',
color: AppColors.textPrimary,
marginBottom: Spacing.xs,
},
subtitle: {
fontSize: FontSizes.base,
color: AppColors.textSecondary,
textAlign: 'center',
},
form: {
marginBottom: Spacing.xl,
},
forgotPassword: {
alignSelf: 'flex-end',
marginBottom: Spacing.lg,
marginTop: -Spacing.sm,
},
forgotPasswordText: {
fontSize: FontSizes.sm,
color: AppColors.primary,
fontWeight: '500',
},
footer: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
marginBottom: Spacing.xl,
},
footerText: {
fontSize: FontSizes.base,
color: AppColors.textSecondary,
},
footerLink: {
fontSize: FontSizes.base,
color: AppColors.primary,
fontWeight: '600',
},
version: {
textAlign: 'center',
fontSize: FontSizes.xs,
color: AppColors.textMuted,
},
});

83
app/(tabs)/_layout.tsx Normal file
View 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>
);
}

View 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,
},
});

View 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,
},
});

View 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
View 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
View 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
View 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&apos;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
View 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
View 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
View 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
View 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,
},
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

BIN
assets/images/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
assets/images/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

BIN
assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

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

View 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',
},
});

View 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',
},
});

View 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
View File

@ -0,0 +1,137 @@
import React from 'react';
import {
TouchableOpacity,
Text,
StyleSheet,
ActivityIndicator,
type TouchableOpacityProps,
type ViewStyle,
type TextStyle,
} from 'react-native';
import { AppColors, BorderRadius, FontSizes, FontWeights, Spacing } from '@/constants/theme';
interface ButtonProps extends TouchableOpacityProps {
title: string;
variant?: 'primary' | 'secondary' | 'outline' | 'ghost';
size?: 'sm' | 'md' | 'lg';
loading?: boolean;
fullWidth?: boolean;
}
export function Button({
title,
variant = 'primary',
size = 'md',
loading = false,
fullWidth = false,
disabled,
style,
...props
}: ButtonProps) {
const isDisabled = disabled || loading;
const buttonStyles: ViewStyle[] = [
styles.base,
styles[variant],
styles[`size_${size}`],
fullWidth && styles.fullWidth,
isDisabled && styles.disabled,
style as ViewStyle,
];
const textStyles: TextStyle[] = [
styles.text,
styles[`text_${variant}`],
styles[`text_${size}`],
isDisabled && styles.textDisabled,
];
return (
<TouchableOpacity
style={buttonStyles}
disabled={isDisabled}
activeOpacity={0.7}
{...props}
>
{loading ? (
<ActivityIndicator
color={variant === 'primary' ? AppColors.white : AppColors.primary}
size="small"
/>
) : (
<Text style={textStyles}>{title}</Text>
)}
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
base: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
borderRadius: BorderRadius.lg,
},
primary: {
backgroundColor: AppColors.primary,
},
secondary: {
backgroundColor: AppColors.surface,
},
outline: {
backgroundColor: 'transparent',
borderWidth: 1,
borderColor: AppColors.primary,
},
ghost: {
backgroundColor: 'transparent',
},
size_sm: {
paddingVertical: Spacing.sm,
paddingHorizontal: Spacing.md,
minHeight: 36,
},
size_md: {
paddingVertical: Spacing.sm + 4,
paddingHorizontal: Spacing.lg,
minHeight: 48,
},
size_lg: {
paddingVertical: Spacing.md,
paddingHorizontal: Spacing.xl,
minHeight: 56,
},
fullWidth: {
width: '100%',
},
disabled: {
opacity: 0.5,
},
text: {
fontWeight: FontWeights.semibold,
},
text_primary: {
color: AppColors.white,
},
text_secondary: {
color: AppColors.textPrimary,
},
text_outline: {
color: AppColors.primary,
},
text_ghost: {
color: AppColors.primary,
},
text_sm: {
fontSize: FontSizes.sm,
},
text_md: {
fontSize: FontSizes.base,
},
text_lg: {
fontSize: FontSizes.lg,
},
textDisabled: {
opacity: 0.7,
},
});

View File

@ -0,0 +1,141 @@
import React from 'react';
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { AppColors, BorderRadius, FontSizes, Spacing } from '@/constants/theme';
interface ErrorMessageProps {
message: string;
onRetry?: () => void;
onDismiss?: () => void;
}
export function ErrorMessage({ message, onRetry, onDismiss }: ErrorMessageProps) {
return (
<View style={styles.container}>
<View style={styles.content}>
<Ionicons name="alert-circle" size={24} color={AppColors.error} />
<Text style={styles.message}>{message}</Text>
</View>
<View style={styles.actions}>
{onRetry && (
<TouchableOpacity onPress={onRetry} style={styles.button}>
<Ionicons name="refresh" size={18} color={AppColors.primary} />
<Text style={styles.buttonText}>Retry</Text>
</TouchableOpacity>
)}
{onDismiss && (
<TouchableOpacity onPress={onDismiss} style={styles.dismissButton}>
<Ionicons name="close" size={18} color={AppColors.textMuted} />
</TouchableOpacity>
)}
</View>
</View>
);
}
interface FullScreenErrorProps {
title?: string;
message: string;
onRetry?: () => void;
}
export function FullScreenError({
title = 'Something went wrong',
message,
onRetry
}: FullScreenErrorProps) {
return (
<View style={styles.fullScreenContainer}>
<Ionicons name="cloud-offline-outline" size={64} color={AppColors.textMuted} />
<Text style={styles.fullScreenTitle}>{title}</Text>
<Text style={styles.fullScreenMessage}>{message}</Text>
{onRetry && (
<TouchableOpacity onPress={onRetry} style={styles.retryButton}>
<Ionicons name="refresh" size={20} color={AppColors.white} />
<Text style={styles.retryButtonText}>Try Again</Text>
</TouchableOpacity>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
backgroundColor: '#FEE2E2',
borderRadius: BorderRadius.lg,
padding: Spacing.md,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginVertical: Spacing.sm,
},
content: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
},
message: {
color: AppColors.error,
fontSize: FontSizes.sm,
marginLeft: Spacing.sm,
flex: 1,
},
actions: {
flexDirection: 'row',
alignItems: 'center',
},
button: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: Spacing.sm,
paddingVertical: Spacing.xs,
},
buttonText: {
color: AppColors.primary,
fontSize: FontSizes.sm,
fontWeight: '500',
marginLeft: Spacing.xs,
},
dismissButton: {
padding: Spacing.xs,
marginLeft: Spacing.xs,
},
// Full Screen Error
fullScreenContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: Spacing.xl,
backgroundColor: AppColors.background,
},
fullScreenTitle: {
fontSize: FontSizes.xl,
fontWeight: '600',
color: AppColors.textPrimary,
marginTop: Spacing.lg,
textAlign: 'center',
},
fullScreenMessage: {
fontSize: FontSizes.base,
color: AppColors.textSecondary,
marginTop: Spacing.sm,
textAlign: 'center',
},
retryButton: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: AppColors.primary,
paddingVertical: Spacing.sm + 4,
paddingHorizontal: Spacing.lg,
borderRadius: BorderRadius.lg,
marginTop: Spacing.xl,
},
retryButtonText: {
color: AppColors.white,
fontSize: FontSizes.base,
fontWeight: '600',
marginLeft: Spacing.sm,
},
});

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

@ -0,0 +1,143 @@
import React, { useState } from 'react';
import {
View,
TextInput,
Text,
StyleSheet,
TouchableOpacity,
type TextInputProps,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { AppColors, BorderRadius, FontSizes, Spacing } from '@/constants/theme';
interface InputProps extends TextInputProps {
label?: string;
error?: string;
leftIcon?: keyof typeof Ionicons.glyphMap;
rightIcon?: keyof typeof Ionicons.glyphMap;
onRightIconPress?: () => void;
}
export function Input({
label,
error,
leftIcon,
rightIcon,
onRightIconPress,
secureTextEntry,
style,
...props
}: InputProps) {
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
const isPassword = secureTextEntry !== undefined;
const handleTogglePassword = () => {
setIsPasswordVisible(!isPasswordVisible);
};
return (
<View style={styles.container}>
{label && <Text style={styles.label}>{label}</Text>}
<View style={[styles.inputContainer, error && styles.inputError]}>
{leftIcon && (
<Ionicons
name={leftIcon}
size={20}
color={AppColors.textMuted}
style={styles.leftIcon}
/>
)}
<TextInput
style={[
styles.input,
leftIcon && styles.inputWithLeftIcon,
(isPassword || rightIcon) && styles.inputWithRightIcon,
style,
]}
placeholderTextColor={AppColors.textMuted}
secureTextEntry={isPassword && !isPasswordVisible}
{...props}
/>
{isPassword && (
<TouchableOpacity
onPress={handleTogglePassword}
style={styles.rightIconButton}
>
<Ionicons
name={isPasswordVisible ? 'eye-off-outline' : 'eye-outline'}
size={20}
color={AppColors.textMuted}
/>
</TouchableOpacity>
)}
{!isPassword && rightIcon && (
<TouchableOpacity
onPress={onRightIconPress}
style={styles.rightIconButton}
disabled={!onRightIconPress}
>
<Ionicons
name={rightIcon}
size={20}
color={AppColors.textMuted}
/>
</TouchableOpacity>
)}
</View>
{error && <Text style={styles.errorText}>{error}</Text>}
</View>
);
}
const styles = StyleSheet.create({
container: {
marginBottom: Spacing.md,
},
label: {
fontSize: FontSizes.sm,
fontWeight: '500',
color: AppColors.textPrimary,
marginBottom: Spacing.xs,
},
inputContainer: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.lg,
borderWidth: 1,
borderColor: AppColors.border,
},
inputError: {
borderColor: AppColors.error,
},
input: {
flex: 1,
paddingVertical: Spacing.sm + 4,
paddingHorizontal: Spacing.md,
fontSize: FontSizes.base,
color: AppColors.textPrimary,
},
inputWithLeftIcon: {
paddingLeft: 0,
},
inputWithRightIcon: {
paddingRight: 0,
},
leftIcon: {
marginLeft: Spacing.md,
marginRight: Spacing.sm,
},
rightIconButton: {
padding: Spacing.md,
},
errorText: {
fontSize: FontSizes.xs,
color: AppColors.error,
marginTop: Spacing.xs,
},
});

View File

@ -0,0 +1,50 @@
import React from 'react';
import { View, ActivityIndicator, Text, StyleSheet } from 'react-native';
import { AppColors, FontSizes, Spacing } from '@/constants/theme';
interface LoadingSpinnerProps {
size?: 'small' | 'large';
color?: string;
message?: string;
fullScreen?: boolean;
}
export function LoadingSpinner({
size = 'large',
color = AppColors.primary,
message,
fullScreen = false,
}: LoadingSpinnerProps) {
const content = (
<>
<ActivityIndicator size={size} color={color} />
{message && <Text style={styles.message}>{message}</Text>}
</>
);
if (fullScreen) {
return <View style={styles.fullScreen}>{content}</View>;
}
return <View style={styles.container}>{content}</View>;
}
const styles = StyleSheet.create({
container: {
alignItems: 'center',
justifyContent: 'center',
padding: Spacing.lg,
},
fullScreen: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: AppColors.background,
},
message: {
marginTop: Spacing.md,
fontSize: FontSizes.base,
color: AppColors.textSecondary,
textAlign: 'center',
},
});

View File

@ -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,
},
});

View 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,
]}
/>
);
}

View 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
View 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
View File

@ -0,0 +1,136 @@
import React, { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
import { api } from '@/services/api';
import type { User, LoginCredentials, ApiError } from '@/types';
interface AuthState {
user: User | null;
isLoading: boolean;
isAuthenticated: boolean;
error: ApiError | null;
}
interface AuthContextType extends AuthState {
login: (credentials: LoginCredentials) => Promise<boolean>;
logout: () => Promise<void>;
clearError: () => void;
}
const AuthContext = createContext<AuthContextType | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<AuthState>({
user: null,
isLoading: true,
isAuthenticated: false,
error: null,
});
// Check authentication on mount
useEffect(() => {
checkAuth();
}, []);
const checkAuth = async () => {
try {
const isAuth = await api.isAuthenticated();
if (isAuth) {
const user = await api.getStoredUser();
setState({
user,
isLoading: false,
isAuthenticated: !!user,
error: null,
});
} else {
setState({
user: null,
isLoading: false,
isAuthenticated: false,
error: null,
});
}
} catch (error) {
setState({
user: null,
isLoading: false,
isAuthenticated: false,
error: { message: 'Failed to check authentication' },
});
}
};
const login = useCallback(async (credentials: LoginCredentials): Promise<boolean> => {
setState((prev) => ({ ...prev, isLoading: true, error: null }));
try {
const response = await api.login(credentials.username, credentials.password);
if (response.ok && response.data) {
const user: User = {
user_id: response.data.user_id,
user_name: credentials.username,
max_role: response.data.max_role,
privileges: response.data.privileges,
};
setState({
user,
isLoading: false,
isAuthenticated: true,
error: null,
});
return true;
}
setState((prev) => ({
...prev,
isLoading: false,
error: response.error || { message: 'Login failed' },
}));
return false;
} catch (error) {
setState((prev) => ({
...prev,
isLoading: false,
error: { message: error instanceof Error ? error.message : 'Login failed' },
}));
return false;
}
}, []);
const logout = useCallback(async () => {
setState((prev) => ({ ...prev, isLoading: true }));
try {
await api.logout();
} finally {
setState({
user: null,
isLoading: false,
isAuthenticated: false,
error: null,
});
}
}, []);
const clearError = useCallback(() => {
setState((prev) => ({ ...prev, error: null }));
}, []);
return (
<AuthContext.Provider value={{ ...state, login, logout, clearError }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}

View 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
View 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
View 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/*'],
},
]);

View File

@ -0,0 +1 @@
export { useColorScheme } from 'react-native';

View 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
View 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

File diff suppressed because it is too large Load Diff

51
package.json Normal file
View 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
View 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
View 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
View 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
View 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;
}