refactor home, add feature for document scanning, processing and viewing
This commit is contained in:
parent
685c6bb2ce
commit
b213bcee8b
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 };
|
||||
};
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
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,
|
||||
};
|
||||
},
|
||||
};
|
||||
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;
|
||||
}
|
||||
},
|
||||
}));
|
||||
Loading…
x
Reference in New Issue
Block a user