252 lines
6.5 KiB
TypeScript
252 lines
6.5 KiB
TypeScript
import { Feather } from '@expo/vector-icons';
|
|
import * as ImagePicker from 'expo-image-picker';
|
|
import { useCallback, useEffect, useState } from 'react';
|
|
import {
|
|
Alert,
|
|
FlatList,
|
|
Image,
|
|
Modal,
|
|
Pressable,
|
|
StyleSheet,
|
|
Text,
|
|
View,
|
|
} from 'react-native';
|
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
|
import { classifyImageUri, type ClassificationResult } from '../lib/classifier';
|
|
import { loadGallery, removeFromGallery, type GalleryItem } from '../lib/gallery';
|
|
import { colors, layout } from '../theme';
|
|
import { GlassSurface } from './GlassSurface';
|
|
import { HotDogIcon } from './HotDogIcon';
|
|
|
|
type ScanGalleryProps = {
|
|
visible: boolean;
|
|
onClose: () => void;
|
|
onSelect: (item: GalleryItem) => void;
|
|
onImport: (uri: string, result: ClassificationResult) => void;
|
|
};
|
|
|
|
export function ScanGallery({
|
|
visible,
|
|
onClose,
|
|
onSelect,
|
|
onImport,
|
|
}: ScanGalleryProps) {
|
|
const [items, setItems] = useState<GalleryItem[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
const refresh = useCallback(async () => {
|
|
setItems(await loadGallery());
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (visible) void refresh();
|
|
}, [visible, refresh]);
|
|
|
|
const handleImport = useCallback(async () => {
|
|
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
|
if (!permission.granted) {
|
|
Alert.alert('Photos access needed to import an image.');
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
try {
|
|
const picked = await ImagePicker.launchImageLibraryAsync({
|
|
mediaTypes: ['images'],
|
|
quality: 0.9,
|
|
});
|
|
|
|
if (picked.canceled || !picked.assets[0]?.uri) return;
|
|
|
|
const uri = picked.assets[0].uri;
|
|
const result = await classifyImageUri(uri);
|
|
onImport(uri, result);
|
|
onClose();
|
|
} catch {
|
|
Alert.alert('Could not scan that photo.');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [onClose, onImport]);
|
|
|
|
const handleDelete = useCallback(
|
|
(item: GalleryItem) => {
|
|
Alert.alert('Delete scan?', undefined, [
|
|
{ text: 'Cancel', style: 'cancel' },
|
|
{
|
|
text: 'Delete',
|
|
style: 'destructive',
|
|
onPress: () => {
|
|
void removeFromGallery(item.id).then(refresh);
|
|
},
|
|
},
|
|
]);
|
|
},
|
|
[refresh],
|
|
);
|
|
|
|
return (
|
|
<Modal visible={visible} animationType="slide" onRequestClose={onClose}>
|
|
<View style={styles.root}>
|
|
<SafeAreaView edges={['top', 'left', 'right']} style={styles.safe}>
|
|
<View style={styles.headerWrap}>
|
|
<GlassSurface appearance="sheet" style={styles.header}>
|
|
<View style={styles.headerRow}>
|
|
<Pressable
|
|
onPress={onClose}
|
|
style={styles.headerButton}
|
|
accessibilityLabel="Close gallery"
|
|
>
|
|
<Feather name="x" size={22} color={colors.text} />
|
|
</Pressable>
|
|
<Text style={styles.headerTitle}>Gallery</Text>
|
|
<Pressable
|
|
onPress={() => void handleImport()}
|
|
style={styles.headerButton}
|
|
disabled={loading}
|
|
accessibilityLabel="Import photo"
|
|
>
|
|
<Feather name="plus" size={22} color={colors.text} />
|
|
</Pressable>
|
|
</View>
|
|
</GlassSurface>
|
|
</View>
|
|
|
|
<FlatList
|
|
data={items}
|
|
keyExtractor={(item) => item.id}
|
|
numColumns={3}
|
|
contentContainerStyle={styles.grid}
|
|
columnWrapperStyle={styles.gridRow}
|
|
ListEmptyComponent={
|
|
<View style={styles.empty}>
|
|
<HotDogIcon size={40} color={colors.textMuted} />
|
|
<Text style={styles.emptyTitle}>No scans yet</Text>
|
|
<Text style={styles.emptyBody}>
|
|
Scan a hot dog or tap + to import a photo.
|
|
</Text>
|
|
</View>
|
|
}
|
|
renderItem={({ item }) => (
|
|
<Pressable
|
|
style={styles.cell}
|
|
onPress={() => {
|
|
onSelect(item);
|
|
onClose();
|
|
}}
|
|
onLongPress={() => handleDelete(item)}
|
|
accessibilityLabel={
|
|
item.result.isHotDog ? 'Hot dog scan' : 'Not hot dog scan'
|
|
}
|
|
>
|
|
<Image source={{ uri: item.uri }} style={styles.thumb} />
|
|
<View
|
|
style={[
|
|
styles.badge,
|
|
{
|
|
backgroundColor: item.result.isHotDog
|
|
? colors.hotDog
|
|
: colors.notHotDog,
|
|
},
|
|
]}
|
|
>
|
|
{item.result.isHotDog ? (
|
|
<HotDogIcon size={12} color={colors.text} />
|
|
) : (
|
|
<Feather name="x" size={12} color={colors.text} />
|
|
)}
|
|
</View>
|
|
</Pressable>
|
|
)}
|
|
/>
|
|
</SafeAreaView>
|
|
</View>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
const GAP = 8;
|
|
|
|
const styles = StyleSheet.create({
|
|
root: {
|
|
flex: 1,
|
|
backgroundColor: colors.bg,
|
|
},
|
|
safe: {
|
|
flex: 1,
|
|
paddingHorizontal: 16,
|
|
},
|
|
headerWrap: {
|
|
marginTop: layout.galleryHeaderTop,
|
|
marginBottom: 16,
|
|
},
|
|
header: {
|
|
paddingHorizontal: 8,
|
|
paddingVertical: 8,
|
|
},
|
|
headerRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
},
|
|
headerButton: {
|
|
width: layout.controlSize,
|
|
height: layout.controlSize,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
headerTitle: {
|
|
color: colors.text,
|
|
fontSize: 17,
|
|
fontWeight: '700',
|
|
},
|
|
grid: {
|
|
paddingBottom: 24,
|
|
flexGrow: 1,
|
|
},
|
|
gridRow: {
|
|
gap: GAP,
|
|
marginBottom: GAP,
|
|
},
|
|
cell: {
|
|
flex: 1,
|
|
aspectRatio: 1,
|
|
borderRadius: layout.radius,
|
|
overflow: 'hidden',
|
|
backgroundColor: 'rgba(255, 255, 255, 0.04)',
|
|
},
|
|
thumb: {
|
|
width: '100%',
|
|
height: '100%',
|
|
},
|
|
badge: {
|
|
position: 'absolute',
|
|
top: 6,
|
|
right: 6,
|
|
width: 22,
|
|
height: 22,
|
|
borderRadius: 11,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
empty: {
|
|
flex: 1,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
paddingTop: 80,
|
|
gap: 10,
|
|
paddingHorizontal: 32,
|
|
},
|
|
emptyTitle: {
|
|
color: colors.text,
|
|
fontSize: 18,
|
|
fontWeight: '700',
|
|
},
|
|
emptyBody: {
|
|
color: colors.textMuted,
|
|
fontSize: 15,
|
|
textAlign: 'center',
|
|
lineHeight: 21,
|
|
},
|
|
});
|