Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/screens/HomeScreen/useDiscoverPhotosSheet.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ const makeWrapper = (photosState?: Partial<PhotosState>) => {
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 }) => <Provider store={store}>{children}</Provider>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 => (
<View style={styles.progressTrack}>
<View style={[styles.progressFill, { width: `${Math.round(progress * 100)}%`, backgroundColor: color }]} />
const BackupProgressBar = ({
progress,
fillColor,
trackColor,
}: {
progress: number;
fillColor: string;
trackColor: string;
}): JSX.Element => (
<View style={[styles.progressTrack, { backgroundColor: trackColor }]}>
<View style={[styles.progressFill, { width: `${Math.round(progress * 100)}%`, backgroundColor: fillColor }]} />
</View>
);

Expand All @@ -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 = (
<View
style={[
tailwind('h-16 justify-center overflow-hidden'),
tailwind('h-16 justify-center'),
!isSticky && { backgroundColor: getColor('bg-surface') },
{ overflow: 'visible' },
]}
>
{isSticky && (
Expand All @@ -67,7 +78,9 @@ const PhotosGroupHeader = memo(
/>
)}

{backupProgress != null && <BackupProgressBar progress={backupProgress} color={primaryColor} />}
{backupUploadProgress != null && (
<BackupProgressBar progress={backupUploadProgress} fillColor={primaryColor} trackColor={progressTrackColor} />
)}

<View style={tailwind('flex-row items-center justify-between px-4')}>
<AppText semibold style={[tailwind('text-lg'), { color: labelColor }]}>
Expand Down
29 changes: 25 additions & 4 deletions src/screens/PhotosScreen/components/PhotoItem.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Animated.View style={[styles.container, { backgroundColor: getColor('bg-primary-10'), opacity: fadeAnim }]} />
);
};

interface PhotoItemProps {
item: PhotoItemType;
isSelectMode?: boolean;
Expand All @@ -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 <View style={containerStyle} />;
return <SkeletonCell />;
}

const containerStyle = [styles.container, { backgroundColor: getColor('bg-gray-1') }];

return (
<TouchableOpacity activeOpacity={0.85} style={containerStyle} onPress={handlePress} onLongPress={handleLongPress}>
<Image source={{ uri: item.uri }} style={StyleSheet.absoluteFillObject} resizeMode="cover" />
Expand Down
27 changes: 27 additions & 0 deletions src/screens/PhotosScreen/components/PhotosEmptyState.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<View style={[tailwind('flex-1 items-center justify-center ')]}>
<ImageIcon size={64} color={getColor('text-primary')} />
<View style={[tailwind('items-center mt-5')]}>
<AppText medium style={tailwind('text-xl text-center text-gray-100')}>
{strings.screens.photos.emptyTitle}
</AppText>
<AppText style={tailwind('text-base text-center text-gray-50')}>{strings.screens.photos.emptySubtitle}</AppText>
</View>
</View>
);
};

export default PhotosEmptyState;
84 changes: 45 additions & 39 deletions src/screens/PhotosScreen/components/PhotosTimeline.tsx
Original file line number Diff line number Diff line change
@@ -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<FlashListProps<FlatItem>>;
const AnimatedFlashList = Animated.createAnimatedComponent(FlashList) as React.ComponentType<
FlashListProps<FlatItem> & { 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<string>;
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) => {
Expand All @@ -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]);

Expand All @@ -69,17 +78,19 @@ const PhotosTimeline = ({
const renderItem: ListRenderItem<FlatItem> = useCallback(
({ item, target }) => {
if (item.type === 'header') {
const isSticky = target === 'StickyHeader';
const showSyncStatus = isSticky || item.isFirst;
return (
<PhotosGroupHeader
label={item.label}
syncStatus={item.syncStatus}
isSticky={target === 'StickyHeader'}
stickyOpacity={target === 'StickyHeader' ? stickyOpacity : undefined}
syncStatus={showSyncStatus ? item.syncStatus : { type: 'count', count: item.count }}
isSticky={isSticky}
stickyOpacity={isSticky ? stickyOpacity : undefined}
/>
);
}
return (
<View style={styles.photoCell}>
<View style={[tailwind('flex-1'), { aspectRatio: 1, margin: 1 }]}>
<PhotoItem
item={item.photo}
isSelectMode={isSelectMode}
Expand All @@ -93,34 +104,29 @@ const PhotosTimeline = ({
[isSelectMode, selectedIds, onPhotoPress, onPhotoLongPress, stickyOpacity],
);

const isEmpty = !isLoading && assetsGroupsByDate.length === 0;

return (
<AnimatedFlashList
data={items}
renderItem={renderItem}
keyExtractor={keyExtractor}
numColumns={NUM_COLUMNS}
estimatedItemSize={ESTIMATED_ITEM_SIZE}
stickyHeaderIndices={headerIndices}
getItemType={getItemType}
overrideItemLayout={overrideItemLayout}
extraData={extraData}
ListHeaderComponent={ListHeaderComponent}
contentContainerStyle={styles.content}
ListEmptyComponent={isEmpty ? <PhotosEmptyState /> : 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;
Loading
Loading