feat: gallery performance, image zoom, advanced 3-way delete, native video controls
Browse files- keystone-app/app/(tabs)/index.tsx +186 -208
- keystone-app/package-lock.json +24 -0
- keystone-app/package.json +1 -0
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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 313 |
Alert.alert(
|
| 314 |
-
`
|
| 315 |
-
'
|
| 316 |
[
|
| 317 |
{ text: 'Cancel', style: 'cancel' },
|
| 318 |
-
{
|
| 319 |
-
|
| 320 |
-
|
| 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 |
-
<
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 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 |
-
<
|
| 635 |
-
|
| 636 |
-
|
| 637 |
-
|
| 638 |
-
|
| 639 |
-
|
| 640 |
-
|
| 641 |
-
|
| 642 |
-
|
| 643 |
-
|
| 644 |
-
|
| 645 |
-
|
| 646 |
-
|
| 647 |
-
|
| 648 |
-
|
| 649 |
-
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
|
| 653 |
-
|
| 654 |
-
|
| 655 |
-
|
| 656 |
-
|
| 657 |
-
/>
|
| 658 |
-
) : (
|
| 659 |
<Image
|
| 660 |
-
source={{ uri:
|
| 661 |
-
style={
|
| 662 |
contentFit="contain"
|
| 663 |
cachePolicy="memory-disk"
|
| 664 |
/>
|
| 665 |
-
)
|
| 666 |
-
|
| 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={() =>
|
|
|
|
|
|
|
|
|
|
| 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"
|