Files
hotdog-app/src/components/ScanGallery.tsx
T
2026-06-29 01:59:27 -04:00

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