initialise and add react native app environment, UI and foundational code for user authentication
40
mobile/.yarnclean
Normal 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
@ -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
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
mobile/assets/icons/android-adaptive.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
mobile/assets/icons/google.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
mobile/assets/icons/ios-dark.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
mobile/assets/icons/ios-light.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
mobile/assets/icons/ios-tinted.png
Normal file
|
After Width: | Height: | Size: 99 KiB |
BIN
mobile/assets/icons/splash-icon-dark.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
mobile/assets/icons/splash-icon-light.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
6
mobile/babel.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
module.exports = function(api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: ['babel-preset-expo'],
|
||||
};
|
||||
};
|
||||
8
mobile/index.js
Normal 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
@ -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
|
||||
}
|
||||
100
mobile/src/components/SocialButtons.tsx
Normal 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
|
||||
}
|
||||
});
|
||||
9
mobile/src/config/social.ts
Normal 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',
|
||||
};
|
||||
48
mobile/src/navigation/AppNavigator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
mobile/src/navigation/themes.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
100
mobile/src/screens/AnimatedSplash.tsx
Normal 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%',
|
||||
}
|
||||
});
|
||||
300
mobile/src/screens/AuthScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
95
mobile/src/screens/ForgotPasswordScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
mobile/src/screens/HomeScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
74
mobile/src/services/api.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
39
mobile/src/store/useAuthStore.ts
Normal 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);
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
24
mobile/src/theme/colors.ts
Normal 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
@ -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
@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base"
|
||||
}
|
||||