refactor home, add feature for document scanning, processing and viewing
This commit is contained in:
@@ -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 };
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
|
};
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { View, StyleSheet, useColorScheme, Text, ActivityIndicator } from 'react-native';
|
||||||
|
import Pdf from 'react-native-pdf';
|
||||||
|
import { getThemeStyles } from '../theme/styles';
|
||||||
|
|
||||||
|
export default function DocumentViewScreen({ route }: any) {
|
||||||
|
const { uri } = route.params;
|
||||||
|
const colorScheme = useColorScheme();
|
||||||
|
const isDark = colorScheme === 'dark';
|
||||||
|
const themeStyles = getThemeStyles(isDark);
|
||||||
|
|
||||||
|
// Use the URI directly.
|
||||||
|
// Note: If filenames have spaces, they should ideally be encoded,
|
||||||
|
// but for local file:// URIs, react-native-pdf often handles them as they are.
|
||||||
|
// If issues persist with spaces, try encodeURI(uri).
|
||||||
|
const source = React.useMemo(() => ({
|
||||||
|
uri: uri,
|
||||||
|
cache: true
|
||||||
|
}), [uri]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, themeStyles.container]}>
|
||||||
|
<Pdf
|
||||||
|
source={source}
|
||||||
|
onLoadComplete={(numberOfPages, filePath) => {
|
||||||
|
console.log(`Number of pages: ${numberOfPages}`);
|
||||||
|
}}
|
||||||
|
onPageChanged={(page, numberOfPages) => {
|
||||||
|
console.log(`Current page: ${page}`);
|
||||||
|
}}
|
||||||
|
onError={(error) => {
|
||||||
|
console.error('PDF Load Error:', error);
|
||||||
|
}}
|
||||||
|
onPressLink={(uri) => {
|
||||||
|
console.log(`Link pressed: ${uri}`);
|
||||||
|
}}
|
||||||
|
style={styles.pdf}
|
||||||
|
trustAllCerts={false}
|
||||||
|
renderActivityIndicator={() => <ActivityIndicator size="large" color={isDark ? 'white' : 'black'} />}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
pdf: {
|
||||||
|
flex: 1,
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,43 +1,95 @@
|
|||||||
import React from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { View, Text, TouchableOpacity, useColorScheme } from 'react-native';
|
import { View, Text, useColorScheme, FlatList, TouchableOpacity, ActivityIndicator } from 'react-native';
|
||||||
import { getThemeStyles, commonStyles } from '../theme/styles';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useNavigation } from '@react-navigation/native';
|
||||||
|
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
|
import { RootStackParamList } from '../navigation/AppNavigator';
|
||||||
|
import { getThemeStyles, commonStyles, homeStyles } from '../theme/styles';
|
||||||
|
import { useDocumentStore } from '../store/useDocumentStore';
|
||||||
import { useAuthStore } from '../store/useAuthStore';
|
import { useAuthStore } from '../store/useAuthStore';
|
||||||
|
import { useDocumentScanner } from '../hooks/useDocumentScanner';
|
||||||
|
import { useDocumentActions } from '../hooks/useDocumentActions';
|
||||||
|
import { COLORS } from '../theme/colors';
|
||||||
|
|
||||||
export default function HomeScreen() {
|
export default function HomeScreen() {
|
||||||
const { user, logout } = useAuthStore();
|
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||||
const colorScheme = useColorScheme();
|
const colorScheme = useColorScheme();
|
||||||
const isDark = colorScheme === 'dark';
|
const isDark = colorScheme === 'dark';
|
||||||
const themeStyles = getThemeStyles(isDark);
|
const themeStyles = getThemeStyles(isDark);
|
||||||
|
const themeColors = isDark ? COLORS.DARK : COLORS.LIGHT;
|
||||||
|
|
||||||
if (!user) return null; // Should not happen if navigation logic is correct
|
const { user } = useAuthStore();
|
||||||
|
const { documents, loadDocuments, isLoading } = useDocumentStore();
|
||||||
|
const { isScanning, scanDocument } = useDocumentScanner();
|
||||||
|
const { handleRename, handleDelete } = useDocumentActions();
|
||||||
|
|
||||||
|
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={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>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={commonStyles.content}>
|
<View style={themeStyles.container}>
|
||||||
<Text style={[commonStyles.title, themeStyles.text]}>Welcome Back!</Text>
|
{documents.length === 0 ? (
|
||||||
<View style={[commonStyles.card, themeStyles.card]}>
|
<View style={commonStyles.centered}>
|
||||||
<View style={commonStyles.userInfo}>
|
<Ionicons name="documents-outline" size={64} color={isDark ? '#4a5568' : '#cbd5e0'} />
|
||||||
<View style={themeStyles.avatar}>
|
<Text style={[themeStyles.subtitle, { marginTop: 16 }]}>No documents yet.</Text>
|
||||||
<Text style={commonStyles.avatarText}>
|
<Text style={[themeStyles.text, { textAlign: 'center', opacity: 0.6 }]}>
|
||||||
{user.user?.email?.charAt(0).toUpperCase() || 'U'}
|
Tap the + button to scan your first document.
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View>
|
) : (
|
||||||
<Text style={[commonStyles.label, themeStyles.subtitle]}>Logged in as:</Text>
|
<FlatList
|
||||||
<Text style={[commonStyles.value, themeStyles.text]}>{user.user?.email}</Text>
|
data={documents}
|
||||||
</View>
|
renderItem={renderItem}
|
||||||
</View>
|
keyExtractor={(item) => item.name}
|
||||||
|
contentContainerStyle={homeStyles.listContent}
|
||||||
|
refreshing={isLoading}
|
||||||
|
onRefresh={() => user?.user.id && loadDocuments(user.user.id)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<View style={[commonStyles.tokenBox, themeStyles.tokenBox]}>
|
{isScanning && (
|
||||||
<Text style={[commonStyles.label, themeStyles.subtitle]}>Your API Token:</Text>
|
<View style={homeStyles.loadingOverlay}>
|
||||||
<Text style={commonStyles.token} numberOfLines={1}>
|
<ActivityIndicator size="large" color="#fff" />
|
||||||
{user.token}
|
<Text style={{ color: '#fff', marginTop: 10 }}>Processing...</Text>
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
<TouchableOpacity style={commonStyles.logoutButton} onPress={logout}>
|
<TouchableOpacity
|
||||||
<Text style={commonStyles.logoutText}>Sign Out</Text>
|
style={[homeStyles.fab, { backgroundColor: themeColors.brand }]}
|
||||||
|
onPress={scanDocument}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<Ionicons name="add" size={32} color="#fff" />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
Reference in New Issue
Block a user