Compare commits

...

10 Commits

17 changed files with 994 additions and 239 deletions

View File

@ -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/

View File

@ -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": {

View 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,
};
};

View 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 };
};

View 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 };
};

View 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,
};
};

View 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,
};
};

View File

@ -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>
);

View File

@ -15,6 +15,7 @@ export default function TabNavigator() {
return (
<Tab.Navigator
id="MainTabs"
screenOptions={({ route }) => ({
tabBarIcon: ({ focused, color, size }) => {
let iconName;

View File

@ -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}
/>

View 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%',
},
});

View File

@ -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>
);
}

View File

@ -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={{

View 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,
};
},
};

View File

@ -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' };
}
},
};

View 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;
}
},
}));

View File

@ -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,
},
});