feat: implement NAS image lazy-loading and enhance service connection UI

This commit is contained in:
bobbert committed 2026-03-04 22:57:03 +00:00
1 parent 6167418074
commit 5158305086
8 files changed
+573 -492

No files matched your search

+21 -2
View File
@@ -11,7 +11,12 @@ app.use(cors());
app.use(express.json({ limit: '100mb' }));
app.use((req, res, next) => {
console.log(`[${new Date().toISOString()}] ${req.method} ${req.path}`);
const start = Date.now();
res.on('finish', () => {
const ms = Date.now() - start;
const user = req.body?.username ? ` user=${req.body.username}` : '';
console.log(`[${new Date().toISOString()}] ${req.method} ${req.path} → ${res.statusCode} (${ms}ms)${user}`);
});
next();
});
@@ -52,6 +57,7 @@ app.post('/api/register', (req, res) => {
}
res.json({ success: true, userId, username: sanitized });
} catch (err) {
console.error('[/api/register] Error:', err.message, err.stack);
res.status(500).json({ error: err.message });
}
});
@@ -79,6 +85,7 @@ app.post('/api/index', (req, res) => {
orphansRemoved: removed,
});
} catch (err) {
console.error('[/api/index] Error:', err.message, err.stack);
res.status(500).json({ error: err.message });
}
});
@@ -107,6 +114,7 @@ app.post('/api/photos', (req, res) => {
})),
});
} catch (err) {
console.error('[/api/photos] Error:', err.message, err.stack);
res.status(500).json({ error: err.message });
}
});
@@ -149,6 +157,7 @@ app.post('/api/photo/fetch', (req, res) => {
},
});
} catch (err) {
console.error('[/api/photo/fetch] Error:', err.message, err.stack);
res.status(500).json({ error: err.message });
}
});
@@ -187,6 +196,7 @@ app.post('/api/photo/upload', (req, res) => {
res.json({ success: true, filename: safeFilename, indexed: result.indexed });
} catch (err) {
console.error('[/api/photo/upload] Error:', err.message, err.stack);
res.status(500).json({ error: err.message });
}
});
@@ -197,10 +207,10 @@ app.post('/api/photo/delete', (req, res) => {
try {
const { photoId } = req.body;
if (!photoId) return res.status(400).json({ error: 'photoId is required' });
const result = db.deletePhoto(photoId);
res.json(result);
} catch (err) {
console.error('[/api/photo/delete] Error:', err.message, err.stack);
res.status(500).json({ error: err.message });
}
});
@@ -234,6 +244,7 @@ app.post('/api/refresh', (req, res) => {
})),
});
} catch (err) {
console.error('[/api/refresh] Error:', err.message, err.stack);
res.status(500).json({ error: err.message });
}
});
@@ -248,6 +259,7 @@ app.post('/api/albums', (req, res) => {
const albums = db.getAlbums(sanitized);
res.json({ success: true, albums });
} catch (err) {
console.error('[/api/albums] Error:', err.message, err.stack);
res.status(500).json({ error: err.message });
}
});
@@ -260,6 +272,7 @@ app.post('/api/album/create', (req, res) => {
const result = db.createAlbum(sanitized, name.trim());
res.json(result);
} catch (err) {
console.error('[/api/album/create] Error:', err.message, err.stack);
res.status(500).json({ error: err.message });
}
});
@@ -281,6 +294,7 @@ app.post('/api/album/photos', (req, res) => {
})),
});
} catch (err) {
console.error('[/api/album/photos] Error:', err.message, err.stack);
res.status(500).json({ error: err.message });
}
});
@@ -292,6 +306,7 @@ app.post('/api/album/add-photo', (req, res) => {
const result = db.addPhotoToAlbum(albumId, photoId);
res.json(result);
} catch (err) {
console.error('[/api/album/add-photo] Error:', err.message, err.stack);
res.status(500).json({ error: err.message });
}
});
@@ -303,6 +318,7 @@ app.post('/api/album/remove-photo', (req, res) => {
const result = db.removePhotoFromAlbum(albumId, photoId);
res.json(result);
} catch (err) {
console.error('[/api/album/remove-photo] Error:', err.message, err.stack);
res.status(500).json({ error: err.message });
}
});
@@ -314,6 +330,7 @@ app.post('/api/album/delete', (req, res) => {
const result = db.deleteAlbum(albumId);
res.json(result);
} catch (err) {
console.error('[/api/album/delete] Error:', err.message, err.stack);
res.status(500).json({ error: err.message });
}
});
@@ -325,6 +342,7 @@ app.post('/api/album/rename', (req, res) => {
const result = db.renameAlbum(albumId, name.trim());
res.json(result);
} catch (err) {
console.error('[/api/album/rename] Error:', err.message, err.stack);
res.status(500).json({ error: err.message });
}
});
@@ -336,6 +354,7 @@ app.get('/api/users', (req, res) => {
const users = db.getAllUsers();
res.json({ success: true, users });
} catch (err) {
console.error('[/api/users] Error:', err.message, err.stack);
res.status(500).json({ error: err.message });
}
});
+1 -12
View File
@@ -17,16 +17,12 @@ class NASAPI {
async connect(host, port, username) {
if (!host || !username) throw new Error('Host and username are required');
this.configure(host, port, username);
// Register user on NAS — NAS creates user folder and DB entry
const response = await axios.post(
`${this.baseUrl}/api/register`,
{ username: this.username },
{ timeout: 10000 },
);
if (response.data?.success) {
this.isConnected = true;
return {
@@ -49,7 +45,6 @@ class NASAPI {
}
}
// Full refresh cycle: POST to re-index + fetch updated photo list
async refreshPhotos() {
if (!this.username) throw new Error('Not connected');
const response = await axios.post(
@@ -60,7 +55,6 @@ class NASAPI {
return response.data;
}
// Fetch photo index only (no re-index)
async getPhotos() {
if (!this.username) throw new Error('Not connected');
const response = await axios.post(
@@ -71,7 +65,6 @@ class NASAPI {
return response.data;
}
// Fetch a single photo as base64 with hash verification
async fetchPhoto(photoId) {
const response = await axios.post(
`${this.baseUrl}/api/photo/fetch`,
@@ -84,7 +77,6 @@ class NASAPI {
return response.data.photo;
}
// Upload a photo from device to NAS as base64
async uploadPhoto(filename, base64Data) {
if (!this.username) throw new Error('Not connected');
const response = await axios.post(
@@ -95,7 +87,6 @@ class NASAPI {
return response.data;
}
// Delete a photo from NAS
async deletePhoto(photoId) {
const response = await axios.post(
`${this.baseUrl}/api/photo/delete`,
@@ -105,7 +96,6 @@ class NASAPI {
return response.data;
}
// Re-index only
async reIndex() {
if (!this.username) throw new Error('Not connected');
const response = await axios.post(
@@ -116,8 +106,6 @@ class NASAPI {
return response.data;
}
// ─── Album operations ───
async getAlbums() {
if (!this.username) throw new Error('Not connected');
const response = await axios.post(
@@ -197,3 +185,4 @@ class NASAPI {
}
export default NASAPI;
+35 -15
View File
@@ -1,7 +1,7 @@
import React, { useRef, useState, useEffect } from 'react';
import {
Modal, View, Text, TouchableOpacity, Image, StyleSheet,
Dimensions, FlatList,
Dimensions, FlatList, ActivityIndicator,
} from 'react-native';
import { useTheme } from '../services/ThemeContext';
import { getServiceIcon, getServiceName } from '../UI/ServiceHelpers';
@@ -32,7 +32,7 @@ const MediaViewer = ({ visible, media, allMedia, onClose, onDelete, onAddToAlbum
const mediaList = allMedia && allMedia.length > 0 ? allMedia : (media ? [media] : []);
const hasCarousel = mediaList.length > 1;
// When the modal opens or media prop changes, scroll to the correct item
// When the modal opens, media prop changes, or allMedia updates — sync currentMedia
useEffect(() => {
if (!visible || !media || mediaList.length === 0) return;
@@ -41,9 +41,9 @@ const MediaViewer = ({ visible, media, allMedia, onClose, onDelete, onAddToAlbum
);
const newIndex = idx >= 0 ? idx : 0;
setActiveIndex(newIndex);
setCurrentMedia(mediaList[newIndex]);
// Always use the freshest version from mediaList (which gets updated when base64 loads)
setCurrentMedia(mediaList[newIndex] || media);
// Delay scroll so FlatList has rendered
setTimeout(() => {
if (mainListRef.current && newIndex >= 0) {
mainListRef.current.scrollToOffset({ offset: newIndex * SCREEN_WIDTH, animated: false });
@@ -54,7 +54,7 @@ const MediaViewer = ({ visible, media, allMedia, onClose, onDelete, onAddToAlbum
} catch (e) { /* index might be out of range during initial render */ }
}
}, 100);
}, [visible, media]);
}, [visible, media, allMedia]);
const activeIndexRef = useRef(0);
@@ -83,10 +83,23 @@ const MediaViewer = ({ visible, media, allMedia, onClose, onDelete, onAddToAlbum
if (!media) return null;
// True while a NAS photo has no local_path yet (still loading base64)
const isNasLoading = (item) =>
item?.source_service === 'NAS' && item?.nas_photo_id && !item?.local_path;
const renderMainItem = ({ item }) => (
<View style={{ width: SCREEN_WIDTH, height: SCREEN_HEIGHT }}>
{item.local_path ? (
<Image source={{ uri: item.local_path }} style={styles.fullImage} resizeMode="contain" />
<Image
source={{ uri: item.local_path }}
style={styles.fullImage}
resizeMode="contain"
/>
) : isNasLoading(item) ? (
<View style={styles.fullPlaceholder}>
<ActivityIndicator size="large" color="#fff" />
<Text style={styles.loadingText}>Loading from NAS…</Text>
</View>
) : (
<View style={styles.fullPlaceholder}>
<Text style={{ fontSize: 64 }}>📷</Text>
@@ -106,6 +119,10 @@ const MediaViewer = ({ visible, media, allMedia, onClose, onDelete, onAddToAlbum
>
{item.local_path ? (
<Image source={{ uri: item.local_path }} style={styles.thumbImage} resizeMode="cover" />
) : isNasLoading(item) ? (
<View style={styles.thumbPlaceholder}>
<ActivityIndicator size="small" color="#aaa" />
</View>
) : (
<View style={styles.thumbPlaceholder}>
<Text style={{ fontSize: 20 }}>📷</Text>
@@ -151,15 +168,17 @@ const MediaViewer = ({ visible, media, allMedia, onClose, onDelete, onAddToAlbum
})}
style={styles.mainList}
/>
) : displayMedia.local_path ? (
<Image source={{ uri: displayMedia.local_path }} style={styles.fullImage} resizeMode="contain" />
) : isNasLoading(displayMedia) ? (
<View style={styles.fullPlaceholder}>
<ActivityIndicator size="large" color="#fff" />
<Text style={styles.loadingText}>Loading from NAS…</Text>
</View>
) : (
// Single image mode (backward compat)
displayMedia.local_path ? (
<Image source={{ uri: displayMedia.local_path }} style={styles.fullImage} resizeMode="contain" />
) : (
<View style={styles.fullPlaceholder}>
<Text style={{ fontSize: 64 }}>📷</Text>
</View>
)
<View style={styles.fullPlaceholder}>
<Text style={{ fontSize: 64 }}>📷</Text>
</View>
)}
{/* Thumbnail carousel */}
@@ -272,7 +291,8 @@ const styles = StyleSheet.create({
counterText: { color: '#fff', fontSize: 14, fontWeight: '600' },
mainList: { flex: 1 },
fullImage: { flex: 1, width: '100%' },
fullPlaceholder: { flex: 1, justifyContent: 'center', alignItems: 'center' },
fullPlaceholder: { flex: 1, justifyContent: 'center', alignItems: 'center', gap: 12 },
loadingText: { color: '#ccc', fontSize: 14, marginTop: 8 },
// Thumbnail carousel
thumbCarousel: {
position: 'absolute', bottom: 110, left: 0, right: 0, zIndex: 15,
+5 -1
View File
@@ -1,6 +1,6 @@
import React from 'react';
import {
View, Text, FlatList, TouchableOpacity, Image, Dimensions, StyleSheet,
View, Text, FlatList, TouchableOpacity, Image, Dimensions, StyleSheet, ActivityIndicator,
} from 'react-native';
import { useTheme } from '../services/ThemeContext';
import { getServiceIcon } from '../UI/ServiceHelpers';
@@ -61,6 +61,10 @@ const PhotoGrid = ({
resizeMode="cover"
onError={(e) => console.warn(`Image load failed [${item.source_service}]: ${item.local_path?.substring(0, 80)}`, e.nativeEvent?.error)}
/>
) : item.source_service === 'NAS' && item.nas_photo_id ? (
<View style={[styles.placeholder, { backgroundColor: theme.borderLight }]}>
<ActivityIndicator size="small" color={theme.textSecondary} />
</View>
) : (
<View style={[styles.placeholder, { backgroundColor: theme.borderLight }]}>
<Text style={{ fontSize: 28 }}>📷</Text>
+296
View File
@@ -0,0 +1,296 @@
import React, { useState } from 'react';
import {
View, Text, TouchableOpacity, TextInput, StyleSheet, ActivityIndicator,
} from 'react-native';
import { useTheme } from '../services/ThemeContext';
import ServiceHealthIndicator from './ServiceHealthIndicator';
import NASSetupInstructions from './NASSetupInstructions';
import DatabaseService from '../services/DatabaseService';
import GoogleAUTH from '../AUTH/GoogleAUTH';
import NASAUTH from '../AUTH/NASAUTH';
/**
* ServiceConnectCard
*
* A self-contained card for connecting / managing a single service.
* Used identically in OnboardingScreen (step 1) and SettingsScreen.
*
* Props:
* serviceType – 'GOOGLE_PHOTOS' | 'ICLOUD_PHOTOS' | 'NAS'
* userId – current user id
* existingData – account_data from DB if already linked (or null)
* isLinked – whether the service is already linked
* onLinked – callback() fired after a successful new connection / re-auth
* onRemoved – callback() fired after the account is deleted (Settings only)
* showRemove – whether to show a Remove button (Settings only)
*/
const ServiceConnectCard = ({
serviceType,
userId,
existingData = null,
isLinked = false,
onLinked,
onRemoved,
showRemove = false,
}) => {
const { theme } = useTheme();
const [loading, setLoading] = useState(false);
const [health, setHealth] = useState(isLinked ? 'healthy' : 'inactive');
const [connected, setConnected] = useState(isLinked);
const [showForm, setShowForm] = useState(!isLinked); // show form if not yet linked
// NAS-specific fields — pre-fill from existingData if editing
const [nasHost, setNasHost] = useState(existingData?.host || '');
const [nasPort, setNasPort] = useState(String(existingData?.port || 9500));
const [nasUsername, setNasUsername] = useState(existingData?.username || '');
// ─── Service metadata ────────────────────────────────────
const META = {
GOOGLE_PHOTOS: { icon: '📷', name: 'Google Photos', desc: 'Access your Google Photos library', color: '#4285F4' },
ICLOUD_PHOTOS: { icon: '🍎', name: 'iCloud Photos', desc: 'Access your iCloud photo library', color: '#555' },
NAS: { icon: '💾', name: 'NAS Storage', desc: 'Connect to your NAS photo server', color: '#0a7ea4' },
};
const meta = META[serviceType] || { icon: '🔌', name: serviceType, desc: '', color: '#888' };
// ─── Connect handlers ────────────────────────────────────
const connectGoogle = async () => {
setLoading(true);
setHealth('checking');
try {
const googleAuth = new GoogleAUTH();
const result = await googleAuth.authenticate();
await DatabaseService.linkAccount(userId, 'GOOGLE_PHOTOS', {
accessToken: result.accessToken,
refreshToken: result.refreshToken || null,
expiresAt: googleAuth.expiresAt || Date.now() + 3600000,
email: result.email || 'google-user',
});
setConnected(true);
setHealth('healthy');
setShowForm(false);
onLinked?.();
} catch (err) {
setHealth('error');
console.error('Google auth error:', err.message);
} finally {
setLoading(false);
}
};
const connectIcloud = async () => {
setLoading(true);
setHealth('checking');
try {
await DatabaseService.linkAccount(userId, 'ICLOUD_PHOTOS', {
status: 'placeholder',
note: 'iCloud integration pending Apple API access',
});
setConnected(true);
setHealth('healthy');
setShowForm(false);
onLinked?.();
} catch (err) {
setHealth('error');
console.error('iCloud auth error:', err.message);
} finally {
setLoading(false);
}
};
const connectNas = async () => {
if (!nasHost.trim() || !nasUsername.trim()) {
// surface validation inline rather than an Alert so it fits the card style
setHealth('error');
return;
}
setLoading(true);
setHealth('checking');
try {
const nasAuth = new NASAUTH();
await nasAuth.authenticate(nasHost.trim(), parseInt(nasPort) || 9500, nasUsername.trim());
await DatabaseService.linkAccount(userId, 'NAS', {
host: nasHost.trim(),
port: parseInt(nasPort) || 9500,
username: nasUsername.trim(),
});
setConnected(true);
setHealth('healthy');
setShowForm(false);
onLinked?.();
} catch (err) {
setHealth('error');
console.error('NAS auth error:', err.message);
} finally {
setLoading(false);
}
};
const handleConnect = () => {
if (serviceType === 'GOOGLE_PHOTOS') return connectGoogle();
if (serviceType === 'ICLOUD_PHOTOS') return connectIcloud();
if (serviceType === 'NAS') return connectNas();
};
// ─── Render ──────────────────────────────────────────────
return (
<View style={[styles.card, { backgroundColor: theme.card, borderColor: theme.border }]}>
{/* Header row */}
<View style={styles.header}>
<ServiceHealthIndicator status={health} size={10} />
<Text style={styles.icon}>{meta.icon}</Text>
<View style={styles.headerInfo}>
<Text style={[styles.name, { color: theme.text }]}>{meta.name}</Text>
<Text style={[styles.desc, { color: theme.textSecondary }]}>{meta.desc}</Text>
</View>
{connected && (
<View style={[styles.badge, { backgroundColor: '#1e7a3e22' }]}>
<Text style={[styles.badgeText, { color: '#1e7a3e' }]}>✓ Connected</Text>
</View>
)}
</View>
{/* NAS setup instructions (collapsible, shown when form is visible) */}
{serviceType === 'NAS' && showForm && <NASSetupInstructions />}
{/* Connection form */}
{showForm && (
<View style={styles.form}>
{serviceType === 'NAS' && (
<>
<Text style={[styles.label, { color: theme.textSecondary }]}>Server Address</Text>
<TextInput
style={[styles.input, { backgroundColor: theme.inputBg, borderColor: health === 'error' && !nasHost.trim() ? '#e53935' : theme.border, color: theme.text }]}
placeholder="192.168.1.100"
placeholderTextColor={theme.textTertiary}
value={nasHost}
onChangeText={setNasHost}
autoCapitalize="none"
editable={!loading}
/>
<Text style={[styles.label, { color: theme.textSecondary }]}>Port</Text>
<TextInput
style={[styles.input, { backgroundColor: theme.inputBg, borderColor: theme.border, color: theme.text }]}
placeholder="9500"
placeholderTextColor={theme.textTertiary}
value={nasPort}
onChangeText={setNasPort}
keyboardType="numeric"
editable={!loading}
/>
<Text style={[styles.label, { color: theme.textSecondary }]}>Username</Text>
<TextInput
style={[styles.input, { backgroundColor: theme.inputBg, borderColor: health === 'error' && !nasUsername.trim() ? '#e53935' : theme.border, color: theme.text }]}
placeholder="Your username"
placeholderTextColor={theme.textTertiary}
value={nasUsername}
onChangeText={setNasUsername}
autoCapitalize="none"
editable={!loading}
/>
</>
)}
{serviceType === 'ICLOUD_PHOTOS' && (
<Text style={[styles.infoText, { color: theme.textSecondary }]}>
Full iCloud integration is coming soon. Tap below to register as a placeholder.
</Text>
)}
<TouchableOpacity
style={[styles.connectBtn, { backgroundColor: meta.color }, loading && styles.btnDisabled]}
onPress={handleConnect}
disabled={loading}
>
{loading
? <ActivityIndicator color="#fff" />
: <Text style={styles.connectBtnText}>
{serviceType === 'NAS' ? 'Test & Connect' : `Sign in with ${meta.name}`}
</Text>
}
</TouchableOpacity>
{health === 'error' && (
<Text style={styles.errorText}>
{serviceType === 'NAS' && (!nasHost.trim() || !nasUsername.trim())
? 'Please fill in all required fields.'
: 'Connection failed. Check your details and try again.'}
</Text>
)}
</View>
)}
{/* Action buttons when already connected */}
{connected && !showForm && (
<View style={styles.actions}>
<TouchableOpacity
style={[styles.actionBtn, { borderColor: theme.primary }]}
onPress={() => setShowForm(true)}
disabled={loading}
>
<Text style={[styles.actionBtnText, { color: theme.primary }]}>
{serviceType === 'NAS' ? 'Edit' : 'Re-authenticate'}
</Text>
</TouchableOpacity>
{showRemove && (
<TouchableOpacity
style={[styles.actionBtn, { borderColor: '#e53935' }]}
onPress={onRemoved}
disabled={loading}
>
<Text style={[styles.actionBtnText, { color: '#e53935' }]}>Remove</Text>
</TouchableOpacity>
)}
</View>
)}
</View>
);
};
const styles = StyleSheet.create({
card: {
borderRadius: 12, borderWidth: 1, marginBottom: 10, overflow: 'hidden',
},
header: {
flexDirection: 'row', alignItems: 'center', padding: 14, gap: 8,
},
icon: { fontSize: 22 },
headerInfo: { flex: 1 },
name: { fontSize: 14, fontWeight: '600', marginBottom: 2 },
desc: { fontSize: 12 },
badge: {
borderRadius: 6, paddingHorizontal: 8, paddingVertical: 3,
},
badgeText: { fontSize: 11, fontWeight: '600' },
form: {
paddingHorizontal: 14, paddingBottom: 14,
},
label: {
fontSize: 12, fontWeight: '600', marginBottom: 5, marginTop: 10,
},
input: {
borderWidth: 1, borderRadius: 8, paddingHorizontal: 12, paddingVertical: 10,
fontSize: 14,
},
infoText: {
fontSize: 13, lineHeight: 18, marginTop: 8, marginBottom: 4,
},
connectBtn: {
borderRadius: 8, paddingVertical: 12, alignItems: 'center', marginTop: 14,
},
connectBtnText: { color: '#fff', fontSize: 14, fontWeight: '600' },
btnDisabled: { opacity: 0.6 },
errorText: {
color: '#e53935', fontSize: 12, marginTop: 6, textAlign: 'center',
},
actions: {
flexDirection: 'row', paddingHorizontal: 14, paddingBottom: 12, gap: 8,
},
actionBtn: {
borderWidth: 1, borderRadius: 6, paddingHorizontal: 14, paddingVertical: 6,
},
actionBtnText: { fontSize: 13, fontWeight: '600' },
});
export default ServiceConnectCard;
+58 -3
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback } from 'react';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import {
StyleSheet, View, Text, ActivityIndicator, RefreshControl, Alert,
} from 'react-native';
@@ -90,6 +90,9 @@ const HomeScreen = ({ navigation, route }) => {
const [actionSheetVisible, setActionSheetVisible] = useState(false);
const [actionSheetTarget, setActionSheetTarget] = useState(null);
// Cache of nas_photo_id → data: URI so we only fetch each image once
const nasImageCache = useRef({});
useFocusEffect(
useCallback(() => {
if (userId) loadUserData();
@@ -314,6 +317,58 @@ const HomeScreen = ({ navigation, route }) => {
}
};
// ─── NAS image lazy-loader ───
const fetchNasImage = async (item) => {
if (!item.nas_photo_id) return item;
// Return cached version immediately
if (nasImageCache.current[item.nas_photo_id]) {
return { ...item, local_path: nasImageCache.current[item.nas_photo_id] };
}
const nasAccount = linkedAccounts.find((a) => a.service_type === 'NAS');
if (!nasAccount) return item;
try {
const nasData = nasAccount.account_data;
const nasAuth = new NASAUTH();
await nasAuth.authenticate(nasData.host, nasData.port, nasData.username);
const photo = await nasAuth.fetchPhoto(item.nas_photo_id);
await nasAuth.revokeAccess();
if (photo?.base64) {
const mimeType = photo.mimeType || item.mimeType || 'image/jpeg';
const dataUri = `data:${mimeType};base64,${photo.base64}`;
nasImageCache.current[item.nas_photo_id] = dataUri;
// Update the item in allMedia state so the grid thumbnail also updates
setAllMedia((prev) =>
prev.map((m) =>
m.nas_photo_id === item.nas_photo_id ? { ...m, local_path: dataUri } : m
)
);
return { ...item, local_path: dataUri };
}
} catch (err) {
console.error('NAS image fetch error:', err.message);
}
return item;
};
const handleMediaPress = async (item) => {
if (item.nas_photo_id && !item.local_path) {
// Show viewer immediately with placeholder, then update when loaded
setSelectedMedia(item);
setViewerVisible(true);
const loaded = await fetchNasImage(item);
setSelectedMedia(loaded);
} else {
setSelectedMedia(item);
setViewerVisible(true);
}
};
// ─── Photo actions ───
const handleDeletePhoto = (item) => {
@@ -378,7 +433,7 @@ const HomeScreen = ({ navigation, route }) => {
const getActionSheetActions = () => {
if (!actionSheetTarget) return [];
const actions = [
{ label: 'View Details', icon: 'ℹ️', onPress: () => { setSelectedMedia(actionSheetTarget); setViewerVisible(true); } },
{ label: 'View Details', icon: 'ℹ️', onPress: () => { handleMediaPress(actionSheetTarget); } },
];
if (actionSheetTarget.nas_photo_id || actionSheetTarget.source_service === 'NAS') {
actions.push({ label: 'Delete from NAS', icon: '🗑️', destructive: true, onPress: () => handleDeletePhoto(actionSheetTarget) });
@@ -455,7 +510,7 @@ const HomeScreen = ({ navigation, route }) => {
) : (
<PhotoGrid
data={filteredMedia}
onItemPress={(item) => { setSelectedMedia(item); setViewerVisible(true); }}
onItemPress={(item) => handleMediaPress(item)}
onItemLongPress={handleLongPress}
onEndReached={loadMorePhotos}
refreshControl={
+30 -184
View File
@@ -1,14 +1,11 @@
import React, { useState } from 'react';
import {
StyleSheet, View, Text, ScrollView, TouchableOpacity, Alert,
ActivityIndicator, Switch, TextInput,
ActivityIndicator, Switch,
} from 'react-native';
import { useTheme } from '../services/ThemeContext';
import DatabaseService from '../services/DatabaseService';
import GoogleAUTH from '../AUTH/GoogleAUTH';
import NASAUTH from '../AUTH/NASAUTH';
import NASSetupInstructions from '../components/NASSetupInstructions';
import ServiceHealthIndicator from '../components/ServiceHealthIndicator';
import ServiceConnectCard from '../components/ServiceConnectCard';
const OnboardingScreen = ({ navigation, route }) => {
const { userId } = route.params;
@@ -16,27 +13,17 @@ const OnboardingScreen = ({ navigation, route }) => {
const [currentStep, setCurrentStep] = useState(0);
const [loading, setLoading] = useState(false);
// Service toggles
// Step 0 — service toggles
const [googleEnabled, setGoogleEnabled] = useState(false);
const [iosEnabled, setIosEnabled] = useState(false);
const [nasEnabled, setNasEnabled] = useState(false);
// Service connection status
// Step 1 — track which services completed connection via ServiceConnectCard callbacks
const [googleConnected, setGoogleConnected] = useState(false);
const [iosConnected, setIosConnected] = useState(false);
const [nasConnected, setNasConnected] = useState(false);
// Service health: 'inactive' | 'healthy' | 'error' | 'checking'
const [googleHealth, setGoogleHealth] = useState('inactive');
const [iosHealth, setIosHealth] = useState('inactive');
const [nasHealth, setNasHealth] = useState('inactive');
// NAS config
const [nasHost, setNasHost] = useState('');
const [nasPort, setNasPort] = useState('9500');
const [nasUsername, setNasUsername] = useState('');
// Auto-backup checklist (multi-select)
// Step 2 — auto-backup checklist
const [autoBackupServices, setAutoBackupServices] = useState([]);
const steps = [
@@ -53,82 +40,6 @@ const OnboardingScreen = ({ navigation, route }) => {
return services;
};
const handleGoogleAuth = async () => {
try {
setLoading(true);
setGoogleHealth('checking');
const googleAuth = new GoogleAUTH();
const result = await googleAuth.authenticate();
await DatabaseService.linkAccount(userId, 'GOOGLE_PHOTOS', {
accessToken: result.accessToken,
refreshToken: result.refreshToken || null,
expiresAt: googleAuth.expiresAt || Date.now() + 3600000,
email: result.email || 'google-user',
});
setGoogleConnected(true);
setGoogleHealth('healthy');
Alert.alert('Success', 'Google Photos connected!');
} catch (error) {
console.error('Google auth error:', error);
setGoogleHealth('error');
Alert.alert('Error', 'Failed to connect Google Photos: ' + error.message);
} finally {
setLoading(false);
}
};
const handleIosAuth = async () => {
try {
setLoading(true);
setIosHealth('checking');
await DatabaseService.linkAccount(userId, 'ICLOUD_PHOTOS', {
status: 'placeholder',
note: 'iCloud integration pending Apple API access',
});
setIosConnected(true);
setIosHealth('healthy');
Alert.alert('Info', 'iCloud Photos registered. Full integration coming soon.');
} catch (error) {
console.error('iCloud auth error:', error);
setIosHealth('error');
Alert.alert('Error', 'Failed to set up iCloud: ' + error.message);
} finally {
setLoading(false);
}
};
const handleNasAuth = async () => {
if (!nasHost.trim() || !nasUsername.trim()) {
Alert.alert('Error', 'Please fill in the NAS host and username');
return;
}
try {
setLoading(true);
setNasHealth('checking');
const nasAuth = new NASAUTH();
await nasAuth.authenticate(
nasHost.trim(), parseInt(nasPort) || 9500,
nasUsername.trim()
);
await DatabaseService.linkAccount(userId, 'NAS', {
host: nasHost.trim(),
port: parseInt(nasPort) || 9500,
username: nasUsername.trim(),
});
setNasConnected(true);
setNasHealth('healthy');
Alert.alert('Success', 'NAS connected!');
} catch (error) {
console.error('NAS auth error:', error);
setNasHealth('error');
Alert.alert('Error', 'Failed to connect NAS: ' + error.message);
} finally {
setLoading(false);
}
};
const toggleAutoBackup = (serviceType) => {
setAutoBackupServices((prev) =>
prev.includes(serviceType) ? prev.filter((s) => s !== serviceType) : [...prev, serviceType]
@@ -137,8 +48,10 @@ const OnboardingScreen = ({ navigation, route }) => {
const handleNext = async () => {
if (currentStep === 0) {
const any = googleEnabled || iosEnabled || nasEnabled;
if (!any) { Alert.alert('Error', 'Please select at least one service'); return; }
if (!googleEnabled && !iosEnabled && !nasEnabled) {
Alert.alert('Error', 'Please select at least one service');
return;
}
setCurrentStep(1);
} else if (currentStep === 1) {
const anyConnected =
@@ -188,84 +101,35 @@ const OnboardingScreen = ({ navigation, route }) => {
<ServiceToggleCard theme={theme} icon="💾" name="NAS Storage"
description="Connect to your NAS photo server (REST API)"
enabled={nasEnabled} onToggle={() => setNasEnabled(!nasEnabled)} />
{/* NAS setup instructions dropdown */}
{nasEnabled && <NASSetupInstructions />}
</View>
)}
{/* Step 1: Configure each service */}
{/* Step 1: Configure each selected service via shared ServiceConnectCard */}
{currentStep === 1 && (
<View style={styles.cardsContainer}>
{googleEnabled && (
<View style={[styles.configCard, { backgroundColor: theme.card, borderColor: theme.border }]}>
<View style={styles.configHeader}>
<ServiceHealthIndicator status={googleHealth} size={10} />
<Text style={styles.configIcon}>📷</Text>
<Text style={[styles.configTitle, { color: theme.text }]}>Google Photos</Text>
{googleConnected && <Text style={[styles.connectedBadge, { color: theme.accent }]}>✓ Connected</Text>}
</View>
{!googleConnected && (
<TouchableOpacity
style={[styles.authButton, { backgroundColor: theme.primary }, loading && styles.buttonDisabled]}
onPress={handleGoogleAuth} disabled={loading}
>
{loading ? <ActivityIndicator color="#fff" /> : <Text style={styles.authButtonText}>Sign in with Google</Text>}
</TouchableOpacity>
)}
</View>
<ServiceConnectCard
serviceType="GOOGLE_PHOTOS"
userId={userId}
isLinked={googleConnected}
onLinked={() => setGoogleConnected(true)}
/>
)}
{iosEnabled && (
<View style={[styles.configCard, { backgroundColor: theme.card, borderColor: theme.border }]}>
<View style={styles.configHeader}>
<ServiceHealthIndicator status={iosHealth} size={10} />
<Text style={styles.configIcon}>🍎</Text>
<Text style={[styles.configTitle, { color: theme.text }]}>iCloud Photos</Text>
{iosConnected && <Text style={[styles.connectedBadge, { color: theme.accent }]}>✓ Connected</Text>}
</View>
{!iosConnected && (
<TouchableOpacity
style={[styles.authButton, { backgroundColor: '#333' }, loading && styles.buttonDisabled]}
onPress={handleIosAuth} disabled={loading}
>
{loading ? <ActivityIndicator color="#fff" /> : <Text style={styles.authButtonText}>Connect iCloud</Text>}
</TouchableOpacity>
)}
</View>
<ServiceConnectCard
serviceType="ICLOUD_PHOTOS"
userId={userId}
isLinked={iosConnected}
onLinked={() => setIosConnected(true)}
/>
)}
{nasEnabled && (
<View style={[styles.configCard, { backgroundColor: theme.card, borderColor: theme.border }]}>
<View style={styles.configHeader}>
<ServiceHealthIndicator status={nasHealth} size={10} />
<Text style={styles.configIcon}>💾</Text>
<Text style={[styles.configTitle, { color: theme.text }]}>NAS Storage</Text>
{nasConnected && <Text style={[styles.connectedBadge, { color: theme.accent }]}>✓ Connected</Text>}
</View>
{!nasConnected && (
<>
<Text style={[styles.fieldLabel, { color: theme.textSecondary }]}>Server Address</Text>
<TextInput style={[styles.fieldInput, { backgroundColor: theme.inputBg, borderColor: theme.border, color: theme.text }]}
placeholder="192.168.1.100" value={nasHost} onChangeText={setNasHost} editable={!loading} placeholderTextColor={theme.textTertiary} />
<Text style={[styles.fieldLabel, { color: theme.textSecondary }]}>Port</Text>
<TextInput style={[styles.fieldInput, { backgroundColor: theme.inputBg, borderColor: theme.border, color: theme.text }]}
placeholder="9500" value={nasPort} onChangeText={setNasPort} editable={!loading} keyboardType="numeric" placeholderTextColor={theme.textTertiary} />
<Text style={[styles.fieldLabel, { color: theme.textSecondary }]}>Username</Text>
<TextInput style={[styles.fieldInput, { backgroundColor: theme.inputBg, borderColor: theme.border, color: theme.text }]}
placeholder="Your username" value={nasUsername} onChangeText={setNasUsername} editable={!loading} autoCapitalize="none" placeholderTextColor={theme.textTertiary} />
<TouchableOpacity
style={[styles.authButton, { backgroundColor: theme.accent }, loading && styles.buttonDisabled]}
onPress={handleNasAuth} disabled={loading}
>
{loading ? <ActivityIndicator color="#fff" /> : <Text style={styles.authButtonText}>Test & Connect</Text>}
</TouchableOpacity>
</>
)}
</View>
<ServiceConnectCard
serviceType="NAS"
userId={userId}
isLinked={nasConnected}
onLinked={() => setNasConnected(true)}
/>
)}
</View>
)}
@@ -277,7 +141,6 @@ const OnboardingScreen = ({ navigation, route }) => {
<Text style={[styles.checklistInfo, { color: theme.textSecondary }]}>
Select which services new photos should automatically upload to. You can select multiple or none.
</Text>
{getConnectedServices().map((svc) => (
<TouchableOpacity
key={svc.type}
@@ -286,8 +149,7 @@ const OnboardingScreen = ({ navigation, route }) => {
activeOpacity={0.7}
>
<View style={[
styles.checkbox,
{ borderColor: theme.primary },
styles.checkbox, { borderColor: theme.primary },
autoBackupServices.includes(svc.type) && { backgroundColor: theme.primary },
]}>
{autoBackupServices.includes(svc.type) && <Text style={styles.checkmark}>✓</Text>}
@@ -296,15 +158,13 @@ const OnboardingScreen = ({ navigation, route }) => {
<Text style={[styles.checkLabel, { color: theme.text }]}>{svc.name}</Text>
</TouchableOpacity>
))}
<TouchableOpacity
style={[styles.checkRow, { borderBottomWidth: 0 }]}
onPress={() => setAutoBackupServices([])}
activeOpacity={0.7}
>
<View style={[
styles.checkbox,
{ borderColor: theme.border },
styles.checkbox, { borderColor: theme.border },
autoBackupServices.length === 0 && { backgroundColor: theme.primary, borderColor: theme.primary },
]}>
{autoBackupServices.length === 0 && <Text style={styles.checkmark}>✓</Text>}
@@ -372,7 +232,6 @@ const styles = StyleSheet.create({
title: { fontSize: 26, fontWeight: '700', marginBottom: 6 },
description: { fontSize: 14 },
cardsContainer: { gap: 12 },
// Toggle cards (Step 0)
toggleCard: {
borderRadius: 12, padding: 16, flexDirection: 'row',
justifyContent: 'space-between', alignItems: 'center', borderWidth: 1.5,
@@ -382,19 +241,7 @@ const styles = StyleSheet.create({
toggleInfo: { flex: 1 },
toggleName: { fontSize: 15, fontWeight: '600', marginBottom: 2 },
toggleDescription: { fontSize: 12 },
// Config cards (Step 1)
configCard: { borderRadius: 12, padding: 16, borderWidth: 1 },
configHeader: { flexDirection: 'row', alignItems: 'center', marginBottom: 12 },
configIcon: { fontSize: 24, marginRight: 10 },
configTitle: { fontSize: 16, fontWeight: '600', flex: 1 },
connectedBadge: { fontSize: 13, fontWeight: '600' },
authButton: { borderRadius: 8, paddingVertical: 12, alignItems: 'center', marginTop: 8 },
authButtonText: { color: '#fff', fontSize: 14, fontWeight: '600' },
fieldLabel: { fontSize: 12, fontWeight: '600', marginTop: 10, marginBottom: 4 },
fieldInput: {
borderWidth: 1, borderRadius: 8, paddingHorizontal: 12, paddingVertical: 10, fontSize: 14,
},
// Auto-backup (Step 2)
checklistInfo: { fontSize: 13, marginBottom: 8 },
checkRow: {
flexDirection: 'row', alignItems: 'center', paddingVertical: 14,
@@ -407,7 +254,6 @@ const styles = StyleSheet.create({
checkmark: { color: '#fff', fontSize: 14, fontWeight: '700' },
checkIcon: { fontSize: 18, marginRight: 8 },
checkLabel: { fontSize: 14, fontWeight: '500', flex: 1 },
// Footer
footer: {
flexDirection: 'row', paddingHorizontal: 20, paddingVertical: 14,
borderTopWidth: 1, gap: 12,
+127 -275
View File
@@ -1,33 +1,32 @@
import React, { useState, useCallback } from 'react';
import {
StyleSheet, View, Text, ScrollView, TouchableOpacity, Switch, TextInput,
Alert, ActivityIndicator,
StyleSheet, View, Text, ScrollView, TouchableOpacity, Switch, Alert, ActivityIndicator,
} from 'react-native';
import { useFocusEffect } from '@react-navigation/native';
import { useTheme } from '../services/ThemeContext';
import DatabaseService from '../services/DatabaseService';
import NASSetupInstructions from '../components/NASSetupInstructions';
import ServiceConnectCard from '../components/ServiceConnectCard';
import ServiceHealthIndicator from '../components/ServiceHealthIndicator';
import NASAUTH from '../AUTH/NASAUTH';
import GoogleAUTH from '../AUTH/GoogleAUTH';
import { getServiceIcon, getServiceName } from '../UI/ServiceHelpers';
// All services the app supports — add new ones here and they appear everywhere automatically
const ALL_SERVICES = [
{ type: 'GOOGLE_PHOTOS', icon: '📷', name: 'Google Photos', desc: 'Access your Google Photos library' },
{ type: 'ICLOUD_PHOTOS', icon: '🍎', name: 'iCloud Photos', desc: 'Access your iCloud photo library' },
{ type: 'NAS', icon: '💾', name: 'NAS Storage', desc: 'Connect to your NAS photo server' },
];
const SettingsScreen = ({ navigation, route }) => {
const userId = route?.params?.userId;
const { theme, themeName, setThemeName } = useTheme();
const [loading, setLoading] = useState(false);
const [accounts, setAccounts] = useState([]);
const [autoBackup, setAutoBackup] = useState([]);
const [nasOffload, setNasOffload] = useState(false);
// Per-service health: { GOOGLE_PHOTOS: 'healthy'|'error'|'inactive', ... }
const [serviceHealth, setServiceHealth] = useState({});
// NAS edit fields
const [editingNas, setEditingNas] = useState(false);
const [nasHost, setNasHost] = useState('');
const [nasPort, setNasPort] = useState('9500');
const [nasUsername, setNasUsername] = useState('');
// Track a reload token so ServiceConnectCards re-read existingData after linking
const [reloadKey, setReloadKey] = useState(0);
useFocusEffect(
useCallback(() => {
@@ -41,41 +40,28 @@ const SettingsScreen = ({ navigation, route }) => {
setAccounts(allAccounts);
const backup = await DatabaseService.getAutoBackupServices(userId);
setAutoBackup(backup);
const nasAcc = allAccounts.find((a) => a.service_type === 'NAS');
if (nasAcc?.account_data) {
setNasHost(nasAcc.account_data.host || '');
setNasPort(String(nasAcc.account_data.port || 9500));
setNasUsername(nasAcc.account_data.username || '');
}
// Check health for each enabled account
checkAllServiceHealth(allAccounts);
} catch (err) {
console.error('Error loading settings:', err);
}
};
// ─── Health checks ─────────────────────────────────────────
// ─── Health checks ───────────────────────────────────────
const checkAllServiceHealth = async (accs) => {
const health = {};
for (const acc of accs) {
if (!acc.is_enabled) {
health[acc.service_type] = 'inactive';
setServiceHealth((prev) => ({ ...prev, [acc.service_type]: 'inactive' }));
continue;
}
health[acc.service_type] = 'checking';
setServiceHealth((prev) => ({ ...prev, [acc.service_type]: 'checking' }));
health[acc.service_type] = await checkSingleHealth(acc);
const status = await checkSingleHealth(acc);
setServiceHealth((prev) => ({ ...prev, [acc.service_type]: status }));
}
setServiceHealth(health);
};
const checkSingleHealth = async (acc) => {
try {
if (acc.service_type === 'GOOGLE_PHOTOS') {
// The Photos Library REST API is deprecated; health-check the token
// by calling the userinfo endpoint instead.
const gAuth = new GoogleAUTH();
gAuth.accessToken = acc.account_data?.accessToken;
gAuth.refreshToken = acc.account_data?.refreshToken;
@@ -91,23 +77,14 @@ const SettingsScreen = ({ navigation, route }) => {
await nasAuth.revokeAccess();
return healthy ? 'healthy' : 'error';
}
if (acc.service_type === 'ICLOUD_PHOTOS') {
// Placeholder – always "healthy" since it's a stub
return 'healthy';
}
if (acc.service_type === 'ICLOUD_PHOTOS') return 'healthy';
return 'inactive';
} catch {
return 'error';
}
};
const recheckHealth = async (acc) => {
setServiceHealth((prev) => ({ ...prev, [acc.service_type]: 'checking' }));
const status = await checkSingleHealth(acc);
setServiceHealth((prev) => ({ ...prev, [acc.service_type]: status }));
};
// ─── Service actions ───────────────────────────────────────
// ─── Service actions ─────────────────────────────────────
const toggleService = async (accountId, currentEnabled) => {
try {
await DatabaseService.toggleAccountEnabled(accountId, !currentEnabled);
@@ -119,23 +96,17 @@ const SettingsScreen = ({ navigation, route }) => {
const deleteAccount = (acc) => {
Alert.alert(
'Delete Service',
`Remove ${getServiceName(acc.service_type)} from your account? This cannot be undone.`,
'Remove Service',
`Remove ${getServiceName(acc.service_type)} from your account?`,
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete',
style: 'destructive',
text: 'Remove', style: 'destructive',
onPress: async () => {
try {
await DatabaseService.removeLinkedAccount(acc.id);
setServiceHealth((prev) => {
const next = { ...prev };
delete next[acc.service_type];
return next;
});
setServiceHealth((prev) => { const n = { ...prev }; delete n[acc.service_type]; return n; });
await loadData();
Alert.alert('Deleted', `${getServiceName(acc.service_type)} has been removed.`);
} catch {
Alert.alert('Error', 'Failed to remove service');
}
@@ -145,75 +116,12 @@ const SettingsScreen = ({ navigation, route }) => {
);
};
const reauthenticateService = async (acc) => {
setLoading(true);
try {
if (acc.service_type === 'GOOGLE_PHOTOS') {
const gAuth = new GoogleAUTH();
const result = await gAuth.authenticate();
await DatabaseService.linkAccount(userId, 'GOOGLE_PHOTOS', {
accessToken: result.accessToken,
refreshToken: result.refreshToken || null,
expiresAt: gAuth.expiresAt || Date.now() + 3600000,
email: result.email || 'google-user',
});
Alert.alert('Success', 'Google Photos re-authenticated!');
} else if (acc.service_type === 'ICLOUD_PHOTOS') {
await DatabaseService.linkAccount(userId, 'ICLOUD_PHOTOS', {
status: 'placeholder',
note: 'iCloud integration pending Apple API access',
});
Alert.alert('Info', 'iCloud Photos re-registered (placeholder).');
} else if (acc.service_type === 'NAS') {
setEditingNas(true);
setLoading(false);
return; // user edits inline
}
await loadData();
} catch (error) {
Alert.alert('Error', 'Re-authentication failed: ' + error.message);
} finally {
setLoading(false);
}
};
const toggleAutoBackup = async (serviceType) => {
const updated = autoBackup.includes(serviceType)
? autoBackup.filter((s) => s !== serviceType)
: [...autoBackup, serviceType];
setAutoBackup(updated);
try {
await DatabaseService.setAutoBackupServices(userId, updated);
} catch (err) {
console.error('Error saving auto backup:', err);
}
};
const saveNasConfig = async () => {
if (!nasHost.trim() || !nasUsername.trim()) {
Alert.alert('Error', 'Please fill in the NAS host and username');
return;
}
setLoading(true);
try {
const nasAuth = new NASAUTH();
await nasAuth.authenticate(
nasHost.trim(), parseInt(nasPort) || 9500,
nasUsername.trim()
);
await DatabaseService.linkAccount(userId, 'NAS', {
host: nasHost.trim(),
port: parseInt(nasPort) || 9500,
username: nasUsername.trim(),
});
setEditingNas(false);
await loadData();
Alert.alert('Success', 'NAS configuration updated');
} catch (err) {
Alert.alert('Error', 'Failed to update NAS: ' + err.message);
} finally {
setLoading(false);
}
try { await DatabaseService.setAutoBackupServices(userId, updated); } catch (err) { console.error(err); }
};
const handleLogout = () => {
@@ -224,8 +132,8 @@ const SettingsScreen = ({ navigation, route }) => {
};
const themeOptions = [
{ key: 'light', label: 'Light', icon: '☀️', desc: 'Standard light mode' },
{ key: 'dark', label: 'Dark', icon: '🌙', desc: 'Dark mode for low light' },
{ key: 'light', label: 'Light', icon: '☀️', desc: 'Standard light mode' },
{ key: 'dark', label: 'Dark', icon: '🌙', desc: 'Dark mode for low light' },
{ key: 'liquidGlass', label: 'Liquid Glass', icon: '💎', desc: 'iOS-style frosted glass' },
];
@@ -242,17 +150,14 @@ const SettingsScreen = ({ navigation, route }) => {
</View>
<ScrollView style={styles.scrollContent} showsVerticalScrollIndicator={false}>
{/* ── Theme ──────────────────────────────────────── */}
{/* ── Appearance ───────────────────────────────── */}
<Text style={[styles.sectionTitle, { color: theme.textSecondary }]}>APPEARANCE</Text>
<View style={[styles.card, { backgroundColor: theme.card, borderColor: theme.border }]}>
{themeOptions.map((opt) => (
<TouchableOpacity
key={opt.key}
style={[
styles.themeRow,
themeName === opt.key && { backgroundColor: theme.cardActive },
{ borderBottomColor: theme.borderLight },
]}
style={[styles.themeRow, themeName === opt.key && { backgroundColor: theme.cardActive }, { borderBottomColor: theme.borderLight }]}
onPress={() => setThemeName(opt.key)}
>
<Text style={styles.themeIcon}>{opt.icon}</Text>
@@ -265,105 +170,87 @@ const SettingsScreen = ({ navigation, route }) => {
))}
</View>
{/* ── Services ──────────────────────────────────── */}
{/* ── Services ─────────────────────────────────── */}
{/* Always shows ALL services — connected ones get a manage strip, unconnected show ServiceConnectCard */}
<Text style={[styles.sectionTitle, { color: theme.textSecondary }]}>SERVICES</Text>
<View style={[styles.card, { backgroundColor: theme.card, borderColor: theme.border }]}>
{accounts.length === 0 ? (
<View style={styles.emptyRow}>
<Text style={[styles.emptyText, { color: theme.textSecondary }]}>
No services connected. Go to onboarding to add services.
</Text>
</View>
) : (
accounts.map((acc) => {
const health = serviceHealth[acc.service_type] || (acc.is_enabled ? 'checking' : 'inactive');
return (
<View key={acc.id}>
{/* Main row */}
<View style={[styles.serviceRow, { borderBottomColor: theme.borderLight }]}>
<TouchableOpacity onPress={() => recheckHealth(acc)} activeOpacity={0.6}>
<ServiceHealthIndicator status={health} size={10} />
</TouchableOpacity>
<Text style={styles.serviceIcon}>{getServiceIcon(acc.service_type)}</Text>
<View style={styles.serviceInfo}>
<Text style={[styles.serviceName, { color: theme.text }]}>
{getServiceName(acc.service_type)}
</Text>
<Text style={[styles.serviceStatus, { color: acc.is_enabled ? theme.accent : theme.textTertiary }]}>
{acc.is_enabled ? 'Enabled' : 'Disabled'}
</Text>
</View>
<Switch
value={!!acc.is_enabled}
onValueChange={() => toggleService(acc.id, acc.is_enabled)}
trackColor={{ false: theme.border, true: '#a8dab5' }}
thumbColor={acc.is_enabled ? theme.accent : '#f4f3f4'}
/>
</View>
{/* Action buttons row */}
<View style={[styles.serviceActions, { borderBottomColor: theme.borderLight }]}>
{acc.service_type === 'NAS' && acc.is_enabled && (
<TouchableOpacity
style={[styles.actionBtn, { borderColor: theme.border }]}
onPress={() => setEditingNas(!editingNas)}
>
<Text style={[styles.actionBtnText, { color: theme.primary }]}>Edit</Text>
</TouchableOpacity>
)}
<TouchableOpacity
style={[styles.actionBtn, { borderColor: theme.border }]}
onPress={() => reauthenticateService(acc)}
disabled={loading}
>
<Text style={[styles.actionBtnText, { color: theme.primary }]}>
{loading ? '...' : 'Re-auth'}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.actionBtn, { borderColor: theme.danger }]}
onPress={() => deleteAccount(acc)}
>
<Text style={[styles.actionBtnText, { color: theme.danger }]}>Delete</Text>
</TouchableOpacity>
<Text style={[styles.sectionHint, { color: theme.textTertiary }]}>
Connect new services or manage existing ones at any time.
</Text>
{ALL_SERVICES.map((svc) => {
const acc = accounts.find((a) => a.service_type === svc.type);
const health = serviceHealth[svc.type] || (acc?.is_enabled ? 'checking' : 'inactive');
if (acc) {
// Already linked — show management card with enable/disable + remove + re-auth via ServiceConnectCard
return (
<View key={svc.type} style={[styles.managedCard, { backgroundColor: theme.card, borderColor: theme.border }]}>
{/* Top row: health, icon, name, enable toggle */}
<View style={styles.managedHeader}>
<TouchableOpacity onPress={() => checkSingleHealth(acc).then((s) => setServiceHealth((p) => ({ ...p, [svc.type]: s })))}>
<ServiceHealthIndicator status={health} size={10} />
</TouchableOpacity>
<Text style={styles.managedIcon}>{svc.icon}</Text>
<View style={styles.managedInfo}>
<Text style={[styles.managedName, { color: theme.text }]}>{svc.name}</Text>
<Text style={[styles.managedStatus, { color: acc.is_enabled ? theme.accent : theme.textTertiary }]}>
{acc.is_enabled ? 'Enabled' : 'Disabled'}
</Text>
</View>
<Switch
value={!!acc.is_enabled}
onValueChange={() => toggleService(acc.id, acc.is_enabled)}
trackColor={{ false: theme.border, true: '#a8dab5' }}
thumbColor={acc.is_enabled ? theme.accent : '#f4f3f4'}
/>
</View>
);
})
)}
</View>
{/* NAS Edit Config */}
{editingNas && (
<View style={[styles.card, { backgroundColor: theme.card, borderColor: theme.border }]}>
<Text style={[styles.cardTitle, { color: theme.text }]}>Edit NAS Configuration</Text>
<Text style={[styles.fieldLabel, { color: theme.textSecondary }]}>Server Address</Text>
<TextInput style={[styles.fieldInput, { backgroundColor: theme.inputBg, borderColor: theme.border, color: theme.text }]}
value={nasHost} onChangeText={setNasHost} placeholder="192.168.1.100" placeholderTextColor={theme.textTertiary} />
<Text style={[styles.fieldLabel, { color: theme.textSecondary }]}>Port</Text>
<TextInput style={[styles.fieldInput, { backgroundColor: theme.inputBg, borderColor: theme.border, color: theme.text }]}
value={nasPort} onChangeText={setNasPort} keyboardType="numeric" placeholder="9500" placeholderTextColor={theme.textTertiary} />
<Text style={[styles.fieldLabel, { color: theme.textSecondary }]}>Username</Text>
<TextInput style={[styles.fieldInput, { backgroundColor: theme.inputBg, borderColor: theme.border, color: theme.text }]}
value={nasUsername} onChangeText={setNasUsername} autoCapitalize="none" placeholder="Your username" placeholderTextColor={theme.textTertiary} />
<TouchableOpacity style={[styles.saveButton, { backgroundColor: theme.primary }]} onPress={saveNasConfig} disabled={loading}>
{loading ? <ActivityIndicator color="#fff" /> : <Text style={styles.saveButtonText}>Save Configuration</Text>}
</TouchableOpacity>
</View>
)}
{/* Re-auth / edit via shared ServiceConnectCard (starts collapsed since already linked) */}
<ServiceConnectCard
key={`${svc.type}-${reloadKey}`}
serviceType={svc.type}
userId={userId}
existingData={acc.account_data}
isLinked={true}
showRemove={true}
onLinked={() => { setReloadKey((k) => k + 1); loadData(); }}
onRemoved={() => deleteAccount(acc)}
/>
</View>
);
}
{/* NAS Setup Instructions */}
<NASSetupInstructions />
// Not yet linked — show ServiceConnectCard directly so user can add it
return (
<View key={svc.type}>
<View style={[styles.notLinkedHeader, { borderColor: theme.border }]}>
<Text style={styles.managedIcon}>{svc.icon}</Text>
<View style={styles.managedInfo}>
<Text style={[styles.managedName, { color: theme.text }]}>{svc.name}</Text>
<Text style={[styles.managedStatus, { color: theme.textTertiary }]}>Not connected</Text>
</View>
</View>
<ServiceConnectCard
key={`${svc.type}-${reloadKey}`}
serviceType={svc.type}
userId={userId}
isLinked={false}
onLinked={() => { setReloadKey((k) => k + 1); loadData(); }}
/>
</View>
);
})}
{/* NAS Google Photos Offloading */}
{/* ── NAS Offloading ────────────────────────────── */}
{hasNasConnected && (
<>
<Text style={[styles.sectionTitle, { color: theme.textSecondary }]}>NAS OFFLOADING</Text>
<View style={[styles.card, { backgroundColor: theme.card, borderColor: theme.border }]}>
<View style={styles.serviceRow}>
<Text style={styles.serviceIcon}>📷</Text>
<View style={styles.serviceInfo}>
<Text style={[styles.serviceName, { color: theme.text }]}>Offload Google Photos to NAS</Text>
<Text style={[styles.serviceStatus, { color: theme.textSecondary }]}>
<Text style={styles.managedIcon}>📷</Text>
<View style={styles.managedInfo}>
<Text style={[styles.managedName, { color: theme.text }]}>Offload Google Photos to NAS</Text>
<Text style={[styles.managedStatus, { color: theme.textSecondary }]}>
Fetch Google Photos data through NAS server
</Text>
</View>
@@ -384,11 +271,7 @@ const SettingsScreen = ({ navigation, route }) => {
<Text style={[styles.cardSubtext, { color: theme.textSecondary }]}>
New photos from your device will be automatically uploaded to the selected services.
</Text>
{[
{ type: 'GOOGLE_PHOTOS', name: 'Google Photos', icon: '📷' },
{ type: 'ICLOUD_PHOTOS', name: 'iCloud Photos', icon: '🍎' },
{ type: 'NAS', name: 'NAS Storage', icon: '💾' },
].map((svc) => {
{ALL_SERVICES.map((svc) => {
const isConnected = accounts.some((a) => a.service_type === svc.type && a.is_enabled);
return (
<TouchableOpacity
@@ -399,36 +282,26 @@ const SettingsScreen = ({ navigation, route }) => {
activeOpacity={0.7}
>
<View style={[
styles.checkbox,
{ borderColor: isConnected ? theme.primary : theme.border },
styles.checkbox, { borderColor: isConnected ? theme.primary : theme.border },
autoBackup.includes(svc.type) && { backgroundColor: theme.primary, borderColor: theme.primary },
]}>
{autoBackup.includes(svc.type) && <Text style={styles.checkmark}>✓</Text>}
</View>
<Text style={styles.checkIcon}>{svc.icon}</Text>
<Text style={[
styles.checkLabel,
{ color: isConnected ? theme.text : theme.textTertiary },
]}>
<Text style={[styles.checkLabel, { color: isConnected ? theme.text : theme.textTertiary }]}>
{svc.name}
</Text>
{!isConnected && (
<Text style={[styles.notConnected, { color: theme.textTertiary }]}>Not connected</Text>
)}
{!isConnected && <Text style={[styles.notConnected, { color: theme.textTertiary }]}>Not connected</Text>}
</TouchableOpacity>
);
})}
<TouchableOpacity
style={[styles.checkRow, { borderBottomWidth: 0 }]}
onPress={() => {
setAutoBackup([]);
DatabaseService.setAutoBackupServices(userId, []);
}}
onPress={() => { setAutoBackup([]); DatabaseService.setAutoBackupServices(userId, []); }}
activeOpacity={0.7}
>
<View style={[
styles.checkbox,
{ borderColor: theme.border },
styles.checkbox, { borderColor: theme.border },
autoBackup.length === 0 && { backgroundColor: theme.primary, borderColor: theme.primary },
]}>
{autoBackup.length === 0 && <Text style={styles.checkmark}>✓</Text>}
@@ -440,13 +313,9 @@ const SettingsScreen = ({ navigation, route }) => {
</View>
{/* ── Sign Out ──────────────────────────────────── */}
<TouchableOpacity
style={[styles.logoutButton, { borderColor: theme.danger }]}
onPress={handleLogout}
>
<TouchableOpacity style={[styles.logoutButton, { borderColor: theme.danger }]} onPress={handleLogout}>
<Text style={[styles.logoutText, { color: theme.danger }]}>Sign Out</Text>
</TouchableOpacity>
<View style={{ height: 40 }} />
</ScrollView>
</View>
@@ -457,17 +326,14 @@ const styles = StyleSheet.create({
container: { flex: 1 },
header: {
flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between',
paddingHorizontal: 16, paddingTop: 52, paddingBottom: 12,
borderBottomWidth: 1,
paddingHorizontal: 16, paddingTop: 52, paddingBottom: 12, borderBottomWidth: 1,
},
backArrow: { fontSize: 24 },
headerTitle: { fontSize: 18, fontWeight: '700' },
scrollContent: { flex: 1, paddingHorizontal: 16 },
sectionTitle: { fontSize: 12, fontWeight: '700', marginTop: 24, marginBottom: 8, letterSpacing: 0.5 },
card: {
borderRadius: 12, borderWidth: 1, overflow: 'hidden', marginBottom: 4,
},
cardTitle: { fontSize: 15, fontWeight: '600', padding: 14, paddingBottom: 4 },
sectionTitle: { fontSize: 12, fontWeight: '700', marginTop: 24, marginBottom: 4, letterSpacing: 0.5 },
sectionHint: { fontSize: 12, marginBottom: 10 },
card: { borderRadius: 12, borderWidth: 1, overflow: 'hidden', marginBottom: 4 },
cardSubtext: { fontSize: 12, paddingHorizontal: 14, paddingTop: 10, paddingBottom: 4 },
// Theme rows
themeRow: {
@@ -478,39 +344,28 @@ const styles = StyleSheet.create({
themeInfo: { flex: 1 },
themeName: { fontSize: 14, fontWeight: '600', marginBottom: 2 },
themeDesc: { fontSize: 12 },
radio: {
width: 20, height: 20, borderRadius: 10, borderWidth: 2, borderColor: '#dadce0',
radio: { width: 20, height: 20, borderRadius: 10, borderWidth: 2, borderColor: '#dadce0' },
// Managed (already linked) service cards
managedCard: {
borderRadius: 12, borderWidth: 1, marginBottom: 10, overflow: 'hidden',
},
// Service rows
managedHeader: {
flexDirection: 'row', alignItems: 'center', paddingHorizontal: 14, paddingVertical: 12,
gap: 8,
},
managedIcon: { fontSize: 22 },
managedInfo: { flex: 1 },
managedName: { fontSize: 14, fontWeight: '600', marginBottom: 2 },
managedStatus: { fontSize: 12 },
// Not-yet-linked header strip
notLinkedHeader: {
flexDirection: 'row', alignItems: 'center', paddingHorizontal: 4, paddingBottom: 2, gap: 8,
},
// Service row (offload section)
serviceRow: {
flexDirection: 'row', alignItems: 'center', paddingHorizontal: 14, paddingVertical: 12,
borderBottomWidth: StyleSheet.hairlineWidth,
},
serviceIcon: { fontSize: 22, marginRight: 8 },
serviceInfo: { flex: 1 },
serviceName: { fontSize: 14, fontWeight: '600', marginBottom: 2 },
serviceStatus: { fontSize: 12 },
serviceActions: {
flexDirection: 'row', paddingHorizontal: 14, paddingVertical: 8, gap: 8,
borderBottomWidth: StyleSheet.hairlineWidth,
},
actionBtn: {
borderWidth: 1, borderRadius: 6, paddingHorizontal: 12, paddingVertical: 5,
},
actionBtnText: { fontSize: 12, fontWeight: '600' },
emptyRow: { padding: 16 },
emptyText: { fontSize: 13, textAlign: 'center' },
// NAS fields
fieldLabel: { fontSize: 12, fontWeight: '600', marginTop: 10, marginBottom: 4, paddingHorizontal: 14 },
fieldInput: {
borderWidth: 1, borderRadius: 8, paddingHorizontal: 12, paddingVertical: 10,
fontSize: 14, marginHorizontal: 14,
},
saveButton: {
borderRadius: 8, paddingVertical: 12, alignItems: 'center', margin: 14,
},
saveButtonText: { color: '#fff', fontSize: 14, fontWeight: '600' },
// Auto backup checkbox rows
// Auto backup
checkRow: {
flexDirection: 'row', alignItems: 'center', paddingHorizontal: 14, paddingVertical: 12,
borderBottomWidth: StyleSheet.hairlineWidth,
@@ -524,10 +379,7 @@ const styles = StyleSheet.create({
checkLabel: { fontSize: 14, fontWeight: '500', flex: 1 },
notConnected: { fontSize: 11 },
// Logout
logoutButton: {
borderWidth: 1.5, borderRadius: 8, paddingVertical: 14,
alignItems: 'center', marginTop: 24,
},
logoutButton: { borderWidth: 1.5, borderRadius: 8, paddingVertical: 14, alignItems: 'center', marginTop: 24 },
logoutText: { fontSize: 15, fontWeight: '600' },
});