import React, { useState, useEffect, useCallback } from 'react'; import { StyleSheet, View, Text, ActivityIndicator, ScrollView, RefreshControl, } from 'react-native'; import { useFocusEffect } from '@react-navigation/native'; import { useTheme } from '../services/ThemeContext'; import DatabaseService from '../services/DatabaseService'; import DeviceMediaService from '../services/DeviceMediaService'; import GoogleAUTH from '../AUTH/GoogleAUTH'; import NASAUTH from '../AUTH/NASAUTH'; import { File } from 'expo-file-system'; import TopBar from '../components/TopBar'; import PhotoGrid from '../components/PhotoGrid'; import MediaViewer from '../components/MediaViewer'; import { logError } from '../services/ErrorHandler'; import { TouchableOpacity } from 'react-native'; /** * Merge media items from different services. * Items with the same filename (title) are merged into a single entry * with a `sources` array listing every service it appears in. * The best local_path is kept (prefer file:// from DEVICE over a cached * Picker download, since device URIs are always fresh). */ const mergeMediaAcrossServices = (items) => { // First pass: group by normalised filename const byFilename = new Map(); const noFilename = []; for (const item of items) { const key = item.title?.toLowerCase().trim(); if (!key || key === 'untitled') { noFilename.push({ ...item, sources: [item.source_service] }); continue; } if (byFilename.has(key)) { const existing = byFilename.get(key); // Add this service to the sources list if (!existing.sources.includes(item.source_service)) { existing.sources.push(item.source_service); } // Prefer a local device path over a cached download if (item.source_service === 'DEVICE' && item.local_path) { existing.local_path = item.local_path; } // Keep the best local_path if the existing one is missing if (!existing.local_path && item.local_path) { existing.local_path = item.local_path; } } else { byFilename.set(key, { ...item, sources: [item.source_service] }); } } // Second pass: also deduplicate by source_id for items with different titles const bySourceId = new Map(); for (const item of [...byFilename.values(), ...noFilename]) { const key = item.source_id || item.id || item.local_path || Math.random().toString(); if (bySourceId.has(key)) { const existing = bySourceId.get(key); for (const src of (item.sources || [])) { if (!existing.sources.includes(src)) existing.sources.push(src); } if (!existing.local_path && item.local_path) existing.local_path = item.local_path; } else { bySourceId.set(key, item); } } return Array.from(bySourceId.values()); }; const HomeScreen = ({ navigation, route }) => { const userId = route?.params?.userId; const { theme } = useTheme(); const [currentUser, setCurrentUser] = useState(null); const [linkedAccounts, setLinkedAccounts] = useState([]); const [allMedia, setAllMedia] = useState([]); const [filteredMedia, setFilteredMedia] = useState([]); const [selectedFilter, setSelectedFilter] = useState('all'); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); const [selectedMedia, setSelectedMedia] = useState(null); const [viewerVisible, setViewerVisible] = useState(false); const [deviceEndCursor, setDeviceEndCursor] = useState(null); const [deviceHasMore, setDeviceHasMore] = useState(false); const [googleNextPage, setGoogleNextPage] = useState(null); const [googleSessionId, setGoogleSessionId] = useState(null); const [loadingMore, setLoadingMore] = useState(false); const [importing, setImporting] = useState(false); useFocusEffect( useCallback(() => { if (userId) loadUserData(); }, [userId]) ); useEffect(() => { if (currentUser) loadAllMedia(); }, [currentUser]); useEffect(() => { filterMedia(); }, [selectedFilter, allMedia]); const loadUserData = async () => { try { const user = await DatabaseService.getUserById(userId); setCurrentUser(user); const accounts = await DatabaseService.getLinkedAccounts(userId); setLinkedAccounts(accounts); } catch (err) { console.error('Error loading user data:', err); } }; const loadAllMedia = async () => { if (!loading) setLoading(true); try { const newMedia = []; // ─── Device photos (local camera roll, screenshots, etc.) ─── try { const deviceResult = await DeviceMediaService.getPhotos(100); if (deviceResult.mediaItems.length) { newMedia.push(...deviceResult.mediaItems); } setDeviceEndCursor(deviceResult.endCursor); setDeviceHasMore(deviceResult.hasNextPage); } catch (err) { console.error('Device media fetch error:', err); } // ─── Previously imported Google Photos (stored in DB) ─── try { const googleMedia = await DatabaseService.getMediaByService(userId, 'GOOGLE_PHOTOS'); if (googleMedia?.length) { // Validate cached files still exist — clear stale paths const validated = googleMedia.map((item) => { if (item.local_path) { try { const f = new File(item.local_path); if (!f.exists || f.size === 0) { return { ...item, local_path: null }; } } catch (_) { return { ...item, local_path: null }; } } return item; }); newMedia.push(...validated); } } catch (err) { console.error('DB Google media fetch error:', err); } // ─── NAS fetch ─── const nasAccount = linkedAccounts.find((a) => a.service_type === 'NAS'); if (nasAccount) { try { const nasAuth = new NASAUTH(); const nasData = nasAccount.account_data; await nasAuth.authenticate(nasData.host, nasData.port, nasData.username, nasData.password, nasData.photoFolder); const nasPhotos = await nasAuth.getPhotos(nasData.photoFolder); if (nasPhotos?.mediaItems) newMedia.push(...nasPhotos.mediaItems); await nasAuth.revokeAccess(); } catch (err) { console.error('NAS fetch error:', err); } } // ─── iCloud (placeholder) ─── const iosAccount = linkedAccounts.find((a) => a.service_type === 'ICLOUD_PHOTOS'); if (iosAccount) { console.log('iCloud: placeholder — would fetch photos here'); } // ─── Merge & cross-reference across services ─── // Match items across sources by filename so we can show "this photo // exists on Device AND Google Photos" etc. const merged = mergeMediaAcrossServices(newMedia); setAllMedia(merged); } catch (err) { console.error('Error loading media:', err); } finally { setLoading(false); setRefreshing(false); } }; // ─── Import from Google Photos (opens picker in browser) ─── const importGooglePhotos = async () => { const googleAccount = linkedAccounts.find((a) => a.service_type === 'GOOGLE_PHOTOS'); if (!googleAccount) { console.warn('No Google account linked'); return; } setImporting(true); try { const data = googleAccount.account_data || {}; const googleAuth = new GoogleAUTH(); googleAuth.accessToken = data.accessToken || null; googleAuth.refreshToken = data.refreshToken || null; googleAuth.expiresAt = data.expiresAt || 0; let result = await googleAuth.pickPhotos(); // If 403 (stale scopes), auto re-auth and retry if (result?.error?.code === 403) { console.log('Stale token — re-authenticating…'); await googleAuth.revokeAccess(); const fresh = await googleAuth.authenticate(); await DatabaseService.linkAccount(userId, 'GOOGLE_PHOTOS', { accessToken: fresh.accessToken, refreshToken: fresh.refreshToken || null, expiresAt: googleAuth.expiresAt, email: fresh.email || data.email || 'google-user', }); result = await googleAuth.pickPhotos(); } if (result?.error) { logError('Import Google Photos', result.error.message); } else if (result?.mediaItems?.length) { // Save imported items to DB so they persist across app restarts for (const item of result.mediaItems) { await DatabaseService.saveMediaItem(userId, item); } setGoogleNextPage(result.nextPageToken || null); setGoogleSessionId(result.sessionId || null); // Merge into current view with cross-service matching setAllMedia((prev) => mergeMediaAcrossServices([...prev, ...result.mediaItems])); console.log(`Imported ${result.mediaItems.length} photos from Google`); } // Persist refreshed tokens if (googleAuth.accessToken && googleAuth.accessToken !== data.accessToken) { await DatabaseService.linkAccount(userId, 'GOOGLE_PHOTOS', { accessToken: googleAuth.accessToken, refreshToken: googleAuth.refreshToken || data.refreshToken, expiresAt: googleAuth.expiresAt, email: data.email || 'google-user', }); } } catch (err) { logError('Import Google Photos', err); } finally { setImporting(false); } }; const filterMedia = () => { let filtered = allMedia; if (selectedFilter !== 'all') { // Check both source_service and the sources array for cross-service items filtered = allMedia.filter((m) => m.source_service === selectedFilter || (m.sources && m.sources.includes(selectedFilter)) ); } filtered.sort((a, b) => new Date(b.created_date || 0) - new Date(a.created_date || 0)); setFilteredMedia(filtered); }; const loadMorePhotos = async () => { if (loadingMore) return; const canLoadDevice = deviceHasMore; const canLoadGoogle = !!googleNextPage && !!googleSessionId; if (!canLoadDevice && !canLoadGoogle) return; setLoadingMore(true); try { const moreMedia = []; if (canLoadDevice) { const result = await DeviceMediaService.getPhotos(100, deviceEndCursor); if (result.mediaItems.length) moreMedia.push(...result.mediaItems); setDeviceEndCursor(result.endCursor); setDeviceHasMore(result.hasNextPage); } if (canLoadGoogle) { const googleAccount = linkedAccounts.find((a) => a.service_type === 'GOOGLE_PHOTOS'); if (googleAccount) { const data = googleAccount.account_data || {}; const googleAuth = new GoogleAUTH(); googleAuth.accessToken = data.accessToken || null; googleAuth.refreshToken = data.refreshToken || null; googleAuth.expiresAt = data.expiresAt || 0; const photos = await googleAuth.getMorePhotos(100, googleNextPage, googleSessionId); if (photos?.mediaItems?.length) { moreMedia.push(...photos.mediaItems); for (const item of photos.mediaItems) { await DatabaseService.saveMediaItem(userId, item); } } setGoogleNextPage(photos?.nextPageToken || null); if (photos?.sessionId) setGoogleSessionId(photos.sessionId); } } if (moreMedia.length) { setAllMedia((prev) => mergeMediaAcrossServices([...prev, ...moreMedia])); } } catch (err) { console.error('Error loading more photos:', err); } finally { setLoadingMore(false); } }; const getServiceIcon = (type) => ({ DEVICE: '📱', GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }[type] || '📱'); const getServiceName = (type) => ({ DEVICE: 'On Device', GOOGLE_PHOTOS: 'Google Photos', ICLOUD_PHOTOS: 'iCloud', NAS: 'NAS' }[type] || type); const hasGoogleAccount = linkedAccounts.some((a) => a.service_type === 'GOOGLE_PHOTOS'); if (loading && allMedia.length === 0) { return ( Loading photos... ); } // Build the list of available source filters const sourceFilters = [ { key: 'DEVICE', icon: '📱', name: 'On Device' }, ...linkedAccounts.map((acc) => ({ key: acc.service_type, icon: getServiceIcon(acc.service_type), name: getServiceName(acc.service_type), })), ]; const FilterChips = () => ( setSelectedFilter('all')} > All {sourceFilters.map((f) => ( setSelectedFilter(f.key)} > {f.icon} {f.name} ))} ); return ( navigation.navigate('Search')} onProfilePress={() => navigation.navigate('Settings', { userId })} onMenuPress={() => navigation.navigate('Settings', { userId })} /> {/* Import from Google Photos button */} {hasGoogleAccount && ( {importing ? ( ) : ( <> 📷 Import from Google Photos )} )} {filteredMedia.length === 0 ? ( 📸 No Photos Yet {hasGoogleAccount ? 'Tap "Import from Google Photos" above to select cloud photos,\nor grant media library permission to see on-device photos.' : 'Grant media library permission or take some photos to get started'} { setRefreshing(true); loadAllMedia(); }} > Refresh ) : ( { setSelectedMedia(item); setViewerVisible(true); }} onEndReached={loadMorePhotos} refreshControl={ { setRefreshing(true); loadAllMedia(); }} /> } ListFooterComponent={loadingMore ? ( ) : null} /> )} setViewerVisible(false)} /> ); }; const styles = StyleSheet.create({ container: { flex: 1 }, centerContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', paddingHorizontal: 24 }, loadingText: { marginTop: 12, fontSize: 14 }, // Filter chips filterContainer: { maxHeight: 40 }, filterRow: { paddingHorizontal: 12, paddingVertical: 6, gap: 6, alignItems: 'center' }, chip: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 10, paddingVertical: 4, borderRadius: 16, gap: 4, height: 28, }, chipIcon: { fontSize: 11 }, chipText: { fontSize: 12, fontWeight: '500', color: '#3c4043' }, // Import button importButton: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', marginHorizontal: 12, marginVertical: 6, paddingVertical: 10, paddingHorizontal: 16, borderRadius: 12, gap: 8, minHeight: 42, }, importIcon: { fontSize: 16 }, importText: { color: '#fff', fontSize: 14, fontWeight: '600' }, // Empty emptyTitle: { fontSize: 18, fontWeight: '600', marginBottom: 6 }, emptyText: { fontSize: 13, textAlign: 'center', marginBottom: 20, lineHeight: 20 }, retryButton: { paddingHorizontal: 24, paddingVertical: 10, borderRadius: 20 }, retryButtonText: { color: '#fff', fontSize: 14, fontWeight: '600' }, }); export default HomeScreen;