mirror of
https://github.com/mudabbir-ahmad/Media-Aggregation-and-Sorting-System.git
synced 2026-10-07 19:40:20 +00:00
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:
1 parent
c1d7139055
commit
6f4ec66e2e
17 files changed
+1761
-1018
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user