refactor home, add feature for document scanning, processing and viewing

This commit is contained in:
Joseph D'Souza 2026-02-11 13:11:49 +01:00
parent 685c6bb2ce
commit b213bcee8b
6 changed files with 437 additions and 26 deletions

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,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

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

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