initialise and add react native app environment, UI and foundational code for user authentication

This commit is contained in:
Joseph D'Souza 2026-02-06 13:16:15 +01:00
parent 02c713f416
commit 30433b7183
27 changed files with 6974 additions and 0 deletions

40
mobile/.yarnclean Normal file
View File

@ -0,0 +1,40 @@
# test directories
__tests__
test
tests
powered-test
# asset directories
doc
website
# examples
example
examples
# code coverage directories
coverage
.nyc_output
# build scripts
Makefile
Gulpfile.js
Gruntfile.js
# configs
appveyor.yml
circle.yml
codeship-services.yml
codeship-steps.yml
wercker.yml
.tern-project
.editorconfig
.eslintrc
.jshintrc
.flowconfig
.documentup.json
.yarn-metadata.json
.travis.yml
# misc
*.md

34
mobile/App.tsx Normal file
View File

@ -0,0 +1,34 @@
import { StatusBar } from 'expo-status-bar';
import { useState } from 'react';
import { useColorScheme } from 'react-native';
import { NavigationContainer } from '@react-navigation/native';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import AppNavigator from './src/navigation/AppNavigator';
import AnimatedSplash from './src/screens/AnimatedSplash';
import * as SplashScreen from 'expo-splash-screen';
import * as SystemUI from 'expo-system-ui';
import { AppLightTheme, AppDarkTheme } from './src/navigation/themes';
SplashScreen.preventAutoHideAsync();
export default function App() {
const [isSplashFinished, setSplashFinished] = useState(false);
const colorScheme = useColorScheme();
// Fix for white background behind modals
const backgroundColor = colorScheme === 'dark' ? AppDarkTheme.colors.background : AppLightTheme.colors.background;
SystemUI.setBackgroundColorAsync(backgroundColor);
return (
<SafeAreaProvider>
<NavigationContainer theme={colorScheme === 'dark' ? AppDarkTheme : AppLightTheme}>
<AppNavigator />
<StatusBar style={colorScheme === 'dark' ? "light" : "dark"} />
</NavigationContainer>
{!isSplashFinished && (
<AnimatedSplash onFinish={() => setSplashFinished(true)} />
)}
</SafeAreaProvider>
);
}

49
mobile/app.json Normal file
View File

@ -0,0 +1,49 @@
{
"expo": {
"name": "CasaDoc",
"slug": "casadoc-mobile",
"scheme": "casadoc",
"version": "1.0.0",
"orientation": "portrait",
"backgroundColor": "#ffffff",
"dark": {
"backgroundColor": "#000000"
},
"userInterfaceStyle": "automatic",
"splash": {
"image": "./assets/icons/splash-icon-dark.png",
"imageWidth": 200,
"resizeMode": "contain",
"backgroundColor": "#ffffff",
"dark": {
"image": "./assets/icons/splash-icon-light.png",
"backgroundColor": "#1a202c"
}
},
"assetBundlePatterns": [
"**/*"
],
"newArchEnabled": true,
"ios": {
"supportsTablet": true,
"icon": {
"dark": "./assets/icons/ios-dark.png",
"light": "./assets/icons/ios-light.png",
"tinted": "./assets/icons/ios-tinted.png"
},
"bundleIdentifier": "com.cesoft.casadoc",
"appleTeamId": "NBX9G827SH"
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/icons/android-adaptive.png",
"monochromeImage": "./assets/icons/android-adaptive.png",
"backgroundColor": "#ffffff"
},
"package": "com.cesoft.casadoc"
},
"web": {
"favicon": "./assets/favicon.png"
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

6
mobile/babel.config.js Normal file
View File

@ -0,0 +1,6 @@
module.exports = function(api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
};
};

8
mobile/index.js Normal file
View File

@ -0,0 +1,8 @@
import { registerRootComponent } from 'expo';
import App from './App';
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
// It also ensures that whether you load the app in Expo Go or in a native build,
// the environment is set up appropriately
registerRootComponent(App);

36
mobile/package.json Normal file
View File

@ -0,0 +1,36 @@
{
"name": "casadoc-mobile",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"start": "expo start --dev-client",
"start:go": "expo start",
"android": "expo run:android",
"ios": "expo run:ios",
"web": "expo start --web"
},
"dependencies": {
"@react-native-async-storage/async-storage": "1.23.1",
"@react-navigation/native": "^7.1.28",
"@react-navigation/native-stack": "^7.12.0",
"expo": "~52.0.0",
"expo-apple-authentication": "~7.1.3",
"expo-auth-session": "~6.0.3",
"expo-crypto": "~14.0.2",
"expo-splash-screen": "~0.29.24",
"expo-status-bar": "~2.0.0",
"expo-system-ui": "~4.0.9",
"expo-web-browser": "~14.0.2",
"react": "18.3.1",
"react-native": "0.76.9",
"react-native-safe-area-context": "4.12.0",
"react-native-screens": "~4.4.0",
"zustand": "^5.0.11"
},
"devDependencies": {
"@babel/core": "^7.20.0",
"@types/react": "~18.3.12",
"typescript": "^5.3.3"
},
"private": true
}

View File

@ -0,0 +1,100 @@
import React from 'react';
import { View, Text, TouchableOpacity, StyleSheet, Platform, useColorScheme, Image } from 'react-native';
import * as AppleAuthentication from 'expo-apple-authentication';
import { FontAwesome } from '@expo/vector-icons';
import { COLORS } from '../theme/colors';
interface SocialButtonsProps {
onGooglePress: () => void;
onFacebookPress: () => void;
onApplePress: () => void;
}
export default function SocialButtons({ onGooglePress, onFacebookPress, onApplePress }: SocialButtonsProps) {
const colorScheme = useColorScheme();
const isDark = colorScheme === 'dark';
return (
<View style={styles.container}>
<Text style={[styles.divider, { color: isDark ? '#a0aec0' : '#888' }]}>Or continue with</Text>
<View style={styles.row}>
{/* Google Button - Multicolored Image */}
<TouchableOpacity
style={[styles.circleButton, { backgroundColor: 'white' }]}
onPress={onGooglePress}
>
<Image
source={require('../../assets/icons/google.png')}
style={{ width: 24, height: 24 }}
resizeMode="contain"
/>
</TouchableOpacity>
{/* Facebook Button - White F on Blue Background */}
<TouchableOpacity
style={[styles.circleButton, { backgroundColor: '#1877F2', borderWidth: 0 }]}
onPress={onFacebookPress}
>
<FontAwesome name="facebook" size={24} color="white" />
</TouchableOpacity>
{/* Apple Button - White on Black (Light) / Black on White (Dark) */}
{Platform.OS === 'ios' && (
<TouchableOpacity
style={[
styles.circleButton,
{ backgroundColor: isDark ? 'white' : 'black', borderWidth: 0 }
]}
onPress={onApplePress}
>
<FontAwesome name="apple" size={24} color={isDark ? "black" : "white"} />
</TouchableOpacity>
)}
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
marginTop: 30,
width: '100%',
alignItems: 'center',
},
divider: {
textAlign: 'center',
marginBottom: 20,
fontSize: 14,
},
row: {
flexDirection: 'row',
justifyContent: 'center',
gap: 20, // Space between buttons
},
circleButton: {
width: 50,
height: 50,
borderRadius: 25,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'white',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
google: {
backgroundColor: 'white',
},
facebook: {
backgroundColor: 'white',
},
appleLight: {
backgroundColor: 'white',
},
appleDark: {
backgroundColor: '#333', // Dark background for Apple button in Dark Mode
}
});

View File

@ -0,0 +1,9 @@
export const GOOGLE_CONFIG = {
iosClientId: '790885459780-8j8tte7sm2vl3kg98aadenvqp9pc5i3g.apps.googleusercontent.com',
androidClientId: 'YOUR_ANDROID_CLIENT_ID.apps.googleusercontent.com',
webClientId: '790885459780-4c7580aqb5uvtt13ec1386kl6k3fdns4.apps.googleusercontent.com',
};
export const FACEBOOK_CONFIG = {
clientId: '1284431066897297',
};

View File

@ -0,0 +1,48 @@
import React from 'react';
import { View, ActivityIndicator } from 'react-native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import AuthScreen from '../screens/AuthScreen';
import HomeScreen from '../screens/HomeScreen';
import ForgotPasswordScreen from '../screens/ForgotPasswordScreen';
import { useAuthStore } from '../store/useAuthStore';
// Define the types for your stack
export type RootStackParamList = {
Auth: undefined;
ForgotPassword: undefined;
Home: undefined;
};
const Stack = createNativeStackNavigator<RootStackParamList>();
export default function AppNavigator() {
const user = useAuthStore((state) => state.user);
const isLoading = useAuthStore((state) => state.isLoading);
if (isLoading) {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<ActivityIndicator size="large" />
</View>
);
}
return (
<Stack.Navigator screenOptions={{ headerShown: false }}>
{user ? (
// Screens for logged-in users
<Stack.Screen name="Home" component={HomeScreen} />
) : (
// Screens for logged-out users
<Stack.Group>
<Stack.Screen name="Auth" component={AuthScreen} />
<Stack.Screen
name="ForgotPassword"
component={ForgotPasswordScreen}
options={{ presentation: 'fullScreenModal' }}
/>
</Stack.Group>
)}
</Stack.Navigator>
);
}

View File

@ -0,0 +1,24 @@
import { DefaultTheme, DarkTheme } from '@react-navigation/native';
import { COLORS } from '../theme/colors';
export const AppLightTheme = {
...DefaultTheme,
colors: {
...DefaultTheme.colors,
background: COLORS.LIGHT.background,
card: COLORS.LIGHT.card,
text: COLORS.LIGHT.text,
primary: COLORS.LIGHT.brand,
},
};
export const AppDarkTheme = {
...DarkTheme,
colors: {
...DarkTheme.colors,
background: COLORS.DARK.background,
card: COLORS.DARK.card,
text: COLORS.DARK.text,
primary: COLORS.DARK.brand,
},
};

View File

@ -0,0 +1,100 @@
import React, { useEffect, useRef, useState, useCallback } from 'react';
import { View, StyleSheet, Image, Animated, useColorScheme } from 'react-native';
import * as SplashScreen from 'expo-splash-screen';
import { COLORS } from '../theme/colors';
interface AnimatedSplashProps {
onFinish: () => void;
}
export default function AnimatedSplash({ onFinish }: AnimatedSplashProps) {
const fadeAnim = useRef(new Animated.Value(1)).current;
const [isAppReady, setAppReady] = useState(false);
const [isLayoutReady, setLayoutReady] = useState(false);
const colorScheme = useColorScheme();
const isDark = colorScheme === 'dark';
useEffect(() => {
async function prepare() {
try {
await new Promise(resolve => setTimeout(resolve, 1000));
} catch (e) {
console.warn(e);
} finally {
setAppReady(true);
}
}
prepare();
}, []);
const onLayoutRootView = useCallback(async () => {
setLayoutReady(true);
}, []);
useEffect(() => {
if (isAppReady && isLayoutReady) {
// Hide native splash screen
SplashScreen.hideAsync();
// Start fade out
Animated.timing(fadeAnim, {
toValue: 0,
duration: 1000,
useNativeDriver: true,
}).start(() => {
onFinish();
});
}
}, [isAppReady, isLayoutReady]);
return (
<Animated.View
style={[
styles.container,
{
opacity: fadeAnim,
backgroundColor: isDark ? '#000000' : '#ffffff'
}
]}
onLayout={onLayoutRootView}
>
<View style={styles.centered}>
<Image
source={
isDark
? require('../../assets/icons/splash-icon-light.png')
: require('../../assets/icons/splash-icon-dark.png')
}
style={styles.logo}
resizeMode="contain"
/>
</View>
</Animated.View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 9999,
},
centered: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
width: '100%',
height: '100%',
},
logo: {
width: '100%',
height: '100%',
}
});

View File

@ -0,0 +1,300 @@
import React, { useState, useEffect } from 'react';
import { View, Text, TextInput, TouchableOpacity, ActivityIndicator, Image, Switch, ScrollView, KeyboardAvoidingView, Platform, Alert, useColorScheme } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { authService } from '../services/api';
import { getThemeStyles, commonStyles } from '../theme/styles';
import { COLORS } from '../theme/colors';
import SocialButtons from '../components/SocialButtons';
import * as AppleAuthentication from 'expo-apple-authentication';
import * as Google from 'expo-auth-session/providers/google';
import * as Facebook from 'expo-auth-session/providers/facebook';
import * as WebBrowser from 'expo-web-browser';
import { GOOGLE_CONFIG, FACEBOOK_CONFIG } from '../config/social';
import { useAuthStore } from '../store/useAuthStore';
import { RootStackParamList } from '../navigation/AppNavigator';
WebBrowser.maybeCompleteAuthSession();
type AuthScreenNavigationProp = NativeStackNavigationProp<RootStackParamList, 'Auth'>;
export default function AuthScreen() {
const navigation = useNavigation<AuthScreenNavigationProp>();
const login = useAuthStore((state) => state.login);
const colorScheme = useColorScheme();
const isDark = colorScheme === 'dark';
const themeStyles = getThemeStyles(isDark);
const brandColor = isDark ? COLORS.DARK.brand : COLORS.LIGHT.brand;
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [username, setUsername] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [tosAccepted, setTosAccepted] = useState(false);
const [isRegistering, setIsRegistering] = useState(false);
const [loading, setLoading] = useState(false);
const [googleRequest, googleResponse, promptGoogleAsync] = Google.useAuthRequest(GOOGLE_CONFIG);
const [fbRequest, fbResponse, promptFacebookAsync] = Facebook.useAuthRequest(FACEBOOK_CONFIG);
useEffect(() => {
if (googleResponse?.type === 'success') {
const { authentication } = googleResponse;
if (authentication?.accessToken) {
handleSocialLogin('google', authentication.accessToken);
}
}
}, [googleResponse]);
useEffect(() => {
if (fbResponse?.type === 'success') {
const { authentication } = fbResponse;
if (authentication?.accessToken) {
handleSocialLogin('facebook', authentication.accessToken);
}
}
}, [fbResponse]);
const handleLogin = async () => {
if (!email || !password) {
Alert.alert('Error', 'Please fill in all fields');
return;
}
setLoading(true);
try {
const data = await authService.login(email, password);
if (data.token) {
login(data);
} else {
Alert.alert('Login Failed', data.message || 'Invalid credentials');
}
} catch (error) {
console.error(error);
Alert.alert('Network Error', 'Could not connect to server.');
} finally {
setLoading(false);
}
};
const handleRegister = async () => {
if (!email || !username || !password || !confirmPassword) {
Alert.alert('Error', 'Please fill in all fields');
return;
}
if (password !== confirmPassword) {
Alert.alert('Error', 'Passwords do not match');
return;
}
if (!tosAccepted) {
Alert.alert('Error', 'You must accept the Terms of Service');
return;
}
setLoading(true);
try {
const { ok, data } = await authService.register({
email,
username,
password,
password_confirmation: confirmPassword,
tos: true,
});
if (ok) {
Alert.alert('Success', 'Account created! Please log in.');
setIsRegistering(false);
} else {
const errorMessage = data.message || 'Registration failed';
const validationErrors = data.errors ? '\n' + Object.values(data.errors).flat().join('\n') : '';
Alert.alert('Registration Failed', errorMessage + validationErrors);
}
} catch (error) {
console.error(error);
Alert.alert('Network Error', 'Could not connect to server.');
} finally {
setLoading(false);
}
};
const handleSocialLogin = async (provider: string, token: string) => {
setLoading(true);
try {
const data = await authService.socialLogin(provider, token);
if (data.token) {
login(data);
} else {
Alert.alert('Social Login Failed', data.message || 'Could not verify token');
}
} catch (error) {
Alert.alert('Error', 'Failed to connect to server');
} finally {
setLoading(false);
}
};
const onAppleButtonPress = async () => {
try {
const credential = await AppleAuthentication.signInAsync({
requestedScopes: [
AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
AppleAuthentication.AppleAuthenticationScope.EMAIL,
],
});
if (credential.identityToken) {
handleSocialLogin('apple', credential.identityToken);
}
} catch (e: any) {
if (e.code === 'ERR_CANCELED') {
// User canceled
} else {
Alert.alert('Error', 'Apple Sign In failed');
}
}
};
const toggleMode = () => {
setIsRegistering(!isRegistering);
setEmail('');
setPassword('');
setUsername('');
setConfirmPassword('');
setTosAccepted(false);
};
return (
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={themeStyles.container}
>
<ScrollView contentContainerStyle={commonStyles.scrollContent}>
<View style={commonStyles.logoContainer}>
<Image
source={
isDark
? require('../../assets/icons/ios-dark.png') // Light logo on dark background
: require('../../assets/icons/ios-light.png') // Dark logo on light background
}
style={commonStyles.logo}
resizeMode="contain"
/>
</View>
<Text style={themeStyles.headerTitle}>CasaDoc</Text>
<Text style={[commonStyles.subtitle, themeStyles.subtitle]}>
{isRegistering ? 'Create a new account' : 'Sign in to your account'}
</Text>
<View style={[commonStyles.form, themeStyles.card]}>
<Text style={[commonStyles.label, themeStyles.subtitle]}>Email{isRegistering ? '' : ' or Username'}</Text>
<TextInput
style={[themeStyles.input]}
placeholder={isRegistering ? "name@example.com" : "Enter email or username"}
placeholderTextColor={isDark ? '#a0aec0' : '#a0aec0'}
value={email}
onChangeText={setEmail}
autoCapitalize="none"
keyboardType={isRegistering ? "email-address" : "default"}
autoCorrect={false}
/>
{isRegistering && (
<>
<Text style={[commonStyles.label, themeStyles.subtitle]}>Username</Text>
<TextInput
style={[themeStyles.input]}
placeholder="Choose a username"
placeholderTextColor={isDark ? '#a0aec0' : '#a0aec0'}
value={username}
onChangeText={setUsername}
autoCapitalize="none"
autoCorrect={false}
/>
</>
)}
<Text style={[commonStyles.label, themeStyles.subtitle]}>Password</Text>
<TextInput
style={[themeStyles.input]}
placeholder="Enter your password"
placeholderTextColor={isDark ? '#a0aec0' : '#a0aec0'}
value={password}
onChangeText={setPassword}
secureTextEntry
/>
{isRegistering && (
<>
<Text style={[commonStyles.label, themeStyles.subtitle]}>Confirm Password</Text>
<TextInput
style={[themeStyles.input]}
placeholder="Confirm your password"
placeholderTextColor={isDark ? '#a0aec0' : '#a0aec0'}
value={confirmPassword}
onChangeText={setConfirmPassword}
secureTextEntry
/>
<View style={commonStyles.tosContainer}>
<Switch
value={tosAccepted}
onValueChange={setTosAccepted}
trackColor={{ false: "#767577", true: brandColor }}
thumbColor={tosAccepted ? "#fff" : "#f4f3f4"}
/>
<Text style={[commonStyles.tosText, themeStyles.subtitle]}>I accept the Terms of Service</Text>
</View>
</>
)}
<TouchableOpacity
style={[themeStyles.button, loading && commonStyles.buttonDisabled]}
onPress={isRegistering ? handleRegister : handleLogin}
disabled={loading}
>
{loading ? (
<ActivityIndicator color="white" />
) : (
<Text style={themeStyles.buttonText}>{isRegistering ? 'Sign Up' : 'Sign In'}</Text>
)}
</TouchableOpacity>
{!isRegistering && (
<TouchableOpacity onPress={() => navigation.navigate('ForgotPassword')} style={{ alignSelf: 'flex-end', marginTop: 10 }}>
<Text style={themeStyles.linkText}>Forgot Password?</Text>
</TouchableOpacity>
)}
<SocialButtons
onGooglePress={() => {
if (googleRequest) {
promptGoogleAsync();
} else {
Alert.alert('Configuration Error', 'Google Auth Request is not ready. Check Client IDs.');
}
}}
onFacebookPress={() => {
if (fbRequest) {
promptFacebookAsync();
} else {
Alert.alert('Configuration Error', 'Facebook Auth Request is not ready. Check App ID.');
}
}}
onApplePress={onAppleButtonPress}
/>
<TouchableOpacity onPress={toggleMode} style={{ marginTop: 20, alignItems: 'center' }}>
<Text style={themeStyles.linkText}>
{isRegistering
? 'Already have an account? Sign In'
: 'Don\'t have an account? Sign Up'}
</Text>
</TouchableOpacity>
</View>
</ScrollView>
</KeyboardAvoidingView>
);
}

View File

@ -0,0 +1,95 @@
import React, { useState } from 'react';
import { View, Text, TextInput, TouchableOpacity, ActivityIndicator, Image, KeyboardAvoidingView, Platform, Alert, useColorScheme, ScrollView } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { authService } from '../services/api';
import { getThemeStyles, commonStyles } from '../theme/styles';
export default function ForgotPasswordScreen() {
const navigation = useNavigation();
const colorScheme = useColorScheme();
const isDark = colorScheme === 'dark';
const themeStyles = getThemeStyles(isDark);
const [email, setEmail] = useState('');
const [loading, setLoading] = useState(false);
const handleReset = async () => {
if (!email) {
Alert.alert('Error', 'Please enter your email');
return;
}
setLoading(true);
try {
const { ok, message } = await authService.sendPasswordResetEmail(email);
if (ok) {
Alert.alert(
'Check your email',
'We have sent you a password reset link.',
[{ text: 'OK', onPress: () => navigation.goBack() }]
);
} else {
Alert.alert('Error', message || 'Could not send reset link');
}
} catch (error) {
console.error(error);
Alert.alert('Network Error', 'Could not connect to server.');
} finally {
setLoading(false);
}
};
return (
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={themeStyles.container}
>
<ScrollView contentContainerStyle={commonStyles.scrollContent}>
<View style={commonStyles.logoContainer}>
<Image
source={isDark ? require('../../assets/icons/ios-dark.png') : require('../../assets/icons/ios-light.png')}
style={commonStyles.logo}
resizeMode="contain"
/>
</View>
<Text style={themeStyles.headerTitle}>CasaDoc</Text>
<Text style={[commonStyles.subtitle, themeStyles.subtitle]}>
Reset Password
</Text>
<View style={[commonStyles.form, themeStyles.card]}>
<Text style={[commonStyles.label, themeStyles.subtitle]}>Email Address</Text>
<TextInput
style={[themeStyles.input]}
placeholder="name@example.com"
placeholderTextColor={isDark ? '#a0aec0' : '#a0aec0'}
value={email}
onChangeText={setEmail}
autoCapitalize="none"
keyboardType="email-address"
autoCorrect={false}
/>
<TouchableOpacity
style={[themeStyles.button, loading && commonStyles.buttonDisabled]}
onPress={handleReset}
disabled={loading}
>
{loading ? (
<ActivityIndicator color="white" />
) : (
<Text style={themeStyles.buttonText}>Send Reset Link</Text>
)}
</TouchableOpacity>
<TouchableOpacity onPress={() => navigation.goBack()} style={{ marginTop: 20, alignItems: 'center' }}>
<Text style={themeStyles.linkText}>
Back to Login
</Text>
</TouchableOpacity>
</View>
</ScrollView>
</KeyboardAvoidingView>
);
}

View File

@ -0,0 +1,43 @@
import React from 'react';
import { View, Text, TouchableOpacity, useColorScheme } from 'react-native';
import { getThemeStyles, commonStyles } from '../theme/styles';
import { useAuthStore } from '../store/useAuthStore';
export default function HomeScreen() {
const { user, logout } = useAuthStore();
const colorScheme = useColorScheme();
const isDark = colorScheme === 'dark';
const themeStyles = getThemeStyles(isDark);
if (!user) return null; // Should not happen if navigation logic is correct
return (
<View style={commonStyles.content}>
<Text style={[commonStyles.title, themeStyles.text]}>Welcome Back!</Text>
<View style={[commonStyles.card, themeStyles.card]}>
<View style={commonStyles.userInfo}>
<View style={themeStyles.avatar}>
<Text style={commonStyles.avatarText}>
{user.user?.email?.charAt(0).toUpperCase() || 'U'}
</Text>
</View>
<View>
<Text style={[commonStyles.label, themeStyles.subtitle]}>Logged in as:</Text>
<Text style={[commonStyles.value, themeStyles.text]}>{user.user?.email}</Text>
</View>
</View>
<View style={[commonStyles.tokenBox, themeStyles.tokenBox]}>
<Text style={[commonStyles.label, themeStyles.subtitle]}>Your API Token:</Text>
<Text style={commonStyles.token} numberOfLines={1}>
{user.token}
</Text>
</View>
</View>
<TouchableOpacity style={commonStyles.logoutButton} onPress={logout}>
<Text style={commonStyles.logoutText}>Sign Out</Text>
</TouchableOpacity>
</View>
);
}

View File

@ -0,0 +1,74 @@
export const API_URL = 'https://supercuriously-precongested-chester.ngrok-free.dev/api';
export const authService = {
login: async (username, password) => {
console.log('Attempting login to:', `${API_URL}/login`);
const response = await fetch(`${API_URL}/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({
username, // Vanguard expects 'username' key even for emails
password,
device_name: 'react-native-app',
}),
});
return response.json();
},
register: async (data) => {
const response = await fetch(`${API_URL}/register`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({
...data,
device_name: 'react-native-app',
}),
});
return {
ok: response.ok,
data: await response.json(),
};
},
sendPasswordResetEmail: async (email) => {
const response = await fetch(`${API_URL}/password/remind`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({ email }),
});
if (response.ok) {
return { ok: true };
}
// Parse error message
const data = await response.json();
return { ok: false, message: data.message || 'Failed to send reset link' };
},
socialLogin: async (provider, token) => {
console.log(`Attempting ${provider} login`);
const response = await fetch(`${API_URL}/login/social`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({
network: provider,
social_token: token,
device_name: 'react-native-app',
}),
});
return response.json();
},
};

View File

@ -0,0 +1,39 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
interface User {
token: string;
user: {
id: number;
email: string;
// Add other user fields as needed
};
}
interface AuthState {
user: User | null;
isLoading: boolean;
login: (userData: User) => void;
logout: () => void;
setLoading: (loading: boolean) => void;
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
isLoading: true,
login: (userData) => set({ user: userData }),
logout: () => set({ user: null }),
setLoading: (loading) => set({ isLoading: loading }),
}),
{
name: 'auth-storage',
storage: createJSONStorage(() => AsyncStorage),
onRehydrateStorage: () => (state) => {
state?.setLoading(false);
},
}
)
);

View File

@ -0,0 +1,24 @@
export const COLORS = {
LIGHT: {
brand: '#23568C', // Dark Blue
background: '#f5f5f5',
card: '#ffffff',
text: '#333333',
subtitle: '#666666',
inputBackground: '#f7fafc',
inputBorder: '#e2e8f0',
inputText: '#2d3748',
tokenBox: '#f7fafc',
},
DARK: {
brand: '#CBEFFF', // Light Blue
background: '#1a202c',
card: '#2d3748',
text: '#ffffff',
subtitle: '#a0aec0',
inputBackground: '#4a5568',
inputBorder: '#718096',
inputText: '#ffffff',
tokenBox: '#1a202c',
}
};

173
mobile/src/theme/styles.ts Normal file
View File

@ -0,0 +1,173 @@
import { StyleSheet } from 'react-native';
import { COLORS } from './colors';
export const getThemeStyles = (isDark: boolean) => {
const theme = isDark ? COLORS.DARK : COLORS.LIGHT;
return StyleSheet.create({
container: { backgroundColor: theme.background, flex: 1 },
text: { color: theme.text },
card: { backgroundColor: theme.card },
input: {
backgroundColor: theme.inputBackground,
color: theme.inputText,
borderColor: theme.inputBorder,
borderWidth: 1,
padding: 15,
borderRadius: 12,
fontSize: 16,
},
subtitle: { color: theme.subtitle },
tokenBox: { backgroundColor: theme.tokenBox },
// Dynamic brand color styles
headerTitle: {
color: theme.brand,
fontSize: 36,
fontWeight: '800',
textAlign: 'center',
marginBottom: 5,
},
button: {
backgroundColor: theme.brand,
padding: 18,
borderRadius: 12,
alignItems: 'center',
marginTop: 35,
marginBottom: 10,
shadowColor: theme.brand,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
},
avatar: {
width: 60,
height: 60,
borderRadius: 30,
backgroundColor: theme.brand,
justifyContent: 'center',
alignItems: 'center',
marginRight: 15,
},
linkText: {
color: theme.brand,
fontWeight: '600',
},
buttonText: {
color: isDark ? COLORS.LIGHT.brand : '#ffffff', // Dark text on light button, white text on dark button
fontSize: 18,
fontWeight: 'bold',
}
});
};
export const commonStyles = StyleSheet.create({
container: {
flex: 1,
},
scrollContent: {
flexGrow: 1,
justifyContent: 'center',
padding: 25,
paddingTop: 60,
paddingBottom: 40,
},
content: {
flex: 1,
justifyContent: 'center',
padding: 25,
},
logoContainer: {
alignItems: 'center',
marginBottom: 20,
},
logo: {
width: 100,
height: 100,
},
subtitle: {
fontSize: 16,
textAlign: 'center',
marginBottom: 40,
},
title: {
fontSize: 28,
fontWeight: 'bold',
marginBottom: 30,
textAlign: 'center',
},
form: {
padding: 25,
borderRadius: 20,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.1,
shadowRadius: 10,
elevation: 5,
},
label: {
fontSize: 14,
marginBottom: 8,
marginTop: 15,
fontWeight: '600',
},
buttonDisabled: {
opacity: 0.7,
},
card: {
padding: 25,
borderRadius: 20,
marginBottom: 20,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 8,
},
userInfo: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 20,
},
avatarText: {
color: 'white',
fontSize: 24,
fontWeight: 'bold',
},
value: {
fontSize: 18,
fontWeight: '600',
},
tokenBox: {
padding: 15,
borderRadius: 10,
marginTop: 10,
},
token: {
fontSize: 12,
color: '#718096',
fontFamily: 'Courier',
marginTop: 5,
},
logoutButton: {
backgroundColor: 'white',
padding: 15,
borderRadius: 12,
alignItems: 'center',
borderWidth: 1,
borderColor: '#e2e8f0',
},
logoutText: {
color: '#e53e3e',
fontSize: 16,
fontWeight: '600',
},
tosContainer: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 15,
},
tosText: {
marginLeft: 10,
fontSize: 14,
},
});

3
mobile/tsconfig.json Normal file
View File

@ -0,0 +1,3 @@
{
"extends": "expo/tsconfig.base"
}

5769
mobile/yarn.lock Normal file

File diff suppressed because it is too large Load Diff