feat: enhance onboarding and settings screens with NAS authentication and configuration

This commit is contained in:
bobbert committed 2026-03-04 23:10:19 +00:00
1 parent 5158305086
commit 09a6d2a57b
2 files changed
+475 -230

No files matched your search

+183 -79
View File
@@ -1,11 +1,14 @@
import React, { useState } from 'react';
import {
StyleSheet, View, Text, ScrollView, TouchableOpacity, Alert,
ActivityIndicator, Switch,
ActivityIndicator, Switch, TextInput,
} from 'react-native';
import { useTheme } from '../services/ThemeContext';
import DatabaseService from '../services/DatabaseService';
import ServiceConnectCard from '../components/ServiceConnectCard';
import GoogleAUTH from '../AUTH/GoogleAUTH';
import NASAUTH from '../AUTH/NASAUTH';
import NASSetupInstructions from '../components/NASSetupInstructions';
import ServiceHealthIndicator from '../components/ServiceHealthIndicator';
const OnboardingScreen = ({ navigation, route }) => {
const { userId } = route.params;
@@ -18,11 +21,21 @@ const OnboardingScreen = ({ navigation, route }) => {
const [iosEnabled, setIosEnabled] = useState(false);
const [nasEnabled, setNasEnabled] = useState(false);
// Step 1 — track which services completed connection via ServiceConnectCard callbacks
// Step 1 — connection status
const [googleConnected, setGoogleConnected] = useState(false);
const [iosConnected, setIosConnected] = useState(false);
const [nasConnected, setNasConnected] = useState(false);
// Step 1 — health indicators
const [googleHealth, setGoogleHealth] = useState('inactive');
const [iosHealth, setIosHealth] = useState('inactive');
const [nasHealth, setNasHealth] = useState('inactive');
// NAS fields
const [nasHost, setNasHost] = useState('');
const [nasPort, setNasPort] = useState('9500');
const [nasUsername, setNasUsername] = useState('');
// Step 2 — auto-backup checklist
const [autoBackupServices, setAutoBackupServices] = useState([]);
@@ -40,6 +53,74 @@ const OnboardingScreen = ({ navigation, route }) => {
return services;
};
const handleGoogleAuth = async () => {
setLoading(true);
setGoogleHealth('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',
});
setGoogleConnected(true);
setGoogleHealth('healthy');
Alert.alert('Success', 'Google Photos connected!');
} catch (error) {
setGoogleHealth('error');
Alert.alert('Error', 'Failed to connect Google Photos: ' + error.message);
} finally {
setLoading(false);
}
};
const handleIosAuth = async () => {
setLoading(true);
setIosHealth('checking');
try {
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) {
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;
}
setLoading(true);
setNasHealth('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(),
});
setNasConnected(true);
setNasHealth('healthy');
Alert.alert('Success', 'NAS connected!');
} catch (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]
@@ -89,7 +170,7 @@ const OnboardingScreen = ({ navigation, route }) => {
<Text style={[styles.description, { color: theme.textSecondary }]}>{steps[currentStep].description}</Text>
</View>
{/* Step 0: Select services */}
{/* ── Step 0: pick services ── */}
{currentStep === 0 && (
<View style={styles.cardsContainer}>
<ServiceToggleCard theme={theme} icon="📷" name="Google Photos"
@@ -101,40 +182,88 @@ 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)} />
{nasEnabled && <NASSetupInstructions />}
</View>
)}
{/* Step 1: Configure each selected service via shared ServiceConnectCard */}
{/* ── Step 1: connect each service ── */}
{currentStep === 1 && (
<View style={styles.cardsContainer}>
{googleEnabled && (
<ServiceConnectCard
serviceType="GOOGLE_PHOTOS"
userId={userId}
isLinked={googleConnected}
onLinked={() => setGoogleConnected(true)}
/>
)}
{iosEnabled && (
<ServiceConnectCard
serviceType="ICLOUD_PHOTOS"
userId={userId}
isLinked={iosConnected}
onLinked={() => setIosConnected(true)}
/>
)}
{nasEnabled && (
<ServiceConnectCard
serviceType="NAS"
userId={userId}
isLinked={nasConnected}
onLinked={() => setNasConnected(true)}
/>
<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: '#4285F4' }, loading && styles.buttonDisabled]}
onPress={handleGoogleAuth} disabled={loading}
>
{loading ? <ActivityIndicator color="#fff" /> : <Text style={styles.authButtonText}>Sign in with Google</Text>}
</TouchableOpacity>
)}
</View>
)}
{/* Step 2: Auto-backup multi-checkbox */}
{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>
)}
{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" placeholderTextColor={theme.textTertiary}
value={nasHost} onChangeText={setNasHost} editable={!loading} />
<Text style={[styles.fieldLabel, { color: theme.textSecondary }]}>Port</Text>
<TextInput style={[styles.fieldInput, { backgroundColor: theme.inputBg, borderColor: theme.border, color: theme.text }]}
placeholder="9500" placeholderTextColor={theme.textTertiary}
value={nasPort} onChangeText={setNasPort} editable={!loading} keyboardType="numeric" />
<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" placeholderTextColor={theme.textTertiary}
value={nasUsername} onChangeText={setNasUsername} editable={!loading} autoCapitalize="none" />
<TouchableOpacity
style={[styles.authButton, { backgroundColor: '#0a7ea4' }, loading && styles.buttonDisabled]}
onPress={handleNasAuth} disabled={loading}
>
{loading ? <ActivityIndicator color="#fff" /> : <Text style={styles.authButtonText}>Test & Connect</Text>}
</TouchableOpacity>
</>
)}
</View>
)}
</View>
)}
{/* ── Step 2: auto-backup ── */}
{currentStep === 2 && (
<View style={styles.cardsContainer}>
<View style={[styles.configCard, { backgroundColor: theme.card, borderColor: theme.border }]}>
@@ -142,31 +271,22 @@ const OnboardingScreen = ({ navigation, route }) => {
Select which services new photos should automatically upload to. You can select multiple or none.
</Text>
{getConnectedServices().map((svc) => (
<TouchableOpacity
key={svc.type}
<TouchableOpacity key={svc.type}
style={[styles.checkRow, { borderBottomColor: theme.borderLight }]}
onPress={() => toggleAutoBackup(svc.type)}
activeOpacity={0.7}
onPress={() => toggleAutoBackup(svc.type)} activeOpacity={0.7}
>
<View style={[
styles.checkbox, { borderColor: theme.primary },
autoBackupServices.includes(svc.type) && { backgroundColor: theme.primary },
]}>
<View style={[styles.checkbox, { borderColor: theme.primary },
autoBackupServices.includes(svc.type) && { backgroundColor: theme.primary }]}>
{autoBackupServices.includes(svc.type) && <Text style={styles.checkmark}>✓</Text>}
</View>
<Text style={styles.checkIcon}>{svc.icon}</Text>
<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 },
autoBackupServices.length === 0 && { backgroundColor: theme.primary, borderColor: theme.primary },
]}>
<TouchableOpacity style={[styles.checkRow, { borderBottomWidth: 0 }]}
onPress={() => setAutoBackupServices([])} activeOpacity={0.7}>
<View style={[styles.checkbox, { borderColor: theme.border },
autoBackupServices.length === 0 && { backgroundColor: theme.primary, borderColor: theme.primary }]}>
{autoBackupServices.length === 0 && <Text style={styles.checkmark}>✓</Text>}
</View>
<Text style={[styles.checkLabel, { color: theme.text }]}>Don't automatically back up</Text>
@@ -183,17 +303,13 @@ const OnboardingScreen = ({ navigation, route }) => {
onPress={() => setCurrentStep(currentStep - 1)} disabled={loading}>
<Text style={[styles.backButtonText, { color: theme.textSecondary }]}>Back</Text>
</TouchableOpacity>
) : (
<View style={{ flex: 1 }} />
)}
) : <View style={{ flex: 1 }} />}
<TouchableOpacity
style={[styles.nextButton, { backgroundColor: theme.primary }, loading && styles.buttonDisabled]}
onPress={handleNext} disabled={loading}
>
{loading ? <ActivityIndicator color="#fff" /> : (
<Text style={styles.nextButtonText}>
{currentStep === steps.length - 1 ? 'Get Started' : 'Next'}
</Text>
<Text style={styles.nextButtonText}>{currentStep === steps.length - 1 ? 'Get Started' : 'Next'}</Text>
)}
</TouchableOpacity>
</View>
@@ -210,21 +326,15 @@ const ServiceToggleCard = ({ theme, icon, name, description, enabled, onToggle }
<Text style={[styles.toggleDescription, { color: theme.textSecondary }]}>{description}</Text>
</View>
</View>
<Switch
value={enabled}
onValueChange={onToggle}
<Switch value={enabled} onValueChange={onToggle}
trackColor={{ false: theme.border, true: '#a8dab5' }}
thumbColor={enabled ? '#34a853' : '#f4f3f4'}
/>
thumbColor={enabled ? '#34a853' : '#f4f3f4'} />
</View>
);
const styles = StyleSheet.create({
container: { flex: 1 },
progressBar: {
flexDirection: 'row', justifyContent: 'center', gap: 8,
paddingTop: 60, paddingBottom: 8,
},
progressBar: { flexDirection: 'row', justifyContent: 'center', gap: 8, paddingTop: 60, paddingBottom: 8 },
progressDot: { width: 32, height: 4, borderRadius: 2 },
content: { flex: 1, paddingHorizontal: 20 },
header: { marginBottom: 24 },
@@ -232,35 +342,29 @@ const styles = StyleSheet.create({
title: { fontSize: 26, fontWeight: '700', marginBottom: 6 },
description: { fontSize: 14 },
cardsContainer: { gap: 12 },
toggleCard: {
borderRadius: 12, padding: 16, flexDirection: 'row',
justifyContent: 'space-between', alignItems: 'center', borderWidth: 1.5,
},
toggleCard: { borderRadius: 12, padding: 16, flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', borderWidth: 1.5 },
toggleCardContent: { flex: 1, flexDirection: 'row', alignItems: 'center' },
toggleIcon: { fontSize: 28, marginRight: 14 },
toggleInfo: { flex: 1 },
toggleName: { fontSize: 15, fontWeight: '600', marginBottom: 2 },
toggleDescription: { fontSize: 12 },
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: 4 },
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 },
checklistInfo: { fontSize: 13, marginBottom: 8 },
checkRow: {
flexDirection: 'row', alignItems: 'center', paddingVertical: 14,
borderBottomWidth: StyleSheet.hairlineWidth,
},
checkbox: {
width: 22, height: 22, borderRadius: 4, borderWidth: 2,
justifyContent: 'center', alignItems: 'center', marginRight: 12,
},
checkRow: { flexDirection: 'row', alignItems: 'center', paddingVertical: 14, borderBottomWidth: StyleSheet.hairlineWidth },
checkbox: { width: 22, height: 22, borderRadius: 4, borderWidth: 2, justifyContent: 'center', alignItems: 'center', marginRight: 12 },
checkmark: { color: '#fff', fontSize: 14, fontWeight: '700' },
checkIcon: { fontSize: 18, marginRight: 8 },
checkLabel: { fontSize: 14, fontWeight: '500', flex: 1 },
footer: {
flexDirection: 'row', paddingHorizontal: 20, paddingVertical: 14,
borderTopWidth: 1, gap: 12,
},
backButton: {
flex: 1, paddingVertical: 12, borderRadius: 8, alignItems: 'center', borderWidth: 1,
},
footer: { flexDirection: 'row', paddingHorizontal: 20, paddingVertical: 14, borderTopWidth: 1, gap: 12 },
backButton: { flex: 1, paddingVertical: 12, borderRadius: 8, alignItems: 'center', borderWidth: 1 },
backButtonText: { fontSize: 15, fontWeight: '600' },
nextButton: { flex: 1, paddingVertical: 12, borderRadius: 8, alignItems: 'center' },
nextButtonText: { fontSize: 15, fontWeight: '600', color: '#fff' },
+292 -151
View File
@@ -1,32 +1,42 @@
import React, { useState, useCallback } from 'react';
import {
StyleSheet, View, Text, ScrollView, TouchableOpacity, Switch, Alert, ActivityIndicator,
StyleSheet, View, Text, ScrollView, TouchableOpacity, Switch, TextInput,
Alert, ActivityIndicator,
} from 'react-native';
import { useFocusEffect } from '@react-navigation/native';
import { useTheme } from '../services/ThemeContext';
import DatabaseService from '../services/DatabaseService';
import ServiceConnectCard from '../components/ServiceConnectCard';
import NASSetupInstructions from '../components/NASSetupInstructions';
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
// Master list — add a new service here and it appears in both sections 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' },
{ type: 'GOOGLE_PHOTOS', icon: '📷', name: 'Google Photos' },
{ type: 'ICLOUD_PHOTOS', icon: '🍎', name: 'iCloud Photos' },
{ type: 'NAS', icon: '💾', name: 'NAS Storage' },
];
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);
const [serviceHealth, setServiceHealth] = useState({});
// Track a reload token so ServiceConnectCards re-read existingData after linking
const [reloadKey, setReloadKey] = useState(0);
// Which unlinked-service "add" form is expanded
const [expandedAdd, setExpandedAdd] = useState(null);
// Which linked-service edit form is expanded
const [expandedEdit, setExpandedEdit] = useState(null);
// NAS form fields (shared for both add and edit)
const [nasHost, setNasHost] = useState('');
const [nasPort, setNasPort] = useState('9500');
const [nasUsername, setNasUsername] = useState('');
useFocusEffect(
useCallback(() => {
@@ -40,64 +50,72 @@ 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 || '');
}
checkAllServiceHealth(allAccounts);
} catch (err) {
console.error('Error loading settings:', err);
}
};
// ─── Health checks ───────────────────────────────────────
// ─── Health ──────────────────────────────────────────────
const checkAllServiceHealth = async (accs) => {
for (const acc of accs) {
if (!acc.is_enabled) {
setServiceHealth((prev) => ({ ...prev, [acc.service_type]: 'inactive' }));
setServiceHealth((p) => ({ ...p, [acc.service_type]: 'inactive' }));
continue;
}
setServiceHealth((prev) => ({ ...prev, [acc.service_type]: 'checking' }));
const status = await checkSingleHealth(acc);
setServiceHealth((prev) => ({ ...prev, [acc.service_type]: status }));
setServiceHealth((p) => ({ ...p, [acc.service_type]: 'checking' }));
const s = await checkSingleHealth(acc);
setServiceHealth((p) => ({ ...p, [acc.service_type]: s }));
}
};
const checkSingleHealth = async (acc) => {
try {
if (acc.service_type === 'GOOGLE_PHOTOS') {
const gAuth = new GoogleAUTH();
gAuth.accessToken = acc.account_data?.accessToken;
gAuth.refreshToken = acc.account_data?.refreshToken;
gAuth.expiresAt = acc.account_data?.expiresAt || 0;
const info = await gAuth.getUserInfo();
const g = new GoogleAUTH();
g.accessToken = acc.account_data?.accessToken;
g.refreshToken = acc.account_data?.refreshToken;
g.expiresAt = acc.account_data?.expiresAt || 0;
const info = await g.getUserInfo();
return info?.email ? 'healthy' : 'error';
}
if (acc.service_type === 'NAS') {
const nasAuth = new NASAUTH();
const n = new NASAUTH();
const d = acc.account_data || {};
await nasAuth.authenticate(d.host, d.port, d.username);
const healthy = await nasAuth.healthCheck();
await nasAuth.revokeAccess();
return healthy ? 'healthy' : 'error';
await n.authenticate(d.host, d.port, d.username);
const ok = await n.healthCheck();
await n.revokeAccess();
return ok ? 'healthy' : 'error';
}
if (acc.service_type === 'ICLOUD_PHOTOS') return 'healthy';
return 'inactive';
} catch {
return 'error';
}
} catch { return 'error'; }
};
// ─── Service actions ─────────────────────────────────────
const recheckHealth = async (acc) => {
setServiceHealth((p) => ({ ...p, [acc.service_type]: 'checking' }));
const s = await checkSingleHealth(acc);
setServiceHealth((p) => ({ ...p, [acc.service_type]: s }));
};
// ─── Actions on existing accounts ────────────────────────
const toggleService = async (accountId, currentEnabled) => {
try {
await DatabaseService.toggleAccountEnabled(accountId, !currentEnabled);
await loadData();
} catch {
Alert.alert('Error', 'Failed to update service');
}
} catch { Alert.alert('Error', 'Failed to update service'); }
};
const deleteAccount = (acc) => {
Alert.alert(
'Remove Service',
`Remove ${getServiceName(acc.service_type)} from your account?`,
`Remove ${getServiceName(acc.service_type)} from your account? This cannot be undone.`,
[
{ text: 'Cancel', style: 'cancel' },
{
@@ -105,23 +123,99 @@ const SettingsScreen = ({ navigation, route }) => {
onPress: async () => {
try {
await DatabaseService.removeLinkedAccount(acc.id);
setServiceHealth((prev) => { const n = { ...prev }; delete n[acc.service_type]; return n; });
setServiceHealth((p) => { const n = { ...p }; delete n[acc.service_type]; return n; });
setExpandedEdit(null);
await loadData();
} catch {
Alert.alert('Error', 'Failed to remove service');
}
} catch { Alert.alert('Error', 'Failed to remove service'); }
},
},
]
);
};
// ─── Re-authenticate / edit existing ─────────────────────
const saveReauth = async (serviceType) => {
setLoading(true);
try {
if (serviceType === 'GOOGLE_PHOTOS') {
const g = new GoogleAUTH();
const result = await g.authenticate();
await DatabaseService.linkAccount(userId, 'GOOGLE_PHOTOS', {
accessToken: result.accessToken,
refreshToken: result.refreshToken || null,
expiresAt: g.expiresAt || Date.now() + 3600000,
email: result.email || 'google-user',
});
Alert.alert('Success', 'Google Photos re-authenticated!');
} else if (serviceType === 'ICLOUD_PHOTOS') {
await DatabaseService.linkAccount(userId, 'ICLOUD_PHOTOS', {
status: 'placeholder', note: 'iCloud integration pending Apple API access',
});
Alert.alert('Info', 'iCloud Photos re-registered.');
} else if (serviceType === 'NAS') {
if (!nasHost.trim() || !nasUsername.trim()) {
Alert.alert('Error', 'Please fill in the NAS host and username');
setLoading(false); return;
}
const n = new NASAUTH();
await n.authenticate(nasHost.trim(), parseInt(nasPort) || 9500, nasUsername.trim());
await DatabaseService.linkAccount(userId, 'NAS', {
host: nasHost.trim(), port: parseInt(nasPort) || 9500, username: nasUsername.trim(),
});
Alert.alert('Success', 'NAS configuration saved!');
}
setExpandedEdit(null);
await loadData();
} catch (err) {
Alert.alert('Error', 'Failed: ' + err.message);
} finally { setLoading(false); }
};
// ─── Add new service ──────────────────────────────────────
const saveNewService = async (serviceType) => {
setLoading(true);
try {
if (serviceType === 'GOOGLE_PHOTOS') {
const g = new GoogleAUTH();
const result = await g.authenticate();
await DatabaseService.linkAccount(userId, 'GOOGLE_PHOTOS', {
accessToken: result.accessToken,
refreshToken: result.refreshToken || null,
expiresAt: g.expiresAt || Date.now() + 3600000,
email: result.email || 'google-user',
});
Alert.alert('Success', 'Google Photos connected!');
} else if (serviceType === 'ICLOUD_PHOTOS') {
await DatabaseService.linkAccount(userId, 'ICLOUD_PHOTOS', {
status: 'placeholder', note: 'iCloud integration pending Apple API access',
});
Alert.alert('Info', 'iCloud Photos registered.');
} else if (serviceType === 'NAS') {
if (!nasHost.trim() || !nasUsername.trim()) {
Alert.alert('Error', 'Please fill in the NAS host and username');
setLoading(false); return;
}
const n = new NASAUTH();
await n.authenticate(nasHost.trim(), parseInt(nasPort) || 9500, nasUsername.trim());
await DatabaseService.linkAccount(userId, 'NAS', {
host: nasHost.trim(), port: parseInt(nasPort) || 9500, username: nasUsername.trim(),
});
Alert.alert('Success', 'NAS connected!');
}
setExpandedAdd(null);
setNasHost(''); setNasPort('9500'); setNasUsername('');
await loadData();
} catch (err) {
Alert.alert('Error', 'Failed to connect: ' + err.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(err); }
try { await DatabaseService.setAutoBackupServices(userId, updated); } catch (e) { console.error(e); }
};
const handleLogout = () => {
@@ -139,6 +233,10 @@ const SettingsScreen = ({ navigation, route }) => {
const hasNasConnected = accounts.some((a) => a.service_type === 'NAS' && a.is_enabled);
// Split into linked and unlinked
const linkedAccounts = accounts.filter((a) => ALL_SERVICES.some((s) => s.type === a.service_type));
const unlinkedServices = ALL_SERVICES.filter((s) => !accounts.some((a) => a.service_type === s.type));
return (
<View style={[styles.container, { backgroundColor: theme.background }]}>
<View style={[styles.header, { backgroundColor: theme.background, borderBottomColor: theme.border }]}>
@@ -151,12 +249,11 @@ const SettingsScreen = ({ navigation, route }) => {
<ScrollView style={styles.scrollContent} showsVerticalScrollIndicator={false}>
{/* ── Appearance ───────────────────────────────── */}
{/* ── 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}
<TouchableOpacity key={opt.key}
style={[styles.themeRow, themeName === opt.key && { backgroundColor: theme.cardActive }, { borderBottomColor: theme.borderLight }]}
onPress={() => setThemeName(opt.key)}
>
@@ -170,30 +267,26 @@ const SettingsScreen = ({ navigation, route }) => {
))}
</View>
{/* ── Services ─────────────────────────────────── */}
{/* Always shows ALL services — connected ones get a manage strip, unconnected show ServiceConnectCard */}
<Text style={[styles.sectionTitle, { color: theme.textSecondary }]}>SERVICES</Text>
<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
{/* ── Connected services ── */}
{linkedAccounts.length > 0 && (
<>
<Text style={[styles.sectionTitle, { color: theme.textSecondary }]}>CONNECTED SERVICES</Text>
<View style={[styles.card, { backgroundColor: theme.card, borderColor: theme.border }]}>
{linkedAccounts.map((acc, idx) => {
const health = serviceHealth[acc.service_type] || (acc.is_enabled ? 'checking' : 'inactive');
const isLastRow = idx === linkedAccounts.length - 1 && expandedEdit !== acc.service_type;
const isEditOpen = expandedEdit === acc.service_type;
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 })))}>
<View key={acc.id}>
{/* Main row */}
<View style={[styles.serviceRow, !isLastRow && { borderBottomColor: theme.borderLight, borderBottomWidth: StyleSheet.hairlineWidth }]}>
<TouchableOpacity onPress={() => recheckHealth(acc)} activeOpacity={0.6}>
<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 }]}>
<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>
@@ -205,67 +298,135 @@ const SettingsScreen = ({ navigation, route }) => {
/>
</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>
);
{/* Action buttons */}
<View style={[styles.serviceActions, { borderBottomColor: theme.borderLight }]}>
<TouchableOpacity
style={[styles.actionBtn, { borderColor: theme.primary }]}
onPress={() => {
if (acc.service_type === 'NAS') {
setExpandedEdit(isEditOpen ? null : acc.service_type);
} else {
saveReauth(acc.service_type);
}
}}
disabled={loading}
>
<Text style={[styles.actionBtnText, { color: theme.primary }]}>
{acc.service_type === 'NAS' ? (isEditOpen ? 'Cancel' : 'Edit') : (loading ? '…' : 'Re-auth')}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.actionBtn, { borderColor: theme.danger }]}
onPress={() => deleteAccount(acc)}
>
<Text style={[styles.actionBtnText, { color: theme.danger }]}>Remove</Text>
</TouchableOpacity>
</View>
// 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>
{/* Inline NAS edit form */}
{isEditOpen && acc.service_type === 'NAS' && (
<View style={[styles.inlineForm, { borderTopColor: theme.borderLight }]}>
<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={() => saveReauth('NAS')} disabled={loading}>
{loading ? <ActivityIndicator color="#fff" /> : <Text style={styles.saveButtonText}>Save Changes</Text>}
</TouchableOpacity>
</View>
</View>
<ServiceConnectCard
key={`${svc.type}-${reloadKey}`}
serviceType={svc.type}
userId={userId}
isLinked={false}
onLinked={() => { setReloadKey((k) => k + 1); loadData(); }}
/>
)}
</View>
);
})}
</View>
</>
)}
{/* ── NAS Offloading ────────────────────────────── */}
{/* ── Add services (only shows services not yet connected) ── */}
{unlinkedServices.length > 0 && (
<>
<Text style={[styles.sectionTitle, { color: theme.textSecondary }]}>ADD SERVICES</Text>
<View style={[styles.card, { backgroundColor: theme.card, borderColor: theme.border }]}>
{unlinkedServices.map((svc, idx) => {
const isLast = idx === unlinkedServices.length - 1;
const isOpen = expandedAdd === svc.type;
return (
<View key={svc.type}>
{/* Service row with Add button */}
<View style={[styles.serviceRow, !isLast && !isOpen && { borderBottomColor: theme.borderLight, borderBottomWidth: StyleSheet.hairlineWidth }]}>
<Text style={styles.serviceIcon}>{svc.icon}</Text>
<View style={styles.serviceInfo}>
<Text style={[styles.serviceName, { color: theme.text }]}>{svc.name}</Text>
<Text style={[styles.serviceStatus, { color: theme.textTertiary }]}>Not connected</Text>
</View>
<TouchableOpacity
style={[styles.addBtn, { backgroundColor: theme.primary }]}
onPress={() => {
if (svc.type === 'NAS') {
setNasHost(''); setNasPort('9500'); setNasUsername('');
setExpandedAdd(isOpen ? null : svc.type);
} else {
saveNewService(svc.type);
}
}}
disabled={loading}
>
<Text style={styles.addBtnText}>{loading && expandedAdd === svc.type ? '…' : 'Add'}</Text>
</TouchableOpacity>
</View>
{/* Inline NAS add form */}
{isOpen && svc.type === 'NAS' && (
<View style={[styles.inlineForm, { borderTopColor: theme.borderLight }]}>
<NASSetupInstructions />
<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: '#0a7ea4' }]}
onPress={() => saveNewService('NAS')} disabled={loading}>
{loading ? <ActivityIndicator color="#fff" /> : <Text style={styles.saveButtonText}>Test & Connect</Text>}
</TouchableOpacity>
</View>
)}
</View>
);
})}
</View>
</>
)}
{/* ── 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.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>
<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 }]}>Fetch Google Photos data through NAS server</Text>
</View>
<Switch
value={nasOffload}
onValueChange={setNasOffload}
<Switch value={nasOffload} onValueChange={setNasOffload}
trackColor={{ false: theme.border, true: '#a8dab5' }}
thumbColor={nasOffload ? theme.accent : '#f4f3f4'}
/>
thumbColor={nasOffload ? theme.accent : '#f4f3f4'} />
</View>
</View>
</>
)}
{/* ── Auto Backup ───────────────────────────────── */}
{/* ── Auto Backup ── */}
<Text style={[styles.sectionTitle, { color: theme.textSecondary }]}>AUTOMATIC BACKUP</Text>
<View style={[styles.card, { backgroundColor: theme.card, borderColor: theme.border }]}>
<Text style={[styles.cardSubtext, { color: theme.textSecondary }]}>
@@ -274,45 +435,34 @@ const SettingsScreen = ({ navigation, route }) => {
{ALL_SERVICES.map((svc) => {
const isConnected = accounts.some((a) => a.service_type === svc.type && a.is_enabled);
return (
<TouchableOpacity
key={svc.type}
<TouchableOpacity key={svc.type}
style={[styles.checkRow, { borderBottomColor: theme.borderLight }]}
onPress={() => isConnected && toggleAutoBackup(svc.type)}
disabled={!isConnected}
activeOpacity={0.7}
disabled={!isConnected} activeOpacity={0.7}
>
<View style={[
styles.checkbox, { borderColor: isConnected ? theme.primary : theme.border },
autoBackup.includes(svc.type) && { backgroundColor: theme.primary, borderColor: theme.primary },
]}>
<View style={[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 }]}>
{svc.name}
</Text>
<Text style={[styles.checkLabel, { color: isConnected ? theme.text : theme.textTertiary }]}>{svc.name}</Text>
{!isConnected && <Text style={[styles.notConnected, { color: theme.textTertiary }]}>Not connected</Text>}
</TouchableOpacity>
);
})}
<TouchableOpacity
style={[styles.checkRow, { borderBottomWidth: 0 }]}
<TouchableOpacity style={[styles.checkRow, { borderBottomWidth: 0 }]}
onPress={() => { setAutoBackup([]); DatabaseService.setAutoBackupServices(userId, []); }}
activeOpacity={0.7}
>
<View style={[
styles.checkbox, { borderColor: theme.border },
autoBackup.length === 0 && { backgroundColor: theme.primary, borderColor: theme.primary },
]}>
<View style={[styles.checkbox, { borderColor: theme.border },
autoBackup.length === 0 && { backgroundColor: theme.primary, borderColor: theme.primary }]}>
{autoBackup.length === 0 && <Text style={styles.checkmark}>✓</Text>}
</View>
<Text style={[styles.checkLabel, { color: theme.text, marginLeft: 0 }]}>
Don't automatically back up
</Text>
<Text style={[styles.checkLabel, { color: theme.text, marginLeft: 0 }]}>Don't automatically back up</Text>
</TouchableOpacity>
</View>
{/* ── Sign Out ──────────────────────────────────── */}
{/* ── Sign Out ── */}
<TouchableOpacity style={[styles.logoutButton, { borderColor: theme.danger }]} onPress={handleLogout}>
<Text style={[styles.logoutText, { color: theme.danger }]}>Sign Out</Text>
</TouchableOpacity>
@@ -331,11 +481,9 @@ const styles = StyleSheet.create({
backArrow: { fontSize: 24 },
headerTitle: { fontSize: 18, fontWeight: '700' },
scrollContent: { flex: 1, paddingHorizontal: 16 },
sectionTitle: { fontSize: 12, fontWeight: '700', marginTop: 24, marginBottom: 4, letterSpacing: 0.5 },
sectionHint: { fontSize: 12, marginBottom: 10 },
sectionTitle: { fontSize: 12, fontWeight: '700', marginTop: 24, marginBottom: 8, letterSpacing: 0.5 },
card: { borderRadius: 12, borderWidth: 1, overflow: 'hidden', marginBottom: 4 },
cardSubtext: { fontSize: 12, paddingHorizontal: 14, paddingTop: 10, paddingBottom: 4 },
// Theme rows
themeRow: {
flexDirection: 'row', alignItems: 'center', paddingHorizontal: 14, paddingVertical: 14,
borderBottomWidth: StyleSheet.hairlineWidth,
@@ -345,40 +493,33 @@ const styles = StyleSheet.create({
themeName: { fontSize: 14, fontWeight: '600', marginBottom: 2 },
themeDesc: { fontSize: 12 },
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',
serviceRow: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 14, paddingVertical: 12 },
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,
},
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,
},
// Auto backup
actionBtn: { borderWidth: 1, borderRadius: 6, paddingHorizontal: 14, paddingVertical: 6 },
actionBtnText: { fontSize: 12, fontWeight: '600' },
addBtn: { borderRadius: 6, paddingHorizontal: 14, paddingVertical: 6 },
addBtnText: { color: '#fff', fontSize: 12, fontWeight: '600' },
inlineForm: { paddingHorizontal: 14, paddingBottom: 14, borderTopWidth: StyleSheet.hairlineWidth },
fieldLabel: { fontSize: 12, fontWeight: '600', marginTop: 10, marginBottom: 4 },
fieldInput: { borderWidth: 1, borderRadius: 8, paddingHorizontal: 12, paddingVertical: 10, fontSize: 14 },
saveButton: { borderRadius: 8, paddingVertical: 12, alignItems: 'center', marginTop: 14 },
saveButtonText: { color: '#fff', fontSize: 14, fontWeight: '600' },
checkRow: {
flexDirection: 'row', alignItems: 'center', paddingHorizontal: 14, paddingVertical: 12,
borderBottomWidth: StyleSheet.hairlineWidth,
},
checkbox: {
width: 22, height: 22, borderRadius: 4, borderWidth: 2,
justifyContent: 'center', alignItems: 'center', marginRight: 12,
},
checkbox: { width: 22, height: 22, borderRadius: 4, borderWidth: 2, justifyContent: 'center', alignItems: 'center', marginRight: 12 },
checkmark: { color: '#fff', fontSize: 14, fontWeight: '700' },
checkIcon: { fontSize: 18, marginRight: 8 },
checkLabel: { fontSize: 14, fontWeight: '500', flex: 1 },
notConnected: { fontSize: 11 },
// Logout
logoutButton: { borderWidth: 1.5, borderRadius: 8, paddingVertical: 14, alignItems: 'center', marginTop: 24 },
logoutText: { fontSize: 15, fontWeight: '600' },
});