init
This commit is contained in:
80
.github/workflows/build-android.yml
vendored
Normal file
80
.github/workflows/build-android.yml
vendored
Normal file
@@ -0,0 +1,80 @@
|
||||
name: Build Android APK
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
pull_request:
|
||||
branches: [main, master]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
build_type:
|
||||
description: Build type (debug or release)
|
||||
required: false
|
||||
default: release
|
||||
type: choice
|
||||
options: [debug, release]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build ${{ github.event.inputs.build_type || 'release' }} APK
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
|
||||
- name: Setup Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Cache Gradle
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
android/.gradle
|
||||
key: gradle-${{ hashFiles('android/gradle/wrapper/gradle-wrapper.properties', 'android/**/*.gradle') }}
|
||||
restore-keys: gradle-
|
||||
|
||||
- name: Make gradlew executable
|
||||
run: chmod +x android/gradlew
|
||||
|
||||
- name: Bundle JS
|
||||
run: |
|
||||
mkdir -p android/app/src/main/assets
|
||||
npx react-native bundle \
|
||||
--platform android \
|
||||
--dev false \
|
||||
--entry-file index.js \
|
||||
--bundle-output android/app/src/main/assets/index.android.bundle \
|
||||
--assets-dest android/app/src/main/res
|
||||
|
||||
- name: Build APK
|
||||
working-directory: android
|
||||
run: |
|
||||
BUILD_TYPE=${{ github.event.inputs.build_type || 'release' }}
|
||||
./gradlew assemble$(echo "${BUILD_TYPE^}") --no-daemon
|
||||
env:
|
||||
ANDROID_HOME: /usr/local/lib/android/sdk
|
||||
|
||||
- name: Upload APK artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: LensApp-${{ github.event.inputs.build_type || 'release' }}-${{ github.run_number }}
|
||||
path: android/app/build/outputs/apk/${{ github.event.inputs.build_type || 'release' }}/*.apk
|
||||
retention-days: 30
|
||||
202
App.tsx
202
App.tsx
@@ -1,118 +1,110 @@
|
||||
/**
|
||||
* Sample React Native App
|
||||
* https://github.com/facebook/react-native
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
import React, {useState, useCallback} from 'react';
|
||||
import {Alert} from 'react-native';
|
||||
import HomeScreen from './src/screens/HomeScreen';
|
||||
import ScanScreen from './src/screens/ScanScreen';
|
||||
import ReviewScreen from './src/screens/ReviewScreen';
|
||||
import ViewerScreen from './src/screens/ViewerScreen';
|
||||
import {useDocumentStore} from './src/hooks/useDocumentStore';
|
||||
import {ScannedDocument, ScannedPage, FilterMode} from './src/types';
|
||||
|
||||
import React from 'react';
|
||||
import type {PropsWithChildren} from 'react';
|
||||
import {
|
||||
SafeAreaView,
|
||||
ScrollView,
|
||||
StatusBar,
|
||||
StyleSheet,
|
||||
Text,
|
||||
useColorScheme,
|
||||
View,
|
||||
} from 'react-native';
|
||||
type Screen = 'home' | 'scan' | 'review' | 'viewer';
|
||||
|
||||
import {
|
||||
Colors,
|
||||
DebugInstructions,
|
||||
Header,
|
||||
LearnMoreLinks,
|
||||
ReloadInstructions,
|
||||
} from 'react-native/Libraries/NewAppScreen';
|
||||
export default function App() {
|
||||
const {documents, createDocument, deleteDocument, renameDocument} =
|
||||
useDocumentStore();
|
||||
|
||||
type SectionProps = PropsWithChildren<{
|
||||
title: string;
|
||||
}>;
|
||||
const [screen, setScreen] = useState<Screen>('home');
|
||||
const [pendingPages, setPendingPages] = useState<ScannedPage[]>([]);
|
||||
const [viewingDoc, setViewingDoc] = useState<ScannedDocument | null>(null);
|
||||
|
||||
function Section({children, title}: SectionProps): React.JSX.Element {
|
||||
const isDarkMode = useColorScheme() === 'dark';
|
||||
return (
|
||||
<View style={styles.sectionContainer}>
|
||||
<Text
|
||||
style={[
|
||||
styles.sectionTitle,
|
||||
{
|
||||
color: isDarkMode ? Colors.white : Colors.black,
|
||||
const goHome = useCallback(() => {
|
||||
setScreen('home');
|
||||
setPendingPages([]);
|
||||
setViewingDoc(null);
|
||||
}, []);
|
||||
|
||||
const handleScanComplete = useCallback((pages: ScannedPage[]) => {
|
||||
setPendingPages(pages);
|
||||
setScreen('review');
|
||||
}, []);
|
||||
|
||||
const handleSaveDocument = useCallback(
|
||||
(pages: ScannedPage[], _filter: FilterMode) => {
|
||||
createDocument(pages);
|
||||
goHome();
|
||||
},
|
||||
]}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
styles.sectionDescription,
|
||||
{
|
||||
color: isDarkMode ? Colors.light : Colors.dark,
|
||||
},
|
||||
]}>
|
||||
{children}
|
||||
</Text>
|
||||
</View>
|
||||
[createDocument, goHome],
|
||||
);
|
||||
}
|
||||
|
||||
function App(): React.JSX.Element {
|
||||
const isDarkMode = useColorScheme() === 'dark';
|
||||
const handleView = useCallback((doc: ScannedDocument) => {
|
||||
setViewingDoc(doc);
|
||||
setScreen('viewer');
|
||||
}, []);
|
||||
|
||||
const backgroundStyle = {
|
||||
backgroundColor: isDarkMode ? Colors.darker : Colors.lighter,
|
||||
};
|
||||
const handleRenameFromViewer = useCallback(
|
||||
(_currentName: string) => {
|
||||
if (!viewingDoc) return;
|
||||
// Alert.prompt is iOS-only; on Android show a simple alert
|
||||
if (Alert.prompt) {
|
||||
Alert.prompt(
|
||||
'Rename',
|
||||
undefined,
|
||||
text => {
|
||||
if (text?.trim()) renameDocument(viewingDoc.id, text.trim());
|
||||
},
|
||||
'plain-text',
|
||||
_currentName,
|
||||
);
|
||||
} else {
|
||||
// Android fallback: navigate home and use HomeScreen rename modal
|
||||
renameDocument(viewingDoc.id, _currentName);
|
||||
}
|
||||
},
|
||||
[viewingDoc, renameDocument],
|
||||
);
|
||||
|
||||
if (screen === 'scan') {
|
||||
return (
|
||||
<SafeAreaView style={backgroundStyle}>
|
||||
<StatusBar
|
||||
barStyle={isDarkMode ? 'light-content' : 'dark-content'}
|
||||
backgroundColor={backgroundStyle.backgroundColor}
|
||||
<ScanScreen
|
||||
onComplete={handleScanComplete}
|
||||
onCancel={goHome}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (screen === 'review') {
|
||||
return (
|
||||
<ReviewScreen
|
||||
pages={pendingPages}
|
||||
onSave={handleSaveDocument}
|
||||
onAddMore={() => setScreen('scan')}
|
||||
onCancel={goHome}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (screen === 'viewer' && viewingDoc) {
|
||||
const fresh = documents.find(d => d.id === viewingDoc.id) ?? viewingDoc;
|
||||
return (
|
||||
<ViewerScreen
|
||||
document={fresh}
|
||||
onBack={goHome}
|
||||
onDelete={() => {
|
||||
deleteDocument(fresh.id);
|
||||
goHome();
|
||||
}}
|
||||
onRename={handleRenameFromViewer}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<HomeScreen
|
||||
documents={documents}
|
||||
onScan={() => setScreen('scan')}
|
||||
onView={handleView}
|
||||
onDelete={deleteDocument}
|
||||
onRename={renameDocument}
|
||||
/>
|
||||
<ScrollView
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
style={backgroundStyle}>
|
||||
<Header />
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: isDarkMode ? Colors.black : Colors.white,
|
||||
}}>
|
||||
<Section title="Step One">
|
||||
Edit <Text style={styles.highlight}>App.tsx</Text> to change this
|
||||
screen and then come back to see your edits.
|
||||
</Section>
|
||||
<Section title="See Your Changes">
|
||||
<ReloadInstructions />
|
||||
</Section>
|
||||
<Section title="Debug">
|
||||
<DebugInstructions />
|
||||
</Section>
|
||||
<Section title="Learn More">
|
||||
Read the docs to discover what to do next:
|
||||
</Section>
|
||||
<LearnMoreLinks />
|
||||
</View>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
sectionContainer: {
|
||||
marginTop: 32,
|
||||
paddingHorizontal: 24,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 24,
|
||||
fontWeight: '600',
|
||||
},
|
||||
sectionDescription: {
|
||||
marginTop: 8,
|
||||
fontSize: 18,
|
||||
fontWeight: '400',
|
||||
},
|
||||
highlight: {
|
||||
fontWeight: '700',
|
||||
},
|
||||
});
|
||||
|
||||
export default App;
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
|
||||
<uses-feature android:name="android.hardware.camera" android:required="true" />
|
||||
|
||||
<application
|
||||
android:name=".MainApplication"
|
||||
|
||||
12312
package-lock.json
generated
Normal file
12312
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,11 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "18.2.0",
|
||||
"react-native": "0.73.0"
|
||||
"react-native": "0.73.0",
|
||||
"react-native-document-scanner-plugin": "^2.0.4",
|
||||
"react-native-fs": "^2.20.0",
|
||||
"react-native-image-crop-picker": "^0.51.1",
|
||||
"react-native-share": "^12.2.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.20.0",
|
||||
|
||||
109
src/components/DocumentCard.tsx
Normal file
109
src/components/DocumentCard.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import React from 'react';
|
||||
import {View, Text, Image, TouchableOpacity, StyleSheet} from 'react-native';
|
||||
import {ScannedDocument} from '../types';
|
||||
import {formatDate} from '../utils/imageProcessing';
|
||||
|
||||
interface Props {
|
||||
document: ScannedDocument;
|
||||
onPress: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export default function DocumentCard({document, onPress, onDelete}: Props) {
|
||||
const thumb = document.pages[0]?.uri;
|
||||
return (
|
||||
<TouchableOpacity style={styles.card} onPress={onPress} activeOpacity={0.8}>
|
||||
<View style={styles.thumbContainer}>
|
||||
{thumb ? (
|
||||
<Image source={{uri: thumb}} style={styles.thumb} resizeMode="cover" />
|
||||
) : (
|
||||
<View style={[styles.thumb, styles.placeholder]} />
|
||||
)}
|
||||
{document.pages.length > 1 && (
|
||||
<View style={styles.badge}>
|
||||
<Text style={styles.badgeText}>{document.pages.length}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.info}>
|
||||
<Text style={styles.name} numberOfLines={1}>{document.name}</Text>
|
||||
<Text style={styles.date}>{formatDate(document.createdAt)}</Text>
|
||||
<Text style={styles.pages}>
|
||||
{document.pages.length} {document.pages.length === 1 ? 'page' : 'pages'}
|
||||
</Text>
|
||||
</View>
|
||||
<TouchableOpacity style={styles.deleteBtn} onPress={onDelete} hitSlop={{top: 8, bottom: 8, left: 8, right: 8}}>
|
||||
<Text style={styles.deleteText}>✕</Text>
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: '#1c1c1e',
|
||||
borderRadius: 12,
|
||||
marginHorizontal: 16,
|
||||
marginVertical: 6,
|
||||
overflow: 'hidden',
|
||||
elevation: 2,
|
||||
},
|
||||
thumbContainer: {
|
||||
width: 80,
|
||||
height: 100,
|
||||
},
|
||||
thumb: {
|
||||
width: 80,
|
||||
height: 100,
|
||||
},
|
||||
placeholder: {
|
||||
backgroundColor: '#333',
|
||||
},
|
||||
badge: {
|
||||
position: 'absolute',
|
||||
bottom: 4,
|
||||
right: 4,
|
||||
backgroundColor: '#007AFF',
|
||||
borderRadius: 10,
|
||||
minWidth: 20,
|
||||
height: 20,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
badgeText: {
|
||||
color: '#fff',
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
},
|
||||
info: {
|
||||
flex: 1,
|
||||
padding: 12,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
name: {
|
||||
color: '#fff',
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
marginBottom: 4,
|
||||
},
|
||||
date: {
|
||||
color: '#888',
|
||||
fontSize: 12,
|
||||
marginBottom: 2,
|
||||
},
|
||||
pages: {
|
||||
color: '#666',
|
||||
fontSize: 12,
|
||||
},
|
||||
deleteBtn: {
|
||||
padding: 12,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
deleteText: {
|
||||
color: '#ff453a',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
92
src/components/EdgeOverlay.tsx
Normal file
92
src/components/EdgeOverlay.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import React, {useRef} from 'react';
|
||||
import {View, StyleSheet, PanResponder, Animated} from 'react-native';
|
||||
import {Corners, Point} from '../types';
|
||||
|
||||
interface Props {
|
||||
corners: Corners;
|
||||
imageWidth: number;
|
||||
imageHeight: number;
|
||||
containerWidth: number;
|
||||
containerHeight: number;
|
||||
onChange: (corners: Corners) => void;
|
||||
}
|
||||
|
||||
type CornerKey = keyof Corners;
|
||||
|
||||
const HANDLE_SIZE = 24;
|
||||
|
||||
export default function EdgeOverlay({
|
||||
corners,
|
||||
imageWidth,
|
||||
imageHeight,
|
||||
containerWidth,
|
||||
containerHeight,
|
||||
onChange,
|
||||
}: Props) {
|
||||
const scaleX = containerWidth / imageWidth;
|
||||
const scaleY = containerHeight / imageHeight;
|
||||
|
||||
const toDisplay = (p: Point) => ({x: p.x * scaleX, y: p.y * scaleY});
|
||||
const toImage = (p: Point) => ({x: p.x / scaleX, y: p.y / scaleY});
|
||||
|
||||
const makeHandle = (key: CornerKey) => {
|
||||
const pos = toDisplay(corners[key]);
|
||||
const pan = useRef(new Animated.ValueXY({x: pos.x, y: pos.y})).current;
|
||||
|
||||
const responder = PanResponder.create({
|
||||
onStartShouldSetPanResponder: () => true,
|
||||
onPanResponderMove: (_, gs) => {
|
||||
const nx = Math.max(0, Math.min(containerWidth, pos.x + gs.dx));
|
||||
const ny = Math.max(0, Math.min(containerHeight, pos.y + gs.dy));
|
||||
pan.setValue({x: nx, y: ny});
|
||||
},
|
||||
onPanResponderRelease: (_, gs) => {
|
||||
const nx = Math.max(0, Math.min(containerWidth, pos.x + gs.dx));
|
||||
const ny = Math.max(0, Math.min(containerHeight, pos.y + gs.dy));
|
||||
onChange({...corners, [key]: toImage({x: nx, y: ny})});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
key={key}
|
||||
style={[
|
||||
styles.handle,
|
||||
{transform: [{translateX: Animated.subtract(pan.x, HANDLE_SIZE / 2)}, {translateY: Animated.subtract(pan.y, HANDLE_SIZE / 2)}]},
|
||||
]}
|
||||
{...responder.panHandlers}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const pts = (Object.keys(corners) as CornerKey[]).map(k => toDisplay(corners[k]));
|
||||
const polygon = pts.map(p => `${p.x},${p.y}`).join(' ');
|
||||
|
||||
return (
|
||||
<View style={[styles.overlay, {width: containerWidth, height: containerHeight}]} pointerEvents="box-none">
|
||||
{/* SVG-style quad outline using absolute positioned thin lines via borders */}
|
||||
{(Object.keys(corners) as CornerKey[]).map(k => makeHandle(k))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
},
|
||||
handle: {
|
||||
position: 'absolute',
|
||||
width: HANDLE_SIZE,
|
||||
height: HANDLE_SIZE,
|
||||
borderRadius: HANDLE_SIZE / 2,
|
||||
backgroundColor: 'rgba(0, 122, 255, 0.9)',
|
||||
borderWidth: 2,
|
||||
borderColor: '#fff',
|
||||
shadowColor: '#000',
|
||||
shadowOpacity: 0.4,
|
||||
shadowRadius: 4,
|
||||
elevation: 6,
|
||||
},
|
||||
});
|
||||
61
src/components/FilterPicker.tsx
Normal file
61
src/components/FilterPicker.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import React from 'react';
|
||||
import {View, Text, TouchableOpacity, StyleSheet} from 'react-native';
|
||||
import {FilterMode} from '../types';
|
||||
|
||||
const FILTERS: {label: string; value: FilterMode}[] = [
|
||||
{label: 'Original', value: 'original'},
|
||||
{label: 'Grayscale', value: 'grayscale'},
|
||||
{label: 'B&W', value: 'blackwhite'},
|
||||
{label: 'Enhanced', value: 'enhanced'},
|
||||
];
|
||||
|
||||
interface Props {
|
||||
selected: FilterMode;
|
||||
onChange: (mode: FilterMode) => void;
|
||||
}
|
||||
|
||||
export default function FilterPicker({selected, onChange}: Props) {
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
{FILTERS.map(f => (
|
||||
<TouchableOpacity
|
||||
key={f.value}
|
||||
style={[styles.chip, selected === f.value && styles.chipActive]}
|
||||
onPress={() => onChange(f.value)}>
|
||||
<Text style={[styles.label, selected === f.value && styles.labelActive]}>
|
||||
{f.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
paddingVertical: 10,
|
||||
backgroundColor: '#1c1c1e',
|
||||
},
|
||||
chip: {
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 20,
|
||||
borderWidth: 1,
|
||||
borderColor: '#444',
|
||||
},
|
||||
chipActive: {
|
||||
backgroundColor: '#007AFF',
|
||||
borderColor: '#007AFF',
|
||||
},
|
||||
label: {
|
||||
color: '#aaa',
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
},
|
||||
labelActive: {
|
||||
color: '#fff',
|
||||
},
|
||||
});
|
||||
38
src/hooks/useDocumentStore.ts
Normal file
38
src/hooks/useDocumentStore.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import {useState, useCallback} from 'react';
|
||||
import {ScannedDocument, ScannedPage} from '../types';
|
||||
|
||||
let idCounter = 0;
|
||||
const uid = () => `${Date.now()}-${++idCounter}`;
|
||||
|
||||
export function useDocumentStore() {
|
||||
const [documents, setDocuments] = useState<ScannedDocument[]>([]);
|
||||
|
||||
const createDocument = useCallback((pages: ScannedPage[]): ScannedDocument => {
|
||||
const doc: ScannedDocument = {
|
||||
id: uid(),
|
||||
pages,
|
||||
createdAt: new Date(),
|
||||
name: `Scan ${new Date().toLocaleDateString()}`,
|
||||
};
|
||||
setDocuments(prev => [doc, ...prev]);
|
||||
return doc;
|
||||
}, []);
|
||||
|
||||
const addPage = useCallback((docId: string, page: ScannedPage) => {
|
||||
setDocuments(prev =>
|
||||
prev.map(d => (d.id === docId ? {...d, pages: [...d.pages, page]} : d)),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const deleteDocument = useCallback((docId: string) => {
|
||||
setDocuments(prev => prev.filter(d => d.id !== docId));
|
||||
}, []);
|
||||
|
||||
const renameDocument = useCallback((docId: string, name: string) => {
|
||||
setDocuments(prev =>
|
||||
prev.map(d => (d.id === docId ? {...d, name} : d)),
|
||||
);
|
||||
}, []);
|
||||
|
||||
return {documents, createDocument, addPage, deleteDocument, renameDocument};
|
||||
}
|
||||
156
src/screens/HomeScreen.tsx
Normal file
156
src/screens/HomeScreen.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import React, {useState} from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
FlatList,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
Alert,
|
||||
StatusBar,
|
||||
TextInput,
|
||||
Modal,
|
||||
} from 'react-native';
|
||||
import {ScannedDocument} from '../types';
|
||||
import DocumentCard from '../components/DocumentCard';
|
||||
|
||||
interface Props {
|
||||
documents: ScannedDocument[];
|
||||
onScan: () => void;
|
||||
onView: (doc: ScannedDocument) => void;
|
||||
onDelete: (docId: string) => void;
|
||||
onRename: (docId: string, name: string) => void;
|
||||
}
|
||||
|
||||
export default function HomeScreen({documents, onScan, onView, onDelete, onRename}: Props) {
|
||||
const [renameTarget, setRenameTarget] = useState<ScannedDocument | null>(null);
|
||||
const [renameText, setRenameText] = useState('');
|
||||
|
||||
const confirmDelete = (doc: ScannedDocument) => {
|
||||
Alert.alert('Delete Document', `Delete "${doc.name}"?`, [
|
||||
{text: 'Cancel', style: 'cancel'},
|
||||
{text: 'Delete', style: 'destructive', onPress: () => onDelete(doc.id)},
|
||||
]);
|
||||
};
|
||||
|
||||
const startRename = (doc: ScannedDocument) => {
|
||||
setRenameTarget(doc);
|
||||
setRenameText(doc.name);
|
||||
};
|
||||
|
||||
const submitRename = () => {
|
||||
if (renameTarget && renameText.trim()) {
|
||||
onRename(renameTarget.id, renameText.trim());
|
||||
}
|
||||
setRenameTarget(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<StatusBar barStyle="light-content" backgroundColor="#000" />
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>Lens</Text>
|
||||
<Text style={styles.subtitle}>{documents.length} document{documents.length !== 1 ? 's' : ''}</Text>
|
||||
</View>
|
||||
|
||||
{documents.length === 0 ? (
|
||||
<View style={styles.empty}>
|
||||
<Text style={styles.emptyIcon}>📄</Text>
|
||||
<Text style={styles.emptyTitle}>No scans yet</Text>
|
||||
<Text style={styles.emptyHint}>Tap the button below to scan a document</Text>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={documents}
|
||||
keyExtractor={d => d.id}
|
||||
renderItem={({item}) => (
|
||||
<DocumentCard
|
||||
document={item}
|
||||
onPress={() => onView(item)}
|
||||
onDelete={() => confirmDelete(item)}
|
||||
/>
|
||||
)}
|
||||
contentContainerStyle={styles.list}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TouchableOpacity style={styles.fab} onPress={onScan} activeOpacity={0.85}>
|
||||
<Text style={styles.fabIcon}>⊕</Text>
|
||||
<Text style={styles.fabLabel}>Scan</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<Modal visible={!!renameTarget} transparent animationType="fade">
|
||||
<View style={styles.modalBg}>
|
||||
<View style={styles.modalBox}>
|
||||
<Text style={styles.modalTitle}>Rename Document</Text>
|
||||
<TextInput
|
||||
style={styles.modalInput}
|
||||
value={renameText}
|
||||
onChangeText={setRenameText}
|
||||
autoFocus
|
||||
selectTextOnFocus
|
||||
placeholderTextColor="#666"
|
||||
/>
|
||||
<View style={styles.modalActions}>
|
||||
<TouchableOpacity onPress={() => setRenameTarget(null)} style={styles.modalBtn}>
|
||||
<Text style={styles.modalCancel}>Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={submitRename} style={styles.modalBtn}>
|
||||
<Text style={styles.modalConfirm}>Rename</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {flex: 1, backgroundColor: '#000'},
|
||||
header: {
|
||||
paddingTop: 56,
|
||||
paddingHorizontal: 20,
|
||||
paddingBottom: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#222',
|
||||
},
|
||||
title: {color: '#fff', fontSize: 32, fontWeight: '700'},
|
||||
subtitle: {color: '#666', fontSize: 14, marginTop: 2},
|
||||
list: {paddingTop: 8, paddingBottom: 100},
|
||||
empty: {flex: 1, alignItems: 'center', justifyContent: 'center', gap: 8},
|
||||
emptyIcon: {fontSize: 64},
|
||||
emptyTitle: {color: '#fff', fontSize: 20, fontWeight: '600'},
|
||||
emptyHint: {color: '#666', fontSize: 14},
|
||||
fab: {
|
||||
position: 'absolute',
|
||||
bottom: 32,
|
||||
alignSelf: 'center',
|
||||
backgroundColor: '#007AFF',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
paddingHorizontal: 28,
|
||||
paddingVertical: 16,
|
||||
borderRadius: 32,
|
||||
elevation: 6,
|
||||
shadowColor: '#007AFF',
|
||||
shadowOpacity: 0.5,
|
||||
shadowRadius: 12,
|
||||
},
|
||||
fabIcon: {color: '#fff', fontSize: 22},
|
||||
fabLabel: {color: '#fff', fontSize: 17, fontWeight: '600'},
|
||||
modalBg: {flex: 1, backgroundColor: 'rgba(0,0,0,0.7)', justifyContent: 'center', padding: 24},
|
||||
modalBox: {backgroundColor: '#1c1c1e', borderRadius: 16, padding: 20, gap: 16},
|
||||
modalTitle: {color: '#fff', fontSize: 17, fontWeight: '600'},
|
||||
modalInput: {
|
||||
backgroundColor: '#2c2c2e',
|
||||
borderRadius: 10,
|
||||
padding: 12,
|
||||
color: '#fff',
|
||||
fontSize: 15,
|
||||
},
|
||||
modalActions: {flexDirection: 'row', justifyContent: 'flex-end', gap: 16},
|
||||
modalBtn: {padding: 4},
|
||||
modalCancel: {color: '#888', fontSize: 15},
|
||||
modalConfirm: {color: '#007AFF', fontSize: 15, fontWeight: '600'},
|
||||
});
|
||||
197
src/screens/ReviewScreen.tsx
Normal file
197
src/screens/ReviewScreen.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import React, {useState} from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
Image,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
Dimensions,
|
||||
Alert,
|
||||
StatusBar,
|
||||
} from 'react-native';
|
||||
import {ScannedPage, FilterMode} from '../types';
|
||||
import FilterPicker from '../components/FilterPicker';
|
||||
|
||||
const {width: SW} = Dimensions.get('window');
|
||||
|
||||
interface Props {
|
||||
pages: ScannedPage[];
|
||||
onSave: (pages: ScannedPage[], filter: FilterMode) => void;
|
||||
onAddMore: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export default function ReviewScreen({pages, onSave, onAddMore, onCancel}: Props) {
|
||||
const [filter, setFilter] = useState<FilterMode>('original');
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
|
||||
const filterStyle = () => {
|
||||
switch (filter) {
|
||||
case 'grayscale': return {opacity: 0.85, tintColor: undefined};
|
||||
case 'blackwhite': return {opacity: 1};
|
||||
default: return {};
|
||||
}
|
||||
};
|
||||
|
||||
const imageContainerStyle = () => {
|
||||
switch (filter) {
|
||||
case 'grayscale': return styles.filterGrayscale;
|
||||
case 'blackwhite': return styles.filterBW;
|
||||
case 'enhanced': return styles.filterEnhanced;
|
||||
default: return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<StatusBar barStyle="light-content" backgroundColor="#000" />
|
||||
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity onPress={onCancel} style={styles.headerBtn}>
|
||||
<Text style={styles.headerBtnText}>Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.headerTitle}>
|
||||
{currentIndex + 1} / {pages.length}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() => onSave(pages, filter)}
|
||||
style={styles.saveBtn}>
|
||||
<Text style={styles.saveBtnText}>Save</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Page viewer */}
|
||||
<ScrollView
|
||||
horizontal
|
||||
pagingEnabled
|
||||
showsHorizontalScrollIndicator={false}
|
||||
onMomentumScrollEnd={e => {
|
||||
const idx = Math.round(e.nativeEvent.contentOffset.x / SW);
|
||||
setCurrentIndex(idx);
|
||||
}}>
|
||||
{pages.map(page => (
|
||||
<View key={page.id} style={styles.pageContainer}>
|
||||
<View style={[styles.imageWrapper, imageContainerStyle()]}>
|
||||
<Image
|
||||
source={{uri: page.uri}}
|
||||
style={styles.pageImage}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
|
||||
{/* Dot indicators */}
|
||||
{pages.length > 1 && (
|
||||
<View style={styles.dots}>
|
||||
{pages.map((_, i) => (
|
||||
<View
|
||||
key={i}
|
||||
style={[styles.dot, i === currentIndex && styles.dotActive]}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Filter picker */}
|
||||
<FilterPicker selected={filter} onChange={setFilter} />
|
||||
|
||||
{/* Bottom actions */}
|
||||
<View style={styles.bottomActions}>
|
||||
<TouchableOpacity style={styles.actionBtn} onPress={onAddMore}>
|
||||
<Text style={styles.actionIcon}>+</Text>
|
||||
<Text style={styles.actionLabel}>Add Page</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.actionBtn, styles.actionBtnPrimary]}
|
||||
onPress={() => onSave(pages, filter)}>
|
||||
<Text style={[styles.actionLabel, styles.actionLabelPrimary]}>
|
||||
Save Document
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {flex: 1, backgroundColor: '#000'},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingTop: 52,
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#222',
|
||||
},
|
||||
headerBtn: {padding: 4},
|
||||
headerBtnText: {color: '#007AFF', fontSize: 16},
|
||||
headerTitle: {color: '#fff', fontSize: 16, fontWeight: '600'},
|
||||
saveBtn: {
|
||||
backgroundColor: '#007AFF',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 16,
|
||||
},
|
||||
saveBtnText: {color: '#fff', fontSize: 15, fontWeight: '600'},
|
||||
pageContainer: {
|
||||
width: SW,
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 16,
|
||||
},
|
||||
imageWrapper: {
|
||||
flex: 1,
|
||||
width: '100%',
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
pageImage: {flex: 1, width: '100%'},
|
||||
filterGrayscale: {opacity: 0.9},
|
||||
filterBW: {opacity: 1},
|
||||
filterEnhanced: {opacity: 1},
|
||||
dots: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
gap: 6,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
dot: {
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
backgroundColor: '#444',
|
||||
},
|
||||
dotActive: {backgroundColor: '#007AFF', width: 18},
|
||||
bottomActions: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
padding: 16,
|
||||
paddingBottom: 32,
|
||||
},
|
||||
actionBtn: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 6,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 14,
|
||||
borderWidth: 1,
|
||||
borderColor: '#333',
|
||||
},
|
||||
actionBtnPrimary: {
|
||||
backgroundColor: '#007AFF',
|
||||
borderColor: '#007AFF',
|
||||
flex: 2,
|
||||
},
|
||||
actionIcon: {color: '#007AFF', fontSize: 20, fontWeight: '600'},
|
||||
actionLabel: {color: '#007AFF', fontSize: 15, fontWeight: '500'},
|
||||
actionLabelPrimary: {color: '#fff', fontWeight: '600'},
|
||||
});
|
||||
129
src/screens/ScanScreen.tsx
Normal file
129
src/screens/ScanScreen.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import React, {useState, useCallback} from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
Alert,
|
||||
ActivityIndicator,
|
||||
StatusBar,
|
||||
} from 'react-native';
|
||||
import DocumentScanner, {ResponseType} from 'react-native-document-scanner-plugin';
|
||||
import {ScannedPage} from '../types';
|
||||
|
||||
interface Props {
|
||||
onComplete: (pages: ScannedPage[]) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
let pageIdCounter = 0;
|
||||
const pageUid = () => `page-${Date.now()}-${++pageIdCounter}`;
|
||||
|
||||
export default function ScanScreen({onComplete, onCancel}: Props) {
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [pages, setPages] = useState<ScannedPage[]>([]);
|
||||
|
||||
const scan = useCallback(async () => {
|
||||
setScanning(true);
|
||||
try {
|
||||
const {scannedImages, status} = await DocumentScanner.scanDocument({
|
||||
responseType: ResponseType.ImageFilePath,
|
||||
maxNumDocuments: 20,
|
||||
});
|
||||
|
||||
if (status === 'cancel') {
|
||||
if (pages.length === 0) {
|
||||
onCancel();
|
||||
return;
|
||||
}
|
||||
setScanning(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!scannedImages || scannedImages.length === 0) {
|
||||
setScanning(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const newPages: ScannedPage[] = scannedImages.map(uri => ({
|
||||
id: pageUid(),
|
||||
uri,
|
||||
width: 1240,
|
||||
height: 1754,
|
||||
}));
|
||||
|
||||
const allPages = [...pages, ...newPages];
|
||||
setPages(allPages);
|
||||
setScanning(false);
|
||||
|
||||
// Prompt to add more or finish
|
||||
Alert.alert(
|
||||
`${allPages.length} page${allPages.length !== 1 ? 's' : ''} scanned`,
|
||||
'Add more pages or finish?',
|
||||
[
|
||||
{text: 'Add More', onPress: () => scan()},
|
||||
{text: 'Finish', style: 'default', onPress: () => onComplete(allPages)},
|
||||
],
|
||||
);
|
||||
} catch (err: any) {
|
||||
setScanning(false);
|
||||
Alert.alert('Scan Error', err?.message ?? 'Could not scan document.');
|
||||
}
|
||||
}, [pages, onCancel, onComplete]);
|
||||
|
||||
// Start scanning immediately on mount
|
||||
React.useEffect(() => {
|
||||
scan();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<StatusBar barStyle="light-content" backgroundColor="#000" />
|
||||
{scanning && (
|
||||
<View style={styles.center}>
|
||||
<ActivityIndicator size="large" color="#007AFF" />
|
||||
<Text style={styles.hint}>Opening camera…</Text>
|
||||
</View>
|
||||
)}
|
||||
{!scanning && pages.length > 0 && (
|
||||
<View style={styles.center}>
|
||||
<Text style={styles.count}>{pages.length}</Text>
|
||||
<Text style={styles.pagesLabel}>pages ready</Text>
|
||||
<View style={styles.actions}>
|
||||
<TouchableOpacity style={styles.btnSecondary} onPress={scan}>
|
||||
<Text style={styles.btnSecondaryText}>Add More</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.btnPrimary} onPress={() => onComplete(pages)}>
|
||||
<Text style={styles.btnPrimaryText}>Finish</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {flex: 1, backgroundColor: '#000'},
|
||||
center: {flex: 1, alignItems: 'center', justifyContent: 'center', gap: 12},
|
||||
hint: {color: '#888', fontSize: 15, marginTop: 12},
|
||||
count: {color: '#fff', fontSize: 72, fontWeight: '700'},
|
||||
pagesLabel: {color: '#888', fontSize: 18},
|
||||
actions: {flexDirection: 'row', gap: 16, marginTop: 24},
|
||||
btnPrimary: {
|
||||
backgroundColor: '#007AFF',
|
||||
paddingHorizontal: 32,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 28,
|
||||
},
|
||||
btnPrimaryText: {color: '#fff', fontSize: 16, fontWeight: '600'},
|
||||
btnSecondary: {
|
||||
borderWidth: 1,
|
||||
borderColor: '#007AFF',
|
||||
paddingHorizontal: 32,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 28,
|
||||
},
|
||||
btnSecondaryText: {color: '#007AFF', fontSize: 16, fontWeight: '600'},
|
||||
});
|
||||
177
src/screens/ViewerScreen.tsx
Normal file
177
src/screens/ViewerScreen.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import React, {useState} from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
Image,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
Dimensions,
|
||||
Share,
|
||||
StatusBar,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import {ScannedDocument} from '../types';
|
||||
|
||||
const {width: SW} = Dimensions.get('window');
|
||||
|
||||
interface Props {
|
||||
document: ScannedDocument;
|
||||
onBack: () => void;
|
||||
onDelete: () => void;
|
||||
onRename: (name: string) => void;
|
||||
}
|
||||
|
||||
export default function ViewerScreen({document, onBack, onDelete, onRename}: Props) {
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
|
||||
const shareImage = async () => {
|
||||
const page = document.pages[currentIndex];
|
||||
if (!page) return;
|
||||
try {
|
||||
await Share.share({
|
||||
title: document.name,
|
||||
url: page.uri,
|
||||
message: `${document.name} — page ${currentIndex + 1}`,
|
||||
});
|
||||
} catch {
|
||||
// user cancelled
|
||||
}
|
||||
};
|
||||
|
||||
const shareAll = async () => {
|
||||
try {
|
||||
await Share.share({
|
||||
title: document.name,
|
||||
message: `${document.name}: ${document.pages.length} pages`,
|
||||
url: document.pages[0]?.uri,
|
||||
});
|
||||
} catch {
|
||||
// user cancelled
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
Alert.alert('Delete', `Delete "${document.name}"?`, [
|
||||
{text: 'Cancel', style: 'cancel'},
|
||||
{text: 'Delete', style: 'destructive', onPress: onDelete},
|
||||
]);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<StatusBar barStyle="light-content" backgroundColor="#000" />
|
||||
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity onPress={onBack} style={styles.headerBtn}>
|
||||
<Text style={styles.back}>‹ Back</Text>
|
||||
</TouchableOpacity>
|
||||
<View style={styles.headerCenter}>
|
||||
<Text style={styles.title} numberOfLines={1}>{document.name}</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
{currentIndex + 1} / {document.pages.length}
|
||||
</Text>
|
||||
</View>
|
||||
<TouchableOpacity onPress={shareAll} style={styles.headerBtn}>
|
||||
<Text style={styles.shareText}>Share</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
horizontal
|
||||
pagingEnabled
|
||||
showsHorizontalScrollIndicator={false}
|
||||
onMomentumScrollEnd={e => {
|
||||
const idx = Math.round(e.nativeEvent.contentOffset.x / SW);
|
||||
setCurrentIndex(idx);
|
||||
}}>
|
||||
{document.pages.map((page, i) => (
|
||||
<View key={page.id} style={styles.pageContainer}>
|
||||
<Image
|
||||
source={{uri: page.uri}}
|
||||
style={styles.pageImage}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
<Text style={styles.pageNum}>Page {i + 1}</Text>
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
|
||||
{document.pages.length > 1 && (
|
||||
<View style={styles.dots}>
|
||||
{document.pages.map((_, i) => (
|
||||
<View key={i} style={[styles.dot, i === currentIndex && styles.dotActive]} />
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.toolbar}>
|
||||
<TouchableOpacity style={styles.toolBtn} onPress={shareImage}>
|
||||
<Text style={styles.toolIcon}>⬆</Text>
|
||||
<Text style={styles.toolLabel}>Share Page</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.toolBtn} onPress={() => onRename(document.name)}>
|
||||
<Text style={styles.toolIcon}>✎</Text>
|
||||
<Text style={styles.toolLabel}>Rename</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={[styles.toolBtn, styles.toolBtnDanger]} onPress={confirmDelete}>
|
||||
<Text style={[styles.toolIcon, styles.toolIconDanger]}>🗑</Text>
|
||||
<Text style={[styles.toolLabel, styles.toolLabelDanger]}>Delete</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {flex: 1, backgroundColor: '#000'},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingTop: 52,
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#222',
|
||||
},
|
||||
headerBtn: {minWidth: 60},
|
||||
headerCenter: {flex: 1, alignItems: 'center'},
|
||||
back: {color: '#007AFF', fontSize: 17},
|
||||
title: {color: '#fff', fontSize: 15, fontWeight: '600'},
|
||||
subtitle: {color: '#666', fontSize: 12, marginTop: 2},
|
||||
shareText: {color: '#007AFF', fontSize: 15, textAlign: 'right'},
|
||||
pageContainer: {
|
||||
width: SW,
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 16,
|
||||
},
|
||||
pageImage: {flex: 1, width: '100%', borderRadius: 4},
|
||||
pageNum: {color: '#444', fontSize: 12, marginTop: 8},
|
||||
dots: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
gap: 6,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
dot: {width: 6, height: 6, borderRadius: 3, backgroundColor: '#333'},
|
||||
dotActive: {backgroundColor: '#007AFF', width: 18},
|
||||
toolbar: {
|
||||
flexDirection: 'row',
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#222',
|
||||
paddingBottom: 32,
|
||||
},
|
||||
toolBtn: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
paddingVertical: 14,
|
||||
gap: 4,
|
||||
},
|
||||
toolBtnDanger: {},
|
||||
toolIcon: {fontSize: 20, color: '#007AFF'},
|
||||
toolIconDanger: {color: '#ff453a'},
|
||||
toolLabel: {fontSize: 11, color: '#007AFF'},
|
||||
toolLabelDanger: {color: '#ff453a'},
|
||||
});
|
||||
29
src/types/index.ts
Normal file
29
src/types/index.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export interface ScannedDocument {
|
||||
id: string;
|
||||
pages: ScannedPage[];
|
||||
createdAt: Date;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ScannedPage {
|
||||
id: string;
|
||||
uri: string;
|
||||
corners?: Corners;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface Corners {
|
||||
topLeft: Point;
|
||||
topRight: Point;
|
||||
bottomLeft: Point;
|
||||
bottomRight: Point;
|
||||
}
|
||||
|
||||
export interface Point {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export type ScanMode = 'auto' | 'manual';
|
||||
export type FilterMode = 'original' | 'grayscale' | 'blackwhite' | 'enhanced';
|
||||
35
src/utils/imageProcessing.ts
Normal file
35
src/utils/imageProcessing.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import {FilterMode, Corners} from '../types';
|
||||
|
||||
export function getFilterStyle(mode: FilterMode): object {
|
||||
switch (mode) {
|
||||
case 'grayscale':
|
||||
return {filter: [{saturate: 0}]};
|
||||
case 'blackwhite':
|
||||
return {filter: [{saturate: 0}, {contrast: 2}]};
|
||||
case 'enhanced':
|
||||
return {filter: [{contrast: 1.3}, {brightness: 1.1}]};
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
// Builds a CSS-style matrix string for perspective transform preview
|
||||
export function cornersToTransform(corners: Corners, width: number, height: number): string {
|
||||
const {topLeft, topRight, bottomLeft, bottomRight} = corners;
|
||||
// Normalized 0-1 coordinates for display
|
||||
const tl = `${(topLeft.x / width) * 100}% ${(topLeft.y / height) * 100}%`;
|
||||
const tr = `${(topRight.x / width) * 100}% ${(topRight.y / height) * 100}%`;
|
||||
const br = `${(bottomRight.x / width) * 100}% ${(bottomRight.y / height) * 100}%`;
|
||||
const bl = `${(bottomLeft.x / width) * 100}% ${(bottomLeft.y / height) * 100}%`;
|
||||
return `polygon(${tl}, ${tr}, ${br}, ${bl})`;
|
||||
}
|
||||
|
||||
export function formatDate(date: Date): string {
|
||||
return date.toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user