added service health indicators for Google Photos, iCloud, and NAS; implemented health check logic in settings and onboarding screens

This commit is contained in:
bobbert committed 2026-02-21 15:45:07 +00:00
1 parent 6f4ec66e2e
commit 9519b32d68
3 files changed
+242 -39

No files matched your search

+41
View File
@@ -0,0 +1,41 @@
import React from 'react';
import { View, StyleSheet } from 'react-native';
/**
* Circular health indicator dot
*
* status values:
* 'inactive' → grey (not used / not connected)
* 'healthy' → green (connected and working)
* 'error' → red (connected but errors fetching data)
* 'checking' → amber (currently checking)
*/
const COLORS = {
inactive: '#9e9e9e',
healthy: '#34a853',
error: '#d93025',
checking: '#fbbc04',
};
const ServiceHealthIndicator = ({ status = 'inactive', size = 10 }) => (
<View
style={[
styles.dot,
{
width: size,
height: size,
borderRadius: size / 2,
backgroundColor: COLORS[status] || COLORS.inactive,
},
]}
/>
);
const styles = StyleSheet.create({
dot: {
marginRight: 8,
},
});
export default ServiceHealthIndicator;
+18
View File
@@ -8,6 +8,7 @@ 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';
const OnboardingScreen = ({ navigation, route }) => {
const { userId } = route.params;
@@ -25,6 +26,11 @@ const OnboardingScreen = ({ navigation, route }) => {
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('22');
@@ -52,6 +58,7 @@ const OnboardingScreen = ({ navigation, route }) => {
const handleGoogleAuth = async () => {
try {
setLoading(true);
setGoogleHealth('checking');
const googleAuth = new GoogleAUTH();
const result = await googleAuth.authenticate();
@@ -62,9 +69,11 @@ const OnboardingScreen = ({ navigation, route }) => {
});
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);
@@ -74,14 +83,17 @@ const OnboardingScreen = ({ navigation, route }) => {
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);
@@ -95,6 +107,7 @@ const OnboardingScreen = ({ navigation, route }) => {
}
try {
setLoading(true);
setNasHealth('checking');
const nasAuth = new NASAUTH();
await nasAuth.authenticate(
nasHost.trim(), parseInt(nasPort) || 22,
@@ -107,9 +120,11 @@ const OnboardingScreen = ({ navigation, route }) => {
photoFolder: nasPhotoFolder.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);
@@ -187,6 +202,7 @@ const OnboardingScreen = ({ navigation, route }) => {
{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>}
@@ -205,6 +221,7 @@ const OnboardingScreen = ({ navigation, route }) => {
{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>}
@@ -223,6 +240,7 @@ const OnboardingScreen = ({ navigation, route }) => {
{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>}
+183 -39
View File
@@ -7,16 +7,21 @@ import { useFocusEffect } from '@react-navigation/native';
import { useTheme } from '../services/ThemeContext';
import DatabaseService from '../services/DatabaseService';
import NASSetupInstructions from '../components/NASSetupInstructions';
import ServiceHealthIndicator from '../components/ServiceHealthIndicator';
import NASAUTH from '../AUTH/NASAUTH';
import GoogleAUTH from '../AUTH/GoogleAUTH';
const SettingsScreen = ({ navigation, route }) => {
const userId = route?.params?.userId;
const { theme, themeName, setThemeName, themes } = useTheme();
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('');
@@ -45,20 +50,131 @@ const SettingsScreen = ({ navigation, route }) => {
setNasUsername(nasAcc.account_data.username || '');
setNasPhotoFolder(nasAcc.account_data.photoFolder || '/home/user/photos');
}
// Check health for each enabled account
checkAllServiceHealth(allAccounts);
} catch (err) {
console.error('Error loading settings:', err);
}
};
// ─── Health checks ─────────────────────────────────────────
const checkAllServiceHealth = async (accs) => {
const health = {};
for (const acc of accs) {
if (!acc.is_enabled) {
health[acc.service_type] = 'inactive';
continue;
}
health[acc.service_type] = 'checking';
setServiceHealth((prev) => ({ ...prev, [acc.service_type]: 'checking' }));
health[acc.service_type] = await checkSingleHealth(acc);
}
setServiceHealth(health);
};
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 = Date.now() + 3600000;
const res = await gAuth.getPhotos(1);
return res?.mediaItems !== undefined ? 'healthy' : 'error';
}
if (acc.service_type === 'NAS') {
const nasAuth = new NASAUTH();
const d = acc.account_data || {};
await nasAuth.authenticate(d.host, d.port, d.username, d.password, d.photoFolder);
await nasAuth.revokeAccess();
return 'healthy';
}
if (acc.service_type === 'ICLOUD_PHOTOS') {
// Placeholder – always "healthy" since it's a stub
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 ───────────────────────────────────────
const toggleService = async (accountId, currentEnabled) => {
try {
await DatabaseService.toggleAccountEnabled(accountId, !currentEnabled);
await loadData();
} catch (err) {
} catch {
Alert.alert('Error', 'Failed to update service');
}
};
const deleteAccount = (acc) => {
Alert.alert(
'Delete Service',
`Remove ${getServiceName(acc.service_type)} from your account? This cannot be undone.`,
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete',
style: 'destructive',
onPress: async () => {
try {
await DatabaseService.removeLinkedAccount(acc.id);
setServiceHealth((prev) => {
const next = { ...prev };
delete next[acc.service_type];
return next;
});
await loadData();
Alert.alert('Deleted', `${getServiceName(acc.service_type)} has been removed.`);
} catch {
Alert.alert('Error', 'Failed to remove service');
}
},
},
]
);
};
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,
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)
@@ -130,7 +246,7 @@ const SettingsScreen = ({ navigation, route }) => {
</View>
<ScrollView style={styles.scrollContent} showsVerticalScrollIndicator={false}>
{/* Theme Section */}
{/* ── Theme ──────────────────────────────────────── */}
<Text style={[styles.sectionTitle, { color: theme.textSecondary }]}>APPEARANCE</Text>
<View style={[styles.card, { backgroundColor: theme.card, borderColor: theme.border }]}>
{themeOptions.map((opt) => (
@@ -153,7 +269,7 @@ const SettingsScreen = ({ navigation, route }) => {
))}
</View>
{/* Services Section */}
{/* ── Services ──────────────────────────────────── */}
<Text style={[styles.sectionTitle, { color: theme.textSecondary }]}>SERVICES</Text>
<View style={[styles.card, { backgroundColor: theme.card, borderColor: theme.border }]}>
{accounts.length === 0 ? (
@@ -163,36 +279,60 @@ const SettingsScreen = ({ navigation, route }) => {
</Text>
</View>
) : (
accounts.map((acc) => (
<View
key={acc.id}
style={[styles.serviceRow, { borderBottomColor: theme.borderLight }]}
>
<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>
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>
</View>
</View>
{acc.service_type === 'NAS' && acc.is_enabled && (
<TouchableOpacity
style={[styles.editButton, { borderColor: theme.border }]}
onPress={() => setEditingNas(!editingNas)}
>
<Text style={[styles.editButtonText, { color: theme.primary }]}>Edit</Text>
</TouchableOpacity>
)}
<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>
@@ -248,7 +388,7 @@ const SettingsScreen = ({ navigation, route }) => {
</>
)}
{/* 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 }]}>
@@ -309,7 +449,7 @@ const SettingsScreen = ({ navigation, route }) => {
</TouchableOpacity>
</View>
{/* Sign Out */}
{/* ── Sign Out ──────────────────────────────────── */}
<TouchableOpacity
style={[styles.logoutButton, { borderColor: theme.danger }]}
onPress={handleLogout}
@@ -356,14 +496,18 @@ const styles = StyleSheet.create({
flexDirection: 'row', alignItems: 'center', paddingHorizontal: 14, paddingVertical: 12,
borderBottomWidth: StyleSheet.hairlineWidth,
},
serviceIcon: { fontSize: 22, marginRight: 12 },
serviceIcon: { fontSize: 22, marginRight: 8 },
serviceInfo: { flex: 1 },
serviceName: { fontSize: 14, fontWeight: '600', marginBottom: 2 },
serviceStatus: { fontSize: 12 },
editButton: {
borderWidth: 1, borderRadius: 6, paddingHorizontal: 10, paddingVertical: 4, marginRight: 10,
serviceActions: {
flexDirection: 'row', paddingHorizontal: 14, paddingVertical: 8, gap: 8,
borderBottomWidth: StyleSheet.hairlineWidth,
},
editButtonText: { fontSize: 12, fontWeight: '600' },
actionBtn: {
borderWidth: 1, borderRadius: 6, paddingHorizontal: 12, paddingVertical: 5,
},
actionBtnText: { fontSize: 12, fontWeight: '600' },
emptyRow: { padding: 16 },
emptyText: { fontSize: 13, textAlign: 'center' },
// NAS fields