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
+1761 -1018

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 { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { ActivityIndicator, View, Text, TouchableOpacity } from 'react-native';
import { ThemeProvider, useTheme } from './src/services/ThemeContext';
import DatabaseService from './src/services/DatabaseService';
import LoginScreen from './src/screens/LoginScreen';
import RegisterScreen from './src/screens/RegisterScreen';
import OnboardingScreen from './src/screens/OnboardingScreen';
import HomeScreen from './src/screens/HomeScreen';
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 Tab = createBottomTabNavigator();
/**
* MainTabs - Bottom tab navigator for logged-in users
* Tabs: Home (Library), Search, Albums
* MainTabs – Bottom tab navigator for logged-in users
* Tabs: Library, Search, Collections
*/
function MainTabs({ route }) {
const { userId } = route.params || {};
const { theme } = useTheme();
return (
<Tab.Navigator
screenOptions={{
headerShown: false,
tabBarActiveTintColor: '#1a73e8',
tabBarInactiveTintColor: '#5f6368',
tabBarActiveTintColor: theme.tabActive,
tabBarInactiveTintColor: theme.tabInactive,
tabBarStyle: {
backgroundColor: '#fff',
borderTopColor: '#e0e0e0',
backgroundColor: theme.tabBar,
borderTopColor: theme.tabBarBorder,
borderTopWidth: 1,
height: 56,
paddingBottom: 6,
paddingTop: 6,
},
tabBarLabelStyle: {
fontSize: 11,
fontWeight: '500',
},
tabBarLabelStyle: { fontSize: 11, fontWeight: '500' },
}}
>
<Tab.Screen
name="Photos"
name="Library"
component={HomeScreen}
initialParams={{ userId }}
options={{
tabBarLabel: 'Photos',
tabBarIcon: ({ color }) => (
<Text style={{ fontSize: 22, color }}>🖼️</Text>
),
tabBarLabel: 'Library',
tabBarIcon: ({ color }) => <Text style={{ fontSize: 22, color }}>🖼️</Text>,
}}
/>
<Tab.Screen
@@ -59,20 +57,16 @@ function MainTabs({ route }) {
initialParams={{ userId }}
options={{
tabBarLabel: 'Search',
tabBarIcon: ({ color }) => (
<Text style={{ fontSize: 22, color }}>🔍</Text>
),
tabBarIcon: ({ color }) => <Text style={{ fontSize: 22, color }}>🔍</Text>,
}}
/>
<Tab.Screen
name="Albums"
component={AlbumsScreen}
name="Collections"
component={CollectionsScreen}
initialParams={{ userId }}
options={{
tabBarLabel: 'Albums',
tabBarIcon: ({ color }) => (
<Text style={{ fontSize: 22, color }}>📁</Text>
),
tabBarLabel: 'Collections',
tabBarIcon: ({ color }) => <Text style={{ fontSize: 22, color }}>📁</Text>,
}}
/>
</Tab.Navigator>
@@ -80,31 +74,26 @@ function MainTabs({ route }) {
}
/**
* AppNavigator - Root stack navigator
* Handles auth flow (Login/Register/Onboarding) then MainTabs
* AppNavigator – Root stack navigator
*/
function AppNavigator() {
return (
<Stack.Navigator
screenOptions={{
headerShown: false,
}}
>
<Stack.Navigator screenOptions={{ headerShown: false }}>
<Stack.Screen name="Login" component={LoginScreen} />
<Stack.Screen name="Register" component={RegisterScreen} />
<Stack.Screen name="Onboarding" component={OnboardingScreen} />
<Stack.Screen name="MainTabs" component={MainTabs} />
<Stack.Screen name="Settings" component={SettingsScreen} />
</Stack.Navigator>
);
}
export default function App() {
function AppInner() {
const { theme } = useTheme();
const [isReady, setIsReady] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
initializeApp();
}, []);
useEffect(() => { initializeApp(); }, []);
const initializeApp = async () => {
try {
@@ -120,21 +109,19 @@ export default function App() {
if (!isReady && !error) {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#fff' }}>
<ActivityIndicator size="large" color="#1a73e8" />
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: theme.background }}>
<ActivityIndicator size="large" color={theme.primary} />
</View>
);
}
if (error) {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', paddingHorizontal: 20, backgroundColor: '#fff' }}>
<Text style={{ fontSize: 16, color: '#333', marginBottom: 12, textAlign: 'center' }}>
{error}
</Text>
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', paddingHorizontal: 20, backgroundColor: theme.background }}>
<Text style={{ fontSize: 16, color: theme.text, marginBottom: 12, textAlign: 'center' }}>{error}</Text>
<TouchableOpacity
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>
</TouchableOpacity>
@@ -147,7 +134,15 @@ export default function App() {
<NavigationContainer>
<AppNavigator />
</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",
"expo": "~54.0.33",
"expo-auth-session": "~7.0.10",
"expo-blur": "~15.0.8",
"expo-crypto": "~15.0.8",
"expo-file-system": "~19.0.21",
"expo-image-picker": "~17.0.10",
+192 -34
View File
@@ -1,18 +1,26 @@
import * as AuthSession from 'expo-auth-session';
import * as WebBrowser from 'expo-web-browser';
import { AuthServiceBase } from './AuthServiceBase';
/**
* 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.
*/
WebBrowser.maybeCompleteAuthSession();
// TODO: Replace with your real Google Cloud OAuth Client ID
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 {
constructor() {
super();
@@ -20,21 +28,21 @@ class GoogleAUTH extends AuthServiceBase {
this.refreshToken = null;
this.expiresAt = null;
this.email = null;
this.idToken = null;
}
/**
* Authenticate with Google
* Currently uses placeholder tokens. Replace with real OAuth when client ID is configured.
* Authenticate with Google using expo-auth-session
* Falls back to placeholder tokens when no real client ID is set
*/
async authenticate() {
try {
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)');
this.accessToken = 'placeholder_google_token_' + Date.now();
this.refreshToken = 'placeholder_refresh_' + Date.now();
this.expiresAt = Date.now() + 3600000;
this.email = 'google-user';
this.email = 'google-user@placeholder.com';
return {
success: true,
@@ -44,26 +52,92 @@ class GoogleAUTH extends AuthServiceBase {
};
}
// Real OAuth flow would go here using expo-auth-session
// const discovery = { authorizationEndpoint: 'https://accounts.google.com/o/oauth2/v2/auth', tokenEndpoint: 'https://oauth2.googleapis.com/token' };
// Use AuthSession.makeRedirectUri() and AuthSession.useAuthRequest()
throw new Error('Google OAuth client ID configured but real OAuth flow not yet implemented');
// Real OAuth flow
const redirectUri = AuthSession.makeRedirectUri({ preferLocalhost: true });
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) {
console.error('Google authentication error:', 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() {
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');
}
async refreshAccessToken() {
try {
console.log('Refreshing Google access token...');
this.accessToken = 'refreshed_google_token_' + Date.now();
this.expiresAt = Date.now() + 3600000;
if (GOOGLE_CLIENT_ID === 'YOUR_GOOGLE_CLIENT_ID.apps.googleusercontent.com') {
this.accessToken = 'refreshed_placeholder_' + Date.now();
this.expiresAt = Date.now() + 3600000;
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) {
console.error('Token refresh error:', error);
@@ -72,6 +146,13 @@ class GoogleAUTH extends AuthServiceBase {
}
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.refreshToken = null;
this.expiresAt = null;
@@ -80,31 +161,108 @@ class GoogleAUTH extends AuthServiceBase {
}
async getUserInfo() {
return {
id: 'google_user',
email: this.email || 'unknown',
name: 'Google User',
};
const info = await this.fetchUserInfo();
return info || { id: '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 {
await this.getAccessToken();
// In production: call Google Photos API
// https://photoslibrary.googleapis.com/v1/mediaItems
return { mediaItems: [], nextPageToken: null };
const token = await this.getAccessToken();
const params = new URLSearchParams({ pageSize: String(pageSize) });
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 };
}
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) {
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 {
await this.getAccessToken();
return { albums: [], nextPageToken: null };
const token = await this.getAccessToken();
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) {
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;
}
}
+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;
+157 -120
View File
@@ -1,210 +1,247 @@
import React, { useState, useCallback } from 'react';
import {
StyleSheet, View, Text, FlatList, TouchableOpacity,
StyleSheet, View, Text, SectionList, FlatList, TouchableOpacity,
Dimensions, Image, ActivityIndicator, RefreshControl,
} from 'react-native';
import { useFocusEffect } from '@react-navigation/native';
import { useTheme } from '../services/ThemeContext';
import DatabaseService from '../services/DatabaseService';
import MediaViewer from '../components/MediaViewer';
const { width } = Dimensions.get('window');
const ALBUM_COLUMNS = 2;
const ALBUM_GAP = 12;
const albumSize = (width - ALBUM_GAP * (ALBUM_COLUMNS + 1)) / ALBUM_COLUMNS;
const AlbumsScreen = ({ route }) => {
const CollectionsScreen = ({ route, navigation }) => {
const userId = route?.params?.userId;
const [albums, setAlbums] = useState([]);
const { theme } = useTheme();
const [sections, setSections] = useState([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [selectedAlbum, setSelectedAlbum] = useState(null);
const [albumMedia, setAlbumMedia] = useState([]);
const [selectedMedia, setSelectedMedia] = useState(null);
const [viewerVisible, setViewerVisible] = useState(false);
useFocusEffect(
useCallback(() => {
if (userId) loadAlbums();
if (userId) loadCollections();
}, [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 {
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) {
const media = await DatabaseService.getMediaByService(userId, acc.service_type);
albumList.push({
service_type: acc.service_type,
name: getServiceName(acc.service_type),
const serviceAlbums = [
{
name: `All ${getServiceName(acc.service_type)}`,
icon: getServiceIcon(acc.service_type),
count: media.length,
coverImage: media.length > 0 ? media[0].local_path : null,
media,
type: acc.service_type,
},
];
sectionList.push({
title: getServiceName(acc.service_type),
icon: getServiceIcon(acc.service_type),
count: media.length,
coverImage: media.length > 0 ? media[0].local_path : null,
media,
data: [{ albums: serviceAlbums }],
});
}
// Also add an "All Photos" album
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);
setSections(sectionList);
} catch (err) {
console.error('Error loading albums:', err);
console.error('Error loading collections:', err);
} finally {
setLoading(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
if (selectedAlbum) {
const gridItemSize = (width - 6) / 3;
return (
<View style={styles.container}>
<View style={styles.albumHeader}>
<View style={[styles.container, { backgroundColor: theme.background }]}>
<View style={[styles.albumHeader, { borderBottomColor: theme.border }]}>
<TouchableOpacity onPress={() => { setSelectedAlbum(null); setAlbumMedia([]); }}>
<Text style={styles.backArrow}>←</Text>
<Text style={[styles.backArrow, { color: theme.text }]}>←</Text>
</TouchableOpacity>
<View style={styles.albumHeaderInfo}>
<Text style={styles.albumHeaderTitle}>{selectedAlbum.name}</Text>
<Text style={styles.albumHeaderCount}>{selectedAlbum.count} items</Text>
<Text style={[styles.albumHeaderTitle, { color: theme.text }]}>{selectedAlbum.name}</Text>
<Text style={[styles.albumHeaderCount, { color: theme.textSecondary }]}>
{selectedAlbum.count} item{selectedAlbum.count !== 1 ? 's' : ''}
</Text>
</View>
</View>
{albumMedia.length === 0 ? (
<View style={styles.centerContainer}>
<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>
) : (
<FlatList
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()}
numColumns={3}
contentContainerStyle={styles.gridContent}
/>
)}
<MediaViewer visible={viewerVisible} media={selectedMedia} onClose={() => setViewerVisible(false)} />
</View>
);
}
// Albums grid view
return (
<View style={styles.container}>
<View style={styles.header}>
<Text style={styles.headerTitle}>Albums</Text>
if (loading) {
return (
<View style={[styles.centerContainer, { backgroundColor: theme.background }]}>
<ActivityIndicator size="large" color={theme.primary} />
<Text style={[styles.loadingText, { color: theme.textSecondary }]}>Loading collections...</Text>
</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}>
<Text style={{ fontSize: 48, marginBottom: 12 }}>📁</Text>
<Text style={styles.emptyTitle}>No Albums</Text>
<Text style={styles.emptyText}>Albums from your connected services will appear here</Text>
<Text style={[styles.emptyTitle, { color: theme.text }]}>No Collections</Text>
<Text style={[styles.emptyText, { color: theme.textSecondary }]}>
Collections from your connected services will appear here
</Text>
</View>
) : (
<FlatList
data={albums}
renderItem={renderAlbumCard}
keyExtractor={(item) => item.service_type}
numColumns={ALBUM_COLUMNS}
columnWrapperStyle={styles.albumRow}
contentContainerStyle={styles.albumsContent}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={() => { setRefreshing(true); loadAlbums(); }} />}
<SectionList
sections={sections}
renderItem={renderAlbumRow}
renderSectionHeader={renderSectionHeader}
keyExtractor={(item, i) => i.toString()}
contentContainerStyle={styles.listContent}
stickySectionHeadersEnabled={false}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={() => { setRefreshing(true); loadCollections(); }} />
}
/>
)}
</View>
);
};
const gridItemSize = (width - 6) / 3;
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#fff' },
container: { flex: 1 },
centerContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', paddingHorizontal: 24 },
header: { paddingHorizontal: 16, paddingTop: 52, paddingBottom: 12 },
headerTitle: { fontSize: 24, fontWeight: '700', color: '#202124' },
albumsContent: { paddingHorizontal: ALBUM_GAP, paddingBottom: 20 },
albumRow: { gap: ALBUM_GAP, marginBottom: ALBUM_GAP },
albumCard: { width: albumSize },
albumCover: {
width: albumSize, height: albumSize, borderRadius: 12, overflow: 'hidden',
backgroundColor: '#f1f3f4', marginBottom: 8,
headerTitle: { fontSize: 24, fontWeight: '700' },
listContent: { paddingHorizontal: ALBUM_GAP, paddingBottom: 20 },
// Section headers
sectionHeader: {
flexDirection: 'row', alignItems: 'center', paddingVertical: 12, gap: 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%' },
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 },
albumCount: { fontSize: 12, color: '#5f6368', paddingHorizontal: 2, marginTop: 2 },
albumName: { fontSize: 14, fontWeight: '600', paddingHorizontal: 2 },
albumCount: { fontSize: 12, paddingHorizontal: 2, marginTop: 2 },
// Album detail
albumHeader: {
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 },
albumHeaderTitle: { fontSize: 20, fontWeight: '700', color: '#202124' },
albumHeaderCount: { fontSize: 12, color: '#5f6368' },
albumHeaderTitle: { fontSize: 20, fontWeight: '700' },
albumHeaderCount: { fontSize: 12 },
gridContent: { paddingBottom: 20 },
gridItem: { width: gridItemSize, height: gridItemSize, margin: 1, backgroundColor: '#f1f3f4' },
gridItem: { margin: 1 },
gridImage: { width: '100%', height: '100%' },
gridPlaceholder: { width: '100%', height: '100%', justifyContent: 'center', alignItems: 'center', backgroundColor: '#e8eaed' },
// Empty
loadingText: { marginTop: 12, fontSize: 14, color: '#5f6368' },
emptyTitle: { fontSize: 18, fontWeight: '600', color: '#202124', marginBottom: 6 },
emptyText: { fontSize: 13, color: '#5f6368', textAlign: 'center' },
gridPlaceholder: { width: '100%', height: '100%', justifyContent: 'center', alignItems: 'center' },
// Empty / Loading
loadingText: { marginTop: 12, fontSize: 14 },
emptyTitle: { fontSize: 18, fontWeight: '600', marginBottom: 6 },
emptyText: { fontSize: 13, textAlign: 'center' },
});
export default AlbumsScreen;
export default CollectionsScreen;
+74 -233
View File
@@ -1,21 +1,20 @@
import React, { useState, useEffect, useCallback } from 'react';
import {
StyleSheet, View, Text, FlatList, ActivityIndicator, Modal,
TouchableOpacity, Dimensions, Image, Alert, ScrollView, RefreshControl,
StyleSheet, View, Text, ActivityIndicator, ScrollView, RefreshControl,
} from 'react-native';
import { useFocusEffect } from '@react-navigation/native';
import { useTheme } from '../services/ThemeContext';
import DatabaseService from '../services/DatabaseService';
import GoogleAUTH from '../AUTH/GoogleAUTH';
import IOSAUTH from '../AUTH/IOSAUTH';
import NASAUTH from '../AUTH/NASAUTH';
const { width } = Dimensions.get('window');
const GRID_COLUMNS = 3;
const GRID_GAP = 2;
const imageSize = (width - GRID_GAP * (GRID_COLUMNS + 1)) / GRID_COLUMNS;
import TopBar from '../components/TopBar';
import PhotoGrid from '../components/PhotoGrid';
import MediaViewer from '../components/MediaViewer';
import { TouchableOpacity } from 'react-native';
const HomeScreen = ({ navigation, route }) => {
const userId = route?.params?.userId;
const { theme } = useTheme();
const [currentUser, setCurrentUser] = useState(null);
const [linkedAccounts, setLinkedAccounts] = useState([]);
const [allMedia, setAllMedia] = useState([]);
@@ -24,8 +23,7 @@ const HomeScreen = ({ navigation, route }) => {
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [selectedMedia, setSelectedMedia] = useState(null);
const [modalVisible, setModalVisible] = useState(false);
const [menuVisible, setMenuVisible] = useState(false);
const [viewerVisible, setViewerVisible] = useState(false);
useFocusEffect(
useCallback(() => {
@@ -37,9 +35,7 @@ const HomeScreen = ({ navigation, route }) => {
if (currentUser) loadAllMedia();
}, [currentUser]);
useEffect(() => {
filterMedia();
}, [selectedFilter, allMedia]);
useEffect(() => { filterMedia(); }, [selectedFilter, allMedia]);
const loadUserData = async () => {
try {
@@ -63,8 +59,11 @@ const HomeScreen = ({ navigation, route }) => {
if (googleAccount) {
try {
const googleAuth = new GoogleAUTH();
// Would fetch real photos with valid token
console.log('Google Photos: ready to fetch when API is configured');
googleAuth.accessToken = googleAccount.account_data?.accessToken;
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) {
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');
if (iosAccount) {
try {
const iosAuth = new IOSAUTH();
console.log('iCloud: placeholder - would fetch photos here');
} catch (err) {
console.error('iCloud fetch error:', err);
@@ -116,259 +114,102 @@ const HomeScreen = ({ navigation, route }) => {
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 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) {
return (
<View style={styles.centerContainer}>
<ActivityIndicator size="large" color="#1a73e8" />
<Text style={styles.loadingText}>Loading photos...</Text>
<View style={[styles.centerContainer, { backgroundColor: theme.background }]}>
<ActivityIndicator size="large" color={theme.primary} />
<Text style={[styles.loadingText, { color: theme.textSecondary }]}>Loading photos...</Text>
</View>
);
}
return (
<View style={styles.container}>
<TopBar />
<DropdownMenu />
const FilterChips = () => (
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.filterRow}>
<TouchableOpacity
style={[styles.chip, { backgroundColor: selectedFilter === 'all' ? theme.chipActiveBg : theme.chipBg }]}
onPress={() => setSelectedFilter('all')}
>
<Text style={[styles.chipText, selectedFilter === 'all' && { color: theme.primary, fontWeight: '600' }]}>All</Text>
</TouchableOpacity>
{linkedAccounts.map((acc) => (
<TouchableOpacity
key={acc.id}
style={[styles.chip, { backgroundColor: selectedFilter === acc.service_type ? theme.chipActiveBg : theme.chipBg }]}
onPress={() => setSelectedFilter(acc.service_type)}
>
<Text style={styles.chipIcon}>{getServiceIcon(acc.service_type)}</Text>
<Text style={[styles.chipText, selectedFilter === acc.service_type && { color: theme.primary, fontWeight: '600' }]}>
{getServiceName(acc.service_type)}
</Text>
</TouchableOpacity>
))}
</ScrollView>
);
{/* Filter chips */}
{linkedAccounts.length > 0 && (
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.filterRow}>
<TouchableOpacity
style={[styles.chip, selectedFilter === 'all' && styles.chipActive]}
onPress={() => setSelectedFilter('all')}
>
<Text style={[styles.chipText, selectedFilter === 'all' && styles.chipTextActive]}>All</Text>
</TouchableOpacity>
{linkedAccounts.map((acc) => (
<TouchableOpacity
key={acc.id}
style={[styles.chip, selectedFilter === acc.service_type && styles.chipActive]}
onPress={() => setSelectedFilter(acc.service_type)}
>
<Text style={styles.chipIcon}>{getServiceIcon(acc.service_type)}</Text>
<Text style={[styles.chipText, selectedFilter === acc.service_type && styles.chipTextActive]}>
{getServiceName(acc.service_type)}
</Text>
</TouchableOpacity>
))}
</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 ? (
<View style={styles.centerContainer}>
<Text style={{ fontSize: 56, marginBottom: 16 }}>📸</Text>
<Text style={styles.emptyTitle}>No Photos Yet</Text>
<Text style={styles.emptyText}>Photos from your connected services will appear here</Text>
<TouchableOpacity style={styles.retryButton} onPress={() => { setRefreshing(true); loadAllMedia(); }}>
<Text style={[styles.emptyTitle, { color: theme.text }]}>No Photos Yet</Text>
<Text style={[styles.emptyText, { color: theme.textSecondary }]}>
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>
</TouchableOpacity>
</View>
) : (
<FlatList
<PhotoGrid
data={filteredMedia}
renderItem={renderMediaItem}
keyExtractor={(item, i) => item.id?.toString() || item.source_id || i.toString()}
numColumns={GRID_COLUMNS}
contentContainerStyle={styles.gridContent}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={() => { setRefreshing(true); loadAllMedia(); }} />}
onItemPress={(item) => { setSelectedMedia(item); setViewerVisible(true); }}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={() => { setRefreshing(true); loadAllMedia(); }} />
}
/>
)}
{/* Full-screen photo viewer */}
<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 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>
<MediaViewer
visible={viewerVisible}
media={selectedMedia}
onClose={() => setViewerVisible(false)}
/>
</View>
);
};
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#fff' },
container: { flex: 1 },
centerContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', paddingHorizontal: 24 },
// Top bar
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' },
loadingText: { marginTop: 12, fontSize: 14 },
// Filter chips
filterRow: { paddingHorizontal: 12, paddingVertical: 8, gap: 8 },
chip: {
flexDirection: 'row', alignItems: 'center', paddingHorizontal: 14, paddingVertical: 7,
backgroundColor: '#f1f3f4', borderRadius: 20, gap: 6,
borderRadius: 20, gap: 6,
},
chipActive: { backgroundColor: '#d2e3fc' },
chipIcon: { fontSize: 13 },
chipText: { fontSize: 13, fontWeight: '500', color: '#3c4043' },
chipTextActive: { color: '#1a73e8', fontWeight: '600' },
// Grid
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' },
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 },
// Empty
emptyTitle: { fontSize: 18, fontWeight: '600', marginBottom: 6 },
emptyText: { fontSize: 13, textAlign: 'center', marginBottom: 20 },
retryButton: { paddingHorizontal: 24, paddingVertical: 10, borderRadius: 20 },
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;
+82 -129
View File
@@ -1,18 +1,19 @@
import React, { useState } from 'react';
import {
StyleSheet,
View,
Text,
TextInput,
TouchableOpacity,
Alert,
ActivityIndicator,
KeyboardAvoidingView,
Platform,
StyleSheet, View, Text, TextInput, TouchableOpacity, Alert,
ActivityIndicator, KeyboardAvoidingView, Platform, Image, ScrollView,
} from 'react-native';
import { useTheme } from '../services/ThemeContext';
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 { theme } = useTheme();
const [identifier, setIdentifier] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
@@ -27,7 +28,6 @@ const LoginScreen = ({ navigation }) => {
try {
const user = await DatabaseService.loginUser(identifier.trim(), password);
if (user) {
// Check if user has completed onboarding (has linked accounts)
const accounts = await DatabaseService.getLinkedAccounts(user.id);
if (accounts.length > 0) {
navigation.replace('MainTabs', { userId: user.id });
@@ -48,145 +48,98 @@ const LoginScreen = ({ navigation }) => {
return (
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.container}
style={[styles.container, { backgroundColor: theme.background }]}
>
<View style={styles.content}>
<View style={styles.headerContainer}>
<Text style={styles.appIcon}>📸</Text>
<Text style={styles.title}>Photo Library</Text>
<Text style={styles.subtitle}>Sign in to your account</Text>
</View>
<ScrollView contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
<View style={styles.content}>
<View style={styles.headerContainer}>
<Image source={APP_ICON} style={styles.appIcon} resizeMode="contain" />
<Text style={[styles.title, { color: theme.text }]}>Photo Library</Text>
<Text style={[styles.subtitle, { color: theme.textSecondary }]}>Sign in to your account</Text>
</View>
<View style={styles.inputContainer}>
<Text style={styles.label}>Username or Email</Text>
<TextInput
style={styles.input}
placeholder="Enter username or email"
placeholderTextColor="#9aa0a6"
value={identifier}
onChangeText={setIdentifier}
editable={!loading}
autoCapitalize="none"
autoCorrect={false}
/>
<View style={styles.inputContainer}>
<Text style={[styles.label, { color: theme.textSecondary }]}>Username or Email</Text>
<TextInput
style={[styles.input, { backgroundColor: theme.inputBg, borderColor: theme.border, color: theme.text }]}
placeholder="Enter username or email"
placeholderTextColor={theme.textTertiary}
value={identifier}
onChangeText={setIdentifier}
editable={!loading}
autoCapitalize="none"
autoCorrect={false}
/>
<Text style={styles.label}>Password</Text>
<TextInput
style={styles.input}
placeholder="Enter your password"
placeholderTextColor="#9aa0a6"
value={password}
onChangeText={setPassword}
editable={!loading}
secureTextEntry
autoCapitalize="none"
/>
</View>
<Text style={[styles.label, { color: theme.textSecondary }]}>Password</Text>
<TextInput
style={[styles.input, { backgroundColor: theme.inputBg, borderColor: theme.border, color: theme.text }]}
placeholder="Enter your password"
placeholderTextColor={theme.textTertiary}
value={password}
onChangeText={setPassword}
editable={!loading}
secureTextEntry
autoCapitalize="none"
/>
<TouchableOpacity
style={[styles.loginButton, loading && styles.buttonDisabled]}
onPress={handleLogin}
disabled={loading}
>
{loading ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.buttonText}>Sign In</Text>
)}
</TouchableOpacity>
<TouchableOpacity style={styles.forgotPassword}>
<Text style={[styles.forgotPasswordText, { color: theme.primary }]}>Forgot Password?</Text>
</TouchableOpacity>
</View>
<View style={styles.registerContainer}>
<Text style={styles.registerText}>Don't have an account? </Text>
{/* Login button – blue */}
<TouchableOpacity
style={[styles.primaryButton, { backgroundColor: theme.primary, borderColor: theme.border }, loading && styles.buttonDisabled]}
onPress={handleLogin}
disabled={loading}
>
{loading ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.primaryButtonText}>Login</Text>
)}
</TouchableOpacity>
{/* Register button – white/outlined */}
<TouchableOpacity
style={[styles.secondaryButton, { backgroundColor: theme.background, borderColor: theme.border }]}
onPress={() => navigation.navigate('Register')}
disabled={loading}
>
<Text style={styles.registerLink}>Create one</Text>
<Text style={[styles.secondaryButtonText, { color: theme.text }]}>Register</Text>
</TouchableOpacity>
</View>
</View>
</ScrollView>
</KeyboardAvoidingView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
},
content: {
flex: 1,
justifyContent: 'center',
paddingHorizontal: 24,
paddingVertical: 40,
},
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,
},
container: { flex: 1 },
scrollContent: { flexGrow: 1, justifyContent: 'center' },
content: { paddingHorizontal: 24, paddingVertical: 40 },
headerContainer: { marginBottom: 36, alignItems: 'center' },
appIcon: { width: 80, height: 80, marginBottom: 16, borderRadius: 16 },
title: { fontSize: 28, fontWeight: '700', marginBottom: 8 },
subtitle: { fontSize: 15 },
inputContainer: { marginBottom: 24 },
label: { fontSize: 13, fontWeight: '600', marginBottom: 6, marginTop: 16 },
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,
borderColor: '#dadce0',
borderRadius: 8,
paddingHorizontal: 14,
paddingVertical: 12,
fontSize: 15,
backgroundColor: '#fff',
color: '#202124',
},
loginButton: {
backgroundColor: '#1a73e8',
borderRadius: 8,
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',
primaryButtonText: { color: '#fff', fontSize: 15, fontWeight: '600' },
secondaryButton: {
borderRadius: 8, paddingVertical: 14, alignItems: 'center', borderWidth: 1,
},
secondaryButtonText: { fontSize: 15, fontWeight: '600' },
buttonDisabled: { opacity: 0.6 },
});
export default LoginScreen;
+146 -185
View File
@@ -1,21 +1,17 @@
import React, { useState } from 'react';
import {
StyleSheet,
View,
Text,
ScrollView,
TouchableOpacity,
Alert,
ActivityIndicator,
Switch,
TextInput,
StyleSheet, View, Text, ScrollView, TouchableOpacity, Alert,
ActivityIndicator, Switch, TextInput,
} from 'react-native';
import { useTheme } from '../services/ThemeContext';
import DatabaseService from '../services/DatabaseService';
import GoogleAUTH from '../AUTH/GoogleAUTH';
import NASAUTH from '../AUTH/NASAUTH';
import NASSetupInstructions from '../components/NASSetupInstructions';
const OnboardingScreen = ({ navigation, route }) => {
const { userId } = route.params;
const { theme } = useTheme();
const [currentStep, setCurrentStep] = useState(0);
const [loading, setLoading] = useState(false);
@@ -36,20 +32,20 @@ const OnboardingScreen = ({ navigation, route }) => {
const [nasPassword, setNasPassword] = useState('');
const [nasPhotoFolder, setNasPhotoFolder] = useState('/home/user/photos');
// Default service
const [defaultService, setDefaultService] = useState(null);
// Auto-backup checklist (multi-select)
const [autoBackupServices, setAutoBackupServices] = useState([]);
const steps = [
{ title: 'Choose Services', description: 'Select which photo services to connect (1-3)' },
{ 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 = [];
if (googleEnabled) services.push({ name: 'Google Photos', type: 'GOOGLE_PHOTOS' });
if (iosEnabled) services.push({ name: 'iCloud Photos', type: 'ICLOUD_PHOTOS' });
if (nasEnabled) services.push({ name: 'NAS Storage', type: 'NAS' });
if (googleEnabled && googleConnected) services.push({ name: 'Google Photos', type: 'GOOGLE_PHOTOS', icon: '📷' });
if (iosEnabled && iosConnected) services.push({ name: 'iCloud Photos', type: 'ICLOUD_PHOTOS', icon: '🍎' });
if (nasEnabled && nasConnected) services.push({ name: 'NAS Storage', type: 'NAS', icon: '💾' });
return services;
};
@@ -78,12 +74,10 @@ const OnboardingScreen = ({ navigation, route }) => {
const handleIosAuth = async () => {
try {
setLoading(true);
// Placeholder - iCloud API not yet available
await DatabaseService.linkAccount(userId, 'ICLOUD_PHOTOS', {
status: 'placeholder',
note: 'iCloud integration pending Apple API access',
});
setIosConnected(true);
Alert.alert('Info', 'iCloud Photos registered. Full integration coming soon.');
} catch (error) {
@@ -99,25 +93,19 @@ const OnboardingScreen = ({ navigation, route }) => {
Alert.alert('Error', 'Please fill in all NAS connection fields');
return;
}
try {
setLoading(true);
const nasAuth = new NASAUTH();
await nasAuth.authenticate(
nasHost.trim(),
parseInt(nasPort) || 22,
nasUsername.trim(),
nasPassword,
nasPhotoFolder.trim()
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(),
});
setNasConnected(true);
Alert.alert('Success', 'NAS connected!');
} 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 () => {
if (currentStep === 0) {
if (getEnabledServices().length === 0) {
Alert.alert('Error', 'Please select at least one service');
return;
}
const any = googleEnabled || iosEnabled || nasEnabled;
if (!any) { Alert.alert('Error', 'Please select at least one service'); return; }
setCurrentStep(1);
} else if (currentStep === 1) {
// Check at least one service is connected
const anyConnected =
(googleEnabled && googleConnected) ||
(iosEnabled && iosConnected) ||
(nasEnabled && nasConnected);
if (!anyConnected) {
Alert.alert('Error', 'Please connect at least one service before continuing');
return;
}
if (!anyConnected) { Alert.alert('Error', 'Please connect at least one service'); return; }
setCurrentStep(2);
} else if (currentStep === 2) {
if (!defaultService) {
Alert.alert('Error', 'Please select a default upload service');
return;
}
try {
setLoading(true);
const accounts = await DatabaseService.getLinkedAccounts(userId);
const defaultAccount = accounts.find((a) => a.service_type === defaultService);
if (defaultAccount) {
await DatabaseService.setDefaultService(userId, defaultAccount.id);
}
await DatabaseService.setAutoBackupServices(userId, autoBackupServices);
navigation.replace('MainTabs', { userId });
} catch (error) {
Alert.alert('Error', 'Failed to complete setup: ' + error.message);
@@ -170,45 +148,36 @@ const OnboardingScreen = ({ navigation, route }) => {
};
return (
<View style={styles.container}>
<View style={[styles.container, { backgroundColor: theme.background }]}>
{/* Progress bar */}
<View style={styles.progressBar}>
{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>
<ScrollView style={styles.content} showsVerticalScrollIndicator={false}>
<View style={styles.header}>
<Text style={styles.stepLabel}>Step {currentStep + 1} of {steps.length}</Text>
<Text style={styles.title}>{steps[currentStep].title}</Text>
<Text style={styles.description}>{steps[currentStep].description}</Text>
<Text style={[styles.stepLabel, { color: theme.primary }]}>Step {currentStep + 1} of {steps.length}</Text>
<Text style={[styles.title, { color: theme.text }]}>{steps[currentStep].title}</Text>
<Text style={[styles.description, { color: theme.textSecondary }]}>{steps[currentStep].description}</Text>
</View>
{/* Step 0: Select services */}
{currentStep === 0 && (
<View style={styles.cardsContainer}>
<ServiceToggleCard
icon="📷"
name="Google Photos"
<ServiceToggleCard theme={theme} icon="📷" name="Google Photos"
description="Access your Google Photos library"
enabled={googleEnabled}
onToggle={() => setGoogleEnabled(!googleEnabled)}
/>
<ServiceToggleCard
icon="🍎"
name="iCloud Photos"
enabled={googleEnabled} onToggle={() => setGoogleEnabled(!googleEnabled)} />
<ServiceToggleCard theme={theme} icon="🍎" name="iCloud Photos"
description="Access your iCloud photo library (placeholder)"
enabled={iosEnabled}
onToggle={() => setIosEnabled(!iosEnabled)}
/>
<ServiceToggleCard
icon="💾"
name="NAS Storage"
enabled={iosEnabled} onToggle={() => setIosEnabled(!iosEnabled)} />
<ServiceToggleCard theme={theme} icon="💾" name="NAS Storage"
description="Connect to your Ubuntu server via SFTP"
enabled={nasEnabled}
onToggle={() => setNasEnabled(!nasEnabled)}
/>
enabled={nasEnabled} onToggle={() => setNasEnabled(!nasEnabled)} />
{/* NAS setup instructions dropdown */}
{nasEnabled && <NASSetupInstructions />}
</View>
)}
@@ -216,84 +185,75 @@ const OnboardingScreen = ({ navigation, route }) => {
{currentStep === 1 && (
<View style={styles.cardsContainer}>
{googleEnabled && (
<View style={styles.configCard}>
<View style={[styles.configCard, { backgroundColor: theme.card, borderColor: theme.border }]}>
<View style={styles.configHeader}>
<Text style={styles.configIcon}>📷</Text>
<Text style={styles.configTitle}>Google Photos</Text>
{googleConnected && <Text style={styles.connectedBadge}>✓ Connected</Text>}
<Text style={[styles.configTitle, { color: theme.text }]}>Google Photos</Text>
{googleConnected && <Text style={[styles.connectedBadge, { color: theme.accent }]}>✓ Connected</Text>}
</View>
{!googleConnected && (
<TouchableOpacity
style={[styles.authButton, loading && styles.buttonDisabled]}
onPress={handleGoogleAuth}
disabled={loading}
style={[styles.authButton, { backgroundColor: theme.primary }, loading && styles.buttonDisabled]}
onPress={handleGoogleAuth} disabled={loading}
>
{loading ? <ActivityIndicator color="#fff" /> : (
<Text style={styles.authButtonText}>Sign in with Google</Text>
)}
{loading ? <ActivityIndicator color="#fff" /> : <Text style={styles.authButtonText}>Sign in with Google</Text>}
</TouchableOpacity>
)}
</View>
)}
{iosEnabled && (
<View style={styles.configCard}>
<View style={[styles.configCard, { backgroundColor: theme.card, borderColor: theme.border }]}>
<View style={styles.configHeader}>
<Text style={styles.configIcon}>🍎</Text>
<Text style={styles.configTitle}>iCloud Photos</Text>
{iosConnected && <Text style={styles.connectedBadge}>✓ Connected</Text>}
<Text style={[styles.configTitle, { color: theme.text }]}>iCloud Photos</Text>
{iosConnected && <Text style={[styles.connectedBadge, { color: theme.accent }]}>✓ Connected</Text>}
</View>
{!iosConnected && (
<TouchableOpacity
style={[styles.authButton, { backgroundColor: '#333' }, loading && styles.buttonDisabled]}
onPress={handleIosAuth}
disabled={loading}
onPress={handleIosAuth} disabled={loading}
>
{loading ? <ActivityIndicator color="#fff" /> : (
<Text style={styles.authButtonText}>Connect iCloud</Text>
)}
{loading ? <ActivityIndicator color="#fff" /> : <Text style={styles.authButtonText}>Connect iCloud</Text>}
</TouchableOpacity>
)}
</View>
)}
{nasEnabled && (
<View style={styles.configCard}>
<View style={[styles.configCard, { backgroundColor: theme.card, borderColor: theme.border }]}>
<View style={styles.configHeader}>
<Text style={styles.configIcon}>💾</Text>
<Text style={styles.configTitle}>NAS Storage</Text>
{nasConnected && <Text style={styles.connectedBadge}>✓ Connected</Text>}
<Text style={[styles.configTitle, { color: theme.text }]}>NAS Storage</Text>
{nasConnected && <Text style={[styles.connectedBadge, { color: theme.accent }]}>✓ Connected</Text>}
</View>
{!nasConnected && (
<>
<Text style={styles.fieldLabel}>Server Address</Text>
<TextInput style={styles.fieldInput} placeholder="192.168.1.100" value={nasHost}
onChangeText={setNasHost} editable={!loading} placeholderTextColor="#9aa0a6" />
<Text style={[styles.fieldLabel, { color: theme.textSecondary }]}>Server Address</Text>
<TextInput style={[styles.fieldInput, { backgroundColor: theme.inputBg, borderColor: theme.border, color: theme.text }]}
placeholder="192.168.1.100" value={nasHost} onChangeText={setNasHost} editable={!loading} placeholderTextColor={theme.textTertiary} />
<Text style={styles.fieldLabel}>Port</Text>
<TextInput style={styles.fieldInput} placeholder="22" value={nasPort}
onChangeText={setNasPort} editable={!loading} keyboardType="numeric" placeholderTextColor="#9aa0a6" />
<Text style={[styles.fieldLabel, { color: theme.textSecondary }]}>Port</Text>
<TextInput style={[styles.fieldInput, { backgroundColor: theme.inputBg, borderColor: theme.border, color: theme.text }]}
placeholder="22" value={nasPort} onChangeText={setNasPort} editable={!loading} keyboardType="numeric" placeholderTextColor={theme.textTertiary} />
<Text style={styles.fieldLabel}>Username</Text>
<TextInput style={styles.fieldInput} placeholder="SSH username" value={nasUsername}
onChangeText={setNasUsername} editable={!loading} autoCapitalize="none" placeholderTextColor="#9aa0a6" />
<Text style={[styles.fieldLabel, { color: theme.textSecondary }]}>Username</Text>
<TextInput style={[styles.fieldInput, { backgroundColor: theme.inputBg, borderColor: theme.border, color: theme.text }]}
placeholder="SSH username" value={nasUsername} onChangeText={setNasUsername} editable={!loading} autoCapitalize="none" placeholderTextColor={theme.textTertiary} />
<Text style={styles.fieldLabel}>Password</Text>
<TextInput style={styles.fieldInput} placeholder="SSH password" value={nasPassword}
onChangeText={setNasPassword} editable={!loading} secureTextEntry autoCapitalize="none" placeholderTextColor="#9aa0a6" />
<Text style={[styles.fieldLabel, { color: theme.textSecondary }]}>Password</Text>
<TextInput style={[styles.fieldInput, { backgroundColor: theme.inputBg, borderColor: theme.border, color: theme.text }]}
placeholder="SSH password" value={nasPassword} onChangeText={setNasPassword} editable={!loading} secureTextEntry autoCapitalize="none" placeholderTextColor={theme.textTertiary} />
<Text style={styles.fieldLabel}>Photos Folder</Text>
<TextInput style={styles.fieldInput} placeholder="/home/user/photos" value={nasPhotoFolder}
onChangeText={setNasPhotoFolder} editable={!loading} placeholderTextColor="#9aa0a6" />
<Text style={[styles.fieldLabel, { color: theme.textSecondary }]}>Photos Folder</Text>
<TextInput style={[styles.fieldInput, { backgroundColor: theme.inputBg, borderColor: theme.border, color: theme.text }]}
placeholder="/home/user/photos" value={nasPhotoFolder} onChangeText={setNasPhotoFolder} editable={!loading} placeholderTextColor={theme.textTertiary} />
<TouchableOpacity
style={[styles.authButton, { backgroundColor: '#34a853' }, loading && styles.buttonDisabled]}
onPress={handleNasAuth}
disabled={loading}
style={[styles.authButton, { backgroundColor: theme.accent }, loading && styles.buttonDisabled]}
onPress={handleNasAuth} disabled={loading}
>
{loading ? <ActivityIndicator color="#fff" /> : (
<Text style={styles.authButtonText}>Test & Connect</Text>
)}
{loading ? <ActivityIndicator color="#fff" /> : <Text style={styles.authButtonText}>Test & Connect</Text>}
</TouchableOpacity>
</>
)}
@@ -302,47 +262,65 @@ const OnboardingScreen = ({ navigation, route }) => {
</View>
)}
{/* Step 2: Default service */}
{/* Step 2: Auto-backup multi-checkbox */}
{currentStep === 2 && (
<View style={styles.cardsContainer}>
{getEnabledServices().map((svc) => {
const connected =
(svc.type === 'GOOGLE_PHOTOS' && googleConnected) ||
(svc.type === 'ICLOUD_PHOTOS' && iosConnected) ||
(svc.type === 'NAS' && nasConnected);
<View style={[styles.configCard, { backgroundColor: theme.card, borderColor: theme.border }]}>
<Text style={[styles.checklistInfo, { color: theme.textSecondary }]}>
Select which services new photos should automatically upload to. You can select multiple or none.
</Text>
if (!connected) return null;
return (
{getConnectedServices().map((svc) => (
<TouchableOpacity
key={svc.type}
style={[styles.selectCard, defaultService === svc.type && styles.selectCardActive]}
onPress={() => setDefaultService(svc.type)}
style={[styles.checkRow, { borderBottomColor: theme.borderLight }]}
onPress={() => toggleAutoBackup(svc.type)}
activeOpacity={0.7}
>
<View style={[styles.radio, defaultService === svc.type && styles.radioActive]} />
<Text style={[styles.selectText, defaultService === svc.type && styles.selectTextActive]}>
{svc.name}
</Text>
<View style={[
styles.checkbox,
{ borderColor: theme.primary },
autoBackupServices.includes(svc.type) && { backgroundColor: theme.primary },
]}>
{autoBackupServices.includes(svc.type) && <Text style={styles.checkmark}>✓</Text>}
</View>
<Text style={styles.checkIcon}>{svc.icon}</Text>
<Text style={[styles.checkLabel, { color: theme.text }]}>{svc.name}</Text>
</TouchableOpacity>
);
})}
))}
<TouchableOpacity
style={[styles.checkRow, { borderBottomWidth: 0 }]}
onPress={() => setAutoBackupServices([])}
activeOpacity={0.7}
>
<View style={[
styles.checkbox,
{ borderColor: theme.border },
autoBackupServices.length === 0 && { backgroundColor: theme.primary, borderColor: theme.primary },
]}>
{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>
)}
</ScrollView>
{/* Footer */}
<View style={styles.footer}>
<View style={[styles.footer, { borderTopColor: theme.border }]}>
{currentStep > 0 ? (
<TouchableOpacity style={styles.backButton} onPress={() => setCurrentStep(currentStep - 1)} disabled={loading}>
<Text style={styles.backButtonText}>Back</Text>
<TouchableOpacity style={[styles.backButton, { backgroundColor: theme.surface, borderColor: theme.border }]}
onPress={() => setCurrentStep(currentStep - 1)} disabled={loading}>
<Text style={[styles.backButtonText, { color: theme.textSecondary }]}>Back</Text>
</TouchableOpacity>
) : (
<View style={{ flex: 1 }} />
)}
<TouchableOpacity
style={[styles.nextButton, loading && styles.buttonDisabled]}
onPress={handleNext}
disabled={loading}
style={[styles.nextButton, { backgroundColor: theme.primary }, loading && styles.buttonDisabled]}
onPress={handleNext} disabled={loading}
>
{loading ? <ActivityIndicator color="#fff" /> : (
<Text style={styles.nextButtonText}>
@@ -355,99 +333,82 @@ const OnboardingScreen = ({ navigation, route }) => {
);
};
const ServiceToggleCard = ({ icon, name, description, enabled, onToggle }) => (
<View style={[styles.toggleCard, enabled && styles.toggleCardActive]}>
const ServiceToggleCard = ({ theme, icon, name, description, enabled, onToggle }) => (
<View style={[styles.toggleCard, { backgroundColor: theme.card, borderColor: enabled ? theme.primary : theme.border }]}>
<View style={styles.toggleCardContent}>
<Text style={styles.toggleIcon}>{icon}</Text>
<View style={styles.toggleInfo}>
<Text style={styles.toggleName}>{name}</Text>
<Text style={styles.toggleDescription}>{description}</Text>
<Text style={[styles.toggleName, { color: theme.text }]}>{name}</Text>
<Text style={[styles.toggleDescription, { color: theme.textSecondary }]}>{description}</Text>
</View>
</View>
<Switch
value={enabled}
onValueChange={onToggle}
trackColor={{ false: '#dadce0', true: '#a8dab5' }}
trackColor={{ false: theme.border, true: '#a8dab5' }}
thumbColor={enabled ? '#34a853' : '#f4f3f4'}
/>
</View>
);
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#fff' },
container: { flex: 1 },
progressBar: {
flexDirection: 'row', justifyContent: 'center', gap: 8,
paddingTop: 60, paddingBottom: 8,
},
progressDot: {
width: 32, height: 4, borderRadius: 2, backgroundColor: '#dadce0',
},
progressDotActive: { backgroundColor: '#1a73e8' },
progressDot: { width: 32, height: 4, borderRadius: 2 },
content: { flex: 1, paddingHorizontal: 20 },
header: { marginBottom: 24 },
stepLabel: { fontSize: 12, color: '#1a73e8', fontWeight: '600', marginBottom: 6 },
title: { fontSize: 26, fontWeight: '700', color: '#202124', marginBottom: 6 },
description: { fontSize: 14, color: '#5f6368' },
stepLabel: { fontSize: 12, fontWeight: '600', marginBottom: 6 },
title: { fontSize: 26, fontWeight: '700', marginBottom: 6 },
description: { fontSize: 14 },
cardsContainer: { gap: 12 },
// Toggle cards (Step 0)
toggleCard: {
backgroundColor: '#f8f9fa', borderRadius: 12, padding: 16,
flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center',
borderWidth: 1.5, borderColor: '#e8eaed',
borderRadius: 12, padding: 16, flexDirection: 'row',
justifyContent: 'space-between', alignItems: 'center', borderWidth: 1.5,
},
toggleCardActive: { borderColor: '#1a73e8', backgroundColor: '#e8f0fe' },
toggleCardContent: { flex: 1, flexDirection: 'row', alignItems: 'center' },
toggleIcon: { fontSize: 28, marginRight: 14 },
toggleInfo: { flex: 1 },
toggleName: { fontSize: 15, fontWeight: '600', color: '#202124', marginBottom: 2 },
toggleDescription: { fontSize: 12, color: '#5f6368' },
toggleName: { fontSize: 15, fontWeight: '600', marginBottom: 2 },
toggleDescription: { fontSize: 12 },
// Config cards (Step 1)
configCard: {
backgroundColor: '#f8f9fa', borderRadius: 12, padding: 16,
borderWidth: 1, borderColor: '#e8eaed',
},
configCard: { borderRadius: 12, padding: 16, borderWidth: 1 },
configHeader: { flexDirection: 'row', alignItems: 'center', marginBottom: 12 },
configIcon: { fontSize: 24, marginRight: 10 },
configTitle: { fontSize: 16, fontWeight: '600', color: '#202124', flex: 1 },
connectedBadge: { fontSize: 13, color: '#34a853', fontWeight: '600' },
authButton: {
backgroundColor: '#1a73e8', borderRadius: 8, paddingVertical: 12, alignItems: 'center',
},
configTitle: { fontSize: 16, fontWeight: '600', flex: 1 },
connectedBadge: { fontSize: 13, fontWeight: '600' },
authButton: { borderRadius: 8, paddingVertical: 12, alignItems: 'center', marginTop: 8 },
authButtonText: { color: '#fff', fontSize: 14, fontWeight: '600' },
fieldLabel: { fontSize: 12, fontWeight: '600', color: '#3c4043', marginTop: 10, marginBottom: 4 },
fieldLabel: { fontSize: 12, fontWeight: '600', marginTop: 10, marginBottom: 4 },
fieldInput: {
borderWidth: 1, borderColor: '#dadce0', borderRadius: 8,
paddingHorizontal: 12, paddingVertical: 10, fontSize: 14,
backgroundColor: '#fff', color: '#202124',
borderWidth: 1, borderRadius: 8, paddingHorizontal: 12, paddingVertical: 10, fontSize: 14,
},
// Select cards (Step 2)
selectCard: {
backgroundColor: '#f8f9fa', borderRadius: 12, padding: 16,
flexDirection: 'row', alignItems: 'center',
borderWidth: 2, borderColor: '#e8eaed',
// Auto-backup (Step 2)
checklistInfo: { fontSize: 13, marginBottom: 8 },
checkRow: {
flexDirection: 'row', alignItems: 'center', paddingVertical: 14,
borderBottomWidth: StyleSheet.hairlineWidth,
},
selectCardActive: { borderColor: '#1a73e8', backgroundColor: '#e8f0fe' },
radio: {
width: 20, height: 20, borderRadius: 10, borderWidth: 2,
borderColor: '#dadce0', marginRight: 12,
checkbox: {
width: 22, height: 22, borderRadius: 4, borderWidth: 2,
justifyContent: 'center', alignItems: 'center', marginRight: 12,
},
radioActive: { borderColor: '#1a73e8', backgroundColor: '#1a73e8' },
selectText: { fontSize: 15, color: '#202124', fontWeight: '500' },
selectTextActive: { color: '#1a73e8', fontWeight: '600' },
checkmark: { color: '#fff', fontSize: 14, fontWeight: '700' },
checkIcon: { fontSize: 18, marginRight: 8 },
checkLabel: { fontSize: 14, fontWeight: '500', flex: 1 },
// Footer
footer: {
flexDirection: 'row', paddingHorizontal: 20, paddingVertical: 14,
borderTopWidth: 1, borderTopColor: '#e8eaed', gap: 12,
borderTopWidth: 1, gap: 12,
},
backButton: {
flex: 1, paddingVertical: 12, borderRadius: 8, alignItems: 'center',
backgroundColor: '#f8f9fa', borderWidth: 1, borderColor: '#dadce0',
},
backButtonText: { fontSize: 15, fontWeight: '600', color: '#5f6368' },
nextButton: {
flex: 1, paddingVertical: 12, borderRadius: 8, alignItems: 'center',
backgroundColor: '#1a73e8',
flex: 1, paddingVertical: 12, borderRadius: 8, alignItems: 'center', borderWidth: 1,
},
backButtonText: { fontSize: 15, fontWeight: '600' },
nextButton: { flex: 1, paddingVertical: 12, borderRadius: 8, alignItems: 'center' },
nextButtonText: { fontSize: 15, fontWeight: '600', color: '#fff' },
buttonDisabled: { opacity: 0.6 },
});
+69 -175
View File
@@ -1,19 +1,16 @@
import React, { useState } from 'react';
import {
StyleSheet,
View,
Text,
TextInput,
TouchableOpacity,
Alert,
ActivityIndicator,
KeyboardAvoidingView,
Platform,
ScrollView,
StyleSheet, View, Text, TextInput, TouchableOpacity, Alert,
ActivityIndicator, KeyboardAvoidingView, Platform, ScrollView, Image,
} from 'react-native';
import { useTheme } from '../services/ThemeContext';
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 { theme } = useTheme();
const [username, setUsername] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
@@ -21,51 +18,23 @@ const RegisterScreen = ({ navigation }) => {
const [loading, setLoading] = useState(false);
const handleRegister = async () => {
if (!username.trim()) {
Alert.alert('Error', 'Please enter a username');
return;
}
if (username.trim().length < 3) {
Alert.alert('Error', 'Username must be at least 3 characters');
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;
}
if (!username.trim()) { Alert.alert('Error', 'Please enter a username'); return; }
if (username.trim().length < 3) { Alert.alert('Error', 'Username must be at least 3 characters'); 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);
try {
const exists = await DatabaseService.userExists(username.trim(), email.trim().toLowerCase());
if (exists) {
Alert.alert('Error', 'Username or email already taken');
setLoading(false);
return;
}
if (exists) { Alert.alert('Error', 'Username or email already taken'); setLoading(false); return; }
const userId = await DatabaseService.createUser(username.trim(), email.trim().toLowerCase(), password);
Alert.alert('Success', 'Account created!', [
{
text: 'OK',
onPress: () => navigation.replace('Onboarding', { userId }),
},
{ text: 'OK', onPress: () => navigation.replace('Onboarding', { userId }) },
]);
} catch (error) {
console.error('Registration error:', error);
@@ -78,85 +47,69 @@ const RegisterScreen = ({ navigation }) => {
return (
<KeyboardAvoidingView
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.headerContainer}>
<Text style={styles.appIcon}>📸</Text>
<Text style={styles.title}>Create Account</Text>
<Text style={styles.subtitle}>Set up your local photo library account</Text>
<Image source={APP_ICON} style={styles.appIcon} resizeMode="contain" />
<Text style={[styles.title, { color: theme.text }]}>Create Account</Text>
<Text style={[styles.subtitle, { color: theme.textSecondary }]}>Set up your local photo library account</Text>
</View>
<View style={styles.inputContainer}>
<Text style={styles.label}>Username</Text>
<Text style={[styles.label, { color: theme.textSecondary }]}>Username</Text>
<TextInput
style={styles.input}
placeholder="Choose a username"
placeholderTextColor="#9aa0a6"
value={username}
onChangeText={setUsername}
editable={!loading}
autoCapitalize="none"
autoCorrect={false}
style={[styles.input, { backgroundColor: theme.inputBg, borderColor: theme.border, color: theme.text }]}
placeholder="Choose a username" placeholderTextColor={theme.textTertiary}
value={username} onChangeText={setUsername} editable={!loading}
autoCapitalize="none" autoCorrect={false}
/>
<Text style={styles.label}>Email</Text>
<Text style={[styles.label, { color: theme.textSecondary }]}>Email</Text>
<TextInput
style={styles.input}
placeholder="Enter your email"
placeholderTextColor="#9aa0a6"
value={email}
onChangeText={setEmail}
editable={!loading}
autoCapitalize="none"
autoCorrect={false}
keyboardType="email-address"
style={[styles.input, { backgroundColor: theme.inputBg, borderColor: theme.border, color: theme.text }]}
placeholder="Enter your email" placeholderTextColor={theme.textTertiary}
value={email} 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
style={styles.input}
placeholder="Create a password (min 6 characters)"
placeholderTextColor="#9aa0a6"
value={password}
onChangeText={setPassword}
editable={!loading}
secureTextEntry
autoCapitalize="none"
style={[styles.input, { backgroundColor: theme.inputBg, borderColor: theme.border, color: theme.text }]}
placeholder="Create a password (min 6 characters)" placeholderTextColor={theme.textTertiary}
value={password} 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
style={styles.input}
placeholder="Confirm your password"
placeholderTextColor="#9aa0a6"
value={confirmPassword}
onChangeText={setConfirmPassword}
editable={!loading}
secureTextEntry
autoCapitalize="none"
style={[styles.input, { backgroundColor: theme.inputBg, borderColor: theme.border, color: theme.text }]}
placeholder="Confirm your password" placeholderTextColor={theme.textTertiary}
value={confirmPassword} onChangeText={setConfirmPassword} editable={!loading}
secureTextEntry autoCapitalize="none"
/>
</View>
{/* Create Account button – blue */}
<TouchableOpacity
style={[styles.registerButton, loading && styles.buttonDisabled]}
style={[styles.primaryButton, { backgroundColor: theme.primary, borderColor: theme.border }, loading && styles.buttonDisabled]}
onPress={handleRegister}
disabled={loading}
>
{loading ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.buttonText}>Create Account</Text>
{loading ? <ActivityIndicator color="#fff" /> : (
<Text style={styles.primaryButtonText}>Create Account</Text>
)}
</TouchableOpacity>
<View style={styles.loginContainer}>
<Text style={styles.loginText}>Already have an account? </Text>
<TouchableOpacity onPress={() => navigation.navigate('Login')} disabled={loading}>
<Text style={styles.loginLink}>Sign in</Text>
</TouchableOpacity>
</View>
{/* Back to Login button – white/outlined */}
<TouchableOpacity
style={[styles.secondaryButton, { backgroundColor: theme.background, borderColor: theme.border }]}
onPress={() => navigation.navigate('Login')}
disabled={loading}
>
<Text style={[styles.secondaryButtonText, { color: theme.text }]}>Back to Login</Text>
</TouchableOpacity>
</View>
</ScrollView>
</KeyboardAvoidingView>
@@ -164,86 +117,27 @@ const RegisterScreen = ({ navigation }) => {
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
},
scrollContent: {
flexGrow: 1,
justifyContent: 'center',
},
content: {
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,
},
container: { flex: 1 },
scrollContent: { flexGrow: 1, justifyContent: 'center' },
content: { paddingHorizontal: 24, paddingVertical: 40 },
headerContainer: { marginBottom: 36, alignItems: 'center' },
appIcon: { width: 80, height: 80, marginBottom: 16, borderRadius: 16 },
title: { fontSize: 28, fontWeight: '700', marginBottom: 8 },
subtitle: { fontSize: 15, textAlign: 'center' },
inputContainer: { marginBottom: 24 },
label: { fontSize: 13, fontWeight: '600', marginBottom: 6, marginTop: 16 },
input: {
borderWidth: 1,
borderColor: '#dadce0',
borderRadius: 8,
paddingHorizontal: 14,
paddingVertical: 12,
fontSize: 15,
backgroundColor: '#fff',
color: '#202124',
borderWidth: 1, borderRadius: 8, paddingHorizontal: 14, paddingVertical: 12, fontSize: 15,
},
registerButton: {
backgroundColor: '#1a73e8',
borderRadius: 8,
paddingVertical: 14,
alignItems: 'center',
marginBottom: 20,
primaryButton: {
borderRadius: 8, paddingVertical: 14, alignItems: 'center', marginBottom: 10, borderWidth: 1,
},
buttonDisabled: {
opacity: 0.6,
},
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',
primaryButtonText: { color: '#fff', fontSize: 15, fontWeight: '600' },
secondaryButton: {
borderRadius: 8, paddingVertical: 14, alignItems: 'center', borderWidth: 1,
},
secondaryButtonText: { fontSize: 15, fontWeight: '600' },
buttonDisabled: { opacity: 0.6 },
});
export default RegisterScreen;
+32 -98
View File
@@ -1,30 +1,25 @@
import React, { useState, useEffect } from 'react';
import {
StyleSheet, View, Text, TextInput, FlatList, TouchableOpacity,
Dimensions, Image, Modal, ActivityIndicator,
StyleSheet, View, Text, TextInput, TouchableOpacity, ActivityIndicator,
} from 'react-native';
import { useTheme } from '../services/ThemeContext';
import DatabaseService from '../services/DatabaseService';
const { width } = Dimensions.get('window');
const GRID_COLUMNS = 3;
const GRID_GAP = 2;
const imageSize = (width - GRID_GAP * (GRID_COLUMNS + 1)) / GRID_COLUMNS;
import PhotoGrid from '../components/PhotoGrid';
import MediaViewer from '../components/MediaViewer';
const SearchScreen = ({ route }) => {
const userId = route?.params?.userId;
const { theme } = useTheme();
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [searching, setSearching] = useState(false);
const [selectedMedia, setSelectedMedia] = useState(null);
const [modalVisible, setModalVisible] = useState(false);
const [viewerVisible, setViewerVisible] = useState(false);
useEffect(() => {
const timer = setTimeout(() => {
if (query.trim().length > 0) {
performSearch();
} else {
setResults([]);
}
if (query.trim().length > 0) performSearch();
else setResults([]);
}, 300);
return () => clearTimeout(timer);
}, [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 (
<View style={styles.container}>
{/* Search bar */}
<View style={styles.searchBar}>
<View style={[styles.container, { backgroundColor: theme.background }]}>
<View style={[styles.searchBar, { backgroundColor: theme.searchBg }]}>
<Text style={styles.searchIcon}>🔍</Text>
<TextInput
style={styles.searchInput}
style={[styles.searchInput, { color: theme.text }]}
placeholder="Search photos and videos"
placeholderTextColor="#9aa0a6"
placeholderTextColor={theme.textTertiary}
value={query}
onChangeText={setQuery}
autoFocus
@@ -80,105 +53,66 @@ const SearchScreen = ({ route }) => {
/>
{query.length > 0 && (
<TouchableOpacity onPress={() => setQuery('')}>
<Text style={styles.clearButton}>✕</Text>
<Text style={[styles.clearButton, { color: theme.textSecondary }]}>✕</Text>
</TouchableOpacity>
)}
</View>
{searching && (
<View style={styles.loadingContainer}>
<ActivityIndicator size="small" color="#1a73e8" />
<ActivityIndicator size="small" color={theme.primary} />
</View>
)}
{!searching && query.length > 0 && results.length === 0 && (
<View style={styles.emptyContainer}>
<Text style={styles.emptyIcon}>🔍</Text>
<Text style={styles.emptyTitle}>No results</Text>
<Text style={styles.emptyText}>Try searching with different keywords</Text>
<Text style={[styles.emptyTitle, { color: theme.text }]}>No results</Text>
<Text style={[styles.emptyText, { color: theme.textSecondary }]}>Try different keywords</Text>
</View>
)}
{query.length === 0 && (
<View style={styles.emptyContainer}>
<Text style={styles.emptyIcon}>🔍</Text>
<Text style={styles.emptyTitle}>Search your photos</Text>
<Text style={styles.emptyText}>Search by title or description</Text>
<Text style={[styles.emptyTitle, { color: theme.text }]}>Search your photos</Text>
<Text style={[styles.emptyText, { color: theme.textSecondary }]}>Search by title or description</Text>
</View>
)}
{results.length > 0 && (
<>
<Text style={styles.resultCount}>{results.length} result{results.length !== 1 ? 's' : ''}</Text>
<FlatList
<Text style={[styles.resultCount, { color: theme.textSecondary }]}>
{results.length} result{results.length !== 1 ? 's' : ''}
</Text>
<PhotoGrid
data={results}
renderItem={renderItem}
keyExtractor={(item, i) => item.id?.toString() || i.toString()}
numColumns={GRID_COLUMNS}
contentContainerStyle={styles.gridContent}
onItemPress={(item) => { setSelectedMedia(item); setViewerVisible(true); }}
/>
</>
)}
{/* Photo viewer modal */}
<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>
<MediaViewer visible={viewerVisible} media={selectedMedia} onClose={() => setViewerVisible(false)} />
</View>
);
};
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#fff' },
container: { flex: 1 },
searchBar: {
flexDirection: 'row', alignItems: 'center', marginHorizontal: 12,
marginTop: 48, marginBottom: 8, backgroundColor: '#f1f3f4',
borderRadius: 24, paddingHorizontal: 14, paddingVertical: 10, gap: 8,
marginTop: 48, marginBottom: 8, borderRadius: 24,
paddingHorizontal: 14, paddingVertical: 10, gap: 8,
},
searchIcon: { fontSize: 16 },
searchInput: { flex: 1, fontSize: 15, color: '#202124', padding: 0 },
clearButton: { fontSize: 16, color: '#5f6368', padding: 4 },
searchInput: { flex: 1, fontSize: 15, padding: 0 },
clearButton: { fontSize: 16, padding: 4 },
loadingContainer: { padding: 20, alignItems: 'center' },
emptyContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', paddingHorizontal: 24 },
emptyIcon: { fontSize: 48, marginBottom: 12 },
emptyTitle: { fontSize: 18, fontWeight: '600', color: '#202124', marginBottom: 6 },
emptyText: { fontSize: 13, color: '#5f6368', textAlign: 'center' },
resultCount: { paddingHorizontal: 16, paddingVertical: 8, fontSize: 13, color: '#5f6368' },
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' },
emptyTitle: { fontSize: 18, fontWeight: '600', marginBottom: 6 },
emptyText: { fontSize: 13, textAlign: 'center' },
resultCount: { paddingHorizontal: 16, paddingVertical: 8, fontSize: 13 },
});
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');
return true;
} 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 () => {
try {
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;