feat: refactor service glyphs and improve loading states; replace inline components with CenteredState and ServiceGlyph

This commit is contained in:
bobbert committed 2026-04-15 17:14:19 +01:00
1 parent bb1eb375ad
commit 126f545c44
9 files changed
+62 -138

No files matched your search

+12 -17
View File
@@ -3,13 +3,13 @@ import { StatusBar } from 'expo-status-bar';
import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { ActivityIndicator, View, Text, TouchableOpacity } from 'react-native';
import { registerRootComponent } from 'expo';
import * as WebBrowser from 'expo-web-browser';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { ThemeProvider, useTheme } from './src/services/ThemeContext';
import DatabaseService from './src/services/DatabaseService';
import { getStoredUserId } from './src/services/AuthSession';
import CenteredState from './src/components/CenteredState';
import LoginScreen from './src/screens/LoginScreen';
import RegisterScreen from './src/screens/RegisterScreen';
import OnboardingScreen from './src/screens/OnboardingScreen';
@@ -32,6 +32,7 @@ function MainTabs({ route }) {
return (
<Tab.Navigator
id="MainTabsNavigator"
screenOptions={{
headerShown: false,
tabBarActiveTintColor: theme.tabActive,
@@ -104,24 +105,18 @@ function AppInner() {
};
if ((!isReady || !bootChecked) && !error) {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: theme.background }}>
<ActivityIndicator size="large" color={theme.primary} />
</View>
);
return <CenteredState loading title="Loading app…" containerStyle={{ backgroundColor: theme.background }} />;
}
if (error) {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', paddingHorizontal: 20, backgroundColor: theme.background }}>
<Text style={{ fontSize: 16, color: theme.text, marginBottom: 12, textAlign: 'center' }}>{error}</Text>
<TouchableOpacity
onPress={initializeApp}
style={{ backgroundColor: theme.primary, paddingHorizontal: 16, paddingVertical: 10, borderRadius: 6 }}
>
<Text style={{ color: '#fff', fontWeight: '600' }}>Retry</Text>
</TouchableOpacity>
</View>
<CenteredState
title="Unable to start app"
description={error}
actionLabel="Retry"
onAction={initializeApp}
containerStyle={{ backgroundColor: theme.background }}
/>
);
}
@@ -129,6 +124,7 @@ function AppInner() {
<>
<NavigationContainer>
<Stack.Navigator
id="RootStackNavigator"
screenOptions={{ headerShown: false }}
initialRouteName={bootUserId ? 'MainTabs' : 'Login'}
>
@@ -143,7 +139,7 @@ function AppInner() {
<Stack.Screen name="Settings" component={SettingsScreen} />
</Stack.Navigator>
</NavigationContainer>
<StatusBar style={theme.statusBarStyle} backgroundColor={theme.background} />
<StatusBar style={theme.statusBarStyle} />
</>
);
}
@@ -158,4 +154,3 @@ function App() {
registerRootComponent(App);
export default App;
-2
View File
@@ -20,7 +20,6 @@
"expo-blur": "~55.0.14",
"expo-crypto": "~55.0.14",
"expo-file-system": "~55.0.16",
"expo-image-picker": "~55.0.18",
"expo-linking": "~55.0.13",
"expo-media-library": "~55.0.14",
"expo-secure-store": "~55.0.13",
@@ -28,7 +27,6 @@
"expo-status-bar": "~55.0.5",
"expo-system-ui": "^55.0.15",
"expo-web-browser": "~55.0.14",
"metro-react-native-babel-transformer": "^0.77.0",
"react": "19.2.0",
"react-native": "0.83.4",
"react-native-gesture-handler": "~2.30.0",
+1 -10
View File
@@ -4,22 +4,13 @@ import {
} from 'react-native';
import { useTheme } from '../services/ThemeContext';
import MediaMetadataService from '../services/MediaMetadataService';
import ServiceGlyph from './ServiceGlyph';
import CameraIcon from '../../assets/icons/camera.svg';
import CheckIcon from '../../assets/icons/check.svg';
import DeviceIcon from '../../assets/icons/device.svg';
import GooglePhotosIcon from '../../assets/icons/google-photos.svg';
import ICloudIcon from '../../assets/icons/icloud.svg';
import NasIcon from '../../assets/icons/nas.svg';
const { width } = Dimensions.get('window');
const GRID_GAP = 2;
const ServiceGlyph = ({ serviceType, size }) => {
if (serviceType === 'GOOGLE_PHOTOS') return <GooglePhotosIcon width={size} height={size} />;
if (serviceType === 'ICLOUD_PHOTOS') return <ICloudIcon width={size} height={size} />;
if (serviceType === 'NAS') return <NasIcon width={size} height={size} />;
return <DeviceIcon width={size} height={size} />;
};
const PhotoGrid = ({
data,
+13 -30
View File
@@ -1,21 +1,19 @@
import React, { useState, useCallback } from 'react';
import {
StyleSheet, View, Text, SectionList, FlatList, TouchableOpacity,
Dimensions, Image, ActivityIndicator, RefreshControl,
Dimensions, Image, RefreshControl,
} from 'react-native';
import { useFocusEffect } from '@react-navigation/native';
import { useTheme } from '../services/ThemeContext';
import DatabaseService from '../services/DatabaseService';
import MediaMetadataService from '../services/MediaMetadataService';
import CenteredState from '../components/CenteredState';
import MediaViewer from '../components/MediaViewer';
import ServiceGlyph from '../components/ServiceGlyph';
import CameraIcon from '../../assets/icons/camera.svg';
import EmptyPhotoIcon from '../../assets/icons/empty-photo.svg';
import FolderIcon from '../../assets/icons/folder.svg';
import FolderOpenIcon from '../../assets/icons/folder-open.svg';
import DeviceIcon from '../../assets/icons/device.svg';
import GooglePhotosIcon from '../../assets/icons/google-photos.svg';
import ICloudIcon from '../../assets/icons/icloud.svg';
import NasIcon from '../../assets/icons/nas.svg';
import HiddenIcon from '../../assets/icons/hidden.svg';
import DuplicatesIcon from '../../assets/icons/duplicates.svg';
import TrashIcon from '../../assets/icons/trash.svg';
@@ -26,12 +24,6 @@ const ALBUM_COLUMNS = 2;
const ALBUM_GAP = 12;
const albumSize = (width - ALBUM_GAP * (ALBUM_COLUMNS + 1)) / ALBUM_COLUMNS;
const SERVICE_NAMES = { DEVICE: 'On Device', GOOGLE_PHOTOS: 'Google Photos', ICLOUD_PHOTOS: 'iCloud Photos', NAS: 'NAS Storage' };
const ServiceGlyph = ({ serviceType, size = 20 }) => {
if (serviceType === 'GOOGLE_PHOTOS') return <GooglePhotosIcon width={size} height={size} />;
if (serviceType === 'ICLOUD_PHOTOS') return <ICloudIcon width={size} height={size} />;
if (serviceType === 'NAS') return <NasIcon width={size} height={size} />;
return <DeviceIcon width={size} height={size} />;
};
const UtilityGlyph = ({ name, size = 20, color = '#8B949E' }) => {
if (name === 'hidden') return <HiddenIcon width={size} height={size} color={color} />;
@@ -145,10 +137,10 @@ const CollectionsScreen = ({ route }) => {
</View>
</View>
{albumMedia.length === 0 ? (
<View style={styles.centerContainer}>
<UtilityGlyph name="emptyMail" size={48} />
<Text style={[styles.emptyTitle, { color: theme.text }]}>No items in this collection</Text>
</View>
<CenteredState
icon={<UtilityGlyph name="emptyMail" size={48} />}
title="No items in this collection"
/>
) : (
<FlatList
data={albumMedia}
@@ -179,10 +171,7 @@ const CollectionsScreen = ({ route }) => {
if (loading) {
return (
<View style={[styles.centerContainer, { backgroundColor: theme.background }]}>
<ActivityIndicator size="large" color={theme.primary} />
<Text style={[styles.loadingText, { color: theme.textSecondary }]}>Loading collections...</Text>
</View>
<CenteredState loading title="Loading collections…" containerStyle={{ backgroundColor: theme.background }} />
);
}
@@ -243,13 +232,11 @@ const CollectionsScreen = ({ route }) => {
{sections.length === 0
|| !sections.some((sec) => sec.data?.[0]?.albums?.some((a) => (a.count || 0) > 0)) ? (
<View style={styles.centerContainer}>
<FolderIcon width={48} height={48} color="#8B949E" />
<Text style={[styles.emptyTitle, { color: theme.text }]}>No Collections</Text>
<Text style={[styles.emptyText, { color: theme.textSecondary }]}>
Collections from your connected services will appear here
</Text>
</View>
<CenteredState
icon={<FolderIcon width={48} height={48} color="#8B949E" />}
title="No Collections"
description="Collections from your connected services will appear here"
/>
) : (
<SectionList
sections={sections}
@@ -269,7 +256,6 @@ const CollectionsScreen = ({ route }) => {
const styles = StyleSheet.create({
container: { flex: 1 },
centerContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', paddingHorizontal: 24 },
header: { paddingHorizontal: 16, paddingTop: 52, paddingBottom: 12 },
headerTitle: { fontSize: 24, fontWeight: '700' },
listContent: { paddingHorizontal: ALBUM_GAP, paddingBottom: 20 },
@@ -299,9 +285,6 @@ const styles = StyleSheet.create({
gridItem: { margin: 1 },
gridImage: { width: '100%', height: '100%' },
gridPlaceholder: { width: '100%', height: '100%', justifyContent: 'center', alignItems: 'center' },
loadingText: { marginTop: 12, fontSize: 14 },
emptyTitle: { fontSize: 18, fontWeight: '600', marginBottom: 6 },
emptyText: { fontSize: 13, textAlign: 'center' },
});
export default CollectionsScreen;
+17 -39
View File
@@ -9,6 +9,9 @@ import DeviceMediaService from '../services/DeviceMediaService';
import GoogleAUTH from '../AUTH/GoogleAUTH';
import NASAUTH from '../AUTH/NASAUTH';
import { File } from 'expo-file-system';
import CenteredState from '../components/CenteredState';
import MediaSortControls from '../components/MediaSortControls';
import ServiceGlyph from '../components/ServiceGlyph';
import TopBar from '../components/TopBar';
import PhotoGrid from '../components/PhotoGrid';
import MediaViewer from '../components/MediaViewer';
@@ -18,18 +21,8 @@ import MediaMetadataService from '../services/MediaMetadataService';
import { logError } from '../services/ErrorHandler';
import CameraIcon from '../../assets/icons/camera.svg';
import EmptyPhotoIcon from '../../assets/icons/empty-photo.svg';
import DeviceIcon from '../../assets/icons/device.svg';
import GooglePhotosIcon from '../../assets/icons/google-photos.svg';
import ICloudIcon from '../../assets/icons/icloud.svg';
import NasIcon from '../../assets/icons/nas.svg';
const SERVICE_NAMES = { DEVICE: 'On Device', GOOGLE_PHOTOS: 'Google Photos', ICLOUD_PHOTOS: 'iCloud', NAS: 'NAS' };
const ServiceGlyph = ({ serviceType, size = 14 }) => {
if (serviceType === 'GOOGLE_PHOTOS') return <GooglePhotosIcon width={size} height={size} />;
if (serviceType === 'ICLOUD_PHOTOS') return <ICloudIcon width={size} height={size} />;
if (serviceType === 'NAS') return <NasIcon width={size} height={size} />;
return <DeviceIcon width={size} height={size} />;
};
/** Keeps device library timestamps and aligns `created_date` with resolved capture time after merges. */
const mergeCaptureFields = (target, donor) => {
@@ -270,10 +263,10 @@ const HomeScreen = ({ navigation, route }) => {
}
};
const refreshMedia = useCallback(() => {
const refreshMedia = () => {
setRefreshing(true);
loadAllMedia();
}, [loadAllMedia]);
};
const importGooglePhotos = async () => {
const googleAccount = linkedAccounts.find((a) => a.service_type === 'GOOGLE_PHOTOS');
@@ -509,10 +502,7 @@ const HomeScreen = ({ navigation, route }) => {
if (loading && allMedia.length === 0) {
return (
<View style={[styles.centerContainer, { backgroundColor: theme.background }]}>
<ActivityIndicator size="large" color={theme.primary} />
<Text style={[styles.loadingText, { color: theme.textSecondary }]}>Loading photos...</Text>
</View>
<CenteredState loading title="Loading photos…" containerStyle={{ backgroundColor: theme.background }} />
);
}
@@ -556,6 +546,7 @@ const HomeScreen = ({ navigation, route }) => {
onMenuPress={() => navigation.navigate('Settings', { userId })}
/>
<MediaSortControls value={sortMode} onChange={setSortMode} />
<FilterChips />
<SelectionToolbar
@@ -584,23 +575,16 @@ const HomeScreen = ({ navigation, route }) => {
)}
{filteredMedia.length === 0 ? (
<View style={styles.centerContainer}>
<View style={{ marginBottom: 16 }}>
<EmptyPhotoIcon width={56} height={56} color="#9CA3AF" />
</View>
<Text style={[styles.emptyTitle, { color: theme.text }]}>No Photos Yet</Text>
<Text style={[styles.emptyText, { color: theme.textSecondary }]}>
{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'}
</Text>
<TouchableOpacity
style={[styles.retryButton, { backgroundColor: theme.primary }]}
onPress={refreshMedia}
>
<Text style={styles.retryButtonText}>Refresh</Text>
</TouchableOpacity>
</View>
<CenteredState
icon={<EmptyPhotoIcon width={56} height={56} color="#9CA3AF" />}
title="No Photos Yet"
description={hasGoogleAccount
? 'Tap "Import from Google Photos" above to select cloud photos, or grant media library permission to see on-device photos.'
: 'Grant media library permission or take some photos to get started'}
actionLabel="Refresh"
onAction={refreshMedia}
containerStyle={{ backgroundColor: theme.background }}
/>
) : (
<PhotoGrid
data={sortMode === 'date_taken' ? [] : filteredMedia}
@@ -645,8 +629,6 @@ const HomeScreen = ({ navigation, route }) => {
const styles = StyleSheet.create({
container: { flex: 1 },
centerContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', paddingHorizontal: 24 },
loadingText: { marginTop: 12, fontSize: 14 },
filterContainer: { maxHeight: 40 },
filterRow: { paddingHorizontal: 12, paddingVertical: 6, gap: 6, alignItems: 'center' },
chip: {
@@ -660,10 +642,6 @@ const styles = StyleSheet.create({
borderRadius: 12, gap: 8, minHeight: 42,
},
importText: { color: '#fff', fontSize: 14, fontWeight: '600' },
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;
+1 -10
View File
@@ -9,11 +9,8 @@ import GoogleAUTH from '../AUTH/GoogleAUTH';
import NASAUTH from '../AUTH/NASAUTH';
import NASSetupInstructions from '../components/NASSetupInstructions';
import ServiceHealthIndicator from '../components/ServiceHealthIndicator';
import ServiceGlyph from '../components/ServiceGlyph';
import CheckIcon from '../../assets/icons/check.svg';
import DeviceIcon from '../../assets/icons/device.svg';
import GooglePhotosIcon from '../../assets/icons/google-photos.svg';
import ICloudIcon from '../../assets/icons/icloud.svg';
import NasIcon from '../../assets/icons/nas.svg';
const SERVICES = {
GOOGLE_PHOTOS: { name: 'Google Photos', type: 'GOOGLE_PHOTOS' },
@@ -25,12 +22,6 @@ const ONBOARDING_STEPS = [
{ title: 'Connect Services', description: 'Set up each selected service' },
{ title: 'Automatic Backup', description: 'Choose where new photos are backed up' },
];
const ServiceGlyph = ({ serviceType, size = 20 }) => {
if (serviceType === 'GOOGLE_PHOTOS') return <GooglePhotosIcon width={size} height={size} />;
if (serviceType === 'ICLOUD_PHOTOS') return <ICloudIcon width={size} height={size} />;
if (serviceType === 'NAS') return <NasIcon width={size} height={size} />;
return <DeviceIcon width={size} height={size} />;
};
const OnboardingScreen = ({ navigation, route }) => {
const userId = route?.params?.userId;
+11 -15
View File
@@ -4,6 +4,7 @@ import {
} from 'react-native';
import { useTheme } from '../services/ThemeContext';
import DatabaseService from '../services/DatabaseService';
import CenteredState from '../components/CenteredState';
import PhotoGrid from '../components/PhotoGrid';
import MediaViewer from '../components/MediaViewer';
import SearchIcon from '../../assets/icons/search.svg';
@@ -66,21 +67,19 @@ const SearchScreen = ({ route }) => {
)}
{!searching && query.length > 0 && results.length === 0 && (
<View style={styles.emptyContainer}>
<SearchIcon width={48} height={48} color={theme.textTertiary} />
<Text style={[styles.emptyTitle, { color: theme.text }]}>No results</Text>
<Text style={[styles.emptyText, { color: theme.textSecondary }]}>Try different keywords</Text>
</View>
<CenteredState
icon={<SearchIcon width={48} height={48} color={theme.textTertiary} />}
title="No results"
description="Try different keywords"
/>
)}
{query.length === 0 && (
<View style={styles.emptyContainer}>
<SearchIcon width={48} height={48} color={theme.textTertiary} />
<Text style={[styles.emptyTitle, { color: theme.text }]}>Search your photos</Text>
<Text style={[styles.emptyText, { color: theme.textSecondary }]}>
Search by filename and indexed metadata
</Text>
</View>
<CenteredState
icon={<SearchIcon width={48} height={48} color={theme.textTertiary} />}
title="Search your photos"
description="Search by filename and indexed metadata"
/>
)}
{results.length > 0 && (
@@ -110,9 +109,6 @@ const styles = StyleSheet.create({
searchInput: { flex: 1, fontSize: 15, padding: 0 },
clearButton: { fontSize: 16, padding: 4 },
loadingContainer: { padding: 20, alignItems: 'center' },
emptyContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', paddingHorizontal: 24 },
emptyTitle: { fontSize: 18, fontWeight: '600', marginBottom: 6 },
emptyText: { fontSize: 13, textAlign: 'center' },
resultCount: { paddingHorizontal: 16, paddingVertical: 8, fontSize: 13 },
});
+1 -10
View File
@@ -8,14 +8,11 @@ import { useTheme } from '../services/ThemeContext';
import DatabaseService from '../services/DatabaseService';
import NASSetupInstructions from '../components/NASSetupInstructions';
import ServiceHealthIndicator from '../components/ServiceHealthIndicator';
import ServiceGlyph from '../components/ServiceGlyph';
import NASAUTH from '../AUTH/NASAUTH';
import GoogleAUTH from '../AUTH/GoogleAUTH';
import { clearStoredUserId } from '../services/AuthSession';
import CheckIcon from '../../assets/icons/check.svg';
import DeviceIcon from '../../assets/icons/device.svg';
import GooglePhotosIcon from '../../assets/icons/google-photos.svg';
import ICloudIcon from '../../assets/icons/icloud.svg';
import NasIcon from '../../assets/icons/nas.svg';
const SERVICE_NAMES = { GOOGLE_PHOTOS: 'Google Photos', ICLOUD_PHOTOS: 'iCloud Photos', NAS: 'NAS Storage' };
const AUTO_BACKUP_SERVICES = [
@@ -23,12 +20,6 @@ const AUTO_BACKUP_SERVICES = [
{ type: 'ICLOUD_PHOTOS', name: 'iCloud Photos' },
{ type: 'NAS', name: 'NAS Storage' },
];
const ServiceGlyph = ({ serviceType, size = 20 }) => {
if (serviceType === 'GOOGLE_PHOTOS') return <GooglePhotosIcon width={size} height={size} />;
if (serviceType === 'ICLOUD_PHOTOS') return <ICloudIcon width={size} height={size} />;
if (serviceType === 'NAS') return <NasIcon width={size} height={size} />;
return <DeviceIcon width={size} height={size} />;
};
const SettingsScreen = ({ navigation, route }) => {
const userId = route?.params?.userId;
+6 -5
View File
@@ -1,13 +1,14 @@
import * as Crypto from 'expo-crypto';
import { requireNativeModule } from 'expo-modules-core';
const ExpoCrypto = requireNativeModule('ExpoCrypto');
const SHA_256 = 'SHA-256';
const HEX_ENCODING = 'hex';
class CredentialEncryption {
async hashPassword(password) {
try {
if (!password) throw new Error('Password is required');
return await Crypto.digestStringAsync(
Crypto.CryptoDigestAlgorithm.SHA256,
password
);
return await ExpoCrypto.digestStringAsync(SHA_256, password, { encoding: HEX_ENCODING });
} catch (error) {
console.error('Password hashing error:', error);
throw new Error('Failed to hash password: ' + error.message);