From 7f70c38e411359bbe8fe0a3f424c39fe9f447f2f Mon Sep 17 00:00:00 2001 From: Ramon Candel Date: Thu, 30 Apr 2026 12:13:29 +0200 Subject: [PATCH] Added Photos screen with timeline view and backup indicators --- .../useDiscoverPhotosSheet.spec.tsx | 2 +- .../GroupHeader/PhotosGroupHeader.tsx | 25 +- .../PhotosScreen/components/PhotoItem.tsx | 29 ++- .../components/PhotosEmptyState.tsx | 27 +++ .../components/PhotosTimeline.tsx | 84 ++++--- .../PhotosScreen/hooks/usePhotosTimeline.ts | 114 +++++++++ src/screens/PhotosScreen/index.tsx | 141 +++-------- .../utils/photoTimelineGroups.spec.ts | 226 ++++++++++++++++++ .../PhotosScreen/utils/photoTimelineGroups.ts | 120 ++++++++++ 9 files changed, 611 insertions(+), 157 deletions(-) create mode 100644 src/screens/PhotosScreen/components/PhotosEmptyState.tsx create mode 100644 src/screens/PhotosScreen/hooks/usePhotosTimeline.ts create mode 100644 src/screens/PhotosScreen/utils/photoTimelineGroups.spec.ts create mode 100644 src/screens/PhotosScreen/utils/photoTimelineGroups.ts diff --git a/src/screens/HomeScreen/useDiscoverPhotosSheet.spec.tsx b/src/screens/HomeScreen/useDiscoverPhotosSheet.spec.tsx index 179207e5c..9e65707eb 100644 --- a/src/screens/HomeScreen/useDiscoverPhotosSheet.spec.tsx +++ b/src/screens/HomeScreen/useDiscoverPhotosSheet.spec.tsx @@ -26,7 +26,7 @@ const makeWrapper = (photosState?: Partial) => { const store = configureStore({ reducer: { photos: photosReducer }, preloadedState: { - photos: { enabled: false, networkCondition: 'wifi-only', permissionStatus: 'undetermined', syncStatus: 'idle', pendingCount: 0, totalScannedCount: 0, deviceId: null, ...photosState }, + photos: { enabled: false, networkCondition: 'wifi-only', permissionStatus: 'undetermined', syncStatus: 'idle', pendingBackupAssets: 0, totalScannedAssets: 0, totalAssetsUploaded: 0, currentUploadProgress: 0, lastSyncTimestamp: null, uploadingAssetIds: [], deviceId: null, sessionTotalAssets: 0, sessionUploadedAssets: 0, ...photosState }, }, }); return ({ children }: { children: React.ReactNode }) => {children}; diff --git a/src/screens/PhotosScreen/components/GroupHeader/PhotosGroupHeader.tsx b/src/screens/PhotosScreen/components/GroupHeader/PhotosGroupHeader.tsx index a7e018666..77c17b026 100644 --- a/src/screens/PhotosScreen/components/GroupHeader/PhotosGroupHeader.tsx +++ b/src/screens/PhotosScreen/components/GroupHeader/PhotosGroupHeader.tsx @@ -33,9 +33,17 @@ interface PhotosGroupHeaderProps { const GRADIENT_LOCATIONS: [number, number, number] = [0, 0.35, 1]; -const BackupProgressBar = ({ progress, color }: { progress: number; color: string }): JSX.Element => ( - - +const BackupProgressBar = ({ + progress, + fillColor, + trackColor, +}: { + progress: number; + fillColor: string; + trackColor: string; +}): JSX.Element => ( + + ); @@ -50,13 +58,16 @@ const PhotosGroupHeader = memo( const dangerColor = getColor('text-red'); const gradientColors: [string, string, string] = [getColor('bg-black-50'), getColor('bg-black-40'), 'transparent']; - const backupProgress = syncStatus.type === 'uploading' ? syncStatus.backupProgress : undefined; + const isUploading = syncStatus.type === 'uploading'; + const backupUploadProgress = isUploading ? (syncStatus.backupProgress ?? 0) : undefined; + const progressTrackColor = isSticky ? getColor('bg-white-25') : getColor('bg-primary-10'); const content = ( {isSticky && ( @@ -67,7 +78,9 @@ const PhotosGroupHeader = memo( /> )} - {backupProgress != null && } + {backupUploadProgress != null && ( + + )} diff --git a/src/screens/PhotosScreen/components/PhotoItem.tsx b/src/screens/PhotosScreen/components/PhotoItem.tsx index 778135c02..10677d3a4 100644 --- a/src/screens/PhotosScreen/components/PhotoItem.tsx +++ b/src/screens/PhotosScreen/components/PhotoItem.tsx @@ -1,13 +1,33 @@ import { LinearGradient } from 'expo-linear-gradient'; import { ArrowUpIcon, CloudSlashIcon } from 'phosphor-react-native'; -import { memo, useCallback } from 'react'; -import { Image, StyleSheet, TouchableOpacity, View } from 'react-native'; +import { memo, useCallback, useEffect, useRef } from 'react'; +import { Animated, Easing, Image, StyleSheet, TouchableOpacity, View } from 'react-native'; import { Circle } from 'react-native-progress'; import AppText from 'src/components/AppText'; import useGetColor from 'src/hooks/useColor'; import { useTailwind } from 'tailwind-rn'; import { PhotoItem as PhotoItemType } from '../types'; +const SkeletonCell = (): JSX.Element => { + const getColor = useGetColor(); + const fadeAnim = useRef(new Animated.Value(1)).current; + + useEffect(() => { + const anim = Animated.loop( + Animated.sequence([ + Animated.timing(fadeAnim, { toValue: 0.2, duration: 1500, easing: Easing.linear, useNativeDriver: false }), + Animated.timing(fadeAnim, { toValue: 1, duration: 800, easing: Easing.linear, useNativeDriver: false }), + ]), + ); + anim.start(); + return () => anim.stop(); + }, []); + + return ( + + ); +}; + interface PhotoItemProps { item: PhotoItemType; isSelectMode?: boolean; @@ -22,12 +42,13 @@ const PhotoItem = memo(({ item, isSelectMode, isSelected, onPress, onLongPress } const handlePress = useCallback(() => onPress?.(item.id), [onPress, item.id]); const handleLongPress = useCallback(() => onLongPress?.(item.id), [onLongPress, item.id]); - const containerStyle = [styles.container, { backgroundColor: getColor('bg-primary-10') }]; if (item.backupState === 'loading' || !item.uri) { - return ; + return ; } + const containerStyle = [styles.container, { backgroundColor: getColor('bg-gray-1') }]; + return ( diff --git a/src/screens/PhotosScreen/components/PhotosEmptyState.tsx b/src/screens/PhotosScreen/components/PhotosEmptyState.tsx new file mode 100644 index 000000000..87fcedda4 --- /dev/null +++ b/src/screens/PhotosScreen/components/PhotosEmptyState.tsx @@ -0,0 +1,27 @@ +import { ImageIcon } from 'phosphor-react-native'; +import { View } from 'react-native'; +import AppText from 'src/components/AppText'; +import useGetColor from 'src/hooks/useColor'; +import { useLanguage } from 'src/hooks/useLanguage'; +import { useTailwind } from 'tailwind-rn'; +import strings from '../../../../assets/lang/strings'; + +const PhotosEmptyState = () => { + const tailwind = useTailwind(); + const getColor = useGetColor(); + useLanguage(); + + return ( + + + + + {strings.screens.photos.emptyTitle} + + {strings.screens.photos.emptySubtitle} + + + ); +}; + +export default PhotosEmptyState; diff --git a/src/screens/PhotosScreen/components/PhotosTimeline.tsx b/src/screens/PhotosScreen/components/PhotosTimeline.tsx index f61fd5b95..52c71d7b6 100644 --- a/src/screens/PhotosScreen/components/PhotosTimeline.tsx +++ b/src/screens/PhotosScreen/components/PhotosTimeline.tsx @@ -1,43 +1,46 @@ import { FlashList, FlashListProps, ListRenderItem } from '@shopify/flash-list'; import { useCallback, useMemo, useRef } from 'react'; -import { Animated, StyleSheet, View } from 'react-native'; -import { PhotoDateGroup, PhotoItem as PhotoItemType } from '../types'; +import { Animated, Dimensions, View } from 'react-native'; +import { useTailwind } from 'tailwind-rn'; +import { PhotoBackupState, PhotoDateGroup } from '../types'; +import { FlatItem, buildTimelineItems } from '../utils/photoTimelineGroups'; import PhotosGroupHeader, { GroupSyncStatus } from './GroupHeader/PhotosGroupHeader'; import PhotoItem from './PhotoItem'; +import PhotosEmptyState from './PhotosEmptyState'; -const AnimatedFlashList = Animated.createAnimatedComponent(FlashList) as React.ComponentType>; +const AnimatedFlashList = Animated.createAnimatedComponent(FlashList) as React.ComponentType< + FlashListProps & { estimatedItemSize?: number } +>; export type TimelineDateGroup = { group: PhotoDateGroup; syncStatus: GroupSyncStatus }; -type FlatItem = - | { type: 'header'; id: string; label: string; syncStatus: GroupSyncStatus } - | { type: 'photo'; photo: PhotoItemType }; +const SKELETON_GROUP: TimelineDateGroup = { + group: { + id: '__skeleton__', + label: '', + photos: Array.from({ length: 12 }, (_, i) => ({ + id: `__skeleton_${i}__`, + backupState: 'loading' as PhotoBackupState, + mediaType: 'photo' as const, + })), + }, + syncStatus: { type: 'none' }, +}; const NUM_COLUMNS = 3; +const ESTIMATED_ITEM_SIZE = Math.round(Dimensions.get('window').width / NUM_COLUMNS); interface PhotosTimelineProps { - groups: TimelineDateGroup[]; + assetsGroupsByDate: TimelineDateGroup[]; + isLoading?: boolean; onPhotoPress?: (id: string) => void; onPhotoLongPress?: (id: string) => void; isSelectMode?: boolean; selectedIds?: Set; ListHeaderComponent?: React.ReactElement; + onEndReached?: () => void; } -const flattenGroups = (groups: TimelineDateGroup[]): { items: FlatItem[]; headerIndices: number[] } => { - const items: FlatItem[] = []; - const headerIndices: number[] = []; - for (const { group, syncStatus } of groups) { - const currentHeaderIndex = items.length; - headerIndices.push(currentHeaderIndex); - items.push({ type: 'header', id: group.id, label: group.label, syncStatus }); - for (const photo of group.photos) { - items.push({ type: 'photo', photo }); - } - } - return { items, headerIndices }; -}; - const getItemType = (item: FlatItem) => item.type; const overrideItemLayout = (layout: { span?: number }, item: FlatItem) => { @@ -49,14 +52,20 @@ const overrideItemLayout = (layout: { span?: number }, item: FlatItem) => { const keyExtractor = (item: FlatItem) => (item.type === 'header' ? `header-${item.id}` : item.photo.id); const PhotosTimeline = ({ - groups, + assetsGroupsByDate, + isLoading, onPhotoPress, onPhotoLongPress, isSelectMode, selectedIds, ListHeaderComponent, + onEndReached, }: PhotosTimelineProps) => { - const { items, headerIndices } = useMemo(() => flattenGroups(groups), [groups]); + const tailwind = useTailwind(); + const { items, headerIndices } = useMemo(() => { + const effectiveGroups = isLoading ? [...assetsGroupsByDate, SKELETON_GROUP] : assetsGroupsByDate; + return buildTimelineItems(effectiveGroups); + }, [assetsGroupsByDate, isLoading]); const extraData = useMemo(() => ({ isSelectMode, selectedIds }), [isSelectMode, selectedIds]); @@ -69,17 +78,19 @@ const PhotosTimeline = ({ const renderItem: ListRenderItem = useCallback( ({ item, target }) => { if (item.type === 'header') { + const isSticky = target === 'StickyHeader'; + const showSyncStatus = isSticky || item.isFirst; return ( ); } return ( - + : undefined} + contentContainerStyle={isEmpty ? { paddingBottom: 80, flexGrow: 1 } : { paddingBottom: 80 }} showsVerticalScrollIndicator={false} + onEndReached={onEndReached} + onEndReachedThreshold={0.5} onScroll={Animated.event([{ nativeEvent: { contentOffset: { y: scrollY } } }], { useNativeDriver: false })} scrollEventThrottle={16} /> ); }; -const styles = StyleSheet.create({ - photoCell: { - flex: 1, - aspectRatio: 1, - margin: 1, - }, - content: { - paddingBottom: 80, - }, -}); - export default PhotosTimeline; diff --git a/src/screens/PhotosScreen/hooks/usePhotosTimeline.ts b/src/screens/PhotosScreen/hooks/usePhotosTimeline.ts new file mode 100644 index 000000000..1e959a8c6 --- /dev/null +++ b/src/screens/PhotosScreen/hooks/usePhotosTimeline.ts @@ -0,0 +1,114 @@ +import * as MediaLibrary from 'expo-media-library'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { AppState } from 'react-native'; +import { photosLocalDB } from 'src/services/photos/database/photosLocalDB'; +import { useAppSelector } from 'src/store/hooks'; +import { TimelineDateGroup } from '../components/PhotosTimeline'; +import { groupAssetsByDate, getGroupSyncStatus } from '../utils/photoTimelineGroups'; + +const PAGE_SIZE = 200; +const MEDIA_TYPES = [MediaLibrary.MediaType.photo, MediaLibrary.MediaType.video]; + +export interface PhotosTimelineResult { + timelineDateGroups: TimelineDateGroup[]; + isLoading: boolean; + loadNextPage: () => void; +} + +export const usePhotosTimeline = (): PhotosTimelineResult => { + const [assets, setAssets] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [syncedIds, setSyncedIds] = useState>(new Set()); + + const cursorRef = useRef(undefined); + const hasMoreRef = useRef(true); + const isLoadingMoreRef = useRef(false); + const appStateRef = useRef(AppState.currentState); + + const { syncStatus, uploadingAssetIds, sessionTotalAssets, sessionUploadedAssets } = useAppSelector( + (state) => state.photos, + ); + + const fetchLocalPage = useCallback(async (after?: string): Promise> => { + return MediaLibrary.getAssetsAsync({ + first: PAGE_SIZE, + after, + mediaType: MEDIA_TYPES, + sortBy: [[MediaLibrary.SortBy.creationTime, false]], + }); + }, []); + + const loadNextPage = useCallback(async () => { + if (isLoadingMoreRef.current || !hasMoreRef.current || !cursorRef.current) return; + isLoadingMoreRef.current = true; + setIsLoading(true); + const page = await fetchLocalPage(cursorRef.current); + setAssets((prev) => [...prev, ...page.assets]); + cursorRef.current = page.hasNextPage ? page.endCursor : undefined; + hasMoreRef.current = page.hasNextPage; + isLoadingMoreRef.current = false; + setIsLoading(false); + }, [fetchLocalPage]); + + const refreshSyncStatusFromDB = useCallback(async () => { + if (assets.length === 0) { + return; + } + const assetIds = assets.map((asset) => asset.id); + await photosLocalDB.init(); + const entries = await photosLocalDB.getSyncedEntries(assetIds); + setSyncedIds(new Set(entries.keys())); + }, [assets]); + + const reloadFromStart = useCallback(async () => { + cursorRef.current = undefined; + hasMoreRef.current = true; + const page = await fetchLocalPage(); + setAssets(page.assets); + cursorRef.current = page.hasNextPage ? page.endCursor : undefined; + hasMoreRef.current = page.hasNextPage; + }, [fetchLocalPage]); + + useEffect(() => { + const loadFirstPage = async () => { + try { + const page = await fetchLocalPage(); + setAssets(page.assets); + cursorRef.current = page.hasNextPage ? page.endCursor : undefined; + hasMoreRef.current = page.hasNextPage; + } finally { + setIsLoading(false); + } + }; + loadFirstPage(); + }, []); + + useEffect(() => { + const subscription = AppState.addEventListener('change', (nextState) => { + if (appStateRef.current !== 'active' && nextState === 'active') { + reloadFromStart(); + } + appStateRef.current = nextState; + }); + return () => subscription.remove(); + }, [reloadFromStart]); + + useEffect(() => { + refreshSyncStatusFromDB(); + }, [refreshSyncStatusFromDB, syncStatus]); + + const uploadingIdSet = useMemo(() => new Set(uploadingAssetIds), [uploadingAssetIds]); + + const remainingCount = Math.max(0, sessionTotalAssets - sessionUploadedAssets); + const backupProgress = sessionTotalAssets > 0 ? sessionUploadedAssets / sessionTotalAssets : undefined; + + const timelineDateGroups = useMemo(() => { + const dateGroups = groupAssetsByDate(assets, syncedIds, uploadingIdSet); + return dateGroups.map((group) => ({ + group, + syncStatus: getGroupSyncStatus(group, syncStatus, remainingCount, backupProgress), + })); + }, [assets, syncedIds, uploadingIdSet, syncStatus, remainingCount, backupProgress]); + + return { timelineDateGroups, isLoading, loadNextPage }; +}; diff --git a/src/screens/PhotosScreen/index.tsx b/src/screens/PhotosScreen/index.tsx index de37bbe39..6a87c48f9 100644 --- a/src/screens/PhotosScreen/index.tsx +++ b/src/screens/PhotosScreen/index.tsx @@ -1,142 +1,69 @@ +import { useFocusEffect } from '@react-navigation/native'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { View } from 'react-native'; import AppScreen from 'src/components/AppScreen'; import { useAppDispatch, useAppSelector } from 'src/store/hooks'; -import { photosActions, PhotoSyncStatus } from 'src/store/slices/photos'; +import { photosActions, runBackupCycleThunk } from 'src/store/slices/photos'; import { useTailwind } from 'tailwind-rn'; import { photoPermissionService } from '../../services/photos/photoPermissionService'; import BackupDisabledBanner from './components/BackupDisabledBanner'; -import { GroupSyncStatus } from './components/GroupHeader/PhotosGroupHeader'; import PhotosHeader from './components/PhotosHeader'; import PhotosLockedOverlay from './components/PhotosLockedOverlay'; -import PhotosTimeline, { TimelineDateGroup } from './components/PhotosTimeline'; +import PhotosTimeline from './components/PhotosTimeline'; import EnableBackupBottomSheet from './EnableBackupBottomSheet'; -import { MOCK_GROUP, MOCK_GROUP_BACKING_UP, MOCK_MULTI_DATE_GROUPS } from './mockData'; -import { PhotosAccessState, PhotosSyncStatus } from './types'; - -type ScreenVariant = - | 'scanning' - | 'fetching' - | 'uploading' - | 'paused' - | 'paused-storage-full' - | 'completed' - | 'synced' - | 'backup-off' - | 'photos-locked'; - -interface ScreenConfig { - syncStatus: PhotosSyncStatus; - accessState: PhotosAccessState; - groups: TimelineDateGroup[]; -} - -const multiDateGroups = ({ first }: { first: GroupSyncStatus }): TimelineDateGroup[] => - MOCK_MULTI_DATE_GROUPS.map((group, i) => ({ - group, - syncStatus: i === 0 ? first : ({ type: 'count', count: group.photos.length } satisfies GroupSyncStatus), - })); - -const getScreenConfig = (variant: ScreenVariant): ScreenConfig => { - switch (variant) { - case 'scanning': - return { - syncStatus: { type: 'fetching' }, - accessState: { type: 'available' }, - groups: [{ group: MOCK_GROUP, syncStatus: { type: 'scanning' } }], - }; - case 'fetching': - return { - syncStatus: { type: 'fetching' }, - accessState: { type: 'available' }, - groups: [{ group: MOCK_GROUP, syncStatus: { type: 'fetching' } }], - }; - case 'uploading': - return { - syncStatus: { type: 'uploading' }, - accessState: { type: 'available' }, - groups: [{ group: MOCK_GROUP_BACKING_UP, syncStatus: { type: 'uploading', count: 1275, backupProgress: 0.6 } }], - }; - case 'paused': - return { - syncStatus: { type: 'paused' }, - accessState: { type: 'available' }, - groups: multiDateGroups({ first: { type: 'paused', count: 1275 } }), - }; - case 'paused-storage-full': - return { - syncStatus: { type: 'paused' }, - accessState: { type: 'available' }, - groups: multiDateGroups({ first: { type: 'paused-storage-full' } }), - }; - case 'completed': - return { - syncStatus: { type: 'completed' }, - accessState: { type: 'available' }, - groups: multiDateGroups({ first: { type: 'completed' } }), - }; - case 'synced': - return { - syncStatus: { type: 'synced' }, - accessState: { type: 'available' }, - groups: multiDateGroups({ first: { type: 'count', count: 55691 } }), - }; - case 'backup-off': - return { - syncStatus: { type: 'synced' }, - accessState: { type: 'backup-off' }, - groups: multiDateGroups({ first: { type: 'count', count: 55691 } }), - }; - case 'photos-locked': - return { - syncStatus: { type: 'synced' }, - accessState: { type: 'photos-locked' }, - groups: multiDateGroups({ first: { type: 'count', count: 55691 } }), - }; - } -}; - -const variantFromSyncStatus: Record = { - scanning: 'scanning', - idle: 'synced', - synced: 'synced', - error: 'synced', -}; +import { usePhotosTimeline } from './hooks/usePhotosTimeline'; +import { PhotosAccessState } from './types'; const PhotosScreen = (): JSX.Element => { const tailwind = useTailwind(); const dispatch = useAppDispatch(); - const { enabled, permissionStatus, syncStatus } = useAppSelector((state) => state.photos); + const { enabled, permissionStatus } = useAppSelector((state) => state.photos); const [isSheetOpen, setIsSheetOpen] = useState(false); + const { timelineDateGroups, isLoading, loadNextPage } = usePhotosTimeline(); + + const accessState: PhotosAccessState = useMemo( + () => (enabled ? { type: 'available' } : { type: 'backup-off' }), + [enabled], + ); + const handleEnableBackup = useCallback(() => setIsSheetOpen(true), []); + const listHeader = + accessState.type === 'backup-off' ? : undefined; + + const handleSelectPress = useCallback(() => undefined, []); + const handleUpgradePress = useCallback(() => undefined, []); useEffect(() => { if (permissionStatus !== 'undetermined') return; const checkPermissionStatus = async () => { - const permissionStatus = await photoPermissionService.getStatus(); - dispatch(photosActions.setPermissionStatus(permissionStatus)); + const photoPermissionstatus = await photoPermissionService.getStatus(); + dispatch(photosActions.setPermissionStatus(photoPermissionstatus)); }; checkPermissionStatus(); }, [permissionStatus]); - const variant: ScreenVariant = enabled ? variantFromSyncStatus[syncStatus] : 'backup-off'; - const { accessState, groups } = useMemo(() => getScreenConfig(variant), [variant]); - - const handleEnableBackup = useCallback(() => setIsSheetOpen(true), []); - const handleSelectPress = useCallback(() => undefined, []); - const handleUpgradePress = useCallback(() => undefined, []); - - const listHeader = - accessState.type === 'backup-off' ? : undefined; + useFocusEffect( + useCallback(() => { + if (enabled) { + dispatch(runBackupCycleThunk()); + } + }, [enabled]), + ); return ( - + {accessState.type === 'photos-locked' && } + setIsSheetOpen(false)} /> ); diff --git a/src/screens/PhotosScreen/utils/photoTimelineGroups.spec.ts b/src/screens/PhotosScreen/utils/photoTimelineGroups.spec.ts new file mode 100644 index 000000000..bdeb97fba --- /dev/null +++ b/src/screens/PhotosScreen/utils/photoTimelineGroups.spec.ts @@ -0,0 +1,226 @@ +import * as MediaLibrary from 'expo-media-library'; +import { GroupSyncStatus } from '../components/GroupHeader/PhotosGroupHeader'; +import { TimelineDateGroup } from '../components/PhotosTimeline'; +import { PhotoDateGroup, PhotoItem } from '../types'; +import { + assetToPhotoItem, + buildTimelineItems, + formatVideoDuration, + getDateLabel, + getGroupSyncStatus, + groupAssetsByDate, +} from './photoTimelineGroups'; + +jest.mock('expo-media-library', () => ({ + MediaType: { photo: 'photo', video: 'video' }, +})); + +const makeAsset = (overrides: Partial = {}): MediaLibrary.Asset => + ({ + id: 'asset-1', + filename: 'photo.jpg', + uri: 'file:///photo.jpg', + mediaType: MediaLibrary.MediaType.photo, + mediaSubtypes: [], + width: 100, + height: 100, + creationTime: new Date('2024-06-15T12:00:00').getTime(), + modificationTime: new Date('2024-06-15T12:00:00').getTime(), + duration: 0, + albumId: undefined, + ...overrides, + }) as MediaLibrary.Asset; + +const makePhotoItem = (overrides: Partial = {}): PhotoItem => ({ + id: 'asset-1', + uri: 'file:///photo.jpg', + backupState: 'not-backed', + mediaType: 'photo', + ...overrides, +}); + +const makeDateGroup = (overrides: Partial = {}): PhotoDateGroup => ({ + id: '2024-06-15', + label: 'Today', + photos: [makePhotoItem()], + ...overrides, +}); + +const makeTimelineGroup = ( + group: PhotoDateGroup, + syncStatus: GroupSyncStatus = { type: 'count', count: group.photos.length }, +): TimelineDateGroup => ({ group, syncStatus }); + +describe('formatVideoDuration', () => { + test('when the video is shorter than a minute, then formats seconds with a zero-padded two-digit field', () => { + expect(formatVideoDuration(45)).toBe('0:45'); + }); + + test('when the video is exactly one minute, then formats as 1:00', () => { + expect(formatVideoDuration(60)).toBe('1:00'); + }); + + test('when the seconds part is a single digit, then pads it with a leading zero', () => { + expect(formatVideoDuration(65)).toBe('1:05'); + }); + + test('when the video is longer than an hour, then shows total minutes without capping at 59', () => { + expect(formatVideoDuration(3661)).toBe('61:01'); + }); + + test('when the duration is zero, then returns 0:00', () => { + expect(formatVideoDuration(0)).toBe('0:00'); + }); +}); + +describe('getDateLabel', () => { + const now = new Date('2024-06-15T12:00:00'); + + test('when the date is the same day as now, then returns Today', () => { + expect(getDateLabel(new Date('2024-06-15T08:00:00'), now)).toBe('Today'); + }); + + test('when the date is the previous day, then returns Yesterday', () => { + expect(getDateLabel(new Date('2024-06-14T08:00:00'), now)).toBe('Yesterday'); + }); + + test('when the date is in the same year but not today or yesterday, then returns a short month and day', () => { + const label = getDateLabel(new Date('2024-03-01T12:00:00'), now); + expect(label).toContain('Mar'); + expect(label).toContain('1'); + expect(label).not.toContain('2024'); + }); + + test('when the date is in a different year, then includes the year in the label', () => { + const label = getDateLabel(new Date('2023-03-01T12:00:00'), now); + expect(label).toContain('Mar'); + expect(label).toContain('2023'); + }); +}); + +describe('assetToPhotoItem', () => { + test('when the asset is not synced and not uploading, then its backup state is not-backed', () => { + const asset = makeAsset(); + const item = assetToPhotoItem(asset, new Set(), new Set()); + expect(item.backupState).toBe('not-backed'); + }); + + test('when the asset is in the synced set, then its backup state is backed', () => { + const asset = makeAsset({ id: 'asset-1' }); + const item = assetToPhotoItem(asset, new Set(['asset-1']), new Set()); + expect(item.backupState).toBe('backed'); + }); + + test('when the asset is currently uploading, then its backup state is uploading regardless of synced set', () => { + const asset = makeAsset({ id: 'asset-1' }); + const item = assetToPhotoItem(asset, new Set(['asset-1']), new Set(['asset-1'])); + expect(item.backupState).toBe('uploading'); + }); + + test('when the asset is a photo, then media type is photo and duration is undefined', () => { + const asset = makeAsset({ mediaType: MediaLibrary.MediaType.photo }); + const item = assetToPhotoItem(asset, new Set(), new Set()); + expect(item.mediaType).toBe('photo'); + expect(item.duration).toBeUndefined(); + }); + + test('when the asset is a video, then media type is video and duration is formatted', () => { + const asset = makeAsset({ mediaType: MediaLibrary.MediaType.video, duration: 125 }); + const item = assetToPhotoItem(asset, new Set(), new Set()); + expect(item.mediaType).toBe('video'); + expect(item.duration).toBe('2:05'); + }); +}); + +describe('groupAssetsByDate', () => { + test('when all assets are on the same day, then a single group is returned', () => { + const assets = [ + makeAsset({ id: 'a1', creationTime: new Date('2024-06-15T08:00:00').getTime() }), + makeAsset({ id: 'a2', creationTime: new Date('2024-06-15T20:00:00').getTime() }), + ]; + const groups = groupAssetsByDate(assets, new Set(), new Set()); + expect(groups).toHaveLength(1); + expect(groups[0].photos).toHaveLength(2); + }); + + test('when assets span two different days, then two groups are returned', () => { + const assets = [ + makeAsset({ id: 'a1', creationTime: new Date('2024-06-15T12:00:00').getTime() }), + makeAsset({ id: 'a2', creationTime: new Date('2024-06-14T12:00:00').getTime() }), + ]; + const groups = groupAssetsByDate(assets, new Set(), new Set()); + expect(groups).toHaveLength(2); + }); + + test('when given an empty asset list, then returns no groups', () => { + expect(groupAssetsByDate([], new Set(), new Set())).toHaveLength(0); + }); + + test('when an asset is synced, then the corresponding photo item has a backed state', () => { + const asset = makeAsset({ id: 'a1' }); + const groups = groupAssetsByDate([asset], new Set(['a1']), new Set()); + expect(groups[0].photos[0].backupState).toBe('backed'); + }); +}); + +describe('getGroupSyncStatus', () => { + const group = makeDateGroup({ photos: [makePhotoItem(), makePhotoItem()] }); + + test('when sync status is scanning, then returns a scanning status', () => { + expect(getGroupSyncStatus(group, 'scanning', 0, undefined)).toEqual({ type: 'scanning' }); + }); + + test('when sync status is uploading, then returns uploading with remaining count and progress', () => { + expect(getGroupSyncStatus(group, 'uploading', 3, 0.5)).toEqual({ + type: 'uploading', + count: 3, + backupProgress: 0.5, + }); + }); + + test('when sync status is idle, then returns a count equal to the number of photos in the group', () => { + expect(getGroupSyncStatus(group, 'idle', 0, undefined)).toEqual({ type: 'count', count: 2 }); + }); + + test('when sync status is synced, then returns a count equal to the number of photos in the group', () => { + expect(getGroupSyncStatus(group, 'synced', 0, undefined)).toEqual({ type: 'count', count: 2 }); + }); +}); + +describe('buildTimelineItems', () => { + test('when a single group with two photos is provided, then three items are produced', () => { + const group = makeDateGroup({ photos: [makePhotoItem({ id: 'p1' }), makePhotoItem({ id: 'p2' })] }); + const { items } = buildTimelineItems([makeTimelineGroup(group)]); + expect(items).toHaveLength(3); + expect(items[0].type).toBe('header'); + expect(items[1].type).toBe('photo'); + expect(items[2].type).toBe('photo'); + }); + + test('when a single group is provided, then headerIndices contains only index 0', () => { + const { headerIndices } = buildTimelineItems([makeTimelineGroup(makeDateGroup())]); + expect(headerIndices).toEqual([0]); + }); + + test('when two groups are provided, then headerIndices points to the start of each group', () => { + const group1 = makeDateGroup({ id: 'g1', photos: [makePhotoItem({ id: 'p1' }), makePhotoItem({ id: 'p2' })] }); + const group2 = makeDateGroup({ id: 'g2', photos: [makePhotoItem({ id: 'p3' })] }); + const { items, headerIndices } = buildTimelineItems([makeTimelineGroup(group1), makeTimelineGroup(group2)]); + expect(headerIndices).toEqual([0, 3]); + expect(items[3].type).toBe('header'); + }); + + test('when multiple groups are provided, then only the first group header has isFirst set to true', () => { + const groups = [makeDateGroup({ id: 'g1' }), makeDateGroup({ id: 'g2' })].map((g) => makeTimelineGroup(g)); + const { items } = buildTimelineItems(groups); + const headers = items.filter((i) => i.type === 'header') as Extract<(typeof items)[number], { type: 'header' }>[]; + expect(headers[0].isFirst).toBe(true); + expect(headers[1].isFirst).toBe(false); + }); + + test('when given an empty group list, then returns no items and no header indices', () => { + const { items, headerIndices } = buildTimelineItems([]); + expect(items).toHaveLength(0); + expect(headerIndices).toHaveLength(0); + }); +}); diff --git a/src/screens/PhotosScreen/utils/photoTimelineGroups.ts b/src/screens/PhotosScreen/utils/photoTimelineGroups.ts new file mode 100644 index 000000000..fc28414c5 --- /dev/null +++ b/src/screens/PhotosScreen/utils/photoTimelineGroups.ts @@ -0,0 +1,120 @@ +import * as MediaLibrary from 'expo-media-library'; +import { GroupSyncStatus } from '../components/GroupHeader/PhotosGroupHeader'; +import { TimelineDateGroup } from '../components/PhotosTimeline'; +import { PhotoBackupState, PhotoDateGroup, PhotoItem } from '../types'; +import { PhotoSyncStatus } from 'src/store/slices/photos'; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +export const formatVideoDuration = (seconds: number): string => { + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + return `${mins}:${secs.toString().padStart(2, '0')}`; +}; + +export const getDateLabel = (date: Date, now: Date): string => { + const todayDateString = now.toDateString(); + const yesterDateString = new Date(now.getTime() - MS_PER_DAY).toDateString(); + + if (date.toDateString() === todayDateString) return 'Today'; + if (date.toDateString() === yesterDateString) return 'Yesterday'; + + if (date.getFullYear() === now.getFullYear()) { + return date.toLocaleDateString('en-US', { day: 'numeric', month: 'short' }); + } + + return date.toLocaleDateString('en-US', { day: 'numeric', month: 'short', year: 'numeric' }); +}; + +export const assetToPhotoItem = ( + asset: MediaLibrary.Asset, + syncedIds: Set, + uploadingIdSet: Set, +): PhotoItem => { + let backupState: PhotoBackupState; + if (uploadingIdSet.has(asset.id)) { + backupState = 'uploading'; + } else if (syncedIds.has(asset.id)) { + backupState = 'backed'; + } else { + backupState = 'not-backed'; + } + + const isVideo = asset.mediaType === MediaLibrary.MediaType.video; + return { + id: asset.id, + uri: asset.uri, + backupState, + mediaType: isVideo ? 'video' : 'photo', + duration: isVideo ? formatVideoDuration(asset.duration) : undefined, + }; +}; + +export const groupAssetsByDate = ( + assets: MediaLibrary.Asset[], + syncedIds: Set, + uploadingIdSet: Set, +): PhotoDateGroup[] => { + const now = new Date(); + const groupMap = new Map(); + + for (const asset of assets) { + const date = new Date(asset.creationTime); + const groupKey = date.toDateString(); + + let group = groupMap.get(groupKey); + if (!group) { + group = { label: getDateLabel(date, now), photos: [] }; + groupMap.set(groupKey, group); + } + group.photos.push(assetToPhotoItem(asset, syncedIds, uploadingIdSet)); + } + + return Array.from(groupMap.entries()).map(([key, { label, photos }]) => ({ + id: key, + label, + photos, + })); +}; + +export const getGroupSyncStatus = ( + group: PhotoDateGroup, + syncStatus: PhotoSyncStatus, + remainingCount: number, + backupProgress: number | undefined, +): GroupSyncStatus => { + switch (syncStatus) { + case 'scanning': + return { type: 'scanning' }; + case 'uploading': + return { type: 'uploading', count: remainingCount, backupProgress }; + default: + return { type: 'count', count: group.photos.length }; + } +}; + +export type FlatItem = + | { type: 'header'; id: string; label: string; syncStatus: GroupSyncStatus; count: number; isFirst: boolean } + | { type: 'photo'; photo: PhotoItem }; + +export const buildTimelineItems = (groups: TimelineDateGroup[]): { items: FlatItem[]; headerIndices: number[] } => { + const items: FlatItem[] = []; + const headerIndices: number[] = []; + + for (const [groupIndex, { group, syncStatus }] of groups.entries()) { + const currentHeaderIndex = items.length; + headerIndices.push(currentHeaderIndex); + items.push({ + type: 'header', + id: group.id, + label: group.label, + syncStatus, + count: group.photos.length, + isFirst: groupIndex === 0, + }); + for (const photo of group.photos) { + items.push({ type: 'photo', photo }); + } + } + return { items, headerIndices }; +};