refactored code into components (major cleanup for reused code) added email for local acccount creation and changes to how nas and google photo connections work and added themes

This commit is contained in:
bobbert committed 2026-02-21 15:04:58 +00:00
1 parent c1d7139055
commit 6f4ec66e2e
17 files changed
+1709 -966

No files matched your search

+39 -44
View File
@@ -4,53 +4,51 @@ import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack'; import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { ActivityIndicator, View, Text, TouchableOpacity } from 'react-native'; import { ActivityIndicator, View, Text, TouchableOpacity } from 'react-native';
import { ThemeProvider, useTheme } from './src/services/ThemeContext';
import DatabaseService from './src/services/DatabaseService'; import DatabaseService from './src/services/DatabaseService';
import LoginScreen from './src/screens/LoginScreen'; import LoginScreen from './src/screens/LoginScreen';
import RegisterScreen from './src/screens/RegisterScreen'; import RegisterScreen from './src/screens/RegisterScreen';
import OnboardingScreen from './src/screens/OnboardingScreen'; import OnboardingScreen from './src/screens/OnboardingScreen';
import HomeScreen from './src/screens/HomeScreen'; import HomeScreen from './src/screens/HomeScreen';
import SearchScreen from './src/screens/SearchScreen'; import SearchScreen from './src/screens/SearchScreen';
import AlbumsScreen from './src/screens/AlbumsScreen'; import CollectionsScreen from './src/screens/AlbumsScreen';
import SettingsScreen from './src/screens/SettingsScreen';
const Stack = createNativeStackNavigator(); const Stack = createNativeStackNavigator();
const Tab = createBottomTabNavigator(); const Tab = createBottomTabNavigator();
/** /**
* MainTabs - Bottom tab navigator for logged-in users * MainTabs – Bottom tab navigator for logged-in users
* Tabs: Home (Library), Search, Albums * Tabs: Library, Search, Collections
*/ */
function MainTabs({ route }) { function MainTabs({ route }) {
const { userId } = route.params || {}; const { userId } = route.params || {};
const { theme } = useTheme();
return ( return (
<Tab.Navigator <Tab.Navigator
screenOptions={{ screenOptions={{
headerShown: false, headerShown: false,
tabBarActiveTintColor: '#1a73e8', tabBarActiveTintColor: theme.tabActive,
tabBarInactiveTintColor: '#5f6368', tabBarInactiveTintColor: theme.tabInactive,
tabBarStyle: { tabBarStyle: {
backgroundColor: '#fff', backgroundColor: theme.tabBar,
borderTopColor: '#e0e0e0', borderTopColor: theme.tabBarBorder,
borderTopWidth: 1, borderTopWidth: 1,
height: 56, height: 56,
paddingBottom: 6, paddingBottom: 6,
paddingTop: 6, paddingTop: 6,
}, },
tabBarLabelStyle: { tabBarLabelStyle: { fontSize: 11, fontWeight: '500' },
fontSize: 11,
fontWeight: '500',
},
}} }}
> >
<Tab.Screen <Tab.Screen
name="Photos" name="Library"
component={HomeScreen} component={HomeScreen}
initialParams={{ userId }} initialParams={{ userId }}
options={{ options={{
tabBarLabel: 'Photos', tabBarLabel: 'Library',
tabBarIcon: ({ color }) => ( tabBarIcon: ({ color }) => <Text style={{ fontSize: 22, color }}>🖼️</Text>,
<Text style={{ fontSize: 22, color }}>🖼️</Text>
),
}} }}
/> />
<Tab.Screen <Tab.Screen
@@ -59,20 +57,16 @@ function MainTabs({ route }) {
initialParams={{ userId }} initialParams={{ userId }}
options={{ options={{
tabBarLabel: 'Search', tabBarLabel: 'Search',
tabBarIcon: ({ color }) => ( tabBarIcon: ({ color }) => <Text style={{ fontSize: 22, color }}>🔍</Text>,
<Text style={{ fontSize: 22, color }}>🔍</Text>
),
}} }}
/> />
<Tab.Screen <Tab.Screen
name="Albums" name="Collections"
component={AlbumsScreen} component={CollectionsScreen}
initialParams={{ userId }} initialParams={{ userId }}
options={{ options={{
tabBarLabel: 'Albums', tabBarLabel: 'Collections',
tabBarIcon: ({ color }) => ( tabBarIcon: ({ color }) => <Text style={{ fontSize: 22, color }}>📁</Text>,
<Text style={{ fontSize: 22, color }}>📁</Text>
),
}} }}
/> />
</Tab.Navigator> </Tab.Navigator>
@@ -80,31 +74,26 @@ function MainTabs({ route }) {
} }
/** /**
* AppNavigator - Root stack navigator * AppNavigator – Root stack navigator
* Handles auth flow (Login/Register/Onboarding) then MainTabs
*/ */
function AppNavigator() { function AppNavigator() {
return ( return (
<Stack.Navigator <Stack.Navigator screenOptions={{ headerShown: false }}>
screenOptions={{
headerShown: false,
}}
>
<Stack.Screen name="Login" component={LoginScreen} /> <Stack.Screen name="Login" component={LoginScreen} />
<Stack.Screen name="Register" component={RegisterScreen} /> <Stack.Screen name="Register" component={RegisterScreen} />
<Stack.Screen name="Onboarding" component={OnboardingScreen} /> <Stack.Screen name="Onboarding" component={OnboardingScreen} />
<Stack.Screen name="MainTabs" component={MainTabs} /> <Stack.Screen name="MainTabs" component={MainTabs} />
<Stack.Screen name="Settings" component={SettingsScreen} />
</Stack.Navigator> </Stack.Navigator>
); );
} }
export default function App() { function AppInner() {
const { theme } = useTheme();
const [isReady, setIsReady] = useState(false); const [isReady, setIsReady] = useState(false);
const [error, setError] = useState(null); const [error, setError] = useState(null);
useEffect(() => { useEffect(() => { initializeApp(); }, []);
initializeApp();
}, []);
const initializeApp = async () => { const initializeApp = async () => {
try { try {
@@ -120,21 +109,19 @@ export default function App() {
if (!isReady && !error) { if (!isReady && !error) {
return ( return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#fff' }}> <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: theme.background }}>
<ActivityIndicator size="large" color="#1a73e8" /> <ActivityIndicator size="large" color={theme.primary} />
</View> </View>
); );
} }
if (error) { if (error) {
return ( return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', paddingHorizontal: 20, backgroundColor: '#fff' }}> <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', paddingHorizontal: 20, backgroundColor: theme.background }}>
<Text style={{ fontSize: 16, color: '#333', marginBottom: 12, textAlign: 'center' }}> <Text style={{ fontSize: 16, color: theme.text, marginBottom: 12, textAlign: 'center' }}>{error}</Text>
{error}
</Text>
<TouchableOpacity <TouchableOpacity
onPress={initializeApp} onPress={initializeApp}
style={{ backgroundColor: '#1a73e8', paddingHorizontal: 16, paddingVertical: 10, borderRadius: 6 }} style={{ backgroundColor: theme.primary, paddingHorizontal: 16, paddingVertical: 10, borderRadius: 6 }}
> >
<Text style={{ color: '#fff', fontWeight: '600' }}>Retry</Text> <Text style={{ color: '#fff', fontWeight: '600' }}>Retry</Text>
</TouchableOpacity> </TouchableOpacity>
@@ -147,7 +134,15 @@ export default function App() {
<NavigationContainer> <NavigationContainer>
<AppNavigator /> <AppNavigator />
</NavigationContainer> </NavigationContainer>
<StatusBar style="dark" backgroundColor="#fff" /> <StatusBar style={theme.statusBarStyle} backgroundColor={theme.background} />
</> </>
); );
} }
export default function App() {
return (
<ThemeProvider>
<AppInner />
</ThemeProvider>
);
}
+27
View File
@@ -0,0 +1,27 @@
# Install Commands
## Client App (root folder)
```bash
npm install expo expo-sqlite expo-crypto expo-secure-store expo-auth-session expo-web-browser expo-linking expo-blur expo-file-system expo-image-picker expo-media-library expo-status-bar react react-native @react-navigation/native @react-navigation/native-stack @react-navigation/bottom-tabs react-native-safe-area-context react-native-screens axios @babel/core
```
## NAS Backend Server (ServerFiles folder)
```bash
cd ServerFiles && npm install express cors dotenv jsonwebtoken express-fileupload body-parser node-fetch@2 nodemon
```
## Quick Setup (both at once)
```bash
npm install && cd ServerFiles && npm install && cd ..
```
## Version Fix (if React version mismatch)
```bash
npx expo install --fix
```
## Clear Cache + Rebuild
```bash
npx expo start --clear
```
+1
View File
@@ -16,6 +16,7 @@
"axios": "^1.7.9", "axios": "^1.7.9",
"expo": "~54.0.33", "expo": "~54.0.33",
"expo-auth-session": "~7.0.10", "expo-auth-session": "~7.0.10",
"expo-blur": "~15.0.8",
"expo-crypto": "~15.0.8", "expo-crypto": "~15.0.8",
"expo-file-system": "~19.0.21", "expo-file-system": "~19.0.21",
"expo-image-picker": "~17.0.10", "expo-image-picker": "~17.0.10",
+190 -32
View File
@@ -1,18 +1,26 @@
import * as AuthSession from 'expo-auth-session';
import * as WebBrowser from 'expo-web-browser';
import { AuthServiceBase } from './AuthServiceBase'; import { AuthServiceBase } from './AuthServiceBase';
/** WebBrowser.maybeCompleteAuthSession();
* Google Photos Authentication Service
*
* Uses placeholder tokens for now.
* To enable real Google OAuth, set up a Google Cloud project with Photos Library API,
* create OAuth 2.0 credentials, and replace GOOGLE_CLIENT_ID below.
*
* Real OAuth flow uses expo-auth-session + expo-web-browser.
*/
// TODO: Replace with your real Google Cloud OAuth Client ID // TODO: Replace with your real Google Cloud OAuth Client ID
const GOOGLE_CLIENT_ID = 'YOUR_GOOGLE_CLIENT_ID.apps.googleusercontent.com'; const GOOGLE_CLIENT_ID = 'YOUR_GOOGLE_CLIENT_ID.apps.googleusercontent.com';
const GOOGLE_DISCOVERY = {
authorizationEndpoint: 'https://accounts.google.com/o/oauth2/v2/auth',
tokenEndpoint: 'https://oauth2.googleapis.com/token',
revocationEndpoint: 'https://oauth2.googleapis.com/revoke',
};
// Google Photos Library API scopes - read and write
const SCOPES = [
'https://www.googleapis.com/auth/photoslibrary',
'https://www.googleapis.com/auth/photoslibrary.appendonly',
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
];
class GoogleAUTH extends AuthServiceBase { class GoogleAUTH extends AuthServiceBase {
constructor() { constructor() {
super(); super();
@@ -20,21 +28,21 @@ class GoogleAUTH extends AuthServiceBase {
this.refreshToken = null; this.refreshToken = null;
this.expiresAt = null; this.expiresAt = null;
this.email = null; this.email = null;
this.idToken = null;
} }
/** /**
* Authenticate with Google * Authenticate with Google using expo-auth-session
* Currently uses placeholder tokens. Replace with real OAuth when client ID is configured. * Falls back to placeholder tokens when no real client ID is set
*/ */
async authenticate() { async authenticate() {
try { try {
if (GOOGLE_CLIENT_ID === 'YOUR_GOOGLE_CLIENT_ID.apps.googleusercontent.com') { if (GOOGLE_CLIENT_ID === 'YOUR_GOOGLE_CLIENT_ID.apps.googleusercontent.com') {
// Placeholder mode - no real client ID configured
console.log('Google Auth: Using placeholder mode (no client ID configured)'); console.log('Google Auth: Using placeholder mode (no client ID configured)');
this.accessToken = 'placeholder_google_token_' + Date.now(); this.accessToken = 'placeholder_google_token_' + Date.now();
this.refreshToken = 'placeholder_refresh_' + Date.now(); this.refreshToken = 'placeholder_refresh_' + Date.now();
this.expiresAt = Date.now() + 3600000; this.expiresAt = Date.now() + 3600000;
this.email = 'google-user'; this.email = 'google-user@placeholder.com';
return { return {
success: true, success: true,
@@ -44,27 +52,93 @@ class GoogleAUTH extends AuthServiceBase {
}; };
} }
// Real OAuth flow would go here using expo-auth-session // Real OAuth flow
// const discovery = { authorizationEndpoint: 'https://accounts.google.com/o/oauth2/v2/auth', tokenEndpoint: 'https://oauth2.googleapis.com/token' }; const redirectUri = AuthSession.makeRedirectUri({ preferLocalhost: true });
// Use AuthSession.makeRedirectUri() and AuthSession.useAuthRequest()
throw new Error('Google OAuth client ID configured but real OAuth flow not yet implemented'); const authRequest = new AuthSession.AuthRequest({
clientId: GOOGLE_CLIENT_ID,
scopes: SCOPES,
redirectUri,
responseType: AuthSession.ResponseType.Code,
usePKCE: true,
extraParams: { access_type: 'offline', prompt: 'consent' },
});
const result = await authRequest.promptAsync(GOOGLE_DISCOVERY);
if (result.type !== 'success') {
throw new Error('Google authentication was cancelled or failed');
}
const tokenResponse = await AuthSession.exchangeCodeAsync(
{
clientId: GOOGLE_CLIENT_ID,
code: result.params.code,
redirectUri,
extraParams: { code_verifier: authRequest.codeVerifier },
},
GOOGLE_DISCOVERY
);
this.accessToken = tokenResponse.accessToken;
this.refreshToken = tokenResponse.refreshToken;
this.idToken = tokenResponse.idToken;
this.expiresAt = tokenResponse.issuedAt
? (tokenResponse.issuedAt + (tokenResponse.expiresIn || 3600)) * 1000
: Date.now() + 3600000;
const userInfo = await this.fetchUserInfo();
this.email = userInfo?.email || 'google-user';
return {
success: true,
accessToken: this.accessToken,
refreshToken: this.refreshToken,
email: this.email,
};
} catch (error) { } catch (error) {
console.error('Google authentication error:', error); console.error('Google authentication error:', error);
throw error; throw error;
} }
} }
async fetchUserInfo() {
try {
const response = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
headers: { Authorization: `Bearer ${this.accessToken}` },
});
if (!response.ok) return null;
return await response.json();
} catch {
return null;
}
}
async getAccessToken() { async getAccessToken() {
if (this.accessToken && this.expiresAt > Date.now()) return this.accessToken; if (this.accessToken && this.expiresAt > Date.now()) return this.accessToken;
if (this.refreshToken) return await this.refreshAccessToken();
throw new Error('No valid Google access token'); throw new Error('No valid Google access token');
} }
async refreshAccessToken() { async refreshAccessToken() {
try { try {
console.log('Refreshing Google access token...'); if (GOOGLE_CLIENT_ID === 'YOUR_GOOGLE_CLIENT_ID.apps.googleusercontent.com') {
this.accessToken = 'refreshed_google_token_' + Date.now(); this.accessToken = 'refreshed_placeholder_' + Date.now();
this.expiresAt = Date.now() + 3600000; this.expiresAt = Date.now() + 3600000;
return this.accessToken; return this.accessToken;
}
const tokenResponse = await AuthSession.refreshAsync(
{ clientId: GOOGLE_CLIENT_ID, refreshToken: this.refreshToken },
GOOGLE_DISCOVERY
);
this.accessToken = tokenResponse.accessToken;
this.expiresAt = tokenResponse.issuedAt
? (tokenResponse.issuedAt + (tokenResponse.expiresIn || 3600)) * 1000
: Date.now() + 3600000;
return this.accessToken;
} catch (error) { } catch (error) {
console.error('Token refresh error:', error); console.error('Token refresh error:', error);
throw error; throw error;
@@ -72,6 +146,13 @@ class GoogleAUTH extends AuthServiceBase {
} }
async revokeAccess() { async revokeAccess() {
try {
if (this.accessToken && GOOGLE_CLIENT_ID !== 'YOUR_GOOGLE_CLIENT_ID.apps.googleusercontent.com') {
await AuthSession.revokeAsync({ token: this.accessToken }, GOOGLE_DISCOVERY);
}
} catch (err) {
console.error('Revoke error:', err);
}
this.accessToken = null; this.accessToken = null;
this.refreshToken = null; this.refreshToken = null;
this.expiresAt = null; this.expiresAt = null;
@@ -80,31 +161,108 @@ class GoogleAUTH extends AuthServiceBase {
} }
async getUserInfo() { async getUserInfo() {
return { const info = await this.fetchUserInfo();
id: 'google_user', return info || { id: 'google_user', email: this.email || 'unknown', name: 'Google User' };
email: this.email || 'unknown',
name: 'Google User',
};
} }
async getPhotos(pageSize = 50) { /**
* Fetch media items from Google Photos Library API
*/
async getPhotos(pageSize = 50, pageToken = null) {
try { try {
await this.getAccessToken(); const token = await this.getAccessToken();
// In production: call Google Photos API const params = new URLSearchParams({ pageSize: String(pageSize) });
// https://photoslibrary.googleapis.com/v1/mediaItems if (pageToken) params.append('pageToken', pageToken);
const response = await fetch(
`https://photoslibrary.googleapis.com/v1/mediaItems?${params.toString()}`,
{ headers: { Authorization: `Bearer ${token}` } }
);
if (!response.ok) {
console.error('Google Photos API error:', response.status);
return { mediaItems: [], nextPageToken: null }; return { mediaItems: [], nextPageToken: null };
}
const data = await response.json();
return {
mediaItems: (data.mediaItems || []).map((item) => ({
source_service: 'GOOGLE_PHOTOS',
source_id: item.id,
title: item.filename || 'Untitled',
description: item.description || '',
local_path: item.baseUrl ? `${item.baseUrl}=w400-h400` : null,
remote_path: item.productUrl || '',
media_type: item.mimeType?.startsWith('video') ? 'video' : 'photo',
created_date: item.mediaMetadata?.creationTime || null,
})),
nextPageToken: data.nextPageToken || null,
};
} catch (error) { } catch (error) {
console.error('Get photos error:', error); console.error('Get photos error:', error);
throw error; return { mediaItems: [], nextPageToken: null };
} }
} }
async getAlbums() { /**
* Fetch albums from Google Photos Library API
*/
async getAlbums(pageSize = 50, pageToken = null) {
try { try {
await this.getAccessToken(); const token = await this.getAccessToken();
return { albums: [], nextPageToken: null }; const params = new URLSearchParams({ pageSize: String(pageSize) });
if (pageToken) params.append('pageToken', pageToken);
const response = await fetch(
`https://photoslibrary.googleapis.com/v1/albums?${params.toString()}`,
{ headers: { Authorization: `Bearer ${token}` } }
);
if (!response.ok) return { albums: [], nextPageToken: null };
const data = await response.json();
return { albums: data.albums || [], nextPageToken: data.nextPageToken || null };
} catch (error) { } catch (error) {
console.error('Get albums error:', error); console.error('Get albums error:', error);
return { albums: [], nextPageToken: null };
}
}
/**
* Upload media to Google Photos Library
*/
async uploadPhoto(filePath, filename, mimeType = 'image/jpeg') {
try {
const token = await this.getAccessToken();
const uploadResponse = await fetch('https://photoslibrary.googleapis.com/v1/uploads', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/octet-stream',
'X-Goog-Upload-File-Name': filename,
'X-Goog-Upload-Protocol': 'raw',
},
body: await fetch(filePath).then((r) => r.blob()),
});
if (!uploadResponse.ok) throw new Error('Upload bytes failed');
const uploadToken = await uploadResponse.text();
const createResponse = await fetch(
'https://photoslibrary.googleapis.com/v1/mediaItems:batchCreate',
{
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
newMediaItems: [{ simpleMediaItem: { uploadToken, fileName: filename } }],
}),
}
);
if (!createResponse.ok) throw new Error('Create media item failed');
return await createResponse.json();
} catch (error) {
console.error('Upload photo error:', error);
throw error; throw error;
} }
} }
+74
View File
@@ -0,0 +1,74 @@
import React from 'react';
import {
Modal, View, Text, TouchableOpacity, Image, StyleSheet,
} from 'react-native';
import { useTheme } from '../services/ThemeContext';
const getServiceIcon = (type) =>
({ GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }[type] || '📱');
const getServiceName = (type) =>
({ GOOGLE_PHOTOS: 'Google Photos', ICLOUD_PHOTOS: 'iCloud', NAS: 'NAS' }[type] || type);
const MediaViewer = ({ visible, media, onClose }) => {
const { theme } = useTheme();
if (!media) return null;
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<View style={[styles.container, { backgroundColor: theme.modalBg }]}>
<TouchableOpacity style={styles.closeButton} onPress={onClose}>
<Text style={styles.closeText}>✕</Text>
</TouchableOpacity>
{media.local_path ? (
<Image source={{ uri: media.local_path }} style={styles.fullImage} resizeMode="contain" />
) : (
<View style={styles.fullPlaceholder}>
<Text style={{ fontSize: 64 }}>📷</Text>
</View>
)}
<View style={[styles.infoPanel, { backgroundColor: theme.modalInfoBg }]}>
<Text style={styles.title}>{media.title || 'Photo'}</Text>
<View style={styles.row}>
<Text style={styles.label}>Source</Text>
<Text style={styles.value}>
{getServiceIcon(media.source_service)} {getServiceName(media.source_service)}
</Text>
</View>
{media.created_date && (
<View style={styles.row}>
<Text style={styles.label}>Date</Text>
<Text style={styles.value}>
{new Date(media.created_date).toLocaleDateString()}
</Text>
</View>
)}
</View>
</View>
</Modal>
);
};
const styles = StyleSheet.create({
container: { flex: 1 },
closeButton: {
position: 'absolute', top: 44, right: 16, zIndex: 20,
width: 36, height: 36, borderRadius: 18,
backgroundColor: 'rgba(255,255,255,0.2)',
justifyContent: 'center', alignItems: 'center',
},
closeText: { color: '#fff', fontSize: 20, fontWeight: '600' },
fullImage: { flex: 1, width: '100%' },
fullPlaceholder: { flex: 1, justifyContent: 'center', alignItems: 'center' },
infoPanel: {
padding: 16, paddingBottom: 32,
borderTopLeftRadius: 16, borderTopRightRadius: 16,
},
title: { fontSize: 16, fontWeight: '600', color: '#fff', marginBottom: 8 },
row: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 4 },
label: { fontSize: 12, color: '#9aa0a6' },
value: { fontSize: 12, color: '#e8eaed', fontWeight: '500' },
});
export default MediaViewer;
+101
View File
@@ -0,0 +1,101 @@
import React, { useState } from 'react';
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
import { useTheme } from '../services/ThemeContext';
const NASSetupInstructions = () => {
const { theme } = useTheme();
const [expanded, setExpanded] = useState(false);
return (
<View style={[styles.container, { backgroundColor: theme.card, borderColor: theme.border }]}>
<TouchableOpacity
style={styles.header}
onPress={() => setExpanded(!expanded)}
activeOpacity={0.7}
>
<Text style={[styles.headerText, { color: theme.text }]}>
📋 How to set up a NAS for photo storage
</Text>
<Text style={[styles.arrow, { color: theme.textSecondary }]}>
{expanded ? '▲' : '▼'}
</Text>
</TouchableOpacity>
{expanded && (
<View style={styles.body}>
<Text style={[styles.stepTitle, { color: theme.primary }]}>Prerequisites</Text>
<Text style={[styles.stepText, { color: theme.textSecondary }]}>
• An Ubuntu server (20.04 LTS or later) with SSH access enabled{'\n'}
• A user account with sudo access on the server{'\n'}
• The server's IP address and SSH port (default: 22)
</Text>
<Text style={[styles.stepTitle, { color: theme.primary }]}>Step 1: Enable SSH</Text>
<Text style={[styles.stepText, { color: theme.textSecondary }]}>
If SSH is not already enabled on your Ubuntu server, open a terminal on the server and run:{'\n\n'}
sudo apt update && sudo apt install openssh-server{'\n'}
sudo systemctl enable ssh && sudo systemctl start ssh
</Text>
<Text style={[styles.stepTitle, { color: theme.primary }]}>Step 2: Create a Photos Directory</Text>
<Text style={[styles.stepText, { color: theme.textSecondary }]}>
Create a dedicated folder for your photo storage:{'\n\n'}
mkdir -p ~/photos{'\n'}
chmod 755 ~/photos
</Text>
<Text style={[styles.stepTitle, { color: theme.primary }]}>Step 3: Configure Firewall (if needed)</Text>
<Text style={[styles.stepText, { color: theme.textSecondary }]}>
If your server uses UFW, allow SSH connections:{'\n\n'}
sudo ufw allow ssh{'\n'}
sudo ufw enable
</Text>
<Text style={[styles.stepTitle, { color: theme.primary }]}>Step 4: Find Your Server IP</Text>
<Text style={[styles.stepText, { color: theme.textSecondary }]}>
Run this on your server to find its local IP:{'\n\n'}
hostname -I{'\n\n'}
Use that IP address in the "Server Address" field above.
</Text>
<Text style={[styles.stepTitle, { color: theme.primary }]}>Step 5: Connect from the App</Text>
<Text style={[styles.stepText, { color: theme.textSecondary }]}>
Enter the server IP, SSH port (22), your username, password, and the photos folder path (e.g. /home/youruser/photos).{'\n\n'}
The app's backend server will handle the SFTP connection securely.
</Text>
<Text style={[styles.stepTitle, { color: theme.primary }]}>Step 6: Run the Backend Server</Text>
<Text style={[styles.stepText, { color: theme.textSecondary }]}>
The NAS connection uses a backend server. Navigate to the ServerFiles folder and run:{'\n\n'}
npm install{'\n'}
node server.js{'\n\n'}
Ensure the backend URL in the app settings matches the server address (e.g. http://192.168.1.100:3001).
</Text>
</View>
)}
</View>
);
};
const styles = StyleSheet.create({
container: {
borderRadius: 12,
borderWidth: 1,
overflow: 'hidden',
marginTop: 8,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 14,
},
headerText: { fontSize: 13, fontWeight: '600', flex: 1, marginRight: 8 },
arrow: { fontSize: 12 },
body: { paddingHorizontal: 14, paddingBottom: 14 },
stepTitle: { fontSize: 13, fontWeight: '700', marginTop: 12, marginBottom: 4 },
stepText: { fontSize: 12, lineHeight: 18 },
});
export default NASSetupInstructions;
+83
View File
@@ -0,0 +1,83 @@
import React from 'react';
import {
View, Text, FlatList, TouchableOpacity, Image, Dimensions, StyleSheet,
} from 'react-native';
import { useTheme } from '../services/ThemeContext';
const { width } = Dimensions.get('window');
const GRID_GAP = 2;
const PhotoGrid = ({
data,
onItemPress,
columns = 3,
refreshControl,
ListHeaderComponent,
ListEmptyComponent,
}) => {
const { theme } = useTheme();
const itemSize = (width - GRID_GAP * (columns + 1)) / columns;
const getServiceIcon = (type) =>
({ GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }[type] || '📱');
const renderItem = ({ item }) => (
<TouchableOpacity
style={[styles.gridItem, { width: itemSize, height: itemSize, backgroundColor: theme.chipBg }]}
onPress={() => onItemPress?.(item)}
activeOpacity={0.8}
>
{item.local_path ? (
<Image source={{ uri: item.local_path }} style={styles.gridImage} resizeMode="cover" />
) : (
<View style={[styles.placeholder, { backgroundColor: theme.borderLight }]}>
<Text style={{ fontSize: 28 }}>📷</Text>
</View>
)}
{item.media_type === 'video' && (
<View style={styles.videoOverlay}>
<Text style={{ color: '#fff', fontSize: 20 }}>▶</Text>
</View>
)}
<View style={styles.sourceBadge}>
<Text style={{ fontSize: 10 }}>{getServiceIcon(item.source_service)}</Text>
</View>
</TouchableOpacity>
);
return (
<FlatList
data={data}
renderItem={renderItem}
keyExtractor={(item, i) => item.id?.toString() || item.source_id || i.toString()}
numColumns={columns}
contentContainerStyle={styles.gridContent}
refreshControl={refreshControl}
ListHeaderComponent={ListHeaderComponent}
ListEmptyComponent={ListEmptyComponent}
/>
);
};
const styles = StyleSheet.create({
gridContent: { paddingBottom: 20 },
gridItem: {
margin: GRID_GAP / 2,
position: 'relative',
},
gridImage: { width: '100%', height: '100%' },
placeholder: {
width: '100%', height: '100%', justifyContent: 'center', alignItems: 'center',
},
videoOverlay: {
position: 'absolute', top: 0, left: 0, right: 0, bottom: 0,
backgroundColor: 'rgba(0,0,0,0.25)', justifyContent: 'center', alignItems: 'center',
},
sourceBadge: {
position: 'absolute', bottom: 4, right: 4, backgroundColor: 'rgba(0,0,0,0.5)',
borderRadius: 8, paddingHorizontal: 4, paddingVertical: 2,
},
});
export default PhotoGrid;
+65
View File
@@ -0,0 +1,65 @@
import React from 'react';
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
import { useTheme } from '../services/ThemeContext';
const TopBar = ({ currentUser, onSearchPress, onProfilePress, onMenuPress }) => {
const { theme } = useTheme();
return (
<View style={[styles.topBar, { backgroundColor: theme.background }]}>
<TouchableOpacity
style={[styles.searchPill, { backgroundColor: theme.searchBg }]}
onPress={onSearchPress}
>
<Text style={styles.searchIcon}>🔍</Text>
<Text style={[styles.searchText, { color: theme.textSecondary }]}>Search your photos</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.profileButton, { backgroundColor: theme.profileBg }]}
onPress={onProfilePress}
>
<Text style={styles.profileInitial}>
{currentUser?.username?.charAt(0)?.toUpperCase() || '?'}
</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.menuButton} onPress={onMenuPress}>
<Text style={[styles.menuDots, { color: theme.textSecondary }]}>⋮</Text>
</TouchableOpacity>
</View>
);
};
const styles = StyleSheet.create({
topBar: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 12,
paddingTop: 48,
paddingBottom: 8,
gap: 8,
},
searchPill: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
borderRadius: 24,
paddingHorizontal: 14,
paddingVertical: 10,
gap: 8,
},
searchIcon: { fontSize: 16 },
searchText: { fontSize: 14 },
profileButton: {
width: 32,
height: 32,
borderRadius: 16,
justifyContent: 'center',
alignItems: 'center',
},
profileInitial: { color: '#fff', fontSize: 14, fontWeight: '700' },
menuButton: { width: 32, height: 32, justifyContent: 'center', alignItems: 'center' },
menuDots: { fontSize: 22, fontWeight: '700' },
});
export default TopBar;
+153 -116
View File
@@ -1,210 +1,247 @@
import React, { useState, useCallback } from 'react'; import React, { useState, useCallback } from 'react';
import { import {
StyleSheet, View, Text, FlatList, TouchableOpacity, StyleSheet, View, Text, SectionList, FlatList, TouchableOpacity,
Dimensions, Image, ActivityIndicator, RefreshControl, Dimensions, Image, ActivityIndicator, RefreshControl,
} from 'react-native'; } from 'react-native';
import { useFocusEffect } from '@react-navigation/native'; import { useFocusEffect } from '@react-navigation/native';
import { useTheme } from '../services/ThemeContext';
import DatabaseService from '../services/DatabaseService'; import DatabaseService from '../services/DatabaseService';
import MediaViewer from '../components/MediaViewer';
const { width } = Dimensions.get('window'); const { width } = Dimensions.get('window');
const ALBUM_COLUMNS = 2; const ALBUM_COLUMNS = 2;
const ALBUM_GAP = 12; const ALBUM_GAP = 12;
const albumSize = (width - ALBUM_GAP * (ALBUM_COLUMNS + 1)) / ALBUM_COLUMNS; const albumSize = (width - ALBUM_GAP * (ALBUM_COLUMNS + 1)) / ALBUM_COLUMNS;
const AlbumsScreen = ({ route }) => { const CollectionsScreen = ({ route, navigation }) => {
const userId = route?.params?.userId; const userId = route?.params?.userId;
const [albums, setAlbums] = useState([]); const { theme } = useTheme();
const [sections, setSections] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const [selectedAlbum, setSelectedAlbum] = useState(null); const [selectedAlbum, setSelectedAlbum] = useState(null);
const [albumMedia, setAlbumMedia] = useState([]); const [albumMedia, setAlbumMedia] = useState([]);
const [selectedMedia, setSelectedMedia] = useState(null);
const [viewerVisible, setViewerVisible] = useState(false);
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
if (userId) loadAlbums(); if (userId) loadCollections();
}, [userId]) }, [userId])
); );
const loadAlbums = async () => { const getServiceIcon = (type) => ({ GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }[type] || '📱');
const getServiceName = (type) => ({ GOOGLE_PHOTOS: 'Google Photos', ICLOUD_PHOTOS: 'iCloud Photos', NAS: 'NAS Storage' }[type] || type);
const loadCollections = async () => {
try { try {
const accounts = await DatabaseService.getLinkedAccounts(userId); const accounts = await DatabaseService.getLinkedAccounts(userId);
const albumList = []; const allMedia = await DatabaseService.getMediaByUser(userId);
const sectionList = [];
// ─── Utility albums (top) ───
const utilityAlbums = [
{ name: 'All Photos', icon: '📸', count: allMedia.length, media: allMedia, type: 'utility' },
{ name: 'Hidden', icon: '🙈', count: 0, media: [], type: 'utility' },
{ name: 'Duplicates', icon: '📋', count: 0, media: [], type: 'utility' },
{ name: 'Recently Deleted', icon: '🗑️', count: 0, media: [], type: 'utility' },
];
sectionList.push({ title: '', data: [{ albums: utilityAlbums }] });
// ─── Per-service categories ───
for (const acc of accounts) { for (const acc of accounts) {
const media = await DatabaseService.getMediaByService(userId, acc.service_type); const media = await DatabaseService.getMediaByService(userId, acc.service_type);
albumList.push({ const serviceAlbums = [
service_type: acc.service_type, {
name: getServiceName(acc.service_type), name: `All ${getServiceName(acc.service_type)}`,
icon: getServiceIcon(acc.service_type), icon: getServiceIcon(acc.service_type),
count: media.length, count: media.length,
coverImage: media.length > 0 ? media[0].local_path : null, coverImage: media.length > 0 ? media[0].local_path : null,
media, media,
type: acc.service_type,
},
];
sectionList.push({
title: getServiceName(acc.service_type),
icon: getServiceIcon(acc.service_type),
data: [{ albums: serviceAlbums }],
}); });
} }
// Also add an "All Photos" album setSections(sectionList);
const allMedia = await DatabaseService.getMediaByUser(userId);
albumList.unshift({
service_type: 'ALL',
name: 'All Photos',
icon: '📸',
count: allMedia.length,
coverImage: allMedia.length > 0 ? allMedia[0].local_path : null,
media: allMedia,
});
setAlbums(albumList);
} catch (err) { } catch (err) {
console.error('Error loading albums:', err); console.error('Error loading collections:', err);
} finally { } finally {
setLoading(false); setLoading(false);
setRefreshing(false); setRefreshing(false);
} }
}; };
const getServiceIcon = (type) => ({ GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }[type] || '📱');
const getServiceName = (type) => ({ GOOGLE_PHOTOS: 'Google Photos', ICLOUD_PHOTOS: 'iCloud', NAS: 'NAS' }[type] || type);
const renderAlbumCard = ({ item }) => (
<TouchableOpacity
style={styles.albumCard}
onPress={() => {
setSelectedAlbum(item);
setAlbumMedia(item.media);
}}
activeOpacity={0.8}
>
<View style={styles.albumCover}>
{item.coverImage ? (
<Image source={{ uri: item.coverImage }} style={styles.albumCoverImage} resizeMode="cover" />
) : (
<View style={styles.albumCoverPlaceholder}>
<Text style={{ fontSize: 36 }}>{item.icon}</Text>
</View>
)}
</View>
<Text style={styles.albumName} numberOfLines={1}>{item.name}</Text>
<Text style={styles.albumCount}>{item.count} item{item.count !== 1 ? 's' : ''}</Text>
</TouchableOpacity>
);
const renderMediaItem = ({ item }) => (
<TouchableOpacity style={styles.gridItem} activeOpacity={0.8}>
{item.local_path ? (
<Image source={{ uri: item.local_path }} style={styles.gridImage} resizeMode="cover" />
) : (
<View style={styles.gridPlaceholder}>
<Text style={{ fontSize: 24 }}>📷</Text>
</View>
)}
</TouchableOpacity>
);
if (loading) {
return (
<View style={styles.centerContainer}>
<ActivityIndicator size="large" color="#1a73e8" />
<Text style={styles.loadingText}>Loading albums...</Text>
</View>
);
}
// Album detail view // Album detail view
if (selectedAlbum) { if (selectedAlbum) {
const gridItemSize = (width - 6) / 3;
return ( return (
<View style={styles.container}> <View style={[styles.container, { backgroundColor: theme.background }]}>
<View style={styles.albumHeader}> <View style={[styles.albumHeader, { borderBottomColor: theme.border }]}>
<TouchableOpacity onPress={() => { setSelectedAlbum(null); setAlbumMedia([]); }}> <TouchableOpacity onPress={() => { setSelectedAlbum(null); setAlbumMedia([]); }}>
<Text style={styles.backArrow}>←</Text> <Text style={[styles.backArrow, { color: theme.text }]}>←</Text>
</TouchableOpacity> </TouchableOpacity>
<View style={styles.albumHeaderInfo}> <View style={styles.albumHeaderInfo}>
<Text style={styles.albumHeaderTitle}>{selectedAlbum.name}</Text> <Text style={[styles.albumHeaderTitle, { color: theme.text }]}>{selectedAlbum.name}</Text>
<Text style={styles.albumHeaderCount}>{selectedAlbum.count} items</Text> <Text style={[styles.albumHeaderCount, { color: theme.textSecondary }]}>
{selectedAlbum.count} item{selectedAlbum.count !== 1 ? 's' : ''}
</Text>
</View> </View>
</View> </View>
{albumMedia.length === 0 ? ( {albumMedia.length === 0 ? (
<View style={styles.centerContainer}> <View style={styles.centerContainer}>
<Text style={{ fontSize: 48, marginBottom: 12 }}>📭</Text> <Text style={{ fontSize: 48, marginBottom: 12 }}>📭</Text>
<Text style={styles.emptyTitle}>No photos in this album</Text> <Text style={[styles.emptyTitle, { color: theme.text }]}>No items in this collection</Text>
</View> </View>
) : ( ) : (
<FlatList <FlatList
data={albumMedia} data={albumMedia}
renderItem={renderMediaItem} renderItem={({ item }) => (
<TouchableOpacity
style={[styles.gridItem, { width: gridItemSize, height: gridItemSize, backgroundColor: theme.chipBg }]}
onPress={() => { setSelectedMedia(item); setViewerVisible(true); }}
activeOpacity={0.8}
>
{item.local_path ? (
<Image source={{ uri: item.local_path }} style={styles.gridImage} resizeMode="cover" />
) : (
<View style={[styles.gridPlaceholder, { backgroundColor: theme.borderLight }]}>
<Text style={{ fontSize: 24 }}>📷</Text>
</View>
)}
</TouchableOpacity>
)}
keyExtractor={(item, i) => item.id?.toString() || i.toString()} keyExtractor={(item, i) => item.id?.toString() || i.toString()}
numColumns={3} numColumns={3}
contentContainerStyle={styles.gridContent} contentContainerStyle={styles.gridContent}
/> />
)} )}
<MediaViewer visible={viewerVisible} media={selectedMedia} onClose={() => setViewerVisible(false)} />
</View> </View>
); );
} }
// Albums grid view if (loading) {
return ( return (
<View style={styles.container}> <View style={[styles.centerContainer, { backgroundColor: theme.background }]}>
<View style={styles.header}> <ActivityIndicator size="large" color={theme.primary} />
<Text style={styles.headerTitle}>Albums</Text> <Text style={[styles.loadingText, { color: theme.textSecondary }]}>Loading collections...</Text>
</View> </View>
{albums.length === 0 ? ( );
}
const renderAlbumRow = ({ item }) => (
<View style={styles.albumRow}>
{item.albums.map((album, idx) => (
<TouchableOpacity
key={album.name + idx}
style={[styles.albumCard, { width: albumSize }]}
onPress={() => { setSelectedAlbum(album); setAlbumMedia(album.media); }}
activeOpacity={0.8}
>
<View style={[styles.albumCover, { width: albumSize, height: albumSize, backgroundColor: theme.chipBg }]}>
{album.coverImage ? (
<Image source={{ uri: album.coverImage }} style={styles.albumCoverImage} resizeMode="cover" />
) : (
<View style={[styles.albumCoverPlaceholder, { backgroundColor: theme.borderLight }]}>
<Text style={{ fontSize: 36 }}>{album.icon}</Text>
</View>
)}
</View>
<Text style={[styles.albumName, { color: theme.text }]} numberOfLines={1}>{album.name}</Text>
<Text style={[styles.albumCount, { color: theme.textSecondary }]}>
{album.count} item{album.count !== 1 ? 's' : ''}
</Text>
</TouchableOpacity>
))}
</View>
);
const renderSectionHeader = ({ section }) => {
if (!section.title) return null;
return (
<View style={styles.sectionHeader}>
<Text style={styles.sectionIcon}>{section.icon}</Text>
<Text style={[styles.sectionTitle, { color: theme.text }]}>{section.title}</Text>
</View>
);
};
return (
<View style={[styles.container, { backgroundColor: theme.background }]}>
<View style={[styles.header, { backgroundColor: theme.background }]}>
<Text style={[styles.headerTitle, { color: theme.text }]}>Collections</Text>
</View>
{sections.length === 0 || (sections.length === 1 && sections[0].data[0].albums[0].count === 0) ? (
<View style={styles.centerContainer}> <View style={styles.centerContainer}>
<Text style={{ fontSize: 48, marginBottom: 12 }}>📁</Text> <Text style={{ fontSize: 48, marginBottom: 12 }}>📁</Text>
<Text style={styles.emptyTitle}>No Albums</Text> <Text style={[styles.emptyTitle, { color: theme.text }]}>No Collections</Text>
<Text style={styles.emptyText}>Albums from your connected services will appear here</Text> <Text style={[styles.emptyText, { color: theme.textSecondary }]}>
Collections from your connected services will appear here
</Text>
</View> </View>
) : ( ) : (
<FlatList <SectionList
data={albums} sections={sections}
renderItem={renderAlbumCard} renderItem={renderAlbumRow}
keyExtractor={(item) => item.service_type} renderSectionHeader={renderSectionHeader}
numColumns={ALBUM_COLUMNS} keyExtractor={(item, i) => i.toString()}
columnWrapperStyle={styles.albumRow} contentContainerStyle={styles.listContent}
contentContainerStyle={styles.albumsContent} stickySectionHeadersEnabled={false}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={() => { setRefreshing(true); loadAlbums(); }} />} refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={() => { setRefreshing(true); loadCollections(); }} />
}
/> />
)} )}
</View> </View>
); );
}; };
const gridItemSize = (width - 6) / 3;
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#fff' }, container: { flex: 1 },
centerContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', paddingHorizontal: 24 }, centerContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', paddingHorizontal: 24 },
header: { paddingHorizontal: 16, paddingTop: 52, paddingBottom: 12 }, header: { paddingHorizontal: 16, paddingTop: 52, paddingBottom: 12 },
headerTitle: { fontSize: 24, fontWeight: '700', color: '#202124' }, headerTitle: { fontSize: 24, fontWeight: '700' },
albumsContent: { paddingHorizontal: ALBUM_GAP, paddingBottom: 20 }, listContent: { paddingHorizontal: ALBUM_GAP, paddingBottom: 20 },
albumRow: { gap: ALBUM_GAP, marginBottom: ALBUM_GAP }, // Section headers
albumCard: { width: albumSize }, sectionHeader: {
albumCover: { flexDirection: 'row', alignItems: 'center', paddingVertical: 12, gap: 8,
width: albumSize, height: albumSize, borderRadius: 12, overflow: 'hidden',
backgroundColor: '#f1f3f4', marginBottom: 8,
}, },
sectionIcon: { fontSize: 20 },
sectionTitle: { fontSize: 16, fontWeight: '700' },
// Album grid
albumRow: { flexDirection: 'row', gap: ALBUM_GAP, marginBottom: ALBUM_GAP },
albumCard: {},
albumCover: { borderRadius: 12, overflow: 'hidden', marginBottom: 8 },
albumCoverImage: { width: '100%', height: '100%' }, albumCoverImage: { width: '100%', height: '100%' },
albumCoverPlaceholder: { albumCoverPlaceholder: {
width: '100%', height: '100%', justifyContent: 'center', alignItems: 'center', backgroundColor: '#e8eaed', width: '100%', height: '100%', justifyContent: 'center', alignItems: 'center',
}, },
albumName: { fontSize: 14, fontWeight: '600', color: '#202124', paddingHorizontal: 2 }, albumName: { fontSize: 14, fontWeight: '600', paddingHorizontal: 2 },
albumCount: { fontSize: 12, color: '#5f6368', paddingHorizontal: 2, marginTop: 2 }, albumCount: { fontSize: 12, paddingHorizontal: 2, marginTop: 2 },
// Album detail // Album detail
albumHeader: { albumHeader: {
flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16,
paddingTop: 52, paddingBottom: 12, gap: 12, paddingTop: 52, paddingBottom: 12, gap: 12, borderBottomWidth: 1,
}, },
backArrow: { fontSize: 24, color: '#202124' }, backArrow: { fontSize: 24 },
albumHeaderInfo: { flex: 1 }, albumHeaderInfo: { flex: 1 },
albumHeaderTitle: { fontSize: 20, fontWeight: '700', color: '#202124' }, albumHeaderTitle: { fontSize: 20, fontWeight: '700' },
albumHeaderCount: { fontSize: 12, color: '#5f6368' }, albumHeaderCount: { fontSize: 12 },
gridContent: { paddingBottom: 20 }, gridContent: { paddingBottom: 20 },
gridItem: { width: gridItemSize, height: gridItemSize, margin: 1, backgroundColor: '#f1f3f4' }, gridItem: { margin: 1 },
gridImage: { width: '100%', height: '100%' }, gridImage: { width: '100%', height: '100%' },
gridPlaceholder: { width: '100%', height: '100%', justifyContent: 'center', alignItems: 'center', backgroundColor: '#e8eaed' }, gridPlaceholder: { width: '100%', height: '100%', justifyContent: 'center', alignItems: 'center' },
// Empty // Empty / Loading
loadingText: { marginTop: 12, fontSize: 14, color: '#5f6368' }, loadingText: { marginTop: 12, fontSize: 14 },
emptyTitle: { fontSize: 18, fontWeight: '600', color: '#202124', marginBottom: 6 }, emptyTitle: { fontSize: 18, fontWeight: '600', marginBottom: 6 },
emptyText: { fontSize: 13, color: '#5f6368', textAlign: 'center' }, emptyText: { fontSize: 13, textAlign: 'center' },
}); });
export default AlbumsScreen; export default CollectionsScreen;
+59 -218
View File
@@ -1,21 +1,20 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { import {
StyleSheet, View, Text, FlatList, ActivityIndicator, Modal, StyleSheet, View, Text, ActivityIndicator, ScrollView, RefreshControl,
TouchableOpacity, Dimensions, Image, Alert, ScrollView, RefreshControl,
} from 'react-native'; } from 'react-native';
import { useFocusEffect } from '@react-navigation/native'; import { useFocusEffect } from '@react-navigation/native';
import { useTheme } from '../services/ThemeContext';
import DatabaseService from '../services/DatabaseService'; import DatabaseService from '../services/DatabaseService';
import GoogleAUTH from '../AUTH/GoogleAUTH'; import GoogleAUTH from '../AUTH/GoogleAUTH';
import IOSAUTH from '../AUTH/IOSAUTH';
import NASAUTH from '../AUTH/NASAUTH'; import NASAUTH from '../AUTH/NASAUTH';
import TopBar from '../components/TopBar';
const { width } = Dimensions.get('window'); import PhotoGrid from '../components/PhotoGrid';
const GRID_COLUMNS = 3; import MediaViewer from '../components/MediaViewer';
const GRID_GAP = 2; import { TouchableOpacity } from 'react-native';
const imageSize = (width - GRID_GAP * (GRID_COLUMNS + 1)) / GRID_COLUMNS;
const HomeScreen = ({ navigation, route }) => { const HomeScreen = ({ navigation, route }) => {
const userId = route?.params?.userId; const userId = route?.params?.userId;
const { theme } = useTheme();
const [currentUser, setCurrentUser] = useState(null); const [currentUser, setCurrentUser] = useState(null);
const [linkedAccounts, setLinkedAccounts] = useState([]); const [linkedAccounts, setLinkedAccounts] = useState([]);
const [allMedia, setAllMedia] = useState([]); const [allMedia, setAllMedia] = useState([]);
@@ -24,8 +23,7 @@ const HomeScreen = ({ navigation, route }) => {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const [selectedMedia, setSelectedMedia] = useState(null); const [selectedMedia, setSelectedMedia] = useState(null);
const [modalVisible, setModalVisible] = useState(false); const [viewerVisible, setViewerVisible] = useState(false);
const [menuVisible, setMenuVisible] = useState(false);
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
@@ -37,9 +35,7 @@ const HomeScreen = ({ navigation, route }) => {
if (currentUser) loadAllMedia(); if (currentUser) loadAllMedia();
}, [currentUser]); }, [currentUser]);
useEffect(() => { useEffect(() => { filterMedia(); }, [selectedFilter, allMedia]);
filterMedia();
}, [selectedFilter, allMedia]);
const loadUserData = async () => { const loadUserData = async () => {
try { try {
@@ -63,8 +59,11 @@ const HomeScreen = ({ navigation, route }) => {
if (googleAccount) { if (googleAccount) {
try { try {
const googleAuth = new GoogleAUTH(); const googleAuth = new GoogleAUTH();
// Would fetch real photos with valid token googleAuth.accessToken = googleAccount.account_data?.accessToken;
console.log('Google Photos: ready to fetch when API is configured'); googleAuth.refreshToken = googleAccount.account_data?.refreshToken;
googleAuth.expiresAt = Date.now() + 3600000;
const photos = await googleAuth.getPhotos(50);
if (photos?.mediaItems?.length) newMedia.push(...photos.mediaItems);
} catch (err) { } catch (err) {
console.error('Google Photos fetch error:', err); console.error('Google Photos fetch error:', err);
} }
@@ -74,7 +73,6 @@ const HomeScreen = ({ navigation, route }) => {
const iosAccount = linkedAccounts.find((a) => a.service_type === 'ICLOUD_PHOTOS'); const iosAccount = linkedAccounts.find((a) => a.service_type === 'ICLOUD_PHOTOS');
if (iosAccount) { if (iosAccount) {
try { try {
const iosAuth = new IOSAUTH();
console.log('iCloud: placeholder - would fetch photos here'); console.log('iCloud: placeholder - would fetch photos here');
} catch (err) { } catch (err) {
console.error('iCloud fetch error:', err); console.error('iCloud fetch error:', err);
@@ -116,259 +114,102 @@ const HomeScreen = ({ navigation, route }) => {
setFilteredMedia(filtered); setFilteredMedia(filtered);
}; };
const handleLogout = () => {
Alert.alert('Logout', 'Are you sure?', [
{ text: 'Cancel', style: 'cancel' },
{ text: 'Logout', style: 'destructive', onPress: () => navigation.replace('Login') },
]);
};
const getServiceIcon = (type) => ({ GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }[type] || '📱'); const getServiceIcon = (type) => ({ GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }[type] || '📱');
const getServiceName = (type) => ({ GOOGLE_PHOTOS: 'Google Photos', ICLOUD_PHOTOS: 'iCloud', NAS: 'NAS' }[type] || type); const getServiceName = (type) => ({ GOOGLE_PHOTOS: 'Google Photos', ICLOUD_PHOTOS: 'iCloud', NAS: 'NAS' }[type] || type);
const renderMediaItem = ({ item }) => (
<TouchableOpacity
style={styles.gridItem}
onPress={() => { setSelectedMedia(item); setModalVisible(true); }}
activeOpacity={0.8}
>
{item.local_path ? (
<Image source={{ uri: item.local_path }} style={styles.gridImage} resizeMode="cover" />
) : (
<View style={styles.placeholder}>
<Text style={{ fontSize: 28 }}>📷</Text>
</View>
)}
{item.media_type === 'video' && (
<View style={styles.videoOverlay}>
<Text style={{ color: '#fff', fontSize: 20 }}>▶</Text>
</View>
)}
<View style={styles.sourceBadge}>
<Text style={{ fontSize: 10 }}>{getServiceIcon(item.source_service)}</Text>
</View>
</TouchableOpacity>
);
// Top bar component
const TopBar = () => (
<View style={styles.topBar}>
<TouchableOpacity style={styles.topBarSearch} onPress={() => navigation.navigate('Search')}>
<Text style={styles.topBarSearchIcon}>🔍</Text>
<Text style={styles.topBarSearchText}>Search your photos</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.profileButton} onPress={() => setMenuVisible(true)}>
<Text style={styles.profileInitial}>
{currentUser?.username?.charAt(0)?.toUpperCase() || '?'}
</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.menuButton} onPress={() => setMenuVisible(true)}>
<Text style={styles.menuDots}>⋮</Text>
</TouchableOpacity>
</View>
);
// Dropdown menu
const DropdownMenu = () => (
<Modal visible={menuVisible} transparent animationType="fade" onRequestClose={() => setMenuVisible(false)}>
<TouchableOpacity style={styles.menuOverlay} onPress={() => setMenuVisible(false)} activeOpacity={1}>
<View style={styles.menuDropdown}>
<View style={styles.menuProfile}>
<View style={styles.menuAvatar}>
<Text style={styles.menuAvatarText}>{currentUser?.username?.charAt(0)?.toUpperCase() || '?'}</Text>
</View>
<Text style={styles.menuUsername}>{currentUser?.username || 'User'}</Text>
</View>
<View style={styles.menuDivider} />
<TouchableOpacity style={styles.menuItem} onPress={() => { setMenuVisible(false); navigation.navigate('Onboarding', { userId }); }}>
<Text style={styles.menuItemText}>Manage Services</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.menuItem} onPress={() => { setMenuVisible(false); handleLogout(); }}>
<Text style={[styles.menuItemText, { color: '#d93025' }]}>Sign Out</Text>
</TouchableOpacity>
</View>
</TouchableOpacity>
</Modal>
);
if (loading && allMedia.length === 0) { if (loading && allMedia.length === 0) {
return ( return (
<View style={styles.centerContainer}> <View style={[styles.centerContainer, { backgroundColor: theme.background }]}>
<ActivityIndicator size="large" color="#1a73e8" /> <ActivityIndicator size="large" color={theme.primary} />
<Text style={styles.loadingText}>Loading photos...</Text> <Text style={[styles.loadingText, { color: theme.textSecondary }]}>Loading photos...</Text>
</View> </View>
); );
} }
return ( const FilterChips = () => (
<View style={styles.container}>
<TopBar />
<DropdownMenu />
{/* Filter chips */}
{linkedAccounts.length > 0 && (
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.filterRow}> <ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.filterRow}>
<TouchableOpacity <TouchableOpacity
style={[styles.chip, selectedFilter === 'all' && styles.chipActive]} style={[styles.chip, { backgroundColor: selectedFilter === 'all' ? theme.chipActiveBg : theme.chipBg }]}
onPress={() => setSelectedFilter('all')} onPress={() => setSelectedFilter('all')}
> >
<Text style={[styles.chipText, selectedFilter === 'all' && styles.chipTextActive]}>All</Text> <Text style={[styles.chipText, selectedFilter === 'all' && { color: theme.primary, fontWeight: '600' }]}>All</Text>
</TouchableOpacity> </TouchableOpacity>
{linkedAccounts.map((acc) => ( {linkedAccounts.map((acc) => (
<TouchableOpacity <TouchableOpacity
key={acc.id} key={acc.id}
style={[styles.chip, selectedFilter === acc.service_type && styles.chipActive]} style={[styles.chip, { backgroundColor: selectedFilter === acc.service_type ? theme.chipActiveBg : theme.chipBg }]}
onPress={() => setSelectedFilter(acc.service_type)} onPress={() => setSelectedFilter(acc.service_type)}
> >
<Text style={styles.chipIcon}>{getServiceIcon(acc.service_type)}</Text> <Text style={styles.chipIcon}>{getServiceIcon(acc.service_type)}</Text>
<Text style={[styles.chipText, selectedFilter === acc.service_type && styles.chipTextActive]}> <Text style={[styles.chipText, selectedFilter === acc.service_type && { color: theme.primary, fontWeight: '600' }]}>
{getServiceName(acc.service_type)} {getServiceName(acc.service_type)}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
))} ))}
</ScrollView> </ScrollView>
)} );
return (
<View style={[styles.container, { backgroundColor: theme.background }]}>
<TopBar
currentUser={currentUser}
onSearchPress={() => navigation.navigate('Search')}
onProfilePress={() => navigation.navigate('Settings', { userId })}
onMenuPress={() => navigation.navigate('Settings', { userId })}
/>
{linkedAccounts.length > 0 && <FilterChips />}
{filteredMedia.length === 0 ? ( {filteredMedia.length === 0 ? (
<View style={styles.centerContainer}> <View style={styles.centerContainer}>
<Text style={{ fontSize: 56, marginBottom: 16 }}>📸</Text> <Text style={{ fontSize: 56, marginBottom: 16 }}>📸</Text>
<Text style={styles.emptyTitle}>No Photos Yet</Text> <Text style={[styles.emptyTitle, { color: theme.text }]}>No Photos Yet</Text>
<Text style={styles.emptyText}>Photos from your connected services will appear here</Text> <Text style={[styles.emptyText, { color: theme.textSecondary }]}>
<TouchableOpacity style={styles.retryButton} onPress={() => { setRefreshing(true); loadAllMedia(); }}> Photos from your connected services will appear here
</Text>
<TouchableOpacity
style={[styles.retryButton, { backgroundColor: theme.primary }]}
onPress={() => { setRefreshing(true); loadAllMedia(); }}
>
<Text style={styles.retryButtonText}>Refresh</Text> <Text style={styles.retryButtonText}>Refresh</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
) : ( ) : (
<FlatList <PhotoGrid
data={filteredMedia} data={filteredMedia}
renderItem={renderMediaItem} onItemPress={(item) => { setSelectedMedia(item); setViewerVisible(true); }}
keyExtractor={(item, i) => item.id?.toString() || item.source_id || i.toString()} refreshControl={
numColumns={GRID_COLUMNS} <RefreshControl refreshing={refreshing} onRefresh={() => { setRefreshing(true); loadAllMedia(); }} />
contentContainerStyle={styles.gridContent} }
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={() => { setRefreshing(true); loadAllMedia(); }} />}
/> />
)} )}
{/* Full-screen photo viewer */} <MediaViewer
<Modal visible={modalVisible} transparent animationType="fade" onRequestClose={() => setModalVisible(false)}> visible={viewerVisible}
<View style={styles.modalContainer}> media={selectedMedia}
<TouchableOpacity style={styles.modalClose} onPress={() => setModalVisible(false)}> onClose={() => setViewerVisible(false)}
<Text style={styles.modalCloseText}>✕</Text> />
</TouchableOpacity>
{selectedMedia?.local_path ? (
<Image source={{ uri: selectedMedia.local_path }} style={styles.fullImage} resizeMode="contain" />
) : (
<View style={styles.fullPlaceholder}>
<Text style={{ fontSize: 64 }}>📷</Text>
</View>
)}
<View style={styles.mediaInfo}>
<Text style={styles.mediaTitle}>{selectedMedia?.title || 'Photo'}</Text>
<View style={styles.mediaRow}>
<Text style={styles.mediaLabel}>Source</Text>
<Text style={styles.mediaValue}>
{getServiceIcon(selectedMedia?.source_service)} {getServiceName(selectedMedia?.source_service)}
</Text>
</View>
{selectedMedia?.created_date && (
<View style={styles.mediaRow}>
<Text style={styles.mediaLabel}>Date</Text>
<Text style={styles.mediaValue}>{new Date(selectedMedia.created_date).toLocaleDateString()}</Text>
</View>
)}
</View>
</View>
</Modal>
</View> </View>
); );
}; };
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#fff' }, container: { flex: 1 },
centerContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', paddingHorizontal: 24 }, centerContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', paddingHorizontal: 24 },
// Top bar loadingText: { marginTop: 12, fontSize: 14 },
topBar: {
flexDirection: 'row', alignItems: 'center', paddingHorizontal: 12, paddingTop: 48, paddingBottom: 8,
backgroundColor: '#fff', gap: 8,
},
topBarSearch: {
flex: 1, flexDirection: 'row', alignItems: 'center', backgroundColor: '#f1f3f4',
borderRadius: 24, paddingHorizontal: 14, paddingVertical: 10, gap: 8,
},
topBarSearchIcon: { fontSize: 16 },
topBarSearchText: { fontSize: 14, color: '#5f6368' },
profileButton: {
width: 32, height: 32, borderRadius: 16, backgroundColor: '#1a73e8',
justifyContent: 'center', alignItems: 'center',
},
profileInitial: { color: '#fff', fontSize: 14, fontWeight: '700' },
menuButton: { width: 32, height: 32, justifyContent: 'center', alignItems: 'center' },
menuDots: { fontSize: 22, color: '#5f6368', fontWeight: '700' },
// Dropdown
menuOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.3)' },
menuDropdown: {
position: 'absolute', top: 90, right: 12, backgroundColor: '#fff',
borderRadius: 12, minWidth: 200, paddingVertical: 8,
shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.15, shadowRadius: 8, elevation: 8,
},
menuProfile: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingVertical: 10 },
menuAvatar: {
width: 36, height: 36, borderRadius: 18, backgroundColor: '#1a73e8',
justifyContent: 'center', alignItems: 'center', marginRight: 10,
},
menuAvatarText: { color: '#fff', fontSize: 16, fontWeight: '700' },
menuUsername: { fontSize: 15, fontWeight: '600', color: '#202124' },
menuDivider: { height: 1, backgroundColor: '#e8eaed', marginVertical: 4 },
menuItem: { paddingHorizontal: 16, paddingVertical: 12 },
menuItemText: { fontSize: 14, color: '#202124' },
// Filter chips // Filter chips
filterRow: { paddingHorizontal: 12, paddingVertical: 8, gap: 8 }, filterRow: { paddingHorizontal: 12, paddingVertical: 8, gap: 8 },
chip: { chip: {
flexDirection: 'row', alignItems: 'center', paddingHorizontal: 14, paddingVertical: 7, flexDirection: 'row', alignItems: 'center', paddingHorizontal: 14, paddingVertical: 7,
backgroundColor: '#f1f3f4', borderRadius: 20, gap: 6, borderRadius: 20, gap: 6,
}, },
chipActive: { backgroundColor: '#d2e3fc' },
chipIcon: { fontSize: 13 }, chipIcon: { fontSize: 13 },
chipText: { fontSize: 13, fontWeight: '500', color: '#3c4043' }, chipText: { fontSize: 13, fontWeight: '500', color: '#3c4043' },
chipTextActive: { color: '#1a73e8', fontWeight: '600' }, // Empty
// Grid emptyTitle: { fontSize: 18, fontWeight: '600', marginBottom: 6 },
gridContent: { paddingBottom: 20 }, emptyText: { fontSize: 13, textAlign: 'center', marginBottom: 20 },
gridItem: { retryButton: { paddingHorizontal: 24, paddingVertical: 10, borderRadius: 20 },
width: imageSize, height: imageSize, margin: GRID_GAP / 2,
backgroundColor: '#f1f3f4', position: 'relative',
},
gridImage: { width: '100%', height: '100%' },
placeholder: { width: '100%', height: '100%', justifyContent: 'center', alignItems: 'center', backgroundColor: '#e8eaed' },
videoOverlay: {
position: 'absolute', top: 0, left: 0, right: 0, bottom: 0,
backgroundColor: 'rgba(0,0,0,0.25)', justifyContent: 'center', alignItems: 'center',
},
sourceBadge: {
position: 'absolute', bottom: 4, right: 4, backgroundColor: 'rgba(0,0,0,0.5)',
borderRadius: 8, paddingHorizontal: 4, paddingVertical: 2,
},
// Empty / Loading
loadingText: { marginTop: 12, fontSize: 14, color: '#5f6368' },
emptyTitle: { fontSize: 18, fontWeight: '600', color: '#202124', marginBottom: 6 },
emptyText: { fontSize: 13, color: '#5f6368', textAlign: 'center', marginBottom: 20 },
retryButton: { backgroundColor: '#1a73e8', paddingHorizontal: 24, paddingVertical: 10, borderRadius: 20 },
retryButtonText: { color: '#fff', fontSize: 14, fontWeight: '600' }, retryButtonText: { color: '#fff', fontSize: 14, fontWeight: '600' },
// Modal (photo viewer)
modalContainer: { flex: 1, backgroundColor: '#000' },
modalClose: {
position: 'absolute', top: 44, right: 16, zIndex: 20, width: 36, height: 36,
borderRadius: 18, backgroundColor: 'rgba(255,255,255,0.2)', justifyContent: 'center', alignItems: 'center',
},
modalCloseText: { color: '#fff', fontSize: 20, fontWeight: '600' },
fullImage: { flex: 1, width: '100%' },
fullPlaceholder: { flex: 1, justifyContent: 'center', alignItems: 'center' },
mediaInfo: {
backgroundColor: '#1a1a1a', padding: 16, paddingBottom: 32, borderTopLeftRadius: 16, borderTopRightRadius: 16,
},
mediaTitle: { fontSize: 16, fontWeight: '600', color: '#fff', marginBottom: 8 },
mediaRow: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 4 },
mediaLabel: { fontSize: 12, color: '#9aa0a6' },
mediaValue: { fontSize: 12, color: '#e8eaed', fontWeight: '500' },
}); });
export default HomeScreen; export default HomeScreen;
+52 -99
View File
@@ -1,18 +1,19 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { import {
StyleSheet, StyleSheet, View, Text, TextInput, TouchableOpacity, Alert,
View, ActivityIndicator, KeyboardAvoidingView, Platform, Image, ScrollView,
Text,
TextInput,
TouchableOpacity,
Alert,
ActivityIndicator,
KeyboardAvoidingView,
Platform,
} from 'react-native'; } from 'react-native';
import { useTheme } from '../services/ThemeContext';
import DatabaseService from '../services/DatabaseService'; import DatabaseService from '../services/DatabaseService';
// ──────────────────────────────────────────────────────────
// App icon placeholder – change the require path when you
// have a final image in the assets folder.
// ──────────────────────────────────────────────────────────
const APP_ICON = require('../../assets/icon.png');
const LoginScreen = ({ navigation }) => { const LoginScreen = ({ navigation }) => {
const { theme } = useTheme();
const [identifier, setIdentifier] = useState(''); const [identifier, setIdentifier] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -27,7 +28,6 @@ const LoginScreen = ({ navigation }) => {
try { try {
const user = await DatabaseService.loginUser(identifier.trim(), password); const user = await DatabaseService.loginUser(identifier.trim(), password);
if (user) { if (user) {
// Check if user has completed onboarding (has linked accounts)
const accounts = await DatabaseService.getLinkedAccounts(user.id); const accounts = await DatabaseService.getLinkedAccounts(user.id);
if (accounts.length > 0) { if (accounts.length > 0) {
navigation.replace('MainTabs', { userId: user.id }); navigation.replace('MainTabs', { userId: user.id });
@@ -48,21 +48,22 @@ const LoginScreen = ({ navigation }) => {
return ( return (
<KeyboardAvoidingView <KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.container} style={[styles.container, { backgroundColor: theme.background }]}
> >
<ScrollView contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
<View style={styles.content}> <View style={styles.content}>
<View style={styles.headerContainer}> <View style={styles.headerContainer}>
<Text style={styles.appIcon}>📸</Text> <Image source={APP_ICON} style={styles.appIcon} resizeMode="contain" />
<Text style={styles.title}>Photo Library</Text> <Text style={[styles.title, { color: theme.text }]}>Photo Library</Text>
<Text style={styles.subtitle}>Sign in to your account</Text> <Text style={[styles.subtitle, { color: theme.textSecondary }]}>Sign in to your account</Text>
</View> </View>
<View style={styles.inputContainer}> <View style={styles.inputContainer}>
<Text style={styles.label}>Username or Email</Text> <Text style={[styles.label, { color: theme.textSecondary }]}>Username or Email</Text>
<TextInput <TextInput
style={styles.input} style={[styles.input, { backgroundColor: theme.inputBg, borderColor: theme.border, color: theme.text }]}
placeholder="Enter username or email" placeholder="Enter username or email"
placeholderTextColor="#9aa0a6" placeholderTextColor={theme.textTertiary}
value={identifier} value={identifier}
onChangeText={setIdentifier} onChangeText={setIdentifier}
editable={!loading} editable={!loading}
@@ -70,123 +71,75 @@ const LoginScreen = ({ navigation }) => {
autoCorrect={false} autoCorrect={false}
/> />
<Text style={styles.label}>Password</Text> <Text style={[styles.label, { color: theme.textSecondary }]}>Password</Text>
<TextInput <TextInput
style={styles.input} style={[styles.input, { backgroundColor: theme.inputBg, borderColor: theme.border, color: theme.text }]}
placeholder="Enter your password" placeholder="Enter your password"
placeholderTextColor="#9aa0a6" placeholderTextColor={theme.textTertiary}
value={password} value={password}
onChangeText={setPassword} onChangeText={setPassword}
editable={!loading} editable={!loading}
secureTextEntry secureTextEntry
autoCapitalize="none" autoCapitalize="none"
/> />
<TouchableOpacity style={styles.forgotPassword}>
<Text style={[styles.forgotPasswordText, { color: theme.primary }]}>Forgot Password?</Text>
</TouchableOpacity>
</View> </View>
{/* Login button – blue */}
<TouchableOpacity <TouchableOpacity
style={[styles.loginButton, loading && styles.buttonDisabled]} style={[styles.primaryButton, { backgroundColor: theme.primary, borderColor: theme.border }, loading && styles.buttonDisabled]}
onPress={handleLogin} onPress={handleLogin}
disabled={loading} disabled={loading}
> >
{loading ? ( {loading ? (
<ActivityIndicator color="#fff" /> <ActivityIndicator color="#fff" />
) : ( ) : (
<Text style={styles.buttonText}>Sign In</Text> <Text style={styles.primaryButtonText}>Login</Text>
)} )}
</TouchableOpacity> </TouchableOpacity>
<View style={styles.registerContainer}> {/* Register button – white/outlined */}
<Text style={styles.registerText}>Don't have an account? </Text>
<TouchableOpacity <TouchableOpacity
style={[styles.secondaryButton, { backgroundColor: theme.background, borderColor: theme.border }]}
onPress={() => navigation.navigate('Register')} onPress={() => navigation.navigate('Register')}
disabled={loading} disabled={loading}
> >
<Text style={styles.registerLink}>Create one</Text> <Text style={[styles.secondaryButtonText, { color: theme.text }]}>Register</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </ScrollView>
</KeyboardAvoidingView> </KeyboardAvoidingView>
); );
}; };
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: { flex: 1 },
flex: 1, scrollContent: { flexGrow: 1, justifyContent: 'center' },
backgroundColor: '#fff', content: { paddingHorizontal: 24, paddingVertical: 40 },
}, headerContainer: { marginBottom: 36, alignItems: 'center' },
content: { appIcon: { width: 80, height: 80, marginBottom: 16, borderRadius: 16 },
flex: 1, title: { fontSize: 28, fontWeight: '700', marginBottom: 8 },
justifyContent: 'center', subtitle: { fontSize: 15 },
paddingHorizontal: 24, inputContainer: { marginBottom: 24 },
paddingVertical: 40, label: { fontSize: 13, fontWeight: '600', marginBottom: 6, marginTop: 16 },
},
headerContainer: {
marginBottom: 40,
alignItems: 'center',
},
appIcon: {
fontSize: 56,
marginBottom: 16,
},
title: {
fontSize: 28,
fontWeight: '700',
color: '#202124',
marginBottom: 8,
},
subtitle: {
fontSize: 15,
color: '#5f6368',
},
inputContainer: {
marginBottom: 24,
},
label: {
fontSize: 13,
fontWeight: '600',
color: '#3c4043',
marginBottom: 6,
marginTop: 16,
},
input: { input: {
borderWidth: 1, borderRadius: 8, paddingHorizontal: 14, paddingVertical: 12, fontSize: 15,
},
forgotPassword: { alignSelf: 'flex-end', marginTop: 8 },
forgotPasswordText: { fontSize: 13, fontWeight: '600' },
primaryButton: {
borderRadius: 8, paddingVertical: 14, alignItems: 'center', marginBottom: 10,
borderWidth: 1, borderWidth: 1,
borderColor: '#dadce0',
borderRadius: 8,
paddingHorizontal: 14,
paddingVertical: 12,
fontSize: 15,
backgroundColor: '#fff',
color: '#202124',
}, },
loginButton: { primaryButtonText: { color: '#fff', fontSize: 15, fontWeight: '600' },
backgroundColor: '#1a73e8', secondaryButton: {
borderRadius: 8, borderRadius: 8, paddingVertical: 14, alignItems: 'center', borderWidth: 1,
paddingVertical: 14,
alignItems: 'center',
marginBottom: 20,
},
buttonDisabled: {
opacity: 0.6,
},
buttonText: {
color: '#fff',
fontSize: 15,
fontWeight: '600',
},
registerContainer: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
},
registerText: {
color: '#5f6368',
fontSize: 14,
},
registerLink: {
color: '#1a73e8',
fontSize: 14,
fontWeight: '600',
}, },
secondaryButtonText: { fontSize: 15, fontWeight: '600' },
buttonDisabled: { opacity: 0.6 },
}); });
export default LoginScreen; export default LoginScreen;
+146 -185
View File
@@ -1,21 +1,17 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { import {
StyleSheet, StyleSheet, View, Text, ScrollView, TouchableOpacity, Alert,
View, ActivityIndicator, Switch, TextInput,
Text,
ScrollView,
TouchableOpacity,
Alert,
ActivityIndicator,
Switch,
TextInput,
} from 'react-native'; } from 'react-native';
import { useTheme } from '../services/ThemeContext';
import DatabaseService from '../services/DatabaseService'; import DatabaseService from '../services/DatabaseService';
import GoogleAUTH from '../AUTH/GoogleAUTH'; import GoogleAUTH from '../AUTH/GoogleAUTH';
import NASAUTH from '../AUTH/NASAUTH'; import NASAUTH from '../AUTH/NASAUTH';
import NASSetupInstructions from '../components/NASSetupInstructions';
const OnboardingScreen = ({ navigation, route }) => { const OnboardingScreen = ({ navigation, route }) => {
const { userId } = route.params; const { userId } = route.params;
const { theme } = useTheme();
const [currentStep, setCurrentStep] = useState(0); const [currentStep, setCurrentStep] = useState(0);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -36,20 +32,20 @@ const OnboardingScreen = ({ navigation, route }) => {
const [nasPassword, setNasPassword] = useState(''); const [nasPassword, setNasPassword] = useState('');
const [nasPhotoFolder, setNasPhotoFolder] = useState('/home/user/photos'); const [nasPhotoFolder, setNasPhotoFolder] = useState('/home/user/photos');
// Default service // Auto-backup checklist (multi-select)
const [defaultService, setDefaultService] = useState(null); const [autoBackupServices, setAutoBackupServices] = useState([]);
const steps = [ const steps = [
{ title: 'Choose Services', description: 'Select which photo services to connect (1-3)' }, { title: 'Choose Services', description: 'Select which photo services to connect (1-3)' },
{ title: 'Connect Services', description: 'Set up each selected service' }, { title: 'Connect Services', description: 'Set up each selected service' },
{ title: 'Default Upload', description: 'Choose where new photos upload by default' }, { title: 'Automatic Backup', description: 'Choose where new photos are backed up' },
]; ];
const getEnabledServices = () => { const getConnectedServices = () => {
const services = []; const services = [];
if (googleEnabled) services.push({ name: 'Google Photos', type: 'GOOGLE_PHOTOS' }); if (googleEnabled && googleConnected) services.push({ name: 'Google Photos', type: 'GOOGLE_PHOTOS', icon: '📷' });
if (iosEnabled) services.push({ name: 'iCloud Photos', type: 'ICLOUD_PHOTOS' }); if (iosEnabled && iosConnected) services.push({ name: 'iCloud Photos', type: 'ICLOUD_PHOTOS', icon: '🍎' });
if (nasEnabled) services.push({ name: 'NAS Storage', type: 'NAS' }); if (nasEnabled && nasConnected) services.push({ name: 'NAS Storage', type: 'NAS', icon: '💾' });
return services; return services;
}; };
@@ -78,12 +74,10 @@ const OnboardingScreen = ({ navigation, route }) => {
const handleIosAuth = async () => { const handleIosAuth = async () => {
try { try {
setLoading(true); setLoading(true);
// Placeholder - iCloud API not yet available
await DatabaseService.linkAccount(userId, 'ICLOUD_PHOTOS', { await DatabaseService.linkAccount(userId, 'ICLOUD_PHOTOS', {
status: 'placeholder', status: 'placeholder',
note: 'iCloud integration pending Apple API access', note: 'iCloud integration pending Apple API access',
}); });
setIosConnected(true); setIosConnected(true);
Alert.alert('Info', 'iCloud Photos registered. Full integration coming soon.'); Alert.alert('Info', 'iCloud Photos registered. Full integration coming soon.');
} catch (error) { } catch (error) {
@@ -99,25 +93,19 @@ const OnboardingScreen = ({ navigation, route }) => {
Alert.alert('Error', 'Please fill in all NAS connection fields'); Alert.alert('Error', 'Please fill in all NAS connection fields');
return; return;
} }
try { try {
setLoading(true); setLoading(true);
const nasAuth = new NASAUTH(); const nasAuth = new NASAUTH();
await nasAuth.authenticate( await nasAuth.authenticate(
nasHost.trim(), nasHost.trim(), parseInt(nasPort) || 22,
parseInt(nasPort) || 22, nasUsername.trim(), nasPassword, nasPhotoFolder.trim()
nasUsername.trim(),
nasPassword,
nasPhotoFolder.trim()
); );
await DatabaseService.linkAccount(userId, 'NAS', { await DatabaseService.linkAccount(userId, 'NAS', {
host: nasHost.trim(), host: nasHost.trim(),
port: parseInt(nasPort) || 22, port: parseInt(nasPort) || 22,
username: nasUsername.trim(), username: nasUsername.trim(),
photoFolder: nasPhotoFolder.trim(), photoFolder: nasPhotoFolder.trim(),
}); });
setNasConnected(true); setNasConnected(true);
Alert.alert('Success', 'NAS connected!'); Alert.alert('Success', 'NAS connected!');
} catch (error) { } catch (error) {
@@ -128,38 +116,28 @@ const OnboardingScreen = ({ navigation, route }) => {
} }
}; };
const toggleAutoBackup = (serviceType) => {
setAutoBackupServices((prev) =>
prev.includes(serviceType) ? prev.filter((s) => s !== serviceType) : [...prev, serviceType]
);
};
const handleNext = async () => { const handleNext = async () => {
if (currentStep === 0) { if (currentStep === 0) {
if (getEnabledServices().length === 0) { const any = googleEnabled || iosEnabled || nasEnabled;
Alert.alert('Error', 'Please select at least one service'); if (!any) { Alert.alert('Error', 'Please select at least one service'); return; }
return;
}
setCurrentStep(1); setCurrentStep(1);
} else if (currentStep === 1) { } else if (currentStep === 1) {
// Check at least one service is connected
const anyConnected = const anyConnected =
(googleEnabled && googleConnected) || (googleEnabled && googleConnected) ||
(iosEnabled && iosConnected) || (iosEnabled && iosConnected) ||
(nasEnabled && nasConnected); (nasEnabled && nasConnected);
if (!anyConnected) { Alert.alert('Error', 'Please connect at least one service'); return; }
if (!anyConnected) {
Alert.alert('Error', 'Please connect at least one service before continuing');
return;
}
setCurrentStep(2); setCurrentStep(2);
} else if (currentStep === 2) { } else if (currentStep === 2) {
if (!defaultService) {
Alert.alert('Error', 'Please select a default upload service');
return;
}
try { try {
setLoading(true); setLoading(true);
const accounts = await DatabaseService.getLinkedAccounts(userId); await DatabaseService.setAutoBackupServices(userId, autoBackupServices);
const defaultAccount = accounts.find((a) => a.service_type === defaultService);
if (defaultAccount) {
await DatabaseService.setDefaultService(userId, defaultAccount.id);
}
navigation.replace('MainTabs', { userId }); navigation.replace('MainTabs', { userId });
} catch (error) { } catch (error) {
Alert.alert('Error', 'Failed to complete setup: ' + error.message); Alert.alert('Error', 'Failed to complete setup: ' + error.message);
@@ -170,45 +148,36 @@ const OnboardingScreen = ({ navigation, route }) => {
}; };
return ( return (
<View style={styles.container}> <View style={[styles.container, { backgroundColor: theme.background }]}>
{/* Progress bar */} {/* Progress bar */}
<View style={styles.progressBar}> <View style={styles.progressBar}>
{steps.map((_, i) => ( {steps.map((_, i) => (
<View key={i} style={[styles.progressDot, i <= currentStep && styles.progressDotActive]} /> <View key={i} style={[styles.progressDot, { backgroundColor: i <= currentStep ? theme.primary : theme.border }]} />
))} ))}
</View> </View>
<ScrollView style={styles.content} showsVerticalScrollIndicator={false}> <ScrollView style={styles.content} showsVerticalScrollIndicator={false}>
<View style={styles.header}> <View style={styles.header}>
<Text style={styles.stepLabel}>Step {currentStep + 1} of {steps.length}</Text> <Text style={[styles.stepLabel, { color: theme.primary }]}>Step {currentStep + 1} of {steps.length}</Text>
<Text style={styles.title}>{steps[currentStep].title}</Text> <Text style={[styles.title, { color: theme.text }]}>{steps[currentStep].title}</Text>
<Text style={styles.description}>{steps[currentStep].description}</Text> <Text style={[styles.description, { color: theme.textSecondary }]}>{steps[currentStep].description}</Text>
</View> </View>
{/* Step 0: Select services */} {/* Step 0: Select services */}
{currentStep === 0 && ( {currentStep === 0 && (
<View style={styles.cardsContainer}> <View style={styles.cardsContainer}>
<ServiceToggleCard <ServiceToggleCard theme={theme} icon="📷" name="Google Photos"
icon="📷"
name="Google Photos"
description="Access your Google Photos library" description="Access your Google Photos library"
enabled={googleEnabled} enabled={googleEnabled} onToggle={() => setGoogleEnabled(!googleEnabled)} />
onToggle={() => setGoogleEnabled(!googleEnabled)} <ServiceToggleCard theme={theme} icon="🍎" name="iCloud Photos"
/>
<ServiceToggleCard
icon="🍎"
name="iCloud Photos"
description="Access your iCloud photo library (placeholder)" description="Access your iCloud photo library (placeholder)"
enabled={iosEnabled} enabled={iosEnabled} onToggle={() => setIosEnabled(!iosEnabled)} />
onToggle={() => setIosEnabled(!iosEnabled)} <ServiceToggleCard theme={theme} icon="💾" name="NAS Storage"
/>
<ServiceToggleCard
icon="💾"
name="NAS Storage"
description="Connect to your Ubuntu server via SFTP" description="Connect to your Ubuntu server via SFTP"
enabled={nasEnabled} enabled={nasEnabled} onToggle={() => setNasEnabled(!nasEnabled)} />
onToggle={() => setNasEnabled(!nasEnabled)}
/> {/* NAS setup instructions dropdown */}
{nasEnabled && <NASSetupInstructions />}
</View> </View>
)} )}
@@ -216,84 +185,75 @@ const OnboardingScreen = ({ navigation, route }) => {
{currentStep === 1 && ( {currentStep === 1 && (
<View style={styles.cardsContainer}> <View style={styles.cardsContainer}>
{googleEnabled && ( {googleEnabled && (
<View style={styles.configCard}> <View style={[styles.configCard, { backgroundColor: theme.card, borderColor: theme.border }]}>
<View style={styles.configHeader}> <View style={styles.configHeader}>
<Text style={styles.configIcon}>📷</Text> <Text style={styles.configIcon}>📷</Text>
<Text style={styles.configTitle}>Google Photos</Text> <Text style={[styles.configTitle, { color: theme.text }]}>Google Photos</Text>
{googleConnected && <Text style={styles.connectedBadge}>✓ Connected</Text>} {googleConnected && <Text style={[styles.connectedBadge, { color: theme.accent }]}>✓ Connected</Text>}
</View> </View>
{!googleConnected && ( {!googleConnected && (
<TouchableOpacity <TouchableOpacity
style={[styles.authButton, loading && styles.buttonDisabled]} style={[styles.authButton, { backgroundColor: theme.primary }, loading && styles.buttonDisabled]}
onPress={handleGoogleAuth} onPress={handleGoogleAuth} disabled={loading}
disabled={loading}
> >
{loading ? <ActivityIndicator color="#fff" /> : ( {loading ? <ActivityIndicator color="#fff" /> : <Text style={styles.authButtonText}>Sign in with Google</Text>}
<Text style={styles.authButtonText}>Sign in with Google</Text>
)}
</TouchableOpacity> </TouchableOpacity>
)} )}
</View> </View>
)} )}
{iosEnabled && ( {iosEnabled && (
<View style={styles.configCard}> <View style={[styles.configCard, { backgroundColor: theme.card, borderColor: theme.border }]}>
<View style={styles.configHeader}> <View style={styles.configHeader}>
<Text style={styles.configIcon}>🍎</Text> <Text style={styles.configIcon}>🍎</Text>
<Text style={styles.configTitle}>iCloud Photos</Text> <Text style={[styles.configTitle, { color: theme.text }]}>iCloud Photos</Text>
{iosConnected && <Text style={styles.connectedBadge}>✓ Connected</Text>} {iosConnected && <Text style={[styles.connectedBadge, { color: theme.accent }]}>✓ Connected</Text>}
</View> </View>
{!iosConnected && ( {!iosConnected && (
<TouchableOpacity <TouchableOpacity
style={[styles.authButton, { backgroundColor: '#333' }, loading && styles.buttonDisabled]} style={[styles.authButton, { backgroundColor: '#333' }, loading && styles.buttonDisabled]}
onPress={handleIosAuth} onPress={handleIosAuth} disabled={loading}
disabled={loading}
> >
{loading ? <ActivityIndicator color="#fff" /> : ( {loading ? <ActivityIndicator color="#fff" /> : <Text style={styles.authButtonText}>Connect iCloud</Text>}
<Text style={styles.authButtonText}>Connect iCloud</Text>
)}
</TouchableOpacity> </TouchableOpacity>
)} )}
</View> </View>
)} )}
{nasEnabled && ( {nasEnabled && (
<View style={styles.configCard}> <View style={[styles.configCard, { backgroundColor: theme.card, borderColor: theme.border }]}>
<View style={styles.configHeader}> <View style={styles.configHeader}>
<Text style={styles.configIcon}>💾</Text> <Text style={styles.configIcon}>💾</Text>
<Text style={styles.configTitle}>NAS Storage</Text> <Text style={[styles.configTitle, { color: theme.text }]}>NAS Storage</Text>
{nasConnected && <Text style={styles.connectedBadge}>✓ Connected</Text>} {nasConnected && <Text style={[styles.connectedBadge, { color: theme.accent }]}>✓ Connected</Text>}
</View> </View>
{!nasConnected && ( {!nasConnected && (
<> <>
<Text style={styles.fieldLabel}>Server Address</Text> <Text style={[styles.fieldLabel, { color: theme.textSecondary }]}>Server Address</Text>
<TextInput style={styles.fieldInput} placeholder="192.168.1.100" value={nasHost} <TextInput style={[styles.fieldInput, { backgroundColor: theme.inputBg, borderColor: theme.border, color: theme.text }]}
onChangeText={setNasHost} editable={!loading} placeholderTextColor="#9aa0a6" /> placeholder="192.168.1.100" value={nasHost} onChangeText={setNasHost} editable={!loading} placeholderTextColor={theme.textTertiary} />
<Text style={styles.fieldLabel}>Port</Text> <Text style={[styles.fieldLabel, { color: theme.textSecondary }]}>Port</Text>
<TextInput style={styles.fieldInput} placeholder="22" value={nasPort} <TextInput style={[styles.fieldInput, { backgroundColor: theme.inputBg, borderColor: theme.border, color: theme.text }]}
onChangeText={setNasPort} editable={!loading} keyboardType="numeric" placeholderTextColor="#9aa0a6" /> placeholder="22" value={nasPort} onChangeText={setNasPort} editable={!loading} keyboardType="numeric" placeholderTextColor={theme.textTertiary} />
<Text style={styles.fieldLabel}>Username</Text> <Text style={[styles.fieldLabel, { color: theme.textSecondary }]}>Username</Text>
<TextInput style={styles.fieldInput} placeholder="SSH username" value={nasUsername} <TextInput style={[styles.fieldInput, { backgroundColor: theme.inputBg, borderColor: theme.border, color: theme.text }]}
onChangeText={setNasUsername} editable={!loading} autoCapitalize="none" placeholderTextColor="#9aa0a6" /> placeholder="SSH username" value={nasUsername} onChangeText={setNasUsername} editable={!loading} autoCapitalize="none" placeholderTextColor={theme.textTertiary} />
<Text style={styles.fieldLabel}>Password</Text> <Text style={[styles.fieldLabel, { color: theme.textSecondary }]}>Password</Text>
<TextInput style={styles.fieldInput} placeholder="SSH password" value={nasPassword} <TextInput style={[styles.fieldInput, { backgroundColor: theme.inputBg, borderColor: theme.border, color: theme.text }]}
onChangeText={setNasPassword} editable={!loading} secureTextEntry autoCapitalize="none" placeholderTextColor="#9aa0a6" /> placeholder="SSH password" value={nasPassword} onChangeText={setNasPassword} editable={!loading} secureTextEntry autoCapitalize="none" placeholderTextColor={theme.textTertiary} />
<Text style={styles.fieldLabel}>Photos Folder</Text> <Text style={[styles.fieldLabel, { color: theme.textSecondary }]}>Photos Folder</Text>
<TextInput style={styles.fieldInput} placeholder="/home/user/photos" value={nasPhotoFolder} <TextInput style={[styles.fieldInput, { backgroundColor: theme.inputBg, borderColor: theme.border, color: theme.text }]}
onChangeText={setNasPhotoFolder} editable={!loading} placeholderTextColor="#9aa0a6" /> placeholder="/home/user/photos" value={nasPhotoFolder} onChangeText={setNasPhotoFolder} editable={!loading} placeholderTextColor={theme.textTertiary} />
<TouchableOpacity <TouchableOpacity
style={[styles.authButton, { backgroundColor: '#34a853' }, loading && styles.buttonDisabled]} style={[styles.authButton, { backgroundColor: theme.accent }, loading && styles.buttonDisabled]}
onPress={handleNasAuth} onPress={handleNasAuth} disabled={loading}
disabled={loading}
> >
{loading ? <ActivityIndicator color="#fff" /> : ( {loading ? <ActivityIndicator color="#fff" /> : <Text style={styles.authButtonText}>Test & Connect</Text>}
<Text style={styles.authButtonText}>Test & Connect</Text>
)}
</TouchableOpacity> </TouchableOpacity>
</> </>
)} )}
@@ -302,47 +262,65 @@ const OnboardingScreen = ({ navigation, route }) => {
</View> </View>
)} )}
{/* Step 2: Default service */} {/* Step 2: Auto-backup multi-checkbox */}
{currentStep === 2 && ( {currentStep === 2 && (
<View style={styles.cardsContainer}> <View style={styles.cardsContainer}>
{getEnabledServices().map((svc) => { <View style={[styles.configCard, { backgroundColor: theme.card, borderColor: theme.border }]}>
const connected = <Text style={[styles.checklistInfo, { color: theme.textSecondary }]}>
(svc.type === 'GOOGLE_PHOTOS' && googleConnected) || Select which services new photos should automatically upload to. You can select multiple or none.
(svc.type === 'ICLOUD_PHOTOS' && iosConnected) || </Text>
(svc.type === 'NAS' && nasConnected);
if (!connected) return null; {getConnectedServices().map((svc) => (
return (
<TouchableOpacity <TouchableOpacity
key={svc.type} key={svc.type}
style={[styles.selectCard, defaultService === svc.type && styles.selectCardActive]} style={[styles.checkRow, { borderBottomColor: theme.borderLight }]}
onPress={() => setDefaultService(svc.type)} onPress={() => toggleAutoBackup(svc.type)}
activeOpacity={0.7}
> >
<View style={[styles.radio, defaultService === svc.type && styles.radioActive]} /> <View style={[
<Text style={[styles.selectText, defaultService === svc.type && styles.selectTextActive]}> styles.checkbox,
{svc.name} { borderColor: theme.primary },
</Text> 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>
); ))}
})}
<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>
</TouchableOpacity>
</View>
</View> </View>
)} )}
</ScrollView> </ScrollView>
{/* Footer */} {/* Footer */}
<View style={styles.footer}> <View style={[styles.footer, { borderTopColor: theme.border }]}>
{currentStep > 0 ? ( {currentStep > 0 ? (
<TouchableOpacity style={styles.backButton} onPress={() => setCurrentStep(currentStep - 1)} disabled={loading}> <TouchableOpacity style={[styles.backButton, { backgroundColor: theme.surface, borderColor: theme.border }]}
<Text style={styles.backButtonText}>Back</Text> onPress={() => setCurrentStep(currentStep - 1)} disabled={loading}>
<Text style={[styles.backButtonText, { color: theme.textSecondary }]}>Back</Text>
</TouchableOpacity> </TouchableOpacity>
) : ( ) : (
<View style={{ flex: 1 }} /> <View style={{ flex: 1 }} />
)} )}
<TouchableOpacity <TouchableOpacity
style={[styles.nextButton, loading && styles.buttonDisabled]} style={[styles.nextButton, { backgroundColor: theme.primary }, loading && styles.buttonDisabled]}
onPress={handleNext} onPress={handleNext} disabled={loading}
disabled={loading}
> >
{loading ? <ActivityIndicator color="#fff" /> : ( {loading ? <ActivityIndicator color="#fff" /> : (
<Text style={styles.nextButtonText}> <Text style={styles.nextButtonText}>
@@ -355,99 +333,82 @@ const OnboardingScreen = ({ navigation, route }) => {
); );
}; };
const ServiceToggleCard = ({ icon, name, description, enabled, onToggle }) => ( const ServiceToggleCard = ({ theme, icon, name, description, enabled, onToggle }) => (
<View style={[styles.toggleCard, enabled && styles.toggleCardActive]}> <View style={[styles.toggleCard, { backgroundColor: theme.card, borderColor: enabled ? theme.primary : theme.border }]}>
<View style={styles.toggleCardContent}> <View style={styles.toggleCardContent}>
<Text style={styles.toggleIcon}>{icon}</Text> <Text style={styles.toggleIcon}>{icon}</Text>
<View style={styles.toggleInfo}> <View style={styles.toggleInfo}>
<Text style={styles.toggleName}>{name}</Text> <Text style={[styles.toggleName, { color: theme.text }]}>{name}</Text>
<Text style={styles.toggleDescription}>{description}</Text> <Text style={[styles.toggleDescription, { color: theme.textSecondary }]}>{description}</Text>
</View> </View>
</View> </View>
<Switch <Switch
value={enabled} value={enabled}
onValueChange={onToggle} onValueChange={onToggle}
trackColor={{ false: '#dadce0', true: '#a8dab5' }} trackColor={{ false: theme.border, true: '#a8dab5' }}
thumbColor={enabled ? '#34a853' : '#f4f3f4'} thumbColor={enabled ? '#34a853' : '#f4f3f4'}
/> />
</View> </View>
); );
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#fff' }, container: { flex: 1 },
progressBar: { progressBar: {
flexDirection: 'row', justifyContent: 'center', gap: 8, flexDirection: 'row', justifyContent: 'center', gap: 8,
paddingTop: 60, paddingBottom: 8, paddingTop: 60, paddingBottom: 8,
}, },
progressDot: { progressDot: { width: 32, height: 4, borderRadius: 2 },
width: 32, height: 4, borderRadius: 2, backgroundColor: '#dadce0',
},
progressDotActive: { backgroundColor: '#1a73e8' },
content: { flex: 1, paddingHorizontal: 20 }, content: { flex: 1, paddingHorizontal: 20 },
header: { marginBottom: 24 }, header: { marginBottom: 24 },
stepLabel: { fontSize: 12, color: '#1a73e8', fontWeight: '600', marginBottom: 6 }, stepLabel: { fontSize: 12, fontWeight: '600', marginBottom: 6 },
title: { fontSize: 26, fontWeight: '700', color: '#202124', marginBottom: 6 }, title: { fontSize: 26, fontWeight: '700', marginBottom: 6 },
description: { fontSize: 14, color: '#5f6368' }, description: { fontSize: 14 },
cardsContainer: { gap: 12 }, cardsContainer: { gap: 12 },
// Toggle cards (Step 0) // Toggle cards (Step 0)
toggleCard: { toggleCard: {
backgroundColor: '#f8f9fa', borderRadius: 12, padding: 16, borderRadius: 12, padding: 16, flexDirection: 'row',
flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', justifyContent: 'space-between', alignItems: 'center', borderWidth: 1.5,
borderWidth: 1.5, borderColor: '#e8eaed',
}, },
toggleCardActive: { borderColor: '#1a73e8', backgroundColor: '#e8f0fe' },
toggleCardContent: { flex: 1, flexDirection: 'row', alignItems: 'center' }, toggleCardContent: { flex: 1, flexDirection: 'row', alignItems: 'center' },
toggleIcon: { fontSize: 28, marginRight: 14 }, toggleIcon: { fontSize: 28, marginRight: 14 },
toggleInfo: { flex: 1 }, toggleInfo: { flex: 1 },
toggleName: { fontSize: 15, fontWeight: '600', color: '#202124', marginBottom: 2 }, toggleName: { fontSize: 15, fontWeight: '600', marginBottom: 2 },
toggleDescription: { fontSize: 12, color: '#5f6368' }, toggleDescription: { fontSize: 12 },
// Config cards (Step 1) // Config cards (Step 1)
configCard: { configCard: { borderRadius: 12, padding: 16, borderWidth: 1 },
backgroundColor: '#f8f9fa', borderRadius: 12, padding: 16,
borderWidth: 1, borderColor: '#e8eaed',
},
configHeader: { flexDirection: 'row', alignItems: 'center', marginBottom: 12 }, configHeader: { flexDirection: 'row', alignItems: 'center', marginBottom: 12 },
configIcon: { fontSize: 24, marginRight: 10 }, configIcon: { fontSize: 24, marginRight: 10 },
configTitle: { fontSize: 16, fontWeight: '600', color: '#202124', flex: 1 }, configTitle: { fontSize: 16, fontWeight: '600', flex: 1 },
connectedBadge: { fontSize: 13, color: '#34a853', fontWeight: '600' }, connectedBadge: { fontSize: 13, fontWeight: '600' },
authButton: { authButton: { borderRadius: 8, paddingVertical: 12, alignItems: 'center', marginTop: 8 },
backgroundColor: '#1a73e8', borderRadius: 8, paddingVertical: 12, alignItems: 'center',
},
authButtonText: { color: '#fff', fontSize: 14, fontWeight: '600' }, authButtonText: { color: '#fff', fontSize: 14, fontWeight: '600' },
fieldLabel: { fontSize: 12, fontWeight: '600', color: '#3c4043', marginTop: 10, marginBottom: 4 }, fieldLabel: { fontSize: 12, fontWeight: '600', marginTop: 10, marginBottom: 4 },
fieldInput: { fieldInput: {
borderWidth: 1, borderColor: '#dadce0', borderRadius: 8, borderWidth: 1, borderRadius: 8, paddingHorizontal: 12, paddingVertical: 10, fontSize: 14,
paddingHorizontal: 12, paddingVertical: 10, fontSize: 14,
backgroundColor: '#fff', color: '#202124',
}, },
// Select cards (Step 2) // Auto-backup (Step 2)
selectCard: { checklistInfo: { fontSize: 13, marginBottom: 8 },
backgroundColor: '#f8f9fa', borderRadius: 12, padding: 16, checkRow: {
flexDirection: 'row', alignItems: 'center', flexDirection: 'row', alignItems: 'center', paddingVertical: 14,
borderWidth: 2, borderColor: '#e8eaed', borderBottomWidth: StyleSheet.hairlineWidth,
}, },
selectCardActive: { borderColor: '#1a73e8', backgroundColor: '#e8f0fe' }, checkbox: {
radio: { width: 22, height: 22, borderRadius: 4, borderWidth: 2,
width: 20, height: 20, borderRadius: 10, borderWidth: 2, justifyContent: 'center', alignItems: 'center', marginRight: 12,
borderColor: '#dadce0', marginRight: 12,
}, },
radioActive: { borderColor: '#1a73e8', backgroundColor: '#1a73e8' }, checkmark: { color: '#fff', fontSize: 14, fontWeight: '700' },
selectText: { fontSize: 15, color: '#202124', fontWeight: '500' }, checkIcon: { fontSize: 18, marginRight: 8 },
selectTextActive: { color: '#1a73e8', fontWeight: '600' }, checkLabel: { fontSize: 14, fontWeight: '500', flex: 1 },
// Footer // Footer
footer: { footer: {
flexDirection: 'row', paddingHorizontal: 20, paddingVertical: 14, flexDirection: 'row', paddingHorizontal: 20, paddingVertical: 14,
borderTopWidth: 1, borderTopColor: '#e8eaed', gap: 12, borderTopWidth: 1, gap: 12,
}, },
backButton: { backButton: {
flex: 1, paddingVertical: 12, borderRadius: 8, alignItems: 'center', flex: 1, paddingVertical: 12, borderRadius: 8, alignItems: 'center', borderWidth: 1,
backgroundColor: '#f8f9fa', borderWidth: 1, borderColor: '#dadce0',
},
backButtonText: { fontSize: 15, fontWeight: '600', color: '#5f6368' },
nextButton: {
flex: 1, paddingVertical: 12, borderRadius: 8, alignItems: 'center',
backgroundColor: '#1a73e8',
}, },
backButtonText: { fontSize: 15, fontWeight: '600' },
nextButton: { flex: 1, paddingVertical: 12, borderRadius: 8, alignItems: 'center' },
nextButtonText: { fontSize: 15, fontWeight: '600', color: '#fff' }, nextButtonText: { fontSize: 15, fontWeight: '600', color: '#fff' },
buttonDisabled: { opacity: 0.6 }, buttonDisabled: { opacity: 0.6 },
}); });
+68 -174
View File
@@ -1,19 +1,16 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { import {
StyleSheet, StyleSheet, View, Text, TextInput, TouchableOpacity, Alert,
View, ActivityIndicator, KeyboardAvoidingView, Platform, ScrollView, Image,
Text,
TextInput,
TouchableOpacity,
Alert,
ActivityIndicator,
KeyboardAvoidingView,
Platform,
ScrollView,
} from 'react-native'; } from 'react-native';
import { useTheme } from '../services/ThemeContext';
import DatabaseService from '../services/DatabaseService'; import DatabaseService from '../services/DatabaseService';
// Same icon constant as LoginScreen – edit in one place
const APP_ICON = require('../../assets/icon.png');
const RegisterScreen = ({ navigation }) => { const RegisterScreen = ({ navigation }) => {
const { theme } = useTheme();
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
@@ -21,51 +18,23 @@ const RegisterScreen = ({ navigation }) => {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const handleRegister = async () => { const handleRegister = async () => {
if (!username.trim()) { if (!username.trim()) { Alert.alert('Error', 'Please enter a username'); return; }
Alert.alert('Error', 'Please enter a username'); if (username.trim().length < 3) { Alert.alert('Error', 'Username must be at least 3 characters'); return; }
return; if (!email.trim()) { Alert.alert('Error', 'Please enter an email'); return; }
} if (!email.includes('@') || !email.includes('.')) { Alert.alert('Error', 'Please enter a valid email address'); return; }
if (username.trim().length < 3) { if (!password.trim()) { Alert.alert('Error', 'Please enter a password'); return; }
Alert.alert('Error', 'Username must be at least 3 characters'); if (password.length < 6) { Alert.alert('Error', 'Password must be at least 6 characters'); return; }
return; if (password !== confirmPassword) { Alert.alert('Error', 'Passwords do not match'); return; }
}
if (!email.trim()) {
Alert.alert('Error', 'Please enter an email');
return;
}
if (!email.includes('@') || !email.includes('.')) {
Alert.alert('Error', 'Please enter a valid email address');
return;
}
if (!password.trim()) {
Alert.alert('Error', 'Please enter a password');
return;
}
if (password.length < 6) {
Alert.alert('Error', 'Password must be at least 6 characters');
return;
}
if (password !== confirmPassword) {
Alert.alert('Error', 'Passwords do not match');
return;
}
setLoading(true); setLoading(true);
try { try {
const exists = await DatabaseService.userExists(username.trim(), email.trim().toLowerCase()); const exists = await DatabaseService.userExists(username.trim(), email.trim().toLowerCase());
if (exists) { if (exists) { Alert.alert('Error', 'Username or email already taken'); setLoading(false); return; }
Alert.alert('Error', 'Username or email already taken');
setLoading(false);
return;
}
const userId = await DatabaseService.createUser(username.trim(), email.trim().toLowerCase(), password); const userId = await DatabaseService.createUser(username.trim(), email.trim().toLowerCase(), password);
Alert.alert('Success', 'Account created!', [ Alert.alert('Success', 'Account created!', [
{ { text: 'OK', onPress: () => navigation.replace('Onboarding', { userId }) },
text: 'OK',
onPress: () => navigation.replace('Onboarding', { userId }),
},
]); ]);
} catch (error) { } catch (error) {
console.error('Registration error:', error); console.error('Registration error:', error);
@@ -78,172 +47,97 @@ const RegisterScreen = ({ navigation }) => {
return ( return (
<KeyboardAvoidingView <KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.container} style={[styles.container, { backgroundColor: theme.background }]}
> >
<ScrollView contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}> <ScrollView contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
<View style={styles.content}> <View style={styles.content}>
<View style={styles.headerContainer}> <View style={styles.headerContainer}>
<Text style={styles.appIcon}>📸</Text> <Image source={APP_ICON} style={styles.appIcon} resizeMode="contain" />
<Text style={styles.title}>Create Account</Text> <Text style={[styles.title, { color: theme.text }]}>Create Account</Text>
<Text style={styles.subtitle}>Set up your local photo library account</Text> <Text style={[styles.subtitle, { color: theme.textSecondary }]}>Set up your local photo library account</Text>
</View> </View>
<View style={styles.inputContainer}> <View style={styles.inputContainer}>
<Text style={styles.label}>Username</Text> <Text style={[styles.label, { color: theme.textSecondary }]}>Username</Text>
<TextInput <TextInput
style={styles.input} style={[styles.input, { backgroundColor: theme.inputBg, borderColor: theme.border, color: theme.text }]}
placeholder="Choose a username" placeholder="Choose a username" placeholderTextColor={theme.textTertiary}
placeholderTextColor="#9aa0a6" value={username} onChangeText={setUsername} editable={!loading}
value={username} autoCapitalize="none" autoCorrect={false}
onChangeText={setUsername}
editable={!loading}
autoCapitalize="none"
autoCorrect={false}
/> />
<Text style={styles.label}>Email</Text> <Text style={[styles.label, { color: theme.textSecondary }]}>Email</Text>
<TextInput <TextInput
style={styles.input} style={[styles.input, { backgroundColor: theme.inputBg, borderColor: theme.border, color: theme.text }]}
placeholder="Enter your email" placeholder="Enter your email" placeholderTextColor={theme.textTertiary}
placeholderTextColor="#9aa0a6" value={email} onChangeText={setEmail} editable={!loading}
value={email} autoCapitalize="none" autoCorrect={false} keyboardType="email-address"
onChangeText={setEmail}
editable={!loading}
autoCapitalize="none"
autoCorrect={false}
keyboardType="email-address"
/> />
<Text style={styles.label}>Password</Text> <Text style={[styles.label, { color: theme.textSecondary }]}>Password</Text>
<TextInput <TextInput
style={styles.input} style={[styles.input, { backgroundColor: theme.inputBg, borderColor: theme.border, color: theme.text }]}
placeholder="Create a password (min 6 characters)" placeholder="Create a password (min 6 characters)" placeholderTextColor={theme.textTertiary}
placeholderTextColor="#9aa0a6" value={password} onChangeText={setPassword} editable={!loading}
value={password} secureTextEntry autoCapitalize="none"
onChangeText={setPassword}
editable={!loading}
secureTextEntry
autoCapitalize="none"
/> />
<Text style={styles.label}>Confirm Password</Text> <Text style={[styles.label, { color: theme.textSecondary }]}>Confirm Password</Text>
<TextInput <TextInput
style={styles.input} style={[styles.input, { backgroundColor: theme.inputBg, borderColor: theme.border, color: theme.text }]}
placeholder="Confirm your password" placeholder="Confirm your password" placeholderTextColor={theme.textTertiary}
placeholderTextColor="#9aa0a6" value={confirmPassword} onChangeText={setConfirmPassword} editable={!loading}
value={confirmPassword} secureTextEntry autoCapitalize="none"
onChangeText={setConfirmPassword}
editable={!loading}
secureTextEntry
autoCapitalize="none"
/> />
</View> </View>
{/* Create Account button – blue */}
<TouchableOpacity <TouchableOpacity
style={[styles.registerButton, loading && styles.buttonDisabled]} style={[styles.primaryButton, { backgroundColor: theme.primary, borderColor: theme.border }, loading && styles.buttonDisabled]}
onPress={handleRegister} onPress={handleRegister}
disabled={loading} disabled={loading}
> >
{loading ? ( {loading ? <ActivityIndicator color="#fff" /> : (
<ActivityIndicator color="#fff" /> <Text style={styles.primaryButtonText}>Create Account</Text>
) : (
<Text style={styles.buttonText}>Create Account</Text>
)} )}
</TouchableOpacity> </TouchableOpacity>
<View style={styles.loginContainer}> {/* Back to Login button – white/outlined */}
<Text style={styles.loginText}>Already have an account? </Text> <TouchableOpacity
<TouchableOpacity onPress={() => navigation.navigate('Login')} disabled={loading}> style={[styles.secondaryButton, { backgroundColor: theme.background, borderColor: theme.border }]}
<Text style={styles.loginLink}>Sign in</Text> onPress={() => navigation.navigate('Login')}
disabled={loading}
>
<Text style={[styles.secondaryButtonText, { color: theme.text }]}>Back to Login</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View>
</ScrollView> </ScrollView>
</KeyboardAvoidingView> </KeyboardAvoidingView>
); );
}; };
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: { flex: 1 },
flex: 1, scrollContent: { flexGrow: 1, justifyContent: 'center' },
backgroundColor: '#fff', content: { paddingHorizontal: 24, paddingVertical: 40 },
}, headerContainer: { marginBottom: 36, alignItems: 'center' },
scrollContent: { appIcon: { width: 80, height: 80, marginBottom: 16, borderRadius: 16 },
flexGrow: 1, title: { fontSize: 28, fontWeight: '700', marginBottom: 8 },
justifyContent: 'center', subtitle: { fontSize: 15, textAlign: 'center' },
}, inputContainer: { marginBottom: 24 },
content: { label: { fontSize: 13, fontWeight: '600', marginBottom: 6, marginTop: 16 },
paddingHorizontal: 24,
paddingVertical: 40,
},
headerContainer: {
marginBottom: 36,
alignItems: 'center',
},
appIcon: {
fontSize: 56,
marginBottom: 16,
},
title: {
fontSize: 28,
fontWeight: '700',
color: '#202124',
marginBottom: 8,
},
subtitle: {
fontSize: 15,
color: '#5f6368',
textAlign: 'center',
},
inputContainer: {
marginBottom: 24,
},
label: {
fontSize: 13,
fontWeight: '600',
color: '#3c4043',
marginBottom: 6,
marginTop: 16,
},
input: { input: {
borderWidth: 1, borderWidth: 1, borderRadius: 8, paddingHorizontal: 14, paddingVertical: 12, fontSize: 15,
borderColor: '#dadce0',
borderRadius: 8,
paddingHorizontal: 14,
paddingVertical: 12,
fontSize: 15,
backgroundColor: '#fff',
color: '#202124',
}, },
registerButton: { primaryButton: {
backgroundColor: '#1a73e8', borderRadius: 8, paddingVertical: 14, alignItems: 'center', marginBottom: 10, borderWidth: 1,
borderRadius: 8,
paddingVertical: 14,
alignItems: 'center',
marginBottom: 20,
}, },
buttonDisabled: { primaryButtonText: { color: '#fff', fontSize: 15, fontWeight: '600' },
opacity: 0.6, secondaryButton: {
}, borderRadius: 8, paddingVertical: 14, alignItems: 'center', borderWidth: 1,
buttonText: {
color: '#fff',
fontSize: 15,
fontWeight: '600',
},
loginContainer: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
},
loginText: {
color: '#5f6368',
fontSize: 14,
},
loginLink: {
color: '#1a73e8',
fontSize: 14,
fontWeight: '600',
}, },
secondaryButtonText: { fontSize: 15, fontWeight: '600' },
buttonDisabled: { opacity: 0.6 },
}); });
export default RegisterScreen; export default RegisterScreen;
+32 -98
View File
@@ -1,30 +1,25 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { import {
StyleSheet, View, Text, TextInput, FlatList, TouchableOpacity, StyleSheet, View, Text, TextInput, TouchableOpacity, ActivityIndicator,
Dimensions, Image, Modal, ActivityIndicator,
} from 'react-native'; } from 'react-native';
import { useTheme } from '../services/ThemeContext';
import DatabaseService from '../services/DatabaseService'; import DatabaseService from '../services/DatabaseService';
import PhotoGrid from '../components/PhotoGrid';
const { width } = Dimensions.get('window'); import MediaViewer from '../components/MediaViewer';
const GRID_COLUMNS = 3;
const GRID_GAP = 2;
const imageSize = (width - GRID_GAP * (GRID_COLUMNS + 1)) / GRID_COLUMNS;
const SearchScreen = ({ route }) => { const SearchScreen = ({ route }) => {
const userId = route?.params?.userId; const userId = route?.params?.userId;
const { theme } = useTheme();
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [results, setResults] = useState([]); const [results, setResults] = useState([]);
const [searching, setSearching] = useState(false); const [searching, setSearching] = useState(false);
const [selectedMedia, setSelectedMedia] = useState(null); const [selectedMedia, setSelectedMedia] = useState(null);
const [modalVisible, setModalVisible] = useState(false); const [viewerVisible, setViewerVisible] = useState(false);
useEffect(() => { useEffect(() => {
const timer = setTimeout(() => { const timer = setTimeout(() => {
if (query.trim().length > 0) { if (query.trim().length > 0) performSearch();
performSearch(); else setResults([]);
} else {
setResults([]);
}
}, 300); }, 300);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [query]); }, [query]);
@@ -42,36 +37,14 @@ const SearchScreen = ({ route }) => {
} }
}; };
const getServiceIcon = (type) => ({ GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }[type] || '📱');
const renderItem = ({ item }) => (
<TouchableOpacity
style={styles.gridItem}
onPress={() => { setSelectedMedia(item); setModalVisible(true); }}
activeOpacity={0.8}
>
{item.local_path ? (
<Image source={{ uri: item.local_path }} style={styles.gridImage} resizeMode="cover" />
) : (
<View style={styles.placeholder}>
<Text style={{ fontSize: 28 }}>📷</Text>
</View>
)}
<View style={styles.sourceBadge}>
<Text style={{ fontSize: 10 }}>{getServiceIcon(item.source_service)}</Text>
</View>
</TouchableOpacity>
);
return ( return (
<View style={styles.container}> <View style={[styles.container, { backgroundColor: theme.background }]}>
{/* Search bar */} <View style={[styles.searchBar, { backgroundColor: theme.searchBg }]}>
<View style={styles.searchBar}>
<Text style={styles.searchIcon}>🔍</Text> <Text style={styles.searchIcon}>🔍</Text>
<TextInput <TextInput
style={styles.searchInput} style={[styles.searchInput, { color: theme.text }]}
placeholder="Search photos and videos" placeholder="Search photos and videos"
placeholderTextColor="#9aa0a6" placeholderTextColor={theme.textTertiary}
value={query} value={query}
onChangeText={setQuery} onChangeText={setQuery}
autoFocus autoFocus
@@ -80,105 +53,66 @@ const SearchScreen = ({ route }) => {
/> />
{query.length > 0 && ( {query.length > 0 && (
<TouchableOpacity onPress={() => setQuery('')}> <TouchableOpacity onPress={() => setQuery('')}>
<Text style={styles.clearButton}>✕</Text> <Text style={[styles.clearButton, { color: theme.textSecondary }]}>✕</Text>
</TouchableOpacity> </TouchableOpacity>
)} )}
</View> </View>
{searching && ( {searching && (
<View style={styles.loadingContainer}> <View style={styles.loadingContainer}>
<ActivityIndicator size="small" color="#1a73e8" /> <ActivityIndicator size="small" color={theme.primary} />
</View> </View>
)} )}
{!searching && query.length > 0 && results.length === 0 && ( {!searching && query.length > 0 && results.length === 0 && (
<View style={styles.emptyContainer}> <View style={styles.emptyContainer}>
<Text style={styles.emptyIcon}>🔍</Text> <Text style={styles.emptyIcon}>🔍</Text>
<Text style={styles.emptyTitle}>No results</Text> <Text style={[styles.emptyTitle, { color: theme.text }]}>No results</Text>
<Text style={styles.emptyText}>Try searching with different keywords</Text> <Text style={[styles.emptyText, { color: theme.textSecondary }]}>Try different keywords</Text>
</View> </View>
)} )}
{query.length === 0 && ( {query.length === 0 && (
<View style={styles.emptyContainer}> <View style={styles.emptyContainer}>
<Text style={styles.emptyIcon}>🔍</Text> <Text style={styles.emptyIcon}>🔍</Text>
<Text style={styles.emptyTitle}>Search your photos</Text> <Text style={[styles.emptyTitle, { color: theme.text }]}>Search your photos</Text>
<Text style={styles.emptyText}>Search by title or description</Text> <Text style={[styles.emptyText, { color: theme.textSecondary }]}>Search by title or description</Text>
</View> </View>
)} )}
{results.length > 0 && ( {results.length > 0 && (
<> <>
<Text style={styles.resultCount}>{results.length} result{results.length !== 1 ? 's' : ''}</Text> <Text style={[styles.resultCount, { color: theme.textSecondary }]}>
<FlatList {results.length} result{results.length !== 1 ? 's' : ''}
</Text>
<PhotoGrid
data={results} data={results}
renderItem={renderItem} onItemPress={(item) => { setSelectedMedia(item); setViewerVisible(true); }}
keyExtractor={(item, i) => item.id?.toString() || i.toString()}
numColumns={GRID_COLUMNS}
contentContainerStyle={styles.gridContent}
/> />
</> </>
)} )}
{/* Photo viewer modal */} <MediaViewer visible={viewerVisible} media={selectedMedia} onClose={() => setViewerVisible(false)} />
<Modal visible={modalVisible} transparent animationType="fade" onRequestClose={() => setModalVisible(false)}>
<View style={styles.modalContainer}>
<TouchableOpacity style={styles.modalClose} onPress={() => setModalVisible(false)}>
<Text style={styles.modalCloseText}>✕</Text>
</TouchableOpacity>
{selectedMedia?.local_path ? (
<Image source={{ uri: selectedMedia.local_path }} style={styles.fullImage} resizeMode="contain" />
) : (
<View style={styles.fullPlaceholder}>
<Text style={{ fontSize: 64 }}>📷</Text>
</View>
)}
<View style={styles.mediaInfo}>
<Text style={styles.mediaTitle}>{selectedMedia?.title || 'Photo'}</Text>
</View>
</View>
</Modal>
</View> </View>
); );
}; };
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#fff' }, container: { flex: 1 },
searchBar: { searchBar: {
flexDirection: 'row', alignItems: 'center', marginHorizontal: 12, flexDirection: 'row', alignItems: 'center', marginHorizontal: 12,
marginTop: 48, marginBottom: 8, backgroundColor: '#f1f3f4', marginTop: 48, marginBottom: 8, borderRadius: 24,
borderRadius: 24, paddingHorizontal: 14, paddingVertical: 10, gap: 8, paddingHorizontal: 14, paddingVertical: 10, gap: 8,
}, },
searchIcon: { fontSize: 16 }, searchIcon: { fontSize: 16 },
searchInput: { flex: 1, fontSize: 15, color: '#202124', padding: 0 }, searchInput: { flex: 1, fontSize: 15, padding: 0 },
clearButton: { fontSize: 16, color: '#5f6368', padding: 4 }, clearButton: { fontSize: 16, padding: 4 },
loadingContainer: { padding: 20, alignItems: 'center' }, loadingContainer: { padding: 20, alignItems: 'center' },
emptyContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', paddingHorizontal: 24 }, emptyContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', paddingHorizontal: 24 },
emptyIcon: { fontSize: 48, marginBottom: 12 }, emptyIcon: { fontSize: 48, marginBottom: 12 },
emptyTitle: { fontSize: 18, fontWeight: '600', color: '#202124', marginBottom: 6 }, emptyTitle: { fontSize: 18, fontWeight: '600', marginBottom: 6 },
emptyText: { fontSize: 13, color: '#5f6368', textAlign: 'center' }, emptyText: { fontSize: 13, textAlign: 'center' },
resultCount: { paddingHorizontal: 16, paddingVertical: 8, fontSize: 13, color: '#5f6368' }, resultCount: { paddingHorizontal: 16, paddingVertical: 8, fontSize: 13 },
gridContent: { paddingBottom: 20 },
gridItem: {
width: imageSize, height: imageSize, margin: GRID_GAP / 2,
backgroundColor: '#f1f3f4', position: 'relative',
},
gridImage: { width: '100%', height: '100%' },
placeholder: { width: '100%', height: '100%', justifyContent: 'center', alignItems: 'center', backgroundColor: '#e8eaed' },
sourceBadge: {
position: 'absolute', bottom: 4, right: 4, backgroundColor: 'rgba(0,0,0,0.5)',
borderRadius: 8, paddingHorizontal: 4, paddingVertical: 2,
},
modalContainer: { flex: 1, backgroundColor: '#000' },
modalClose: {
position: 'absolute', top: 44, right: 16, zIndex: 20, width: 36, height: 36,
borderRadius: 18, backgroundColor: 'rgba(255,255,255,0.2)', justifyContent: 'center', alignItems: 'center',
},
modalCloseText: { color: '#fff', fontSize: 20, fontWeight: '600' },
fullImage: { flex: 1, width: '100%' },
fullPlaceholder: { flex: 1, justifyContent: 'center', alignItems: 'center' },
mediaInfo: { backgroundColor: '#1a1a1a', padding: 16, paddingBottom: 32, borderTopLeftRadius: 16, borderTopRightRadius: 16 },
mediaTitle: { fontSize: 16, fontWeight: '600', color: '#fff' },
}); });
export default SearchScreen; export default SearchScreen;
+401
View File
@@ -0,0 +1,401 @@
import React, { useState, useCallback } from 'react';
import {
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 NASSetupInstructions from '../components/NASSetupInstructions';
import NASAUTH from '../AUTH/NASAUTH';
const SettingsScreen = ({ navigation, route }) => {
const userId = route?.params?.userId;
const { theme, themeName, setThemeName, themes } = useTheme();
const [loading, setLoading] = useState(false);
const [accounts, setAccounts] = useState([]);
const [autoBackup, setAutoBackup] = useState([]);
const [nasOffload, setNasOffload] = useState(false);
// NAS edit fields
const [editingNas, setEditingNas] = useState(false);
const [nasHost, setNasHost] = useState('');
const [nasPort, setNasPort] = useState('22');
const [nasUsername, setNasUsername] = useState('');
const [nasPassword, setNasPassword] = useState('');
const [nasPhotoFolder, setNasPhotoFolder] = useState('/home/user/photos');
useFocusEffect(
useCallback(() => {
if (userId) loadData();
}, [userId])
);
const loadData = async () => {
try {
const allAccounts = await DatabaseService.getAllLinkedAccounts(userId);
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 || 22));
setNasUsername(nasAcc.account_data.username || '');
setNasPhotoFolder(nasAcc.account_data.photoFolder || '/home/user/photos');
}
} catch (err) {
console.error('Error loading settings:', err);
}
};
const toggleService = async (accountId, currentEnabled) => {
try {
await DatabaseService.toggleAccountEnabled(accountId, !currentEnabled);
await loadData();
} catch (err) {
Alert.alert('Error', 'Failed to update service');
}
};
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() || !nasPassword.trim()) {
Alert.alert('Error', 'Please fill in all NAS fields');
return;
}
setLoading(true);
try {
const nasAuth = new NASAUTH();
await nasAuth.authenticate(
nasHost.trim(), parseInt(nasPort) || 22,
nasUsername.trim(), nasPassword, nasPhotoFolder.trim()
);
await DatabaseService.linkAccount(userId, 'NAS', {
host: nasHost.trim(),
port: parseInt(nasPort) || 22,
username: nasUsername.trim(),
photoFolder: nasPhotoFolder.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);
}
};
const handleLogout = () => {
Alert.alert('Logout', 'Are you sure you want to sign out?', [
{ text: 'Cancel', style: 'cancel' },
{ text: 'Sign Out', style: 'destructive', onPress: () => navigation.replace('Login') },
]);
};
const getServiceIcon = (type) =>
({ GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }[type] || '📱');
const getServiceName = (type) =>
({ GOOGLE_PHOTOS: 'Google Photos', ICLOUD_PHOTOS: 'iCloud Photos', NAS: 'NAS Storage' }[type] || type);
const themeOptions = [
{ 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' },
];
const hasNasConnected = accounts.some((a) => a.service_type === 'NAS' && a.is_enabled);
return (
<View style={[styles.container, { backgroundColor: theme.background }]}>
<View style={[styles.header, { backgroundColor: theme.background, borderBottomColor: theme.border }]}>
<TouchableOpacity onPress={() => navigation.goBack()}>
<Text style={[styles.backArrow, { color: theme.text }]}>←</Text>
</TouchableOpacity>
<Text style={[styles.headerTitle, { color: theme.text }]}>Settings</Text>
<View style={{ width: 32 }} />
</View>
<ScrollView style={styles.scrollContent} showsVerticalScrollIndicator={false}>
{/* Theme Section */}
<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 },
]}
onPress={() => setThemeName(opt.key)}
>
<Text style={styles.themeIcon}>{opt.icon}</Text>
<View style={styles.themeInfo}>
<Text style={[styles.themeName, { color: theme.text }]}>{opt.label}</Text>
<Text style={[styles.themeDesc, { color: theme.textSecondary }]}>{opt.desc}</Text>
</View>
<View style={[styles.radio, themeName === opt.key && { borderColor: theme.primary, backgroundColor: theme.primary }]} />
</TouchableOpacity>
))}
</View>
{/* Services Section */}
<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) => (
<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>
</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>
{/* 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" 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" placeholderTextColor={theme.textTertiary} />
<Text style={[styles.fieldLabel, { color: theme.textSecondary }]}>Password</Text>
<TextInput style={[styles.fieldInput, { backgroundColor: theme.inputBg, borderColor: theme.border, color: theme.text }]}
value={nasPassword} onChangeText={setNasPassword} secureTextEntry autoCapitalize="none" placeholder="Enter password" placeholderTextColor={theme.textTertiary} />
<Text style={[styles.fieldLabel, { color: theme.textSecondary }]}>Photos Folder</Text>
<TextInput style={[styles.fieldInput, { backgroundColor: theme.inputBg, borderColor: theme.border, color: theme.text }]}
value={nasPhotoFolder} onChangeText={setNasPhotoFolder} 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>
)}
{/* NAS Setup Instructions */}
<NASSetupInstructions />
{/* NAS Google Photos 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 }]}>
Fetch Google Photos data through NAS server
</Text>
</View>
<Switch
value={nasOffload}
onValueChange={setNasOffload}
trackColor={{ false: theme.border, true: '#a8dab5' }}
thumbColor={nasOffload ? theme.accent : '#f4f3f4'}
/>
</View>
</View>
</>
)}
{/* 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 }]}>
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) => {
const isConnected = accounts.some((a) => a.service_type === svc.type && a.is_enabled);
return (
<TouchableOpacity
key={svc.type}
style={[styles.checkRow, { borderBottomColor: theme.borderLight }]}
onPress={() => isConnected && toggleAutoBackup(svc.type)}
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 },
]}>
{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>
{!isConnected && (
<Text style={[styles.notConnected, { color: theme.textTertiary }]}>Not connected</Text>
)}
</TouchableOpacity>
);
})}
<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 },
]}>
{autoBackup.length === 0 && <Text style={styles.checkmark}>✓</Text>}
</View>
<Text style={[styles.checkLabel, { color: theme.text, marginLeft: 0 }]}>
Don't automatically back up
</Text>
</TouchableOpacity>
</View>
{/* Sign Out */}
<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>
);
};
const styles = StyleSheet.create({
container: { flex: 1 },
header: {
flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between',
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 },
cardSubtext: { fontSize: 12, paddingHorizontal: 14, paddingTop: 10, paddingBottom: 4 },
// Theme rows
themeRow: {
flexDirection: 'row', alignItems: 'center', paddingHorizontal: 14, paddingVertical: 14,
borderBottomWidth: StyleSheet.hairlineWidth,
},
themeIcon: { fontSize: 22, marginRight: 12 },
themeInfo: { flex: 1 },
themeName: { fontSize: 14, fontWeight: '600', marginBottom: 2 },
themeDesc: { fontSize: 12 },
radio: {
width: 20, height: 20, borderRadius: 10, borderWidth: 2, borderColor: '#dadce0',
},
// Service rows
serviceRow: {
flexDirection: 'row', alignItems: 'center', paddingHorizontal: 14, paddingVertical: 12,
borderBottomWidth: StyleSheet.hairlineWidth,
},
serviceIcon: { fontSize: 22, marginRight: 12 },
serviceInfo: { flex: 1 },
serviceName: { fontSize: 14, fontWeight: '600', marginBottom: 2 },
serviceStatus: { fontSize: 12 },
editButton: {
borderWidth: 1, borderRadius: 6, paddingHorizontal: 10, paddingVertical: 4, marginRight: 10,
},
editButtonText: { 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
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,
},
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' },
});
export default SettingsScreen;
+67
View File
@@ -63,6 +63,14 @@ const DatabaseService = {
) )
`); `);
// Migration: add columns if they don't exist
try {
await db.execAsync(`ALTER TABLE users ADD COLUMN auto_backup_services TEXT DEFAULT '[]'`);
} catch (e) { /* column already exists */ }
try {
await db.execAsync(`ALTER TABLE users ADD COLUMN theme_preference TEXT DEFAULT 'light'`);
} catch (e) { /* column already exists */ }
console.log('Database initialized successfully'); console.log('Database initialized successfully');
return true; return true;
} catch (error) { } catch (error) {
@@ -299,6 +307,65 @@ const DatabaseService = {
} }
}, },
getAutoBackupServices: async (userId) => {
try {
await DatabaseService.ensureDb();
const row = await db.getFirstAsync('SELECT auto_backup_services FROM users WHERE id = ?', [userId]);
if (!row?.auto_backup_services) return [];
return JSON.parse(row.auto_backup_services);
} catch (error) {
console.error('Error getting auto backup services:', error);
return [];
}
},
setAutoBackupServices: async (userId, services) => {
try {
await DatabaseService.ensureDb();
await db.runAsync('UPDATE users SET auto_backup_services = ? WHERE id = ?', [JSON.stringify(services), userId]);
} catch (error) {
console.error('Error setting auto backup services:', error);
throw error;
}
},
toggleAccountEnabled: async (accountId, enabled) => {
try {
await DatabaseService.ensureDb();
await db.runAsync('UPDATE linked_accounts SET is_enabled = ? WHERE id = ?', [enabled ? 1 : 0, accountId]);
} catch (error) {
console.error('Error toggling account:', error);
throw error;
}
},
removeLinkedAccount: async (accountId) => {
try {
await DatabaseService.ensureDb();
await db.runAsync('DELETE FROM linked_accounts WHERE id = ?', [accountId]);
} catch (error) {
console.error('Error removing account:', error);
throw error;
}
},
getAllLinkedAccounts: async (userId) => {
try {
await DatabaseService.ensureDb();
const rows = await db.getAllAsync(
'SELECT * FROM linked_accounts WHERE user_id = ?',
[userId]
);
return rows.map((row) => ({
...row,
account_data: CredentialEncryption.decrypt(row.account_data),
}));
} catch (error) {
console.error('Error getting all linked accounts:', error);
throw error;
}
},
closeDatabase: async () => { closeDatabase: async () => {
try { try {
if (db?.closeAsync) await db.closeAsync(); if (db?.closeAsync) await db.closeAsync();
+151
View File
@@ -0,0 +1,151 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import * as SecureStore from 'expo-secure-store';
const THEME_KEY = 'app_theme_preference';
const lightTheme = {
name: 'light',
background: '#ffffff',
surface: '#f8f9fa',
surfaceElevated: '#ffffff',
text: '#202124',
textSecondary: '#5f6368',
textTertiary: '#9aa0a6',
primary: '#1a73e8',
primaryLight: '#d2e3fc',
accent: '#34a853',
danger: '#d93025',
border: '#dadce0',
borderLight: '#e8eaed',
inputBg: '#ffffff',
chipBg: '#f1f3f4',
chipActiveBg: '#d2e3fc',
tabBar: '#ffffff',
tabBarBorder: '#e0e0e0',
tabActive: '#1a73e8',
tabInactive: '#5f6368',
card: '#f8f9fa',
cardActive: '#e8f0fe',
overlay: 'rgba(0,0,0,0.3)',
statusBarStyle: 'dark',
searchBg: '#f1f3f4',
profileBg: '#1a73e8',
modalBg: '#000000',
modalInfoBg: '#1a1a1a',
blur: false,
};
const darkTheme = {
name: 'dark',
background: '#121212',
surface: '#1e1e1e',
surfaceElevated: '#2d2d2d',
text: '#e8eaed',
textSecondary: '#9aa0a6',
textTertiary: '#5f6368',
primary: '#8ab4f8',
primaryLight: '#1f3a5f',
accent: '#81c995',
danger: '#f28b82',
border: '#3c4043',
borderLight: '#2d2d2d',
inputBg: '#2d2d2d',
chipBg: '#2d2d2d',
chipActiveBg: '#1f3a5f',
tabBar: '#1e1e1e',
tabBarBorder: '#3c4043',
tabActive: '#8ab4f8',
tabInactive: '#9aa0a6',
card: '#1e1e1e',
cardActive: '#1f3a5f',
overlay: 'rgba(0,0,0,0.6)',
statusBarStyle: 'light',
searchBg: '#2d2d2d',
profileBg: '#8ab4f8',
modalBg: '#000000',
modalInfoBg: '#1e1e1e',
blur: false,
};
const liquidGlassTheme = {
name: 'liquidGlass',
background: '#f0f0f3',
surface: 'rgba(255,255,255,0.45)',
surfaceElevated: 'rgba(255,255,255,0.7)',
text: '#1c1c1e',
textSecondary: '#636366',
textTertiary: '#aeaeb2',
primary: '#007aff',
primaryLight: 'rgba(0,122,255,0.12)',
accent: '#34c759',
danger: '#ff3b30',
border: 'rgba(0,0,0,0.08)',
borderLight: 'rgba(0,0,0,0.04)',
inputBg: 'rgba(255,255,255,0.6)',
chipBg: 'rgba(255,255,255,0.5)',
chipActiveBg: 'rgba(0,122,255,0.15)',
tabBar: 'rgba(249,249,249,0.8)',
tabBarBorder: 'rgba(0,0,0,0.08)',
tabActive: '#007aff',
tabInactive: '#8e8e93',
card: 'rgba(255,255,255,0.5)',
cardActive: 'rgba(0,122,255,0.12)',
overlay: 'rgba(0,0,0,0.25)',
statusBarStyle: 'dark',
searchBg: 'rgba(118,118,128,0.12)',
profileBg: '#007aff',
modalBg: 'rgba(0,0,0,0.9)',
modalInfoBg: 'rgba(30,30,30,0.9)',
blur: true,
blurIntensity: 60,
blurTint: 'light',
};
const themes = { light: lightTheme, dark: darkTheme, liquidGlass: liquidGlassTheme };
const ThemeContext = createContext({
theme: lightTheme,
themeName: 'light',
setThemeName: () => {},
themes,
});
export const ThemeProvider = ({ children }) => {
const [themeName, setThemeNameState] = useState('light');
useEffect(() => {
loadTheme();
}, []);
const loadTheme = async () => {
try {
const saved = await SecureStore.getItemAsync(THEME_KEY);
if (saved && themes[saved]) {
setThemeNameState(saved);
}
} catch (err) {
console.log('Could not load theme preference:', err.message);
}
};
const setThemeName = async (name) => {
if (!themes[name]) return;
setThemeNameState(name);
try {
await SecureStore.setItemAsync(THEME_KEY, name);
} catch (err) {
console.log('Could not save theme preference:', err.message);
}
};
return (
<ThemeContext.Provider value={{ theme: themes[themeName], themeName, setThemeName, themes }}>
{children}
</ThemeContext.Provider>
);
};
export const useTheme = () => useContext(ThemeContext);
export default ThemeContext;