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
|
||||
tty: true
|
||||
ports:
|
||||
- "8000:80"
|
||||
- "80" # Here port 80 in the container is mapped to any ephemeral port (random Docker chosen port) on the host.
|
||||
volumes:
|
||||
- ./server:/var/www
|
||||
- ./docker/nginx/conf.d/:/etc/nginx/conf.d/
|
||||
|
||||
@ -32,7 +32,10 @@
|
||||
"tinted": "./assets/icons/ios-tinted.png"
|
||||
},
|
||||
"bundleIdentifier": "com.cesoft.casadoc",
|
||||
"appleTeamId": "NBX9G827SH"
|
||||
"appleTeamId": "NBX9G827SH",
|
||||
"infoPlist": {
|
||||
"NSCameraUsageDescription": "This app needs access to the camera to scan documents."
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
"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 { createNativeStackNavigator } from '@react-navigation/native-stack';
|
||||
import AuthScreen from '../screens/AuthScreen';
|
||||
import HomeScreen from '../screens/HomeScreen';
|
||||
import TabNavigator from './TabNavigator';
|
||||
import EditProfileScreen from '../screens/EditProfileScreen';
|
||||
import DocumentViewScreen from '../screens/DocumentViewScreen';
|
||||
import ForgotPasswordScreen from '../screens/ForgotPasswordScreen';
|
||||
import { useAuthStore } from '../store/useAuthStore';
|
||||
|
||||
// Define the types for your stack
|
||||
// Define the types for the stack
|
||||
export type RootStackParamList = {
|
||||
Auth: undefined;
|
||||
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>();
|
||||
@ -29,20 +33,40 @@ export default function AppNavigator() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack.Navigator screenOptions={{ headerShown: false }}>
|
||||
<Stack.Navigator id="RootStack" screenOptions={{ headerShown: false }}>
|
||||
{user ? (
|
||||
// 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
|
||||
<Stack.Group>
|
||||
<>
|
||||
<Stack.Screen name="Auth" component={AuthScreen} />
|
||||
<Stack.Screen
|
||||
name="ForgotPassword"
|
||||
component={ForgotPasswordScreen}
|
||||
options={{ presentation: 'fullScreenModal' }}
|
||||
/>
|
||||
</Stack.Group>
|
||||
</>
|
||||
)}
|
||||
</Stack.Navigator>
|
||||
);
|
||||
|
||||
@ -15,6 +15,7 @@ export default function TabNavigator() {
|
||||
|
||||
return (
|
||||
<Tab.Navigator
|
||||
id="MainTabs"
|
||||
screenOptions={({ route }) => ({
|
||||
tabBarIcon: ({ focused, color, size }) => {
|
||||
let iconName;
|
||||
|
||||
@ -1,26 +1,17 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { View, Text, TextInput, TouchableOpacity, ActivityIndicator, Image, Switch, ScrollView, KeyboardAvoidingView, Platform, Alert, useColorScheme } from 'react-native';
|
||||
import React, { useState } from 'react';
|
||||
import { View, Text, TextInput, TouchableOpacity, ActivityIndicator, Image, Switch, ScrollView, KeyboardAvoidingView, Platform, 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 { useAuthHandler } from '../hooks/useAuthHandler';
|
||||
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';
|
||||
@ -33,129 +24,24 @@ export default function AuthScreen() {
|
||||
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);
|
||||
const {
|
||||
loading,
|
||||
handleLogin,
|
||||
handleRegister,
|
||||
promptGoogle,
|
||||
promptFacebook,
|
||||
onAppleButtonPress,
|
||||
} = useAuthHandler();
|
||||
|
||||
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 onRegister = async () => {
|
||||
const success = await handleRegister({ email, username, password, confirmPassword, tosAccepted });
|
||||
if (success) {
|
||||
toggleMode(); // Switch back to login form on success
|
||||
}
|
||||
};
|
||||
|
||||
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 onLogin = () => handleLogin({ email, password });
|
||||
|
||||
const toggleMode = () => {
|
||||
setIsRegistering(!isRegistering);
|
||||
@ -176,8 +62,8 @@ export default function AuthScreen() {
|
||||
<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
|
||||
? require('../../assets/icons/ios-dark.png')
|
||||
: require('../../assets/icons/ios-light.png')
|
||||
}
|
||||
style={commonStyles.logo}
|
||||
resizeMode="contain"
|
||||
@ -191,7 +77,7 @@ export default function AuthScreen() {
|
||||
<View style={[commonStyles.form, themeStyles.card]}>
|
||||
<Text style={[commonStyles.label, themeStyles.subtitle]}>Email{isRegistering ? '' : ' or Username'}</Text>
|
||||
<TextInput
|
||||
style={[themeStyles.input]}
|
||||
style={themeStyles.input}
|
||||
placeholder={isRegistering ? "name@example.com" : "Enter email or username"}
|
||||
placeholderTextColor={isDark ? '#a0aec0' : '#a0aec0'}
|
||||
value={email}
|
||||
@ -205,7 +91,7 @@ export default function AuthScreen() {
|
||||
<>
|
||||
<Text style={[commonStyles.label, themeStyles.subtitle]}>Username</Text>
|
||||
<TextInput
|
||||
style={[themeStyles.input]}
|
||||
style={themeStyles.input}
|
||||
placeholder="Choose a username"
|
||||
placeholderTextColor={isDark ? '#a0aec0' : '#a0aec0'}
|
||||
value={username}
|
||||
@ -218,7 +104,7 @@ export default function AuthScreen() {
|
||||
|
||||
<Text style={[commonStyles.label, themeStyles.subtitle]}>Password</Text>
|
||||
<TextInput
|
||||
style={[themeStyles.input]}
|
||||
style={themeStyles.input}
|
||||
placeholder="Enter your password"
|
||||
placeholderTextColor={isDark ? '#a0aec0' : '#a0aec0'}
|
||||
value={password}
|
||||
@ -230,7 +116,7 @@ export default function AuthScreen() {
|
||||
<>
|
||||
<Text style={[commonStyles.label, themeStyles.subtitle]}>Confirm Password</Text>
|
||||
<TextInput
|
||||
style={[themeStyles.input]}
|
||||
style={themeStyles.input}
|
||||
placeholder="Confirm your password"
|
||||
placeholderTextColor={isDark ? '#a0aec0' : '#a0aec0'}
|
||||
value={confirmPassword}
|
||||
@ -252,7 +138,7 @@ export default function AuthScreen() {
|
||||
|
||||
<TouchableOpacity
|
||||
style={[themeStyles.button, loading && commonStyles.buttonDisabled]}
|
||||
onPress={isRegistering ? handleRegister : handleLogin}
|
||||
onPress={isRegistering ? onRegister : onLogin}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
@ -269,20 +155,8 @@ export default function AuthScreen() {
|
||||
)}
|
||||
|
||||
<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.');
|
||||
}
|
||||
}}
|
||||
onGooglePress={promptGoogle}
|
||||
onFacebookPress={promptFacebook}
|
||||
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 { View, Text, TouchableOpacity, useColorScheme } from 'react-native';
|
||||
import { getThemeStyles, commonStyles } from '../theme/styles';
|
||||
import React, { useEffect } from 'react';
|
||||
import { View, Text, useColorScheme, FlatList, TouchableOpacity, ActivityIndicator } from 'react-native';
|
||||
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 { useDocumentScanner } from '../hooks/useDocumentScanner';
|
||||
import { useDocumentActions } from '../hooks/useDocumentActions';
|
||||
import { COLORS } from '../theme/colors';
|
||||
|
||||
export default function HomeScreen() {
|
||||
const { user, logout } = useAuthStore();
|
||||
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||
const colorScheme = useColorScheme();
|
||||
const isDark = colorScheme === 'dark';
|
||||
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 (
|
||||
<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>
|
||||
useEffect(() => {
|
||||
if (user?.user?.id) {
|
||||
loadDocuments(user.user.id);
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const renderItem = ({ item }: { item: any }) => (
|
||||
<TouchableOpacity onPress={() => navigation.navigate('DocumentView', { uri: item.uri })} activeOpacity={0.7}>
|
||||
<View style={[homeStyles.docCard, { backgroundColor: themeColors.card, borderColor: isDark ? '#2d3748' : '#e2e8f0' }]}>
|
||||
<View style={homeStyles.docIcon}>
|
||||
<Ionicons name="document-text-outline" size={32} color={themeColors.brand} />
|
||||
</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}
|
||||
<View style={homeStyles.docInfo}>
|
||||
<Text style={[homeStyles.docName, { color: themeColors.text }]} numberOfLines={1}>
|
||||
{item.name.replace('.pdf', '')}
|
||||
</Text>
|
||||
<Text style={[homeStyles.docDate, { color: isDark ? '#a0aec0' : '#718096' }]}>
|
||||
{new Date(item.timestamp).toLocaleDateString()}
|
||||
</Text>
|
||||
</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>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
<TouchableOpacity style={commonStyles.logoutButton} onPress={logout}>
|
||||
<Text style={commonStyles.logoutText}>Sign Out</Text>
|
||||
return (
|
||||
<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>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -1,68 +1,34 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, Text, TouchableOpacity, useColorScheme, ScrollView, Alert, Image, ActivityIndicator, Modal } from 'react-native';
|
||||
import React from 'react';
|
||||
import { View, Text, TouchableOpacity, useColorScheme, ScrollView, Image, ActivityIndicator, Modal, Alert } from 'react-native';
|
||||
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 } from '../theme/styles';
|
||||
import { useAuthStore } from '../store/useAuthStore';
|
||||
import { authService } from '../services/api';
|
||||
import { getThemeStyles } from '../theme/styles';
|
||||
import { COLORS } from '../theme/colors';
|
||||
import { useProfileLogic } from '../hooks/useProfileLogic';
|
||||
import appConfig from '../../app.json';
|
||||
|
||||
export default function ProfileScreen() {
|
||||
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||
const { user, logout } = useAuthStore();
|
||||
const [isResending, setIsResending] = useState(false);
|
||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||
const colorScheme = useColorScheme();
|
||||
const isDark = colorScheme === 'dark';
|
||||
const themeStyles = getThemeStyles(isDark);
|
||||
const themeColors = isDark ? COLORS.DARK : COLORS.LIGHT;
|
||||
|
||||
const {
|
||||
user,
|
||||
displayName,
|
||||
isResending,
|
||||
isModalVisible,
|
||||
setIsModalVisible,
|
||||
handleResendVerification,
|
||||
handleDeleteAccount,
|
||||
logout,
|
||||
} = useProfileLogic();
|
||||
|
||||
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 }) => (
|
||||
<TouchableOpacity
|
||||
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 = {
|
||||
login: async (username, password) => {
|
||||
@ -45,11 +55,11 @@ export const authService = {
|
||||
},
|
||||
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' };
|
||||
@ -71,4 +81,114 @@ export const authService = {
|
||||
});
|
||||
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',
|
||||
},
|
||||
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,
|
||||
fontWeight: 'bold',
|
||||
}
|
||||
@ -170,4 +170,69 @@ export const commonStyles = StyleSheet.create({
|
||||
marginLeft: 10,
|
||||
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