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