mirror of
https://github.com/mudabbir-ahmad/Media-Aggregation-and-Sorting-System.git
synced 2026-10-07 19:40:20 +00:00
feat: implement NAS image lazy-loading and enhance service connection UI
This commit is contained in:
1 parent
6167418074
commit
5158305086
8 files changed
+573
-492
No files matched your search
+1
-12
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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' },
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user