dpv007 commited on
Commit
2a83bb2
·
1 Parent(s): cd18655

feat: gallery performance, image zoom, advanced 3-way delete, native video controls

Browse files
keystone-app/app/(tabs)/index.tsx CHANGED
@@ -25,12 +25,14 @@ import { StatusBar } from 'expo-status-bar';
25
  import { MaterialIcons } from '@expo/vector-icons';
26
  import { format, isToday, isYesterday } from 'date-fns';
27
  import AsyncStorage from '@react-native-async-storage/async-storage';
 
28
 
29
  import { SYNC_ON_CELLULAR_KEY } from './settings';
30
  import { runSyncCycle, BACKEND_URL, getToken, getSyncedList, deleteFromCloud } from '../utils/syncManager';
31
  import { useAuth } from '../../components/AuthContext';
32
 
33
  export const TRASHED_ASSETS_KEY = '@trashed_assets';
 
34
 
35
  const { width, height } = Dimensions.get('window');
36
  const NUM_COLUMNS = 4;
@@ -42,6 +44,7 @@ export type UnifiedAsset = {
42
  originalId?: string; // local ID if it exists
43
  uri: string; // local uri or remote URL
44
  cloudUri?: string; // remote URL if backed up
 
45
  filename: string;
46
  mediaType: 'photo' | 'video';
47
  creationTime: number;
@@ -65,6 +68,93 @@ function SyncProgressBar({ progress, total }: { progress: number; total: number
65
  );
66
  }
67
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  // ─── Main component ──────────────────────────────────────────────
69
  export default function PhotosScreen() {
70
  const insets = useSafeAreaInsets();
@@ -98,7 +188,6 @@ export default function PhotosScreen() {
98
  const [viewerIndex, setViewerIndex] = useState<number | null>(null);
99
  const viewerRef = useRef<any>(null);
100
  const [showInfo, setShowInfo] = useState(false);
101
- const [showDeleteMenu, setShowDeleteMenu] = useState(false);
102
  const [isPlaying, setIsPlaying] = useState(true);
103
 
104
  // ── Network & Settings Listeners ──────────────────────────────
@@ -188,7 +277,18 @@ export default function PhotosScreen() {
188
 
189
  const loadAssets = useCallback(async () => {
190
  try {
191
- setIsLoading(true);
 
 
 
 
 
 
 
 
 
 
 
192
  // 1. Fetch Local
193
  const media = await MediaLibrary.getAssetsAsync({
194
  first: 800,
@@ -238,6 +338,7 @@ export default function PhotosScreen() {
238
  isSynced: isSynced || !!matchCloud,
239
  isCloudOnly: false,
240
  cloudUri: matchCloud ? `${BACKEND_URL}${matchCloud.uri}?token=${await getToken()}` : undefined,
 
241
  });
242
  }
243
 
@@ -247,6 +348,7 @@ export default function PhotosScreen() {
247
  id: c.id,
248
  uri: `${BACKEND_URL}${c.uri}?token=${await getToken()}`,
249
  cloudUri: `${BACKEND_URL}${c.uri}?token=${await getToken()}`,
 
250
  filename: c.filename,
251
  mediaType: c.mediaType,
252
  creationTime: c.creationTime,
@@ -265,6 +367,9 @@ export default function PhotosScreen() {
265
  setAllAssets(unified);
266
  setSections(groupAssetsByDate(unified));
267
 
 
 
 
268
  const toSync = unified.filter(a => !a.isSynced).length;
269
  setItemsToBackup(toSync);
270
  setIsLoading(false);
@@ -309,32 +414,51 @@ export default function PhotosScreen() {
309
  };
310
 
311
  // ── Delete ────────────────────────────────────────────────────
312
- const deleteSelected = () => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
313
  Alert.alert(
314
- `Move ${selectedIds.size} item${selectedIds.size > 1 ? 's' : ''} to Bin?`,
315
- 'Items will be moved to your local bin.',
316
  [
317
  { text: 'Cancel', style: 'cancel' },
318
- {
319
- text: 'Move to Bin',
320
- style: 'destructive',
321
- onPress: async () => {
322
- try {
323
- const trashed = await getTrashedAssets();
324
- const itemsToTrash = allAssets.filter(a => selectedIds.has(a.id));
325
- const newTrashed = [...trashed, ...itemsToTrash];
326
- await AsyncStorage.setItem(TRASHED_ASSETS_KEY, JSON.stringify(newTrashed));
327
- setSelectedIds(new Set());
328
- await loadAssets();
329
- } catch (e) {
330
- Alert.alert('Error', 'Could not move items to bin.');
331
- }
332
- },
333
- },
334
  ]
335
  );
336
  };
337
 
 
 
 
 
 
338
  // ── Share ─────────────────────────────────────────────────────
339
  const shareSelected = async () => {
340
  const ids = [...selectedIds];
@@ -422,70 +546,14 @@ export default function PhotosScreen() {
422
 
423
  // ── Render helpers ────────────────────────────────────────────
424
  const renderRow = useCallback(({ item: row }: { item: UnifiedAsset[] }) => (
425
- <View style={styles.row}>
426
- {row.map(asset => {
427
- const selected = selectedIds.has(asset.id);
428
- const isVideo = asset.mediaType === MediaLibrary.MediaType.video;
429
- return (
430
- <TouchableOpacity
431
- key={asset.id}
432
- style={[styles.tileContainer, selected && styles.tileSelected]}
433
- onPress={() => openViewer(asset)}
434
- onLongPress={() => toggleSelect(asset.id)}
435
- activeOpacity={0.85}
436
- >
437
- <Image
438
- source={{ uri: asset.uri }}
439
- style={styles.image}
440
- contentFit="cover"
441
- cachePolicy="memory-disk"
442
- />
443
- {isVideo && (
444
- <View style={styles.videoOverlay}>
445
- <MaterialIcons name="play-circle-fill" size={22} color="rgba(255,255,255,0.9)" />
446
- {asset.duration > 0 && (
447
- <Text style={styles.videoDuration}>
448
- {Math.floor(asset.duration / 60)}:{String(Math.floor(asset.duration % 60)).padStart(2, '0')}
449
- </Text>
450
- )}
451
- </View>
452
- )}
453
-
454
- {/* Unsynced Cloud Indicator */}
455
- {!asset.isSynced && !asset.isCloudOnly && (
456
- <View style={styles.unsyncedOverlay}>
457
- <MaterialIcons name="cloud-off" size={16} color="rgba(255,255,255,0.8)" />
458
- </View>
459
- )}
460
-
461
- {/* Cloud-Only Indicator */}
462
- {asset.isCloudOnly && (
463
- <View style={styles.cloudOnlyOverlay}>
464
- <MaterialIcons name="cloud" size={16} color="rgba(255,255,255,0.8)" />
465
- </View>
466
- )}
467
- {/* Selection check circle — always show in selection mode */}
468
- {isSelecting && (
469
- <View style={styles.selectionCircleOverlay}>
470
- {selected
471
- ? <MaterialIcons name="check-circle" size={22} color="#1a73e8" />
472
- : <MaterialIcons name="radio-button-unchecked" size={22} color="rgba(255,255,255,0.8)" />
473
- }
474
- </View>
475
- )}
476
- {!isSelecting && selected && (
477
- <View style={styles.selectedOverlay}>
478
- <MaterialIcons name="check-circle" size={22} color="#1a73e8" />
479
- </View>
480
- )}
481
- </TouchableOpacity>
482
- );
483
- })}
484
- {Array.from({ length: NUM_COLUMNS - row.length }).map((_, i) => (
485
- <View key={`empty-${i}`} style={styles.tileFiller} />
486
- ))}
487
- </View>
488
- ), [selectedIds, isSelecting]);
489
 
490
  const renderSectionHeader = useCallback(({ section }: { section: SectionData }) => {
491
  const allDaySelected = isDayFullySelected(section);
@@ -631,41 +699,39 @@ export default function PhotosScreen() {
631
  </View>
632
  </TouchableWithoutFeedback>
633
 
634
- <FlatList
635
- ref={viewerRef}
636
- data={allAssets}
637
- horizontal
638
- pagingEnabled
639
- initialScrollIndex={viewerIndex ?? 0}
640
- getItemLayout={(_, index) => ({ length: width, offset: width * index, index })}
641
- keyExtractor={item => item.id}
642
- showsHorizontalScrollIndicator={false}
643
- onMomentumScrollEnd={(e) => {
644
- const newIndex = Math.round(e.nativeEvent.contentOffset.x / width);
645
- setViewerIndex(newIndex);
646
- setIsPlaying(true);
647
- }}
648
- renderItem={({ item, index }) => (
649
- <View style={{ width, height: '100%', justifyContent: 'center' }}>
650
- {item.mediaType === 'video' ? (
651
- <Video
652
- source={{ uri: item.uri }}
653
- style={styles.viewerMedia}
654
- resizeMode={ResizeMode.CONTAIN}
655
- useNativeControls={true}
656
- shouldPlay={isPlaying && viewerIndex === index}
657
- />
658
- ) : (
659
  <Image
660
- source={{ uri: item.uri }}
661
- style={styles.viewerMedia}
662
  contentFit="contain"
663
  cachePolicy="memory-disk"
664
  />
665
- )}
666
- </View>
667
- )}
668
- />
669
 
670
  {/* Viewer action bar */}
671
  <View style={[styles.viewerActions, { paddingBottom: insets.bottom + 8 }]}>
@@ -696,7 +762,10 @@ export default function PhotosScreen() {
696
 
697
  <TouchableOpacity
698
  style={styles.viewerActionBtn}
699
- onPress={() => setShowDeleteMenu(true)}
 
 
 
700
  >
701
  <MaterialIcons name="delete-outline" size={26} color="#ff6b6b" />
702
  <Text style={[styles.viewerActionLabel, { color: '#ff6b6b' }]}>Delete</Text>
@@ -747,98 +816,7 @@ export default function PhotosScreen() {
747
  </Modal>
748
  )}
749
 
750
- {/* Delete Action Sheet Modal */}
751
- {showDeleteMenu && viewerIndex !== null && (
752
- <Modal transparent animationType="slide" onRequestClose={() => setShowDeleteMenu(false)}>
753
- <TouchableOpacity style={styles.infoModalBg} activeOpacity={1} onPress={() => setShowDeleteMenu(false)}>
754
- <TouchableWithoutFeedback>
755
- <View style={[styles.infoSheet, { paddingBottom: insets.bottom + 20 }]}>
756
- <Text style={styles.infoTitle}>Delete Photo</Text>
757
-
758
- <TouchableOpacity style={styles.infoRow} onPress={async () => {
759
- setShowDeleteMenu(false);
760
- const asset = allAssets[viewerIndex];
761
- try {
762
- const trashed = await getTrashedAssets();
763
- await AsyncStorage.setItem(TRASHED_ASSETS_KEY, JSON.stringify([...trashed, asset]));
764
- setViewerIndex(null);
765
- await loadAssets();
766
- } catch { Alert.alert('Error', 'Could not move to bin.'); }
767
- }}>
768
- <MaterialIcons name="delete-outline" size={24} color="#202124" />
769
- <View>
770
- <Text style={[styles.infoText, { fontWeight: '600' }]}>Move to Bin</Text>
771
- <Text style={{ fontSize: 13, color: '#5f6368' }}>Hide from timeline, keep on device and cloud.</Text>
772
- </View>
773
- </TouchableOpacity>
774
-
775
- {!allAssets[viewerIndex].isCloudOnly && (
776
- <TouchableOpacity style={styles.infoRow} onPress={async () => {
777
- setShowDeleteMenu(false);
778
- const asset = allAssets[viewerIndex];
779
- try {
780
- if (asset.originalId) {
781
- await MediaLibrary.deleteAssetsAsync([{ id: asset.originalId }] as any);
782
- setViewerIndex(null);
783
- await loadAssets();
784
- }
785
- } catch { Alert.alert('Error', 'Could not delete from device.'); }
786
- }}>
787
- <MaterialIcons name="smartphone" size={24} color="#d93025" />
788
- <View>
789
- <Text style={[styles.infoText, { fontWeight: '600', color: '#d93025' }]}>Delete from Device Only</Text>
790
- <Text style={{ fontSize: 13, color: '#5f6368' }}>Remove from phone. Keep in cloud.</Text>
791
- </View>
792
- </TouchableOpacity>
793
- )}
794
 
795
- {allAssets[viewerIndex].isSynced && (
796
- <TouchableOpacity style={styles.infoRow} onPress={async () => {
797
- setShowDeleteMenu(false);
798
- const asset = allAssets[viewerIndex];
799
- try {
800
- const success = await deleteFromCloud(asset.id);
801
- if (success) {
802
- setViewerIndex(null);
803
- await loadAssets();
804
- } else {
805
- Alert.alert('Error', 'Could not delete from cloud.');
806
- }
807
- } catch { Alert.alert('Error', 'Could not delete from cloud.'); }
808
- }}>
809
- <MaterialIcons name="cloud-off" size={24} color="#d93025" />
810
- <View>
811
- <Text style={[styles.infoText, { fontWeight: '600', color: '#d93025' }]}>Delete from Cloud Only</Text>
812
- <Text style={{ fontSize: 13, color: '#5f6368' }}>Remove from backup. Keep on phone.</Text>
813
- </View>
814
- </TouchableOpacity>
815
- )}
816
-
817
- <TouchableOpacity style={styles.infoRow} onPress={async () => {
818
- setShowDeleteMenu(false);
819
- const asset = allAssets[viewerIndex];
820
- try {
821
- if (!asset.isCloudOnly && asset.originalId) {
822
- await MediaLibrary.deleteAssetsAsync([{ id: asset.originalId }] as any);
823
- }
824
- if (asset.isSynced) {
825
- await deleteFromCloud(asset.id);
826
- }
827
- setViewerIndex(null);
828
- await loadAssets();
829
- } catch { Alert.alert('Error', 'Could not delete.'); }
830
- }}>
831
- <MaterialIcons name="delete-forever" size={24} color="#d93025" />
832
- <View>
833
- <Text style={[styles.infoText, { fontWeight: '600', color: '#d93025' }]}>Delete from Both</Text>
834
- <Text style={{ fontSize: 13, color: '#5f6368' }}>Permanently obliterate from device and cloud.</Text>
835
- </View>
836
- </TouchableOpacity>
837
- </View>
838
- </TouchableWithoutFeedback>
839
- </TouchableOpacity>
840
- </Modal>
841
- )}
842
 
843
  </SafeAreaView>
844
  );
 
25
  import { MaterialIcons } from '@expo/vector-icons';
26
  import { format, isToday, isYesterday } from 'date-fns';
27
  import AsyncStorage from '@react-native-async-storage/async-storage';
28
+ import ImageViewer from 'react-native-image-zoom-viewer';
29
 
30
  import { SYNC_ON_CELLULAR_KEY } from './settings';
31
  import { runSyncCycle, BACKEND_URL, getToken, getSyncedList, deleteFromCloud } from '../utils/syncManager';
32
  import { useAuth } from '../../components/AuthContext';
33
 
34
  export const TRASHED_ASSETS_KEY = '@trashed_assets';
35
+ export const CACHED_ASSETS_KEY = '@cached_assets';
36
 
37
  const { width, height } = Dimensions.get('window');
38
  const NUM_COLUMNS = 4;
 
44
  originalId?: string; // local ID if it exists
45
  uri: string; // local uri or remote URL
46
  cloudUri?: string; // remote URL if backed up
47
+ cloudId?: string; // remote relative path for deletion
48
  filename: string;
49
  mediaType: 'photo' | 'video';
50
  creationTime: number;
 
68
  );
69
  }
70
 
71
+ // ─── Memoized Gallery Row ────────────────────────────────────────
72
+ const GalleryRow = React.memo(({
73
+ row,
74
+ selectedIds,
75
+ isSelecting,
76
+ openViewer,
77
+ toggleSelect,
78
+ }: {
79
+ row: UnifiedAsset[];
80
+ selectedIds: Set<string>;
81
+ isSelecting: boolean;
82
+ openViewer: (a: UnifiedAsset) => void;
83
+ toggleSelect: (id: string) => void;
84
+ }) => {
85
+ return (
86
+ <View style={styles.row}>
87
+ {row.map(asset => {
88
+ const selected = selectedIds.has(asset.id);
89
+ const isVideo = asset.mediaType === MediaLibrary.MediaType.video;
90
+ return (
91
+ <TouchableOpacity
92
+ key={asset.id}
93
+ style={[styles.tileContainer, selected && styles.tileSelected]}
94
+ onPress={() => openViewer(asset)}
95
+ onLongPress={() => toggleSelect(asset.id)}
96
+ activeOpacity={0.85}
97
+ >
98
+ <Image
99
+ source={{ uri: asset.uri }}
100
+ style={styles.image}
101
+ contentFit="cover"
102
+ cachePolicy="memory-disk"
103
+ />
104
+ {isVideo && (
105
+ <View style={styles.videoOverlay}>
106
+ <MaterialIcons name="play-circle-fill" size={22} color="rgba(255,255,255,0.9)" />
107
+ {asset.duration > 0 && (
108
+ <Text style={styles.videoDuration}>
109
+ {Math.floor(asset.duration / 60)}:{String(Math.floor(asset.duration % 60)).padStart(2, '0')}
110
+ </Text>
111
+ )}
112
+ </View>
113
+ )}
114
+
115
+ {/* Unsynced Cloud Indicator */}
116
+ {!asset.isSynced && !asset.isCloudOnly && (
117
+ <View style={styles.unsyncedOverlay}>
118
+ <MaterialIcons name="cloud-off" size={16} color="rgba(255,255,255,0.8)" />
119
+ </View>
120
+ )}
121
+
122
+ {/* Cloud-Only Indicator */}
123
+ {asset.isCloudOnly && (
124
+ <View style={styles.cloudOnlyOverlay}>
125
+ <MaterialIcons name="cloud" size={16} color="rgba(255,255,255,0.8)" />
126
+ </View>
127
+ )}
128
+ {/* Selection check circle — always show in selection mode */}
129
+ {isSelecting && (
130
+ <View style={styles.selectionCircleOverlay}>
131
+ {selected
132
+ ? <MaterialIcons name="check-circle" size={22} color="#1a73e8" />
133
+ : <MaterialIcons name="radio-button-unchecked" size={22} color="rgba(255,255,255,0.8)" />
134
+ }
135
+ </View>
136
+ )}
137
+ {!isSelecting && selected && (
138
+ <View style={styles.selectedOverlay}>
139
+ <MaterialIcons name="check-circle" size={22} color="#1a73e8" />
140
+ </View>
141
+ )}
142
+ </TouchableOpacity>
143
+ );
144
+ })}
145
+ {Array.from({ length: NUM_COLUMNS - row.length }).map((_, i) => (
146
+ <View key={`empty-${i}`} style={styles.tileFiller} />
147
+ ))}
148
+ </View>
149
+ );
150
+ }, (prev, next) => {
151
+ if (prev.isSelecting !== next.isSelecting) return false;
152
+ // If no selection changes affect this row, we can skip render
153
+ const prevSelected = prev.row.map(a => prev.selectedIds.has(a.id));
154
+ const nextSelected = next.row.map(a => next.selectedIds.has(a.id));
155
+ return prevSelected.every((s, i) => s === nextSelected[i]);
156
+ });
157
+
158
  // ─── Main component ──────────────────────────────────────────────
159
  export default function PhotosScreen() {
160
  const insets = useSafeAreaInsets();
 
188
  const [viewerIndex, setViewerIndex] = useState<number | null>(null);
189
  const viewerRef = useRef<any>(null);
190
  const [showInfo, setShowInfo] = useState(false);
 
191
  const [isPlaying, setIsPlaying] = useState(true);
192
 
193
  // ── Network & Settings Listeners ──────────────────────────────
 
277
 
278
  const loadAssets = useCallback(async () => {
279
  try {
280
+ // 0. Fast load from Cache
281
+ try {
282
+ const cached = await AsyncStorage.getItem(CACHED_ASSETS_KEY);
283
+ if (cached) {
284
+ const parsedAssets = JSON.parse(cached);
285
+ setAllAssets(parsedAssets);
286
+ setSections(groupAssetsByDate(parsedAssets));
287
+ }
288
+ } catch (e) { console.warn('Cache load error', e); }
289
+
290
+ // Skip showing skeleton loader if we already have cached assets
291
+ if (allAssets.length === 0) setIsLoading(true);
292
  // 1. Fetch Local
293
  const media = await MediaLibrary.getAssetsAsync({
294
  first: 800,
 
338
  isSynced: isSynced || !!matchCloud,
339
  isCloudOnly: false,
340
  cloudUri: matchCloud ? `${BACKEND_URL}${matchCloud.uri}?token=${await getToken()}` : undefined,
341
+ cloudId: matchCloud ? matchCloud.id : undefined,
342
  });
343
  }
344
 
 
348
  id: c.id,
349
  uri: `${BACKEND_URL}${c.uri}?token=${await getToken()}`,
350
  cloudUri: `${BACKEND_URL}${c.uri}?token=${await getToken()}`,
351
+ cloudId: c.id,
352
  filename: c.filename,
353
  mediaType: c.mediaType,
354
  creationTime: c.creationTime,
 
367
  setAllAssets(unified);
368
  setSections(groupAssetsByDate(unified));
369
 
370
+ // Save to cache silently
371
+ AsyncStorage.setItem(CACHED_ASSETS_KEY, JSON.stringify(unified)).catch(() => {});
372
+
373
  const toSync = unified.filter(a => !a.isSynced).length;
374
  setItemsToBackup(toSync);
375
  setIsLoading(false);
 
414
  };
415
 
416
  // ── Delete ────────────────────────────────────────────────────
417
+ const performAdvancedDelete = async (items: UnifiedAsset[], target: 'device' | 'cloud' | 'both') => {
418
+ try {
419
+ const token = await getToken();
420
+ // 1. Delete from Device (Move to local bin)
421
+ if (target === 'device' || target === 'both') {
422
+ const trashed = await getTrashedAssets();
423
+ const newTrashed = [...trashed, ...items];
424
+ await AsyncStorage.setItem(TRASHED_ASSETS_KEY, JSON.stringify(newTrashed));
425
+ }
426
+ // 2. Delete from Cloud
427
+ if (target === 'cloud' || target === 'both') {
428
+ for (const item of items) {
429
+ if (item.cloudId) {
430
+ await deleteFromCloud(item.cloudId);
431
+ }
432
+ }
433
+ }
434
+
435
+ setSelectedIds(new Set());
436
+ setViewerIndex(null);
437
+ await loadAssets();
438
+ } catch (e) {
439
+ Alert.alert('Error', 'Failed to delete items.');
440
+ }
441
+ };
442
+
443
+ const showDeleteOptions = (items: UnifiedAsset[]) => {
444
+ if (items.length === 0) return;
445
  Alert.alert(
446
+ `Delete ${items.length} item${items.length > 1 ? 's' : ''}?`,
447
+ 'Choose how you want to delete:',
448
  [
449
  { text: 'Cancel', style: 'cancel' },
450
+ { text: 'Delete from Device (Bin)', style: 'destructive', onPress: () => performAdvancedDelete(items, 'device') },
451
+ { text: 'Delete from Cloud', style: 'destructive', onPress: () => performAdvancedDelete(items, 'cloud') },
452
+ { text: 'Delete from Both', style: 'destructive', onPress: () => performAdvancedDelete(items, 'both') },
 
 
 
 
 
 
 
 
 
 
 
 
 
453
  ]
454
  );
455
  };
456
 
457
+ const deleteSelected = () => {
458
+ const items = allAssets.filter(a => selectedIds.has(a.id));
459
+ showDeleteOptions(items);
460
+ };
461
+
462
  // ── Share ─────────────────────────────────────────────────────
463
  const shareSelected = async () => {
464
  const ids = [...selectedIds];
 
546
 
547
  // ── Render helpers ────────────────────────────────────────────
548
  const renderRow = useCallback(({ item: row }: { item: UnifiedAsset[] }) => (
549
+ <GalleryRow
550
+ row={row}
551
+ selectedIds={selectedIds}
552
+ isSelecting={isSelecting}
553
+ openViewer={openViewer}
554
+ toggleSelect={toggleSelect}
555
+ />
556
+ ), [selectedIds, isSelecting, openViewer, toggleSelect]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
557
 
558
  const renderSectionHeader = useCallback(({ section }: { section: SectionData }) => {
559
  const allDaySelected = isDayFullySelected(section);
 
699
  </View>
700
  </TouchableWithoutFeedback>
701
 
702
+ <View style={{ flex: 1, backgroundColor: '#000' }}>
703
+ <ImageViewer
704
+ imageUrls={allAssets.map(a => ({ url: a.uri }))}
705
+ index={viewerIndex ?? 0}
706
+ onChange={idx => { setViewerIndex(idx ?? null); setIsPlaying(true); }}
707
+ enableSwipeDown={true}
708
+ onSwipeDown={() => setViewerIndex(null)}
709
+ renderIndicator={() => <View />}
710
+ saveToLocalByLongPress={false}
711
+ renderImage={(props) => {
712
+ const asset = allAssets.find(a => a.uri === props.source.uri);
713
+ if (asset?.mediaType === 'video') {
714
+ return (
715
+ <Video
716
+ source={{ uri: asset.uri }}
717
+ style={{ width, height: '100%' }}
718
+ resizeMode={ResizeMode.CONTAIN}
719
+ useNativeControls={true}
720
+ shouldPlay={isPlaying}
721
+ />
722
+ );
723
+ }
724
+ return (
 
 
725
  <Image
726
+ source={{ uri: props.source.uri }}
727
+ style={{ width, height: '100%' }}
728
  contentFit="contain"
729
  cachePolicy="memory-disk"
730
  />
731
+ );
732
+ }}
733
+ />
734
+ </View>
735
 
736
  {/* Viewer action bar */}
737
  <View style={[styles.viewerActions, { paddingBottom: insets.bottom + 8 }]}>
 
762
 
763
  <TouchableOpacity
764
  style={styles.viewerActionBtn}
765
+ onPress={() => {
766
+ const asset = viewerIndex !== null ? allAssets[viewerIndex] : null;
767
+ if (asset) showDeleteOptions([asset]);
768
+ }}
769
  >
770
  <MaterialIcons name="delete-outline" size={26} color="#ff6b6b" />
771
  <Text style={[styles.viewerActionLabel, { color: '#ff6b6b' }]}>Delete</Text>
 
816
  </Modal>
817
  )}
818
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
819
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
820
 
821
  </SafeAreaView>
822
  );
keystone-app/package-lock.json CHANGED
@@ -29,6 +29,7 @@
29
  "react": "19.1.0",
30
  "react-dom": "19.1.0",
31
  "react-native": "0.81.5",
 
32
  "react-native-safe-area-context": "~5.6.0",
33
  "react-native-screens": "~4.16.0",
34
  "react-native-web": "^0.21.2"
@@ -7819,6 +7820,29 @@
7819
  }
7820
  }
7821
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7822
  "node_modules/react-native-is-edge-to-edge": {
7823
  "version": "1.3.1",
7824
  "license": "MIT",
 
29
  "react": "19.1.0",
30
  "react-dom": "19.1.0",
31
  "react-native": "0.81.5",
32
+ "react-native-image-zoom-viewer": "^3.0.1",
33
  "react-native-safe-area-context": "~5.6.0",
34
  "react-native-screens": "~4.16.0",
35
  "react-native-web": "^0.21.2"
 
7820
  }
7821
  }
7822
  },
7823
+ "node_modules/react-native-image-pan-zoom": {
7824
+ "version": "2.1.12",
7825
+ "resolved": "https://registry.npmjs.org/react-native-image-pan-zoom/-/react-native-image-pan-zoom-2.1.12.tgz",
7826
+ "integrity": "sha512-BF66XeP6dzuANsPmmFsJshM2Jyh/Mo1t8FsGc1L9Q9/sVP8MJULDabB1hms+eAoqgtyhMr5BuXV3E1hJ5U5H6Q==",
7827
+ "license": "ISC",
7828
+ "peerDependencies": {
7829
+ "react": "*",
7830
+ "react-native": "*"
7831
+ }
7832
+ },
7833
+ "node_modules/react-native-image-zoom-viewer": {
7834
+ "version": "3.0.1",
7835
+ "resolved": "https://registry.npmjs.org/react-native-image-zoom-viewer/-/react-native-image-zoom-viewer-3.0.1.tgz",
7836
+ "integrity": "sha512-la6s5DNSuq4GCRLsi5CZ29FPjgTpdCuGIRdO5T9rUrAtxrlpBPhhSnHrbmPVxsdtOUvxHacTh2Gfa9+RraMZQA==",
7837
+ "license": "MIT",
7838
+ "dependencies": {
7839
+ "react-native-image-pan-zoom": "^2.1.12"
7840
+ },
7841
+ "peerDependencies": {
7842
+ "react": "*",
7843
+ "react-native": "*"
7844
+ }
7845
+ },
7846
  "node_modules/react-native-is-edge-to-edge": {
7847
  "version": "1.3.1",
7848
  "license": "MIT",
keystone-app/package.json CHANGED
@@ -24,6 +24,7 @@
24
  "react": "19.1.0",
25
  "react-dom": "19.1.0",
26
  "react-native": "0.81.5",
 
27
  "react-native-safe-area-context": "~5.6.0",
28
  "react-native-screens": "~4.16.0",
29
  "react-native-web": "^0.21.2"
 
24
  "react": "19.1.0",
25
  "react-dom": "19.1.0",
26
  "react-native": "0.81.5",
27
+ "react-native-image-zoom-viewer": "^3.0.1",
28
  "react-native-safe-area-context": "~5.6.0",
29
  "react-native-screens": "~4.16.0",
30
  "react-native-web": "^0.21.2"