This commit is contained in:
2026-04-18 12:50:58 +05:30
parent 150672f305
commit 59f8ea394b
15 changed files with 13523 additions and 108 deletions

80
.github/workflows/build-android.yml vendored Normal file
View 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
View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

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

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

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

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

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

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

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