Compare commits
10 Commits
da9ba3094c
...
2321e5389d
| Author | SHA1 | Date | |
|---|---|---|---|
| 2321e5389d | |||
| 962a5be29f | |||
| 7a8f16c624 | |||
| cc1a3728e5 | |||
| dc46ba0a4b | |||
| 2e19a3c036 | |||
| b0caa50e7c | |||
| 18474d30ab | |||
| b213bcee8b | |||
| 685c6bb2ce |
@ -28,7 +28,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
tty: true
|
tty: true
|
||||||
ports:
|
ports:
|
||||||
- "8000:80"
|
- "80" # Here port 80 in the container is mapped to any ephemeral port (random Docker chosen port) on the host.
|
||||||
volumes:
|
volumes:
|
||||||
- ./server:/var/www
|
- ./server:/var/www
|
||||||
- ./docker/nginx/conf.d/:/etc/nginx/conf.d/
|
- ./docker/nginx/conf.d/:/etc/nginx/conf.d/
|
||||||
|
|||||||
@ -32,7 +32,10 @@
|
|||||||
"tinted": "./assets/icons/ios-tinted.png"
|
"tinted": "./assets/icons/ios-tinted.png"
|
||||||
},
|
},
|
||||||
"bundleIdentifier": "com.cesoft.casadoc",
|
"bundleIdentifier": "com.cesoft.casadoc",
|
||||||
"appleTeamId": "NBX9G827SH"
|
"appleTeamId": "NBX9G827SH",
|
||||||
|
"infoPlist": {
|
||||||
|
"NSCameraUsageDescription": "This app needs access to the camera to scan documents."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
|
|||||||
138
mobile/src/hooks/useAuthHandler.ts
Normal file
138
mobile/src/hooks/useAuthHandler.ts
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Alert } from 'react-native';
|
||||||
|
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 { useAuthStore } from '../store/useAuthStore';
|
||||||
|
import { authService } from '../services/api';
|
||||||
|
import { GOOGLE_CONFIG, FACEBOOK_CONFIG } from '../config/social';
|
||||||
|
|
||||||
|
WebBrowser.maybeCompleteAuthSession();
|
||||||
|
|
||||||
|
export const useAuthHandler = () => {
|
||||||
|
const login = useAuthStore((state) => state.login);
|
||||||
|
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' && googleResponse.authentication?.accessToken) {
|
||||||
|
handleSocialLogin('google', googleResponse.authentication.accessToken);
|
||||||
|
}
|
||||||
|
}, [googleResponse]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (fbResponse?.type === 'success' && fbResponse.authentication?.accessToken) {
|
||||||
|
handleSocialLogin('facebook', fbResponse.authentication.accessToken);
|
||||||
|
}
|
||||||
|
}, [fbResponse]);
|
||||||
|
|
||||||
|
const afterLoginSuccess = async (data: any) => {
|
||||||
|
login(data);
|
||||||
|
const profileData = await authService.getProfile();
|
||||||
|
if (profileData && profileData.data) {
|
||||||
|
useAuthStore.getState().updateUser(profileData.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogin = async ({ email, password }) => {
|
||||||
|
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) {
|
||||||
|
await afterLoginSuccess(data);
|
||||||
|
} else {
|
||||||
|
Alert.alert('Login Failed', data.message || 'Invalid credentials');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Alert.alert('Network Error', 'Could not connect to server.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRegister = async ({ email, username, password, confirmPassword, tosAccepted }) => {
|
||||||
|
if (!email || !username || !password || !confirmPassword) {
|
||||||
|
return Alert.alert('Error', 'Please fill in all fields');
|
||||||
|
}
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
return Alert.alert('Error', 'Passwords do not match');
|
||||||
|
}
|
||||||
|
if (!tosAccepted) {
|
||||||
|
return Alert.alert('Error', 'You must accept the Terms of Service');
|
||||||
|
}
|
||||||
|
|
||||||
|
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.');
|
||||||
|
return true; // Indicate success to toggle form
|
||||||
|
} 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) {
|
||||||
|
Alert.alert('Network Error', 'Could not connect to server.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSocialLogin = async (provider: string, token: string) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await authService.socialLogin(provider, token);
|
||||||
|
if (data.token) {
|
||||||
|
await afterLoginSuccess(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') {
|
||||||
|
Alert.alert('Error', 'Apple Sign In failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
loading,
|
||||||
|
handleLogin,
|
||||||
|
handleRegister,
|
||||||
|
promptGoogle: () => {
|
||||||
|
if (googleRequest) promptGoogleAsync();
|
||||||
|
else Alert.alert('Configuration Error', 'Google Auth Request is not ready.');
|
||||||
|
},
|
||||||
|
promptFacebook: () => {
|
||||||
|
if (fbRequest) promptFacebookAsync();
|
||||||
|
else Alert.alert('Configuration Error', 'Facebook Auth Request is not ready.');
|
||||||
|
},
|
||||||
|
onAppleButtonPress,
|
||||||
|
};
|
||||||
|
};
|
||||||
49
mobile/src/hooks/useDocumentActions.ts
Normal file
49
mobile/src/hooks/useDocumentActions.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import { Alert } from 'react-native';
|
||||||
|
import { useDocumentStore } from '../store/useDocumentStore';
|
||||||
|
import { useAuthStore } from '../store/useAuthStore';
|
||||||
|
|
||||||
|
export const useDocumentActions = () => {
|
||||||
|
const { renameDocument, removeDocument } = useDocumentStore();
|
||||||
|
const { user } = useAuthStore();
|
||||||
|
|
||||||
|
const handleRename = (oldName: string) => {
|
||||||
|
Alert.prompt(
|
||||||
|
'Rename Document',
|
||||||
|
'Enter a new name for the document.',
|
||||||
|
[
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Save',
|
||||||
|
onPress: (newName) => {
|
||||||
|
if (newName && newName !== oldName.replace('.pdf', '') && user?.user.id) {
|
||||||
|
renameDocument(user.user.id, oldName, newName);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'plain-text',
|
||||||
|
oldName.replace('.pdf', '')
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (fileName: string) => {
|
||||||
|
Alert.alert(
|
||||||
|
'Delete Document',
|
||||||
|
'Are you sure you want to delete this document?',
|
||||||
|
[
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Delete',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: () => {
|
||||||
|
if (user?.user.id) {
|
||||||
|
removeDocument(user.user.id, fileName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { handleRename, handleDelete };
|
||||||
|
};
|
||||||
89
mobile/src/hooks/useDocumentScanner.ts
Normal file
89
mobile/src/hooks/useDocumentScanner.ts
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Alert } from 'react-native';
|
||||||
|
import DocumentScanner from 'react-native-document-scanner-plugin';
|
||||||
|
import * as Print from 'expo-print';
|
||||||
|
import * as FileSystem from 'expo-file-system/legacy';
|
||||||
|
import { useDocumentStore } from '../store/useDocumentStore';
|
||||||
|
import { useAuthStore } from '../store/useAuthStore';
|
||||||
|
|
||||||
|
export const useDocumentScanner = () => {
|
||||||
|
const { addDocument } = useDocumentStore();
|
||||||
|
const { user } = useAuthStore();
|
||||||
|
const [isScanning, setIsScanning] = useState(false);
|
||||||
|
|
||||||
|
const createPdfFromImages = async (imageUris: string[]) => {
|
||||||
|
try {
|
||||||
|
const imageSources = await Promise.all(
|
||||||
|
imageUris.map(async (uri) => {
|
||||||
|
const fileUri = uri.startsWith('file://') ? uri : `file://${uri}`;
|
||||||
|
const base64 = await FileSystem.readAsStringAsync(fileUri, {
|
||||||
|
encoding: FileSystem.EncodingType.Base64,
|
||||||
|
});
|
||||||
|
return `data:image/jpeg;base64,${base64}`;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const htmlContent = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<body style="margin: 0; padding: 0;">
|
||||||
|
${imageSources.map(src => `
|
||||||
|
<div style="width: 100vw; height: 100vh; display: flex; justify-content: center; align-items: center;">
|
||||||
|
<img src="${src}" style="width: 100%; height: 100%; object-fit: contain;" />
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const { uri } = await Print.printToFileAsync({ html: htmlContent });
|
||||||
|
return uri;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create PDF', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const scanDocument = async () => {
|
||||||
|
if (!user?.user.id) {
|
||||||
|
Alert.alert('Error', 'You must be logged in to scan documents.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsScanning(true);
|
||||||
|
try {
|
||||||
|
const { scannedImages } = await DocumentScanner.scanDocument();
|
||||||
|
|
||||||
|
if (scannedImages && scannedImages.length > 0) {
|
||||||
|
Alert.prompt(
|
||||||
|
'Name Your Document',
|
||||||
|
'Enter a name for your new document.',
|
||||||
|
[
|
||||||
|
{ text: 'Cancel', style: 'cancel', onPress: () => {} },
|
||||||
|
{
|
||||||
|
text: 'Save',
|
||||||
|
onPress: async (fileName) => {
|
||||||
|
if (fileName) {
|
||||||
|
try {
|
||||||
|
const pdfUri = await createPdfFromImages(scannedImages);
|
||||||
|
await addDocument(user.user.id, pdfUri, fileName);
|
||||||
|
} catch (e) {
|
||||||
|
Alert.alert('Error', 'Could not save document.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'plain-text',
|
||||||
|
`Scan_${Date.now()}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Scanning failed', error);
|
||||||
|
} finally {
|
||||||
|
setIsScanning(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { isScanning, scanDocument };
|
||||||
|
};
|
||||||
62
mobile/src/hooks/useProfileLogic.ts
Normal file
62
mobile/src/hooks/useProfileLogic.ts
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Alert } from 'react-native';
|
||||||
|
import { useAuthStore } from '../store/useAuthStore';
|
||||||
|
import { authService } from '../services/api';
|
||||||
|
|
||||||
|
export const useProfileLogic = () => {
|
||||||
|
const { user, logout } = useAuthStore();
|
||||||
|
const [isResending, setIsResending] = useState(false);
|
||||||
|
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||||
|
|
||||||
|
const displayName =
|
||||||
|
(user?.user?.first_name && user?.user?.last_name) ? `${user.user.first_name} ${user.user.last_name}` :
|
||||||
|
user?.user?.username ? user.user.username :
|
||||||
|
user?.user?.email ? user.user.email.split('@')[0] :
|
||||||
|
'User Name';
|
||||||
|
|
||||||
|
const handleResendVerification = async () => {
|
||||||
|
setIsResending(true);
|
||||||
|
const result = await authService.resendVerificationEmail();
|
||||||
|
setIsResending(false);
|
||||||
|
|
||||||
|
if (result.ok) {
|
||||||
|
Alert.alert('Email Sent', 'Please check your email (or server logs) for the verification link.');
|
||||||
|
} else {
|
||||||
|
Alert.alert('Error', result.message || 'Failed to resend verification email');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteAccount = () => {
|
||||||
|
Alert.alert(
|
||||||
|
'Delete Account',
|
||||||
|
'Are you sure you want to permanently delete your account? This action cannot be undone.',
|
||||||
|
[
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Delete',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: async () => {
|
||||||
|
const result = await authService.deleteAccount();
|
||||||
|
if (result.ok) {
|
||||||
|
logout();
|
||||||
|
Alert.alert('Success', 'Your account has been deleted.');
|
||||||
|
} else {
|
||||||
|
Alert.alert('Error', 'Failed to delete account.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
displayName,
|
||||||
|
isResending,
|
||||||
|
isModalVisible,
|
||||||
|
setIsModalVisible,
|
||||||
|
handleResendVerification,
|
||||||
|
handleDeleteAccount,
|
||||||
|
logout,
|
||||||
|
};
|
||||||
|
};
|
||||||
91
mobile/src/hooks/useProfileManager.ts
Normal file
91
mobile/src/hooks/useProfileManager.ts
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Alert } from 'react-native';
|
||||||
|
import { useNavigation } from '@react-navigation/native';
|
||||||
|
import * as ImagePicker from 'expo-image-picker';
|
||||||
|
import { useAuthStore } from '../store/useAuthStore';
|
||||||
|
import { authService } from '../services/api';
|
||||||
|
|
||||||
|
export const useProfileManager = () => {
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const { user, updateUser } = useAuthStore();
|
||||||
|
|
||||||
|
const [firstName, setFirstName] = useState(user?.user?.first_name || '');
|
||||||
|
const [lastName, setLastName] = useState(user?.user?.last_name || '');
|
||||||
|
const [phone, setPhone] = useState(user?.user?.phone || '');
|
||||||
|
const [address, setAddress] = useState(user?.user?.address || '');
|
||||||
|
const [avatar, setAvatar] = useState<string | null>(user?.user?.avatar || null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const pickImage = async () => {
|
||||||
|
const result = await ImagePicker.launchImageLibraryAsync({
|
||||||
|
mediaTypes: 'images',
|
||||||
|
allowsEditing: true,
|
||||||
|
aspect: [1, 1],
|
||||||
|
quality: 0.5,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.canceled) {
|
||||||
|
setAvatar(result.assets[0].uri);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
let updatedAvatarUrl = user?.user?.avatar;
|
||||||
|
|
||||||
|
// Upload avatar if it has changed
|
||||||
|
if (avatar && avatar !== user?.user?.avatar) {
|
||||||
|
const avatarResult = await authService.uploadAvatar(avatar);
|
||||||
|
if (avatarResult.ok && avatarResult.data.data.avatar) {
|
||||||
|
updatedAvatarUrl = avatarResult.data.data.avatar;
|
||||||
|
} else {
|
||||||
|
Alert.alert('Warning', 'Failed to upload new profile picture, but proceeding with other updates.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates = {
|
||||||
|
first_name: firstName,
|
||||||
|
last_name: lastName,
|
||||||
|
phone,
|
||||||
|
address,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await authService.updateProfile(updates);
|
||||||
|
|
||||||
|
if (result.ok) {
|
||||||
|
const finalUserData = {
|
||||||
|
...(result.data.data || result.data),
|
||||||
|
avatar: updatedAvatarUrl
|
||||||
|
};
|
||||||
|
updateUser(finalUserData);
|
||||||
|
|
||||||
|
Alert.alert('Success', 'Profile updated successfully', [
|
||||||
|
{ text: 'OK', onPress: () => navigation.goBack() }
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
Alert.alert('Error', result.message || 'Failed to update profile');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
Alert.alert('Error', 'An unexpected error occurred.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
loading,
|
||||||
|
avatar,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
phone,
|
||||||
|
address,
|
||||||
|
setFirstName,
|
||||||
|
setLastName,
|
||||||
|
setPhone,
|
||||||
|
setAddress,
|
||||||
|
pickImage,
|
||||||
|
handleSave,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -2,16 +2,20 @@ import React from 'react';
|
|||||||
import { View, ActivityIndicator } from 'react-native';
|
import { View, ActivityIndicator } from 'react-native';
|
||||||
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
||||||
import AuthScreen from '../screens/AuthScreen';
|
import AuthScreen from '../screens/AuthScreen';
|
||||||
import HomeScreen from '../screens/HomeScreen';
|
|
||||||
import TabNavigator from './TabNavigator';
|
import TabNavigator from './TabNavigator';
|
||||||
|
import EditProfileScreen from '../screens/EditProfileScreen';
|
||||||
|
import DocumentViewScreen from '../screens/DocumentViewScreen';
|
||||||
import ForgotPasswordScreen from '../screens/ForgotPasswordScreen';
|
import ForgotPasswordScreen from '../screens/ForgotPasswordScreen';
|
||||||
import { useAuthStore } from '../store/useAuthStore';
|
import { useAuthStore } from '../store/useAuthStore';
|
||||||
|
|
||||||
// Define the types for your stack
|
// Define the types for the stack
|
||||||
export type RootStackParamList = {
|
export type RootStackParamList = {
|
||||||
Auth: undefined;
|
Auth: undefined;
|
||||||
Home: undefined; // This is now a nested navigator
|
Home: undefined; // This is now a nested navigator
|
||||||
Main: undefined; // The tab navigator 'Main'
|
Main: undefined; // The tab navigator
|
||||||
|
EditProfile: undefined;
|
||||||
|
DocumentView: { uri: string };
|
||||||
|
ForgotPassword: undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Stack = createNativeStackNavigator<RootStackParamList>();
|
const Stack = createNativeStackNavigator<RootStackParamList>();
|
||||||
@ -29,20 +33,40 @@ export default function AppNavigator() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack.Navigator screenOptions={{ headerShown: false }}>
|
<Stack.Navigator id="RootStack" screenOptions={{ headerShown: false }}>
|
||||||
{user ? (
|
{user ? (
|
||||||
// Screens for logged-in users
|
// Screens for logged-in users
|
||||||
<Stack.Screen name="Main" component={TabNavigator} options={{ headerShown: false }} />
|
<>
|
||||||
|
<Stack.Screen name="Main" component={TabNavigator} options={{ headerShown: false }} />
|
||||||
|
<Stack.Screen
|
||||||
|
name="EditProfile"
|
||||||
|
component={EditProfileScreen}
|
||||||
|
options={{
|
||||||
|
title: 'Edit Profile',
|
||||||
|
headerBackTitle: '',
|
||||||
|
headerShown: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="DocumentView"
|
||||||
|
component={DocumentViewScreen}
|
||||||
|
options={{
|
||||||
|
title: 'Document',
|
||||||
|
headerBackTitle: '',
|
||||||
|
headerShown: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
// Screens for logged-out users
|
// Screens for logged-out users
|
||||||
<Stack.Group>
|
<>
|
||||||
<Stack.Screen name="Auth" component={AuthScreen} />
|
<Stack.Screen name="Auth" component={AuthScreen} />
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="ForgotPassword"
|
name="ForgotPassword"
|
||||||
component={ForgotPasswordScreen}
|
component={ForgotPasswordScreen}
|
||||||
options={{ presentation: 'fullScreenModal' }}
|
options={{ presentation: 'fullScreenModal' }}
|
||||||
/>
|
/>
|
||||||
</Stack.Group>
|
</>
|
||||||
)}
|
)}
|
||||||
</Stack.Navigator>
|
</Stack.Navigator>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -15,6 +15,7 @@ export default function TabNavigator() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Tab.Navigator
|
<Tab.Navigator
|
||||||
|
id="MainTabs"
|
||||||
screenOptions={({ route }) => ({
|
screenOptions={({ route }) => ({
|
||||||
tabBarIcon: ({ focused, color, size }) => {
|
tabBarIcon: ({ focused, color, size }) => {
|
||||||
let iconName;
|
let iconName;
|
||||||
|
|||||||
@ -1,26 +1,17 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { View, Text, TextInput, TouchableOpacity, ActivityIndicator, Image, Switch, ScrollView, KeyboardAvoidingView, Platform, Alert, useColorScheme } from 'react-native';
|
import { View, Text, TextInput, TouchableOpacity, ActivityIndicator, Image, Switch, ScrollView, KeyboardAvoidingView, Platform, useColorScheme } from 'react-native';
|
||||||
import { useNavigation } from '@react-navigation/native';
|
import { useNavigation } from '@react-navigation/native';
|
||||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
import { authService } from '../services/api';
|
|
||||||
import { getThemeStyles, commonStyles } from '../theme/styles';
|
import { getThemeStyles, commonStyles } from '../theme/styles';
|
||||||
import { COLORS } from '../theme/colors';
|
import { COLORS } from '../theme/colors';
|
||||||
import SocialButtons from '../components/SocialButtons';
|
import SocialButtons from '../components/SocialButtons';
|
||||||
import * as AppleAuthentication from 'expo-apple-authentication';
|
import { useAuthHandler } from '../hooks/useAuthHandler';
|
||||||
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';
|
import { RootStackParamList } from '../navigation/AppNavigator';
|
||||||
|
|
||||||
WebBrowser.maybeCompleteAuthSession();
|
|
||||||
|
|
||||||
type AuthScreenNavigationProp = NativeStackNavigationProp<RootStackParamList, 'Auth'>;
|
type AuthScreenNavigationProp = NativeStackNavigationProp<RootStackParamList, 'Auth'>;
|
||||||
|
|
||||||
export default function AuthScreen() {
|
export default function AuthScreen() {
|
||||||
const navigation = useNavigation<AuthScreenNavigationProp>();
|
const navigation = useNavigation<AuthScreenNavigationProp>();
|
||||||
const login = useAuthStore((state) => state.login);
|
|
||||||
|
|
||||||
const colorScheme = useColorScheme();
|
const colorScheme = useColorScheme();
|
||||||
const isDark = colorScheme === 'dark';
|
const isDark = colorScheme === 'dark';
|
||||||
@ -33,129 +24,24 @@ export default function AuthScreen() {
|
|||||||
const [confirmPassword, setConfirmPassword] = useState('');
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
const [tosAccepted, setTosAccepted] = useState(false);
|
const [tosAccepted, setTosAccepted] = useState(false);
|
||||||
const [isRegistering, setIsRegistering] = useState(false);
|
const [isRegistering, setIsRegistering] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
const [googleRequest, googleResponse, promptGoogleAsync] = Google.useAuthRequest(GOOGLE_CONFIG);
|
const {
|
||||||
const [fbRequest, fbResponse, promptFacebookAsync] = Facebook.useAuthRequest(FACEBOOK_CONFIG);
|
loading,
|
||||||
|
handleLogin,
|
||||||
|
handleRegister,
|
||||||
|
promptGoogle,
|
||||||
|
promptFacebook,
|
||||||
|
onAppleButtonPress,
|
||||||
|
} = useAuthHandler();
|
||||||
|
|
||||||
useEffect(() => {
|
const onRegister = async () => {
|
||||||
if (googleResponse?.type === 'success') {
|
const success = await handleRegister({ email, username, password, confirmPassword, tosAccepted });
|
||||||
const { authentication } = googleResponse;
|
if (success) {
|
||||||
if (authentication?.accessToken) {
|
toggleMode(); // Switch back to login form on success
|
||||||
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 () => {
|
const onLogin = () => handleLogin({ email, password });
|
||||||
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 = () => {
|
const toggleMode = () => {
|
||||||
setIsRegistering(!isRegistering);
|
setIsRegistering(!isRegistering);
|
||||||
@ -176,8 +62,8 @@ export default function AuthScreen() {
|
|||||||
<Image
|
<Image
|
||||||
source={
|
source={
|
||||||
isDark
|
isDark
|
||||||
? require('../../assets/icons/ios-dark.png') // Light logo on dark background
|
? require('../../assets/icons/ios-dark.png')
|
||||||
: require('../../assets/icons/ios-light.png') // Dark logo on light background
|
: require('../../assets/icons/ios-light.png')
|
||||||
}
|
}
|
||||||
style={commonStyles.logo}
|
style={commonStyles.logo}
|
||||||
resizeMode="contain"
|
resizeMode="contain"
|
||||||
@ -191,7 +77,7 @@ export default function AuthScreen() {
|
|||||||
<View style={[commonStyles.form, themeStyles.card]}>
|
<View style={[commonStyles.form, themeStyles.card]}>
|
||||||
<Text style={[commonStyles.label, themeStyles.subtitle]}>Email{isRegistering ? '' : ' or Username'}</Text>
|
<Text style={[commonStyles.label, themeStyles.subtitle]}>Email{isRegistering ? '' : ' or Username'}</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={[themeStyles.input]}
|
style={themeStyles.input}
|
||||||
placeholder={isRegistering ? "name@example.com" : "Enter email or username"}
|
placeholder={isRegistering ? "name@example.com" : "Enter email or username"}
|
||||||
placeholderTextColor={isDark ? '#a0aec0' : '#a0aec0'}
|
placeholderTextColor={isDark ? '#a0aec0' : '#a0aec0'}
|
||||||
value={email}
|
value={email}
|
||||||
@ -205,7 +91,7 @@ export default function AuthScreen() {
|
|||||||
<>
|
<>
|
||||||
<Text style={[commonStyles.label, themeStyles.subtitle]}>Username</Text>
|
<Text style={[commonStyles.label, themeStyles.subtitle]}>Username</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={[themeStyles.input]}
|
style={themeStyles.input}
|
||||||
placeholder="Choose a username"
|
placeholder="Choose a username"
|
||||||
placeholderTextColor={isDark ? '#a0aec0' : '#a0aec0'}
|
placeholderTextColor={isDark ? '#a0aec0' : '#a0aec0'}
|
||||||
value={username}
|
value={username}
|
||||||
@ -218,7 +104,7 @@ export default function AuthScreen() {
|
|||||||
|
|
||||||
<Text style={[commonStyles.label, themeStyles.subtitle]}>Password</Text>
|
<Text style={[commonStyles.label, themeStyles.subtitle]}>Password</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={[themeStyles.input]}
|
style={themeStyles.input}
|
||||||
placeholder="Enter your password"
|
placeholder="Enter your password"
|
||||||
placeholderTextColor={isDark ? '#a0aec0' : '#a0aec0'}
|
placeholderTextColor={isDark ? '#a0aec0' : '#a0aec0'}
|
||||||
value={password}
|
value={password}
|
||||||
@ -230,7 +116,7 @@ export default function AuthScreen() {
|
|||||||
<>
|
<>
|
||||||
<Text style={[commonStyles.label, themeStyles.subtitle]}>Confirm Password</Text>
|
<Text style={[commonStyles.label, themeStyles.subtitle]}>Confirm Password</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={[themeStyles.input]}
|
style={themeStyles.input}
|
||||||
placeholder="Confirm your password"
|
placeholder="Confirm your password"
|
||||||
placeholderTextColor={isDark ? '#a0aec0' : '#a0aec0'}
|
placeholderTextColor={isDark ? '#a0aec0' : '#a0aec0'}
|
||||||
value={confirmPassword}
|
value={confirmPassword}
|
||||||
@ -252,7 +138,7 @@ export default function AuthScreen() {
|
|||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[themeStyles.button, loading && commonStyles.buttonDisabled]}
|
style={[themeStyles.button, loading && commonStyles.buttonDisabled]}
|
||||||
onPress={isRegistering ? handleRegister : handleLogin}
|
onPress={isRegistering ? onRegister : onLogin}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@ -269,20 +155,8 @@ export default function AuthScreen() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<SocialButtons
|
<SocialButtons
|
||||||
onGooglePress={() => {
|
onGooglePress={promptGoogle}
|
||||||
if (googleRequest) {
|
onFacebookPress={promptFacebook}
|
||||||
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}
|
onApplePress={onAppleButtonPress}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
55
mobile/src/screens/DocumentViewScreen.tsx
Normal file
55
mobile/src/screens/DocumentViewScreen.tsx
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { View, StyleSheet, useColorScheme, Text, ActivityIndicator } from 'react-native';
|
||||||
|
import Pdf from 'react-native-pdf';
|
||||||
|
import { getThemeStyles } from '../theme/styles';
|
||||||
|
|
||||||
|
export default function DocumentViewScreen({ route }: any) {
|
||||||
|
const { uri } = route.params;
|
||||||
|
const colorScheme = useColorScheme();
|
||||||
|
const isDark = colorScheme === 'dark';
|
||||||
|
const themeStyles = getThemeStyles(isDark);
|
||||||
|
|
||||||
|
// Use the URI directly.
|
||||||
|
// Note: If filenames have spaces, they should ideally be encoded,
|
||||||
|
// but for local file:// URIs, react-native-pdf often handles them as they are.
|
||||||
|
// If issues persist with spaces, try encodeURI(uri).
|
||||||
|
const source = React.useMemo(() => ({
|
||||||
|
uri: uri,
|
||||||
|
cache: true
|
||||||
|
}), [uri]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, themeStyles.container]}>
|
||||||
|
<Pdf
|
||||||
|
source={source}
|
||||||
|
onLoadComplete={(numberOfPages, filePath) => {
|
||||||
|
console.log(`Number of pages: ${numberOfPages}`);
|
||||||
|
}}
|
||||||
|
onPageChanged={(page, numberOfPages) => {
|
||||||
|
console.log(`Current page: ${page}`);
|
||||||
|
}}
|
||||||
|
onError={(error) => {
|
||||||
|
console.error('PDF Load Error:', error);
|
||||||
|
}}
|
||||||
|
onPressLink={(uri) => {
|
||||||
|
console.log(`Link pressed: ${uri}`);
|
||||||
|
}}
|
||||||
|
style={styles.pdf}
|
||||||
|
trustAllCerts={false}
|
||||||
|
renderActivityIndicator={() => <ActivityIndicator size="large" color={isDark ? 'white' : 'black'} />}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
pdf: {
|
||||||
|
flex: 1,
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -1,43 +1,95 @@
|
|||||||
import React from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { View, Text, TouchableOpacity, useColorScheme } from 'react-native';
|
import { View, Text, useColorScheme, FlatList, TouchableOpacity, ActivityIndicator } from 'react-native';
|
||||||
import { getThemeStyles, commonStyles } from '../theme/styles';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useNavigation } from '@react-navigation/native';
|
||||||
|
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
|
import { RootStackParamList } from '../navigation/AppNavigator';
|
||||||
|
import { getThemeStyles, commonStyles, homeStyles } from '../theme/styles';
|
||||||
|
import { useDocumentStore } from '../store/useDocumentStore';
|
||||||
import { useAuthStore } from '../store/useAuthStore';
|
import { useAuthStore } from '../store/useAuthStore';
|
||||||
|
import { useDocumentScanner } from '../hooks/useDocumentScanner';
|
||||||
|
import { useDocumentActions } from '../hooks/useDocumentActions';
|
||||||
|
import { COLORS } from '../theme/colors';
|
||||||
|
|
||||||
export default function HomeScreen() {
|
export default function HomeScreen() {
|
||||||
const { user, logout } = useAuthStore();
|
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||||
const colorScheme = useColorScheme();
|
const colorScheme = useColorScheme();
|
||||||
const isDark = colorScheme === 'dark';
|
const isDark = colorScheme === 'dark';
|
||||||
const themeStyles = getThemeStyles(isDark);
|
const themeStyles = getThemeStyles(isDark);
|
||||||
|
const themeColors = isDark ? COLORS.DARK : COLORS.LIGHT;
|
||||||
|
|
||||||
if (!user) return null; // Should not happen if navigation logic is correct
|
const { user } = useAuthStore();
|
||||||
|
const { documents, loadDocuments, isLoading } = useDocumentStore();
|
||||||
|
const { isScanning, scanDocument } = useDocumentScanner();
|
||||||
|
const { handleRename, handleDelete } = useDocumentActions();
|
||||||
|
|
||||||
return (
|
useEffect(() => {
|
||||||
<View style={commonStyles.content}>
|
if (user?.user?.id) {
|
||||||
<Text style={[commonStyles.title, themeStyles.text]}>Welcome Back!</Text>
|
loadDocuments(user.user.id);
|
||||||
<View style={[commonStyles.card, themeStyles.card]}>
|
}
|
||||||
<View style={commonStyles.userInfo}>
|
}, [user]);
|
||||||
<View style={themeStyles.avatar}>
|
|
||||||
<Text style={commonStyles.avatarText}>
|
const renderItem = ({ item }: { item: any }) => (
|
||||||
{user.user?.email?.charAt(0).toUpperCase() || 'U'}
|
<TouchableOpacity onPress={() => navigation.navigate('DocumentView', { uri: item.uri })} activeOpacity={0.7}>
|
||||||
</Text>
|
<View style={[homeStyles.docCard, { backgroundColor: themeColors.card, borderColor: isDark ? '#2d3748' : '#e2e8f0' }]}>
|
||||||
</View>
|
<View style={homeStyles.docIcon}>
|
||||||
<View>
|
<Ionicons name="document-text-outline" size={32} color={themeColors.brand} />
|
||||||
<Text style={[commonStyles.label, themeStyles.subtitle]}>Logged in as:</Text>
|
|
||||||
<Text style={[commonStyles.value, themeStyles.text]}>{user.user?.email}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
|
<View style={homeStyles.docInfo}>
|
||||||
<View style={[commonStyles.tokenBox, themeStyles.tokenBox]}>
|
<Text style={[homeStyles.docName, { color: themeColors.text }]} numberOfLines={1}>
|
||||||
<Text style={[commonStyles.label, themeStyles.subtitle]}>Your API Token:</Text>
|
{item.name.replace('.pdf', '')}
|
||||||
<Text style={commonStyles.token} numberOfLines={1}>
|
</Text>
|
||||||
{user.token}
|
<Text style={[homeStyles.docDate, { color: isDark ? '#a0aec0' : '#718096' }]}>
|
||||||
|
{new Date(item.timestamp).toLocaleDateString()}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
<TouchableOpacity onPress={() => handleRename(item.name)} style={homeStyles.deleteBtn}>
|
||||||
|
<Ionicons name="create-outline" size={22} color={isDark ? '#a0aec0' : '#718096'} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity onPress={() => handleDelete(item.name)} style={homeStyles.deleteBtn}>
|
||||||
|
<Ionicons name="trash-outline" size={22} color="#ef4444" />
|
||||||
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
|
||||||
<TouchableOpacity style={commonStyles.logoutButton} onPress={logout}>
|
return (
|
||||||
<Text style={commonStyles.logoutText}>Sign Out</Text>
|
<View style={themeStyles.container}>
|
||||||
|
{documents.length === 0 ? (
|
||||||
|
<View style={commonStyles.centered}>
|
||||||
|
<Ionicons name="documents-outline" size={64} color={isDark ? '#4a5568' : '#cbd5e0'} />
|
||||||
|
<Text style={[themeStyles.subtitle, { marginTop: 16 }]}>No documents yet.</Text>
|
||||||
|
<Text style={[themeStyles.text, { textAlign: 'center', opacity: 0.6 }]}>
|
||||||
|
Tap the + button to scan your first document.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
data={documents}
|
||||||
|
renderItem={renderItem}
|
||||||
|
keyExtractor={(item) => item.name}
|
||||||
|
contentContainerStyle={homeStyles.listContent}
|
||||||
|
refreshing={isLoading}
|
||||||
|
onRefresh={() => user?.user.id && loadDocuments(user.user.id)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isScanning && (
|
||||||
|
<View style={homeStyles.loadingOverlay}>
|
||||||
|
<ActivityIndicator size="large" color="#fff" />
|
||||||
|
<Text style={{ color: '#fff', marginTop: 10 }}>Processing...</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[homeStyles.fab, { backgroundColor: themeColors.brand }]}
|
||||||
|
onPress={scanDocument}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<Ionicons name="add" size={32} color="#fff" />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,68 +1,34 @@
|
|||||||
import React, { useState } from 'react';
|
import React from 'react';
|
||||||
import { View, Text, TouchableOpacity, useColorScheme, ScrollView, Alert, Image, ActivityIndicator, Modal } from 'react-native';
|
import { View, Text, TouchableOpacity, useColorScheme, ScrollView, Image, ActivityIndicator, Modal, Alert } from 'react-native';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useNavigation } from '@react-navigation/native';
|
import { useNavigation } from '@react-navigation/native';
|
||||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
import { RootStackParamList } from '../navigation/AppNavigator';
|
import { RootStackParamList } from '../navigation/AppNavigator';
|
||||||
import { getThemeStyles, commonStyles } from '../theme/styles';
|
import { getThemeStyles } from '../theme/styles';
|
||||||
import { useAuthStore } from '../store/useAuthStore';
|
|
||||||
import { authService } from '../services/api';
|
|
||||||
import { COLORS } from '../theme/colors';
|
import { COLORS } from '../theme/colors';
|
||||||
|
import { useProfileLogic } from '../hooks/useProfileLogic';
|
||||||
import appConfig from '../../app.json';
|
import appConfig from '../../app.json';
|
||||||
|
|
||||||
export default function ProfileScreen() {
|
export default function ProfileScreen() {
|
||||||
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||||
const { user, logout } = useAuthStore();
|
|
||||||
const [isResending, setIsResending] = useState(false);
|
|
||||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
|
||||||
const colorScheme = useColorScheme();
|
const colorScheme = useColorScheme();
|
||||||
const isDark = colorScheme === 'dark';
|
const isDark = colorScheme === 'dark';
|
||||||
const themeStyles = getThemeStyles(isDark);
|
const themeStyles = getThemeStyles(isDark);
|
||||||
const themeColors = isDark ? COLORS.DARK : COLORS.LIGHT;
|
const themeColors = isDark ? COLORS.DARK : COLORS.LIGHT;
|
||||||
|
|
||||||
|
const {
|
||||||
|
user,
|
||||||
|
displayName,
|
||||||
|
isResending,
|
||||||
|
isModalVisible,
|
||||||
|
setIsModalVisible,
|
||||||
|
handleResendVerification,
|
||||||
|
handleDeleteAccount,
|
||||||
|
logout,
|
||||||
|
} = useProfileLogic();
|
||||||
|
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
const displayName =
|
|
||||||
(user.user?.first_name && user.user?.last_name) ? `${user.user.first_name} ${user.user.last_name}` :
|
|
||||||
user.user?.username ? user.user.username :
|
|
||||||
user.user?.email ? user.user.email.split('@')[0] :
|
|
||||||
'User Name';
|
|
||||||
|
|
||||||
const handleResendVerification = async () => {
|
|
||||||
setIsResending(true);
|
|
||||||
const result = await authService.resendVerificationEmail();
|
|
||||||
setIsResending(false);
|
|
||||||
|
|
||||||
if (result.ok) {
|
|
||||||
Alert.alert('Email Sent', 'Please check your email (or server logs) for the verification link.');
|
|
||||||
} else {
|
|
||||||
Alert.alert('Error', result.message || 'Failed to resend verification email');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteAccount = () => {
|
|
||||||
Alert.alert(
|
|
||||||
'Delete Account',
|
|
||||||
'Are you sure you want to permanently delete your account? This action cannot be undone.',
|
|
||||||
[
|
|
||||||
{ text: 'Cancel', style: 'cancel' },
|
|
||||||
{
|
|
||||||
text: 'Delete',
|
|
||||||
style: 'destructive',
|
|
||||||
onPress: async () => {
|
|
||||||
const result = await authService.deleteAccount();
|
|
||||||
if (result.ok) {
|
|
||||||
logout();
|
|
||||||
Alert.alert('Success', 'Your account has been deleted.');
|
|
||||||
} else {
|
|
||||||
Alert.alert('Error', 'Failed to delete account.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
]
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const MenuItem = ({ icon, label, onPress, isDestructive = false }: { icon: string, label: string, onPress: () => void, isDestructive?: boolean }) => (
|
const MenuItem = ({ icon, label, onPress, isDestructive = false }: { icon: string, label: string, onPress: () => void, isDestructive?: boolean }) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
101
mobile/src/services/DocumentService.ts
Normal file
101
mobile/src/services/DocumentService.ts
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
import * as FileSystem from 'expo-file-system/legacy';
|
||||||
|
|
||||||
|
const getDocumentDir = (userId: string | number) => `${FileSystem.documentDirectory}documents/user_${userId}/`;
|
||||||
|
|
||||||
|
export interface ScannedDocument {
|
||||||
|
uri: string;
|
||||||
|
name: string;
|
||||||
|
timestamp: number;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DocumentService = {
|
||||||
|
// Ensure the documents directory exists for the specific user
|
||||||
|
init: async (userId: string | number) => {
|
||||||
|
const dir = getDocumentDir(userId);
|
||||||
|
const dirInfo = await FileSystem.getInfoAsync(dir);
|
||||||
|
if (!dirInfo.exists) {
|
||||||
|
await FileSystem.makeDirectoryAsync(dir, { intermediates: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Save a scanned file from a temporary URI to permanent storage
|
||||||
|
saveDocument: async (userId: string | number, tempUri: string, fileName: string): Promise<ScannedDocument> => {
|
||||||
|
await DocumentService.init(userId);
|
||||||
|
|
||||||
|
const dir = getDocumentDir(userId);
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const destination = `${dir}${fileName}.pdf`;
|
||||||
|
|
||||||
|
await FileSystem.moveAsync({
|
||||||
|
from: tempUri,
|
||||||
|
to: destination,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fileInfo = await FileSystem.getInfoAsync(destination);
|
||||||
|
|
||||||
|
return {
|
||||||
|
uri: destination,
|
||||||
|
name: `${fileName}.pdf`,
|
||||||
|
timestamp,
|
||||||
|
size: fileInfo.exists ? fileInfo.size : 0,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get a list of all saved documents for a user
|
||||||
|
getDocuments: async (userId: string | number): Promise<ScannedDocument[]> => {
|
||||||
|
await DocumentService.init(userId);
|
||||||
|
|
||||||
|
const dir = getDocumentDir(userId);
|
||||||
|
const files = await FileSystem.readDirectoryAsync(dir);
|
||||||
|
|
||||||
|
const docs: ScannedDocument[] = await Promise.all(
|
||||||
|
files.map(async (fileName) => {
|
||||||
|
const uri = `${dir}${fileName}`;
|
||||||
|
const info = await FileSystem.getInfoAsync(uri);
|
||||||
|
// Extract timestamp from filename if possible, else use modification time
|
||||||
|
const match = fileName.match(/scan_(\d+)/);
|
||||||
|
const timestamp = match ? parseInt(match[1]) : (info.exists ? info.modificationTime || 0 : 0) * 1000;
|
||||||
|
|
||||||
|
return {
|
||||||
|
uri,
|
||||||
|
name: fileName,
|
||||||
|
timestamp,
|
||||||
|
size: info.exists ? info.size : 0,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sort by newest first
|
||||||
|
return docs.sort((a, b) => b.timestamp - a.timestamp);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Delete a document
|
||||||
|
deleteDocument: async (userId: string | number, fileName: string) => {
|
||||||
|
const dir = getDocumentDir(userId);
|
||||||
|
const uri = `${dir}${fileName}`;
|
||||||
|
await FileSystem.deleteAsync(uri, { idempotent: true });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Rename a document
|
||||||
|
renameDocument: async (userId: string | number, oldName: string, newName: string): Promise<ScannedDocument> => {
|
||||||
|
const dir = getDocumentDir(userId);
|
||||||
|
const oldUri = `${dir}${oldName}`;
|
||||||
|
const newUri = `${dir}${newName}.pdf`; // Assuming pdf extension
|
||||||
|
|
||||||
|
await FileSystem.moveAsync({
|
||||||
|
from: oldUri,
|
||||||
|
to: newUri,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fileInfo = await FileSystem.getInfoAsync(newUri);
|
||||||
|
const timestamp = fileInfo.exists ? (fileInfo.modificationTime || 0) * 1000 : Date.now();
|
||||||
|
|
||||||
|
return {
|
||||||
|
uri: newUri,
|
||||||
|
name: `${newName}.pdf`,
|
||||||
|
timestamp,
|
||||||
|
size: fileInfo.exists ? fileInfo.size : 0,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -1,4 +1,14 @@
|
|||||||
export const API_URL = 'https://supercuriously-precongested-chester.ngrok-free.dev/api';
|
import { useAuthStore } from '../store/useAuthStore';
|
||||||
|
|
||||||
|
const getAuthHeaders = () => {
|
||||||
|
const token = useAuthStore.getState().user?.token;
|
||||||
|
return {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const API_URL = 'https://supercuriously-precongested-chester.ngrok-free.dev/api'; // ngrok URL for testing
|
||||||
|
|
||||||
export const authService = {
|
export const authService = {
|
||||||
login: async (username, password) => {
|
login: async (username, password) => {
|
||||||
@ -71,4 +81,114 @@ export const authService = {
|
|||||||
});
|
});
|
||||||
return response.json();
|
return response.json();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getProfile: async () => {
|
||||||
|
const response = await fetch(`${API_URL}/me`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
});
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
updateProfile: async (data: any) => {
|
||||||
|
const response = await fetch(`${API_URL}/me/details`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
...getAuthHeaders(),
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(text);
|
||||||
|
if (response.ok) {
|
||||||
|
return { ok: true, data: json };
|
||||||
|
}
|
||||||
|
return { ok: false, message: json.message || 'Failed to update profile' };
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Update Profile Error (Raw Response):', text);
|
||||||
|
return { ok: false, message: 'Server returned an invalid response' };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
uploadAvatar: async (imageUri: string) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
|
||||||
|
// Simplification: Always name the file 'avatar.jpg' and type 'image/jpeg'.
|
||||||
|
// This avoids issues with weird temp filenames or missing extensions
|
||||||
|
// that cause backend validation ("must be an image") to fail.
|
||||||
|
// React Native's FormData will read the file bytes correctly from the URI.
|
||||||
|
const filename = 'avatar.jpg';
|
||||||
|
const type = 'image/jpeg';
|
||||||
|
|
||||||
|
// React Native FormData expects an object with uri, name, type.
|
||||||
|
// Cast to 'any' to avoid TypeScript type mismatch.
|
||||||
|
formData.append('file', { uri: imageUri, name: filename, type } as any);
|
||||||
|
|
||||||
|
const response = await fetch(`${API_URL}/me/avatar`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...getAuthHeaders(),
|
||||||
|
},
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(text);
|
||||||
|
if (response.ok) {
|
||||||
|
return { ok: true, data: json };
|
||||||
|
}
|
||||||
|
console.error('Upload Avatar Failed (Server Message):', json);
|
||||||
|
return { ok: false, message: json.message || 'Failed to upload avatar' };
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Upload Avatar Error (Raw Response):', text);
|
||||||
|
return { ok: false, message: 'Server returned an invalid response' };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
resendVerificationEmail: async () => {
|
||||||
|
const response = await fetch(`${API_URL}/email/resend`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...getAuthHeaders(),
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(text);
|
||||||
|
return { ok: false, message: json.message || 'Failed to send verification email' };
|
||||||
|
} catch {
|
||||||
|
return { ok: false, message: 'Network error' };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteAccount: async () => {
|
||||||
|
const response = await fetch(`${API_URL}/me`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(text);
|
||||||
|
console.error('Delete Account Failed (Server Message):', json);
|
||||||
|
return { ok: false, message: json.message || 'Failed to delete account' };
|
||||||
|
} catch {
|
||||||
|
console.error('Delete Account Failed (Raw Response):', text);
|
||||||
|
return { ok: false, message: 'Server returned an invalid response' };
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
65
mobile/src/store/useDocumentStore.ts
Normal file
65
mobile/src/store/useDocumentStore.ts
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { DocumentService, ScannedDocument } from '../services/DocumentService';
|
||||||
|
|
||||||
|
interface DocumentState {
|
||||||
|
documents: ScannedDocument[];
|
||||||
|
isLoading: boolean;
|
||||||
|
loadDocuments: (userId: string | number) => Promise<void>;
|
||||||
|
addDocument: (userId: string | number, tempUri: string, fileName: string) => Promise<void>;
|
||||||
|
removeDocument: (userId: string | number, fileName: string) => Promise<void>;
|
||||||
|
renameDocument: (userId: string | number, oldName: string, newName: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useDocumentStore = create<DocumentState>((set, get) => ({
|
||||||
|
documents: [],
|
||||||
|
isLoading: false,
|
||||||
|
|
||||||
|
loadDocuments: async (userId: string | number) => {
|
||||||
|
set({ isLoading: true });
|
||||||
|
try {
|
||||||
|
const docs = await DocumentService.getDocuments(userId);
|
||||||
|
set({ documents: docs });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load documents', error);
|
||||||
|
set({ documents: [] });
|
||||||
|
} finally {
|
||||||
|
set({ isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
addDocument: async (userId: string | number, tempUri: string, fileName: string) => {
|
||||||
|
try {
|
||||||
|
const newDoc = await DocumentService.saveDocument(userId, tempUri, fileName);
|
||||||
|
set((state) => ({
|
||||||
|
documents: [newDoc, ...state.documents].sort((a, b) => b.timestamp - a.timestamp),
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save document', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
removeDocument: async (userId: string | number, fileName: string) => {
|
||||||
|
try {
|
||||||
|
await DocumentService.deleteDocument(userId, fileName);
|
||||||
|
set((state) => ({
|
||||||
|
documents: state.documents.filter((doc) => doc.name !== fileName),
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete document', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
renameDocument: async (userId: string | number, oldName: string, newName: string) => {
|
||||||
|
try {
|
||||||
|
const updatedDoc = await DocumentService.renameDocument(userId, oldName, newName);
|
||||||
|
set((state) => ({
|
||||||
|
documents: state.documents.map((doc) => (doc.name === oldName ? updatedDoc : doc)),
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to rename document', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
@ -54,7 +54,7 @@ export const getThemeStyles = (isDark: boolean) => {
|
|||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
buttonText: {
|
buttonText: {
|
||||||
color: isDark ? COLORS.LIGHT.brand : '#ffffff', // Dark text on light button, white text on dark button
|
color: isDark ? COLORS.LIGHT.brand : '#ffffff',
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
}
|
}
|
||||||
@ -170,4 +170,69 @@ export const commonStyles = StyleSheet.create({
|
|||||||
marginLeft: 10,
|
marginLeft: 10,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
},
|
},
|
||||||
|
centered: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 20,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const homeStyles = StyleSheet.create({
|
||||||
|
listContent: {
|
||||||
|
padding: 16,
|
||||||
|
paddingBottom: 100, // Space for FAB
|
||||||
|
},
|
||||||
|
docCard: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 16,
|
||||||
|
borderRadius: 12,
|
||||||
|
marginBottom: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.05,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
docIcon: {
|
||||||
|
marginRight: 16,
|
||||||
|
},
|
||||||
|
docInfo: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
docName: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
docDate: {
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
deleteBtn: {
|
||||||
|
padding: 8,
|
||||||
|
},
|
||||||
|
fab: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 24,
|
||||||
|
right: 24,
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
borderRadius: 32,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.3,
|
||||||
|
shadowRadius: 4.65,
|
||||||
|
elevation: 8,
|
||||||
|
},
|
||||||
|
loadingOverlay: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.7)',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
zIndex: 1000,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user