Merge pull request #16 from fullsizemalt/claude/add-mobile-handoff-doc-01C2LQpdUfs85pWjnfT86XBH

Add React Native mobile handoff document
This commit is contained in:
fullsizemalt 2025-11-18 12:40:21 -07:00 committed by GitHub
commit 79edb05c20
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 2909 additions and 17 deletions

View file

@ -0,0 +1,565 @@
import React, { useState } from 'react';
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
ScrollView,
KeyboardAvoidingView,
Platform,
ActivityIndicator,
} from 'react-native';
import { useRouter } from 'expo-router';
import { authApi, handleApiError } from '../lib/api';
type Step = 'email' | 'sent' | 'reset';
export default function ForgotPasswordScreen() {
const router = useRouter();
const [step, setStep] = useState<Step>('email');
const [email, setEmail] = useState('');
const [resetToken, setResetToken] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [validationErrors, setValidationErrors] = useState<{
email?: string;
resetToken?: string;
newPassword?: string;
confirmPassword?: string;
}>({});
// Validate email format
const validateEmail = (email: string): boolean => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
};
// Validate email step
const validateEmailStep = (): boolean => {
const errors: { email?: string } = {};
if (!email.trim()) {
errors.email = 'Email is required';
} else if (!validateEmail(email)) {
errors.email = 'Please enter a valid email';
}
setValidationErrors(errors);
return Object.keys(errors).length === 0;
};
// Validate reset step
const validateResetStep = (): boolean => {
const errors: {
resetToken?: string;
newPassword?: string;
confirmPassword?: string;
} = {};
if (!resetToken.trim()) {
errors.resetToken = 'Reset code is required';
}
if (!newPassword) {
errors.newPassword = 'New password is required';
} else if (newPassword.length < 8) {
errors.newPassword = 'Password must be at least 8 characters';
}
if (!confirmPassword) {
errors.confirmPassword = 'Please confirm your password';
} else if (newPassword !== confirmPassword) {
errors.confirmPassword = 'Passwords do not match';
}
setValidationErrors(errors);
return Object.keys(errors).length === 0;
};
// Handle send reset email
const handleSendReset = async () => {
setError(null);
if (!validateEmailStep()) {
return;
}
setIsLoading(true);
try {
await authApi.forgotPassword({ email: email.trim().toLowerCase() });
setStep('sent');
} catch (err) {
setError(handleApiError(err));
} finally {
setIsLoading(false);
}
};
// Handle reset password
const handleResetPassword = async () => {
setError(null);
if (!validateResetStep()) {
return;
}
setIsLoading(true);
try {
await authApi.resetPassword({
token: resetToken.trim(),
newPassword,
});
// Navigate to login with success message
router.replace('/(auth)/login');
} catch (err) {
setError(handleApiError(err));
} finally {
setIsLoading(false);
}
};
// Navigate back to login
const handleBackToLogin = () => {
router.push('/(auth)/login');
};
// Render email step
const renderEmailStep = () => (
<>
<View style={styles.header}>
<Text style={styles.title}>Forgot Password</Text>
<Text style={styles.subtitle}>
Enter your email address and we'll send you a link to reset your password.
</Text>
</View>
<View style={styles.form}>
<View style={styles.inputContainer}>
<Text style={styles.label}>Email</Text>
<TextInput
style={[styles.input, validationErrors.email && styles.inputError]}
placeholder="Enter your email"
placeholderTextColor="#999"
value={email}
onChangeText={(text) => {
setEmail(text);
if (validationErrors.email) {
setValidationErrors((prev) => ({ ...prev, email: undefined }));
}
}}
keyboardType="email-address"
autoCapitalize="none"
autoCorrect={false}
autoComplete="email"
editable={!isLoading}
/>
{validationErrors.email && (
<Text style={styles.errorText}>{validationErrors.email}</Text>
)}
</View>
{error && (
<View style={styles.apiError}>
<Text style={styles.apiErrorText}>{error}</Text>
</View>
)}
<TouchableOpacity
style={[styles.button, isLoading && styles.buttonDisabled]}
onPress={handleSendReset}
disabled={isLoading}
>
{isLoading ? (
<ActivityIndicator color="#FFFFFF" />
) : (
<Text style={styles.buttonText}>Send Reset Link</Text>
)}
</TouchableOpacity>
<TouchableOpacity
style={styles.backButton}
onPress={handleBackToLogin}
disabled={isLoading}
>
<Text style={styles.backButtonText}>Back to Sign In</Text>
</TouchableOpacity>
</View>
</>
);
// Render sent confirmation step
const renderSentStep = () => (
<>
<View style={styles.header}>
<View style={styles.iconContainer}>
<Text style={styles.checkIcon}></Text>
</View>
<Text style={styles.title}>Check Your Email</Text>
<Text style={styles.subtitle}>
We've sent a password reset link to{'\n'}
<Text style={styles.emailHighlight}>{email}</Text>
</Text>
</View>
<View style={styles.form}>
<View style={styles.instructionsContainer}>
<Text style={styles.instructionsTitle}>What's next?</Text>
<Text style={styles.instructionsText}>
1. Check your email inbox{'\n'}
2. Click the reset link in the email{'\n'}
3. Enter your reset code below
</Text>
</View>
<TouchableOpacity
style={styles.button}
onPress={() => setStep('reset')}
>
<Text style={styles.buttonText}>I Have My Reset Code</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.resendButton}
onPress={handleSendReset}
disabled={isLoading}
>
{isLoading ? (
<ActivityIndicator color="#007AFF" />
) : (
<Text style={styles.resendButtonText}>Resend Email</Text>
)}
</TouchableOpacity>
<TouchableOpacity
style={styles.backButton}
onPress={handleBackToLogin}
disabled={isLoading}
>
<Text style={styles.backButtonText}>Back to Sign In</Text>
</TouchableOpacity>
</View>
</>
);
// Render reset password step
const renderResetStep = () => (
<>
<View style={styles.header}>
<Text style={styles.title}>Reset Password</Text>
<Text style={styles.subtitle}>
Enter the code from your email and create a new password.
</Text>
</View>
<View style={styles.form}>
{/* Reset Token Input */}
<View style={styles.inputContainer}>
<Text style={styles.label}>Reset Code</Text>
<TextInput
style={[styles.input, validationErrors.resetToken && styles.inputError]}
placeholder="Enter reset code"
placeholderTextColor="#999"
value={resetToken}
onChangeText={(text) => {
setResetToken(text);
if (validationErrors.resetToken) {
setValidationErrors((prev) => ({ ...prev, resetToken: undefined }));
}
}}
autoCapitalize="none"
autoCorrect={false}
editable={!isLoading}
/>
{validationErrors.resetToken && (
<Text style={styles.errorText}>{validationErrors.resetToken}</Text>
)}
</View>
{/* New Password Input */}
<View style={styles.inputContainer}>
<Text style={styles.label}>New Password</Text>
<View style={styles.passwordContainer}>
<TextInput
style={[
styles.input,
styles.passwordInput,
validationErrors.newPassword && styles.inputError,
]}
placeholder="Create new password"
placeholderTextColor="#999"
value={newPassword}
onChangeText={(text) => {
setNewPassword(text);
if (validationErrors.newPassword) {
setValidationErrors((prev) => ({ ...prev, newPassword: undefined }));
}
}}
secureTextEntry={!showPassword}
autoCapitalize="none"
autoCorrect={false}
editable={!isLoading}
/>
<TouchableOpacity
style={styles.showPasswordButton}
onPress={() => setShowPassword(!showPassword)}
disabled={isLoading}
>
<Text style={styles.showPasswordText}>
{showPassword ? 'Hide' : 'Show'}
</Text>
</TouchableOpacity>
</View>
{validationErrors.newPassword && (
<Text style={styles.errorText}>{validationErrors.newPassword}</Text>
)}
</View>
{/* Confirm Password Input */}
<View style={styles.inputContainer}>
<Text style={styles.label}>Confirm New Password</Text>
<TextInput
style={[
styles.input,
validationErrors.confirmPassword && styles.inputError,
]}
placeholder="Confirm new password"
placeholderTextColor="#999"
value={confirmPassword}
onChangeText={(text) => {
setConfirmPassword(text);
if (validationErrors.confirmPassword) {
setValidationErrors((prev) => ({
...prev,
confirmPassword: undefined,
}));
}
}}
secureTextEntry={!showPassword}
autoCapitalize="none"
autoCorrect={false}
editable={!isLoading}
/>
{validationErrors.confirmPassword && (
<Text style={styles.errorText}>{validationErrors.confirmPassword}</Text>
)}
</View>
{error && (
<View style={styles.apiError}>
<Text style={styles.apiErrorText}>{error}</Text>
</View>
)}
<TouchableOpacity
style={[styles.button, isLoading && styles.buttonDisabled]}
onPress={handleResetPassword}
disabled={isLoading}
>
{isLoading ? (
<ActivityIndicator color="#FFFFFF" />
) : (
<Text style={styles.buttonText}>Reset Password</Text>
)}
</TouchableOpacity>
<TouchableOpacity
style={styles.backButton}
onPress={() => setStep('sent')}
disabled={isLoading}
>
<Text style={styles.backButtonText}>Go Back</Text>
</TouchableOpacity>
</View>
</>
);
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<ScrollView
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{step === 'email' && renderEmailStep()}
{step === 'sent' && renderSentStep()}
{step === 'reset' && renderResetStep()}
</ScrollView>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#FFFFFF',
},
scrollContent: {
flexGrow: 1,
paddingHorizontal: 24,
paddingTop: 60,
paddingBottom: 40,
},
header: {
marginBottom: 32,
alignItems: 'center',
},
iconContainer: {
width: 64,
height: 64,
borderRadius: 32,
backgroundColor: '#E8F5E9',
justifyContent: 'center',
alignItems: 'center',
marginBottom: 24,
},
checkIcon: {
fontSize: 32,
color: '#34C759',
},
title: {
fontSize: 28,
fontWeight: 'bold',
color: '#333333',
marginBottom: 12,
textAlign: 'center',
},
subtitle: {
fontSize: 16,
color: '#666666',
textAlign: 'center',
lineHeight: 24,
},
emailHighlight: {
fontWeight: '600',
color: '#007AFF',
},
form: {
flex: 1,
},
inputContainer: {
marginBottom: 20,
},
label: {
fontSize: 14,
fontWeight: '600',
color: '#333333',
marginBottom: 8,
},
input: {
height: 50,
borderWidth: 1,
borderColor: '#E0E0E0',
borderRadius: 8,
paddingHorizontal: 16,
fontSize: 16,
color: '#333333',
backgroundColor: '#FAFAFA',
},
inputError: {
borderColor: '#FF3B30',
},
passwordContainer: {
position: 'relative',
},
passwordInput: {
paddingRight: 60,
},
showPasswordButton: {
position: 'absolute',
right: 16,
top: 0,
bottom: 0,
justifyContent: 'center',
},
showPasswordText: {
fontSize: 14,
color: '#007AFF',
fontWeight: '600',
},
errorText: {
fontSize: 12,
color: '#FF3B30',
marginTop: 4,
},
apiError: {
backgroundColor: '#FFF0F0',
borderRadius: 8,
padding: 12,
marginBottom: 16,
borderWidth: 1,
borderColor: '#FF3B30',
},
apiErrorText: {
fontSize: 14,
color: '#FF3B30',
textAlign: 'center',
},
instructionsContainer: {
backgroundColor: '#F5F5F5',
borderRadius: 8,
padding: 16,
marginBottom: 24,
},
instructionsTitle: {
fontSize: 14,
fontWeight: '600',
color: '#333333',
marginBottom: 8,
},
instructionsText: {
fontSize: 14,
color: '#666666',
lineHeight: 22,
},
button: {
height: 50,
backgroundColor: '#007AFF',
borderRadius: 8,
justifyContent: 'center',
alignItems: 'center',
marginBottom: 16,
},
buttonDisabled: {
backgroundColor: '#B0B0B0',
},
buttonText: {
fontSize: 16,
fontWeight: '600',
color: '#FFFFFF',
},
resendButton: {
height: 50,
backgroundColor: '#FFFFFF',
borderRadius: 8,
borderWidth: 1,
borderColor: '#007AFF',
justifyContent: 'center',
alignItems: 'center',
marginBottom: 16,
},
resendButtonText: {
fontSize: 16,
fontWeight: '600',
color: '#007AFF',
},
backButton: {
height: 50,
justifyContent: 'center',
alignItems: 'center',
},
backButtonText: {
fontSize: 14,
color: '#666666',
fontWeight: '600',
},
});

354
mobile/app/(auth)/login.tsx Normal file
View file

@ -0,0 +1,354 @@
import React, { useState } from 'react';
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
ScrollView,
KeyboardAvoidingView,
Platform,
ActivityIndicator,
Alert,
} from 'react-native';
import { useRouter } from 'expo-router';
import { useAuthStore } from '../lib/auth-store';
export default function LoginScreen() {
const router = useRouter();
const { login, isLoading, error, clearError } = useAuthStore();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [validationErrors, setValidationErrors] = useState<{
email?: string;
password?: string;
}>({});
// Validate email format
const validateEmail = (email: string): boolean => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
};
// Validate form
const validateForm = (): boolean => {
const errors: { email?: string; password?: string } = {};
if (!email.trim()) {
errors.email = 'Email is required';
} else if (!validateEmail(email)) {
errors.email = 'Please enter a valid email';
}
if (!password) {
errors.password = 'Password is required';
} else if (password.length < 6) {
errors.password = 'Password must be at least 6 characters';
}
setValidationErrors(errors);
return Object.keys(errors).length === 0;
};
// Handle login
const handleLogin = async () => {
clearError();
if (!validateForm()) {
return;
}
try {
await login({ email: email.trim().toLowerCase(), password });
router.replace('/(tabs)');
} catch {
// Error is handled by the store
}
};
// Navigate to signup
const handleSignup = () => {
clearError();
router.push('/(auth)/signup');
};
// Navigate to forgot password
const handleForgotPassword = () => {
clearError();
router.push('/(auth)/forgot-password');
};
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<ScrollView
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{/* Header */}
<View style={styles.header}>
<Text style={styles.title}>Welcome Back</Text>
<Text style={styles.subtitle}>Sign in to your account</Text>
</View>
{/* Form */}
<View style={styles.form}>
{/* Email Input */}
<View style={styles.inputContainer}>
<Text style={styles.label}>Email</Text>
<TextInput
style={[styles.input, validationErrors.email && styles.inputError]}
placeholder="Enter your email"
placeholderTextColor="#999"
value={email}
onChangeText={(text) => {
setEmail(text);
if (validationErrors.email) {
setValidationErrors((prev) => ({ ...prev, email: undefined }));
}
}}
keyboardType="email-address"
autoCapitalize="none"
autoCorrect={false}
autoComplete="email"
editable={!isLoading}
/>
{validationErrors.email && (
<Text style={styles.errorText}>{validationErrors.email}</Text>
)}
</View>
{/* Password Input */}
<View style={styles.inputContainer}>
<Text style={styles.label}>Password</Text>
<View style={styles.passwordContainer}>
<TextInput
style={[
styles.input,
styles.passwordInput,
validationErrors.password && styles.inputError,
]}
placeholder="Enter your password"
placeholderTextColor="#999"
value={password}
onChangeText={(text) => {
setPassword(text);
if (validationErrors.password) {
setValidationErrors((prev) => ({ ...prev, password: undefined }));
}
}}
secureTextEntry={!showPassword}
autoCapitalize="none"
autoCorrect={false}
autoComplete="password"
editable={!isLoading}
/>
<TouchableOpacity
style={styles.showPasswordButton}
onPress={() => setShowPassword(!showPassword)}
disabled={isLoading}
>
<Text style={styles.showPasswordText}>
{showPassword ? 'Hide' : 'Show'}
</Text>
</TouchableOpacity>
</View>
{validationErrors.password && (
<Text style={styles.errorText}>{validationErrors.password}</Text>
)}
</View>
{/* Forgot Password Link */}
<TouchableOpacity
style={styles.forgotPassword}
onPress={handleForgotPassword}
disabled={isLoading}
>
<Text style={styles.forgotPasswordText}>Forgot Password?</Text>
</TouchableOpacity>
{/* API Error */}
{error && (
<View style={styles.apiError}>
<Text style={styles.apiErrorText}>{error}</Text>
</View>
)}
{/* Sign In Button */}
<TouchableOpacity
style={[styles.button, isLoading && styles.buttonDisabled]}
onPress={handleLogin}
disabled={isLoading}
>
{isLoading ? (
<ActivityIndicator color="#FFFFFF" />
) : (
<Text style={styles.buttonText}>Sign In</Text>
)}
</TouchableOpacity>
{/* Sign Up Link */}
<View style={styles.signupContainer}>
<Text style={styles.signupText}>Don't have an account? </Text>
<TouchableOpacity onPress={handleSignup} disabled={isLoading}>
<Text style={styles.signupLink}>Sign Up</Text>
</TouchableOpacity>
</View>
</View>
{/* App Info */}
<View style={styles.footer}>
<Text style={styles.footerText}>MoreThanADiagnosis</Text>
<Text style={styles.footerSubtext}>
Supporting the kidney disease community
</Text>
</View>
</ScrollView>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#FFFFFF',
},
scrollContent: {
flexGrow: 1,
paddingHorizontal: 24,
paddingTop: 60,
paddingBottom: 40,
},
header: {
marginBottom: 40,
},
title: {
fontSize: 32,
fontWeight: 'bold',
color: '#333333',
marginBottom: 8,
},
subtitle: {
fontSize: 16,
color: '#666666',
},
form: {
flex: 1,
},
inputContainer: {
marginBottom: 20,
},
label: {
fontSize: 14,
fontWeight: '600',
color: '#333333',
marginBottom: 8,
},
input: {
height: 50,
borderWidth: 1,
borderColor: '#E0E0E0',
borderRadius: 8,
paddingHorizontal: 16,
fontSize: 16,
color: '#333333',
backgroundColor: '#FAFAFA',
},
inputError: {
borderColor: '#FF3B30',
},
passwordContainer: {
position: 'relative',
},
passwordInput: {
paddingRight: 60,
},
showPasswordButton: {
position: 'absolute',
right: 16,
top: 0,
bottom: 0,
justifyContent: 'center',
},
showPasswordText: {
fontSize: 14,
color: '#007AFF',
fontWeight: '600',
},
errorText: {
fontSize: 12,
color: '#FF3B30',
marginTop: 4,
},
forgotPassword: {
alignSelf: 'flex-end',
marginBottom: 24,
},
forgotPasswordText: {
fontSize: 14,
color: '#007AFF',
fontWeight: '600',
},
apiError: {
backgroundColor: '#FFF0F0',
borderRadius: 8,
padding: 12,
marginBottom: 16,
borderWidth: 1,
borderColor: '#FF3B30',
},
apiErrorText: {
fontSize: 14,
color: '#FF3B30',
textAlign: 'center',
},
button: {
height: 50,
backgroundColor: '#007AFF',
borderRadius: 8,
justifyContent: 'center',
alignItems: 'center',
marginBottom: 24,
},
buttonDisabled: {
backgroundColor: '#B0B0B0',
},
buttonText: {
fontSize: 16,
fontWeight: '600',
color: '#FFFFFF',
},
signupContainer: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
},
signupText: {
fontSize: 14,
color: '#666666',
},
signupLink: {
fontSize: 14,
color: '#007AFF',
fontWeight: '600',
},
footer: {
marginTop: 40,
alignItems: 'center',
},
footerText: {
fontSize: 16,
fontWeight: '600',
color: '#333333',
marginBottom: 4,
},
footerSubtext: {
fontSize: 12,
color: '#999999',
},
});

View file

@ -0,0 +1,475 @@
import React, { useState } from 'react';
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
ScrollView,
KeyboardAvoidingView,
Platform,
ActivityIndicator,
} from 'react-native';
import { useRouter } from 'expo-router';
import { useAuthStore } from '../lib/auth-store';
export default function SignupScreen() {
const router = useRouter();
const { signup, isLoading, error, clearError } = useAuthStore();
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [validationErrors, setValidationErrors] = useState<{
name?: string;
email?: string;
password?: string;
confirmPassword?: string;
}>({});
// Validate email format
const validateEmail = (email: string): boolean => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
};
// Calculate password strength
const getPasswordStrength = (password: string): { level: number; text: string; color: string } => {
if (!password) {
return { level: 0, text: '', color: '#E0E0E0' };
}
let strength = 0;
if (password.length >= 8) strength++;
if (/[a-z]/.test(password) && /[A-Z]/.test(password)) strength++;
if (/\d/.test(password)) strength++;
if (/[^a-zA-Z0-9]/.test(password)) strength++;
const levels = [
{ level: 1, text: 'Weak', color: '#FF3B30' },
{ level: 2, text: 'Fair', color: '#FF9500' },
{ level: 3, text: 'Good', color: '#FFCC00' },
{ level: 4, text: 'Strong', color: '#34C759' },
];
return levels[Math.min(strength, 4) - 1] || { level: 0, text: 'Too short', color: '#FF3B30' };
};
// Validate form
const validateForm = (): boolean => {
const errors: {
name?: string;
email?: string;
password?: string;
confirmPassword?: string;
} = {};
if (!name.trim()) {
errors.name = 'Name is required';
} else if (name.trim().length < 2) {
errors.name = 'Name must be at least 2 characters';
}
if (!email.trim()) {
errors.email = 'Email is required';
} else if (!validateEmail(email)) {
errors.email = 'Please enter a valid email';
}
if (!password) {
errors.password = 'Password is required';
} else if (password.length < 8) {
errors.password = 'Password must be at least 8 characters';
}
if (!confirmPassword) {
errors.confirmPassword = 'Please confirm your password';
} else if (password !== confirmPassword) {
errors.confirmPassword = 'Passwords do not match';
}
setValidationErrors(errors);
return Object.keys(errors).length === 0;
};
// Handle signup
const handleSignup = async () => {
clearError();
if (!validateForm()) {
return;
}
try {
await signup({
name: name.trim(),
email: email.trim().toLowerCase(),
password,
});
router.replace('/(tabs)');
} catch {
// Error is handled by the store
}
};
// Navigate to login
const handleLogin = () => {
clearError();
router.push('/(auth)/login');
};
const passwordStrength = getPasswordStrength(password);
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<ScrollView
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{/* Header */}
<View style={styles.header}>
<Text style={styles.title}>Create Account</Text>
<Text style={styles.subtitle}>Join our community today</Text>
</View>
{/* Form */}
<View style={styles.form}>
{/* Name Input */}
<View style={styles.inputContainer}>
<Text style={styles.label}>Name</Text>
<TextInput
style={[styles.input, validationErrors.name && styles.inputError]}
placeholder="Enter your name"
placeholderTextColor="#999"
value={name}
onChangeText={(text) => {
setName(text);
if (validationErrors.name) {
setValidationErrors((prev) => ({ ...prev, name: undefined }));
}
}}
autoCapitalize="words"
autoCorrect={false}
autoComplete="name"
editable={!isLoading}
/>
{validationErrors.name && (
<Text style={styles.errorText}>{validationErrors.name}</Text>
)}
</View>
{/* Email Input */}
<View style={styles.inputContainer}>
<Text style={styles.label}>Email</Text>
<TextInput
style={[styles.input, validationErrors.email && styles.inputError]}
placeholder="Enter your email"
placeholderTextColor="#999"
value={email}
onChangeText={(text) => {
setEmail(text);
if (validationErrors.email) {
setValidationErrors((prev) => ({ ...prev, email: undefined }));
}
}}
keyboardType="email-address"
autoCapitalize="none"
autoCorrect={false}
autoComplete="email"
editable={!isLoading}
/>
{validationErrors.email && (
<Text style={styles.errorText}>{validationErrors.email}</Text>
)}
</View>
{/* Password Input */}
<View style={styles.inputContainer}>
<Text style={styles.label}>Password</Text>
<View style={styles.passwordContainer}>
<TextInput
style={[
styles.input,
styles.passwordInput,
validationErrors.password && styles.inputError,
]}
placeholder="Create a password"
placeholderTextColor="#999"
value={password}
onChangeText={(text) => {
setPassword(text);
if (validationErrors.password) {
setValidationErrors((prev) => ({ ...prev, password: undefined }));
}
}}
secureTextEntry={!showPassword}
autoCapitalize="none"
autoCorrect={false}
autoComplete="password-new"
editable={!isLoading}
/>
<TouchableOpacity
style={styles.showPasswordButton}
onPress={() => setShowPassword(!showPassword)}
disabled={isLoading}
>
<Text style={styles.showPasswordText}>
{showPassword ? 'Hide' : 'Show'}
</Text>
</TouchableOpacity>
</View>
{validationErrors.password && (
<Text style={styles.errorText}>{validationErrors.password}</Text>
)}
{/* Password Strength Indicator */}
{password.length > 0 && (
<View style={styles.strengthContainer}>
<View style={styles.strengthBars}>
{[1, 2, 3, 4].map((level) => (
<View
key={level}
style={[
styles.strengthBar,
{
backgroundColor:
level <= passwordStrength.level
? passwordStrength.color
: '#E0E0E0',
},
]}
/>
))}
</View>
<Text style={[styles.strengthText, { color: passwordStrength.color }]}>
{passwordStrength.text}
</Text>
</View>
)}
</View>
{/* Confirm Password Input */}
<View style={styles.inputContainer}>
<Text style={styles.label}>Confirm Password</Text>
<TextInput
style={[
styles.input,
validationErrors.confirmPassword && styles.inputError,
]}
placeholder="Confirm your password"
placeholderTextColor="#999"
value={confirmPassword}
onChangeText={(text) => {
setConfirmPassword(text);
if (validationErrors.confirmPassword) {
setValidationErrors((prev) => ({
...prev,
confirmPassword: undefined,
}));
}
}}
secureTextEntry={!showPassword}
autoCapitalize="none"
autoCorrect={false}
autoComplete="password-new"
editable={!isLoading}
/>
{validationErrors.confirmPassword && (
<Text style={styles.errorText}>{validationErrors.confirmPassword}</Text>
)}
</View>
{/* API Error */}
{error && (
<View style={styles.apiError}>
<Text style={styles.apiErrorText}>{error}</Text>
</View>
)}
{/* Create Account Button */}
<TouchableOpacity
style={[styles.button, isLoading && styles.buttonDisabled]}
onPress={handleSignup}
disabled={isLoading}
>
{isLoading ? (
<ActivityIndicator color="#FFFFFF" />
) : (
<Text style={styles.buttonText}>Create Account</Text>
)}
</TouchableOpacity>
{/* Terms */}
<Text style={styles.termsText}>
By creating an account, you agree to our{' '}
<Text style={styles.termsLink}>Terms of Service</Text> and{' '}
<Text style={styles.termsLink}>Privacy Policy</Text>
</Text>
{/* Sign In Link */}
<View style={styles.loginContainer}>
<Text style={styles.loginText}>Already have an account? </Text>
<TouchableOpacity onPress={handleLogin} disabled={isLoading}>
<Text style={styles.loginLink}>Sign In</Text>
</TouchableOpacity>
</View>
</View>
</ScrollView>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#FFFFFF',
},
scrollContent: {
flexGrow: 1,
paddingHorizontal: 24,
paddingTop: 60,
paddingBottom: 40,
},
header: {
marginBottom: 32,
},
title: {
fontSize: 32,
fontWeight: 'bold',
color: '#333333',
marginBottom: 8,
},
subtitle: {
fontSize: 16,
color: '#666666',
},
form: {
flex: 1,
},
inputContainer: {
marginBottom: 20,
},
label: {
fontSize: 14,
fontWeight: '600',
color: '#333333',
marginBottom: 8,
},
input: {
height: 50,
borderWidth: 1,
borderColor: '#E0E0E0',
borderRadius: 8,
paddingHorizontal: 16,
fontSize: 16,
color: '#333333',
backgroundColor: '#FAFAFA',
},
inputError: {
borderColor: '#FF3B30',
},
passwordContainer: {
position: 'relative',
},
passwordInput: {
paddingRight: 60,
},
showPasswordButton: {
position: 'absolute',
right: 16,
top: 0,
bottom: 0,
justifyContent: 'center',
},
showPasswordText: {
fontSize: 14,
color: '#007AFF',
fontWeight: '600',
},
errorText: {
fontSize: 12,
color: '#FF3B30',
marginTop: 4,
},
strengthContainer: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 8,
},
strengthBars: {
flexDirection: 'row',
flex: 1,
gap: 4,
},
strengthBar: {
flex: 1,
height: 4,
borderRadius: 2,
},
strengthText: {
fontSize: 12,
fontWeight: '600',
marginLeft: 8,
minWidth: 50,
},
apiError: {
backgroundColor: '#FFF0F0',
borderRadius: 8,
padding: 12,
marginBottom: 16,
borderWidth: 1,
borderColor: '#FF3B30',
},
apiErrorText: {
fontSize: 14,
color: '#FF3B30',
textAlign: 'center',
},
button: {
height: 50,
backgroundColor: '#007AFF',
borderRadius: 8,
justifyContent: 'center',
alignItems: 'center',
marginBottom: 16,
},
buttonDisabled: {
backgroundColor: '#B0B0B0',
},
buttonText: {
fontSize: 16,
fontWeight: '600',
color: '#FFFFFF',
},
termsText: {
fontSize: 12,
color: '#666666',
textAlign: 'center',
marginBottom: 24,
lineHeight: 18,
},
termsLink: {
color: '#007AFF',
fontWeight: '600',
},
loginContainer: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
},
loginText: {
fontSize: 14,
color: '#666666',
},
loginLink: {
fontSize: 14,
color: '#007AFF',
fontWeight: '600',
},
});

View file

@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React from 'react';
import {
View,
ScrollView,
@ -7,8 +7,11 @@ import {
SafeAreaView,
TouchableOpacity,
Alert,
ActivityIndicator,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { useAuthStore } from '../lib/auth-store';
const styles = StyleSheet.create({
container: {
@ -111,21 +114,38 @@ const styles = StyleSheet.create({
});
export default function ProfileScreen() {
const [isLoggedIn, setIsLoggedIn] = useState(false);
const router = useRouter();
const { user, isAuthenticated, isLoading, logout } = useAuthStore();
const handleSignOut = () => {
Alert.alert('Sign Out', 'Are you sure you want to sign out?', [
{ text: 'Cancel', onPress: () => {} },
{ text: 'Cancel', style: 'cancel' },
{
text: 'Sign Out',
onPress: () => {
setIsLoggedIn(false);
style: 'destructive',
onPress: async () => {
await logout();
router.replace('/(auth)/login');
},
},
]);
};
if (!isLoggedIn) {
const handleSignIn = () => {
router.push('/(auth)/login');
};
// Get user initials for avatar
const getInitials = () => {
if (!user?.name) return '?';
const names = user.name.split(' ');
if (names.length >= 2) {
return `${names[0][0]}${names[1][0]}`.toUpperCase();
}
return names[0][0].toUpperCase();
};
if (!isAuthenticated) {
return (
<SafeAreaView style={styles.container}>
<View style={styles.profileHeader}>
@ -139,7 +159,7 @@ export default function ProfileScreen() {
<View style={styles.section}>
<TouchableOpacity
style={[styles.menuItem, styles.menuItemFirst, styles.menuItemLast]}
onPress={() => setIsLoggedIn(true)}
onPress={handleSignIn}
>
<View style={styles.menuIcon}>
<Ionicons name="log-in" size={20} color="#0066cc" />
@ -182,10 +202,10 @@ export default function ProfileScreen() {
<ScrollView showsVerticalScrollIndicator={false}>
<View style={styles.profileHeader}>
<View style={styles.avatar}>
<Text style={styles.avatarText}>👤</Text>
<Text style={styles.avatarText}>{getInitials()}</Text>
</View>
<Text style={styles.profileName}>John Doe</Text>
<Text style={styles.profileStatus}>Active Member</Text>
<Text style={styles.profileName}>{user?.name || 'User'}</Text>
<Text style={styles.profileStatus}>{user?.email || 'Active Member'}</Text>
</View>
<View style={styles.section}>

View file

@ -1,16 +1,57 @@
import { Stack } from 'expo-router';
import { Stack, useRouter, useSegments } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { useEffect } from 'react';
import { View, ActivityIndicator, StyleSheet } from 'react-native';
import { useAuthStore } from './lib/auth-store';
export const unstable_settings = {
initialRouteName: '(tabs)',
};
export default function RootLayout() {
// Auth protection hook
function useProtectedRoute() {
const { isAuthenticated, isInitialized } = useAuthStore();
const segments = useSegments();
const router = useRouter();
useEffect(() => {
// Setup any global configuration here
if (!isInitialized) {
return;
}
const inAuthGroup = segments[0] === '(auth)';
if (!isAuthenticated && !inAuthGroup) {
// Redirect to login if not authenticated and not in auth group
router.replace('/(auth)/login');
} else if (isAuthenticated && inAuthGroup) {
// Redirect to tabs if authenticated and in auth group
router.replace('/(tabs)');
}
}, [isAuthenticated, isInitialized, segments]);
}
export default function RootLayout() {
const { initialize, isInitialized } = useAuthStore();
// Initialize auth state on app start
useEffect(() => {
initialize();
}, []);
// Use protected route hook
useProtectedRoute();
// Show loading screen while initializing
if (!isInitialized) {
return (
<View style={styles.loadingContainer}>
<StatusBar style="dark" />
<ActivityIndicator size="large" color="#007AFF" />
</View>
);
}
return (
<>
<StatusBar style="dark" />
@ -21,3 +62,12 @@ export default function RootLayout() {
</>
);
}
const styles = StyleSheet.create({
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#FFFFFF',
},
});

284
mobile/app/lib/api.ts Normal file
View file

@ -0,0 +1,284 @@
import axios, { AxiosError, AxiosInstance, InternalAxiosRequestConfig } from 'axios';
import AsyncStorage from '@react-native-async-storage/async-storage';
import {
LoginRequest,
SignupRequest,
ForgotPasswordRequest,
ResetPasswordRequest,
AuthResponse,
User,
Resource,
ResourceCategory,
CommunitySpace,
UserPreferences,
ApiError,
} from './types';
// Storage keys
const TOKEN_KEY = 'auth_token';
const REFRESH_TOKEN_KEY = 'refresh_token';
// API base URL - update this for your environment
const API_BASE_URL = process.env.EXPO_PUBLIC_API_URL || 'https://api.morethanadiagnosis.org/api/v1';
// Create axios instance
const api: AxiosInstance = axios.create({
baseURL: API_BASE_URL,
timeout: 30000,
headers: {
'Content-Type': 'application/json',
},
});
// Request interceptor - add auth token to requests
api.interceptors.request.use(
async (config: InternalAxiosRequestConfig) => {
const token = await AsyncStorage.getItem(TOKEN_KEY);
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Response interceptor - handle token refresh
api.interceptors.response.use(
(response) => response,
async (error: AxiosError<ApiError>) => {
const originalRequest = error.config;
// If 401 and we haven't tried to refresh yet
if (error.response?.status === 401 && originalRequest && !originalRequest._retry) {
originalRequest._retry = true;
try {
const refreshToken = await AsyncStorage.getItem(REFRESH_TOKEN_KEY);
if (refreshToken) {
const response = await axios.post<AuthResponse>(`${API_BASE_URL}/auth/refresh`, {
refreshToken,
});
const { accessToken, refreshToken: newRefreshToken } = response.data;
// Store new tokens
await AsyncStorage.setItem(TOKEN_KEY, accessToken);
await AsyncStorage.setItem(REFRESH_TOKEN_KEY, newRefreshToken);
// Retry original request with new token
if (originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
}
return api(originalRequest);
}
} catch (refreshError) {
// Refresh failed - clear tokens and redirect to login
await AsyncStorage.multiRemove([TOKEN_KEY, REFRESH_TOKEN_KEY]);
// The auth store should handle navigation
}
}
return Promise.reject(error);
}
);
// Extend AxiosRequestConfig to include _retry property
declare module 'axios' {
export interface InternalAxiosRequestConfig {
_retry?: boolean;
}
}
// ==========================================
// Authentication API
// ==========================================
export const authApi = {
login: async (data: LoginRequest): Promise<AuthResponse> => {
const response = await api.post<AuthResponse>('/auth/login', data);
const { accessToken, refreshToken } = response.data;
// Store tokens
await AsyncStorage.setItem(TOKEN_KEY, accessToken);
await AsyncStorage.setItem(REFRESH_TOKEN_KEY, refreshToken);
return response.data;
},
signup: async (data: SignupRequest): Promise<AuthResponse> => {
const response = await api.post<AuthResponse>('/auth/signup', data);
const { accessToken, refreshToken } = response.data;
// Store tokens
await AsyncStorage.setItem(TOKEN_KEY, accessToken);
await AsyncStorage.setItem(REFRESH_TOKEN_KEY, refreshToken);
return response.data;
},
forgotPassword: async (data: ForgotPasswordRequest): Promise<{ message: string }> => {
const response = await api.post<{ message: string }>('/auth/forgot-password', data);
return response.data;
},
resetPassword: async (data: ResetPasswordRequest): Promise<{ message: string }> => {
const response = await api.post<{ message: string }>('/auth/reset-password', data);
return response.data;
},
logout: async (): Promise<void> => {
try {
await api.post('/auth/logout');
} catch {
// Ignore errors on logout
} finally {
await AsyncStorage.multiRemove([TOKEN_KEY, REFRESH_TOKEN_KEY]);
}
},
refreshToken: async (): Promise<AuthResponse> => {
const refreshToken = await AsyncStorage.getItem(REFRESH_TOKEN_KEY);
if (!refreshToken) {
throw new Error('No refresh token available');
}
const response = await api.post<AuthResponse>('/auth/refresh', { refreshToken });
const { accessToken, refreshToken: newRefreshToken } = response.data;
await AsyncStorage.setItem(TOKEN_KEY, accessToken);
await AsyncStorage.setItem(REFRESH_TOKEN_KEY, newRefreshToken);
return response.data;
},
getStoredToken: async (): Promise<string | null> => {
return AsyncStorage.getItem(TOKEN_KEY);
},
};
// ==========================================
// User API
// ==========================================
export const userApi = {
getMe: async (): Promise<User> => {
const response = await api.get<User>('/user/me');
return response.data;
},
updateMe: async (data: Partial<User>): Promise<User> => {
const response = await api.put<User>('/user/me', data);
return response.data;
},
getPreferences: async (): Promise<UserPreferences> => {
const response = await api.get<UserPreferences>('/user/preferences');
return response.data;
},
updatePreferences: async (data: Partial<UserPreferences>): Promise<UserPreferences> => {
const response = await api.put<UserPreferences>('/user/preferences', data);
return response.data;
},
};
// ==========================================
// Resources API
// ==========================================
export const resourcesApi = {
getCategories: async (): Promise<ResourceCategory[]> => {
const response = await api.get<ResourceCategory[]>('/resources/categories');
return response.data;
},
getAll: async (): Promise<Resource[]> => {
const response = await api.get<Resource[]>('/resources');
return response.data;
},
getById: async (id: string): Promise<Resource> => {
const response = await api.get<Resource>(`/resources/${id}`);
return response.data;
},
getByCategory: async (categoryId: string): Promise<Resource[]> => {
const response = await api.get<Resource[]>(`/resources/category/${categoryId}`);
return response.data;
},
};
// ==========================================
// Community API
// ==========================================
export const communityApi = {
getSpaces: async (): Promise<CommunitySpace[]> => {
const response = await api.get<CommunitySpace[]>('/community');
return response.data;
},
getSpaceById: async (id: string): Promise<CommunitySpace> => {
const response = await api.get<CommunitySpace>(`/community/${id}`);
return response.data;
},
joinSpace: async (id: string): Promise<{ message: string }> => {
const response = await api.post<{ message: string }>(`/community/${id}/join`);
return response.data;
},
leaveSpace: async (id: string): Promise<{ message: string }> => {
const response = await api.post<{ message: string }>(`/community/${id}/leave`);
return response.data;
},
};
// ==========================================
// Helper functions
// ==========================================
export const handleApiError = (error: unknown): string => {
if (axios.isAxiosError(error)) {
const axiosError = error as AxiosError<ApiError>;
// Network error
if (!axiosError.response) {
return 'Network error. Please check your connection.';
}
// API error response
const apiError = axiosError.response.data;
if (apiError?.message) {
return apiError.message;
}
// HTTP status errors
switch (axiosError.response.status) {
case 400:
return 'Invalid request. Please check your input.';
case 401:
return 'Invalid credentials. Please try again.';
case 403:
return 'You do not have permission to perform this action.';
case 404:
return 'Resource not found.';
case 422:
return 'Validation error. Please check your input.';
case 500:
return 'Server error. Please try again later.';
default:
return 'An unexpected error occurred.';
}
}
if (error instanceof Error) {
return error.message;
}
return 'An unexpected error occurred.';
};
export default api;

View file

@ -0,0 +1,220 @@
import { create } from 'zustand';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { authApi, userApi, handleApiError } from './api';
import { User, LoginRequest, SignupRequest } from './types';
// Storage keys
const USER_KEY = 'auth_user';
interface AuthState {
// State
user: User | null;
isAuthenticated: boolean;
isLoading: boolean;
isInitialized: boolean;
error: string | null;
// Actions
initialize: () => Promise<void>;
login: (data: LoginRequest) => Promise<void>;
signup: (data: SignupRequest) => Promise<void>;
logout: () => Promise<void>;
clearError: () => void;
setUser: (user: User) => void;
updateUser: (data: Partial<User>) => Promise<void>;
}
export const useAuthStore = create<AuthState>((set, get) => ({
// Initial state
user: null,
isAuthenticated: false,
isLoading: false,
isInitialized: false,
error: null,
// Initialize auth state on app start
initialize: async () => {
try {
set({ isLoading: true, error: null });
// Check for stored token
const token = await authApi.getStoredToken();
if (token) {
// Try to fetch current user
try {
const user = await userApi.getMe();
await AsyncStorage.setItem(USER_KEY, JSON.stringify(user));
set({
user,
isAuthenticated: true,
isInitialized: true,
isLoading: false,
});
} catch {
// Token invalid or expired - try to refresh
try {
await authApi.refreshToken();
const user = await userApi.getMe();
await AsyncStorage.setItem(USER_KEY, JSON.stringify(user));
set({
user,
isAuthenticated: true,
isInitialized: true,
isLoading: false,
});
} catch {
// Refresh failed - clear auth state
await authApi.logout();
await AsyncStorage.removeItem(USER_KEY);
set({
user: null,
isAuthenticated: false,
isInitialized: true,
isLoading: false,
});
}
}
} else {
// No token - check for cached user data for offline display
const cachedUser = await AsyncStorage.getItem(USER_KEY);
set({
user: cachedUser ? JSON.parse(cachedUser) : null,
isAuthenticated: false,
isInitialized: true,
isLoading: false,
});
}
} catch (error) {
set({
user: null,
isAuthenticated: false,
isInitialized: true,
isLoading: false,
error: handleApiError(error),
});
}
},
// Login
login: async (data: LoginRequest) => {
try {
set({ isLoading: true, error: null });
const response = await authApi.login(data);
const { user } = response;
// Cache user data
await AsyncStorage.setItem(USER_KEY, JSON.stringify(user));
set({
user,
isAuthenticated: true,
isLoading: false,
error: null,
});
} catch (error) {
set({
isLoading: false,
error: handleApiError(error),
});
throw error;
}
},
// Signup
signup: async (data: SignupRequest) => {
try {
set({ isLoading: true, error: null });
const response = await authApi.signup(data);
const { user } = response;
// Cache user data
await AsyncStorage.setItem(USER_KEY, JSON.stringify(user));
set({
user,
isAuthenticated: true,
isLoading: false,
error: null,
});
} catch (error) {
set({
isLoading: false,
error: handleApiError(error),
});
throw error;
}
},
// Logout
logout: async () => {
try {
set({ isLoading: true });
await authApi.logout();
await AsyncStorage.removeItem(USER_KEY);
set({
user: null,
isAuthenticated: false,
isLoading: false,
error: null,
});
} catch (error) {
// Still clear local state even if API call fails
await AsyncStorage.removeItem(USER_KEY);
set({
user: null,
isAuthenticated: false,
isLoading: false,
error: null,
});
}
},
// Clear error
clearError: () => {
set({ error: null });
},
// Set user (for updates from other parts of the app)
setUser: (user: User) => {
set({ user });
AsyncStorage.setItem(USER_KEY, JSON.stringify(user));
},
// Update user profile
updateUser: async (data: Partial<User>) => {
try {
set({ isLoading: true, error: null });
const updatedUser = await userApi.updateMe(data);
await AsyncStorage.setItem(USER_KEY, JSON.stringify(updatedUser));
set({
user: updatedUser,
isLoading: false,
});
} catch (error) {
set({
isLoading: false,
error: handleApiError(error),
});
throw error;
}
},
}));
// Selector hooks for specific state slices
export const useUser = () => useAuthStore((state) => state.user);
export const useIsAuthenticated = () => useAuthStore((state) => state.isAuthenticated);
export const useIsLoading = () => useAuthStore((state) => state.isLoading);
export const useAuthError = () => useAuthStore((state) => state.error);
export const useIsInitialized = () => useAuthStore((state) => state.isInitialized);

93
mobile/app/lib/types.ts Normal file
View file

@ -0,0 +1,93 @@
// User types
export interface User {
id: string;
email: string;
name: string;
displayName?: string;
avatarUrl?: string;
createdAt: string;
updatedAt: string;
}
// Auth types
export interface LoginRequest {
email: string;
password: string;
}
export interface SignupRequest {
email: string;
password: string;
name: string;
}
export interface ForgotPasswordRequest {
email: string;
}
export interface ResetPasswordRequest {
token: string;
newPassword: string;
}
export interface AuthResponse {
user: User;
accessToken: string;
refreshToken: string;
expiresIn: number;
}
export interface RefreshTokenRequest {
refreshToken: string;
}
// Resource types
export interface Resource {
id: string;
title: string;
description: string;
category: string;
url?: string;
imageUrl?: string;
createdAt: string;
}
export interface ResourceCategory {
id: string;
name: string;
description: string;
icon: string;
resourceCount: number;
}
// Community types
export interface CommunitySpace {
id: string;
name: string;
description: string;
memberCount: number;
isPrivate: boolean;
imageUrl?: string;
}
// User preferences
export interface UserPreferences {
notifications: boolean;
emailUpdates: boolean;
darkMode: boolean;
language: string;
}
// API Error response
export interface ApiError {
message: string;
code?: string;
details?: Record<string, string[]>;
}
// Generic API response wrapper
export interface ApiResponse<T> {
data: T;
message?: string;
success: boolean;
}

837
mobile/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -16,12 +16,14 @@
"type-check": "tsc --noEmit"
},
"dependencies": {
"@react-native-async-storage/async-storage": "^2.2.0",
"@tanstack/react-query": "^5.25.0",
"axios": "^1.6.0",
"expo": "^51.0.0",
"expo-constants": "~15.4.0",
"expo-linking": "~6.0.0",
"expo-router": "^3.4.0",
"expo-secure-store": "^15.0.7",
"expo-splash-screen": "^31.0.11",
"expo-status-bar": "~1.12.1",
"react": "^18.2.0",