initial commit
This commit is contained in:
@@ -0,0 +1,251 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user