slight refactor of all the code, made it so that login and onboarding works, need to add system to validate connections to google photos + NAS before just blindly accepting

Added library home page and navigation bar at the bottom, using emojis as icon placeholders, album page also works.

elipses for settings and icon also work.
This commit is contained in:
bobbert committed 2026-02-21 13:52:28 +00:00
1 parent 37511780b7
commit c1d7139055
17 files changed
+1411 -10020

No files matched your search

+81 -55
View File
@@ -2,66 +2,98 @@ import React, { useEffect, useState } from 'react';
import { StatusBar } from 'expo-status-bar'; import { StatusBar } from 'expo-status-bar';
import { NavigationContainer } from '@react-navigation/native'; import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack'; import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { ActivityIndicator, View, Text, TouchableOpacity } from 'react-native'; import { ActivityIndicator, View, Text, TouchableOpacity } from 'react-native';
import DatabaseService from './src/services/DatabaseService'; import DatabaseService from './src/services/DatabaseService';
import LoginScreen from './src/screens/LoginScreen'; import LoginScreen from './src/screens/LoginScreen';
import RegisterScreen from './src/screens/RegisterScreen'; import RegisterScreen from './src/screens/RegisterScreen';
import OnboardingScreen from './src/screens/OnboardingScreen'; import OnboardingScreen from './src/screens/OnboardingScreen';
import HomeScreen from './src/screens/HomeScreen'; import HomeScreen from './src/screens/HomeScreen';
import SearchScreen from './src/screens/SearchScreen';
import AlbumsScreen from './src/screens/AlbumsScreen';
const Stack = createNativeStackNavigator(); const Stack = createNativeStackNavigator();
const Tab = createBottomTabNavigator();
/** /**
* App.js - Navigation Controller Only * MainTabs - Bottom tab navigator for logged-in users
* * Tabs: Home (Library), Search, Albums
* This component ONLY handles:
* 1. Database initialization
* 2. Navigation stack setup
* 3. Routing to appropriate first screen
*
* NO DATA STORAGE OR DISPLAY HERE
* All screens handle their own state and data
*/ */
function MainTabs({ route }) {
const { userId } = route.params || {};
return (
<Tab.Navigator
screenOptions={{
headerShown: false,
tabBarActiveTintColor: '#1a73e8',
tabBarInactiveTintColor: '#5f6368',
tabBarStyle: {
backgroundColor: '#fff',
borderTopColor: '#e0e0e0',
borderTopWidth: 1,
height: 56,
paddingBottom: 6,
paddingTop: 6,
},
tabBarLabelStyle: {
fontSize: 11,
fontWeight: '500',
},
}}
>
<Tab.Screen
name="Photos"
component={HomeScreen}
initialParams={{ userId }}
options={{
tabBarLabel: 'Photos',
tabBarIcon: ({ color }) => (
<Text style={{ fontSize: 22, color }}>🖼️</Text>
),
}}
/>
<Tab.Screen
name="Search"
component={SearchScreen}
initialParams={{ userId }}
options={{
tabBarLabel: 'Search',
tabBarIcon: ({ color }) => (
<Text style={{ fontSize: 22, color }}>🔍</Text>
),
}}
/>
<Tab.Screen
name="Albums"
component={AlbumsScreen}
initialParams={{ userId }}
options={{
tabBarLabel: 'Albums',
tabBarIcon: ({ color }) => (
<Text style={{ fontSize: 22, color }}>📁</Text>
),
}}
/>
</Tab.Navigator>
);
}
/**
* AppNavigator - Root stack navigator
* Handles auth flow (Login/Register/Onboarding) then MainTabs
*/
function AppNavigator() { function AppNavigator() {
return ( return (
<Stack.Navigator <Stack.Navigator
screenOptions={{ screenOptions={{
headerShown: false, headerShown: false,
cardStyle: { backgroundColor: '#f5f5f5' },
}} }}
> >
{/* Authentication Stack - User not logged in */} <Stack.Screen name="Login" component={LoginScreen} />
<Stack.Screen <Stack.Screen name="Register" component={RegisterScreen} />
name="Login" <Stack.Screen name="Onboarding" component={OnboardingScreen} />
component={LoginScreen} <Stack.Screen name="MainTabs" component={MainTabs} />
options={{
animationEnabled: false,
}}
/>
<Stack.Screen
name="Register"
component={RegisterScreen}
options={{
animationEnabled: true,
}}
/>
<Stack.Screen
name="Onboarding"
component={OnboardingScreen}
options={{
animationEnabled: true,
}}
/>
{/* App Stack - User logged in */}
<Stack.Screen
name="Home"
component={HomeScreen}
options={{
animationEnabled: true,
}}
/>
</Stack.Navigator> </Stack.Navigator>
); );
} }
@@ -74,41 +106,35 @@ export default function App() {
initializeApp(); initializeApp();
}, []); }, []);
/**
* Initialize database - only purpose of App.js useEffect
* All other logic belongs in screen components
*/
const initializeApp = async () => { const initializeApp = async () => {
try { try {
setError(null); setError(null);
await DatabaseService.initDatabase(); await DatabaseService.initDatabase();
setIsReady(true); setIsReady(true);
} catch (error) { } catch (err) {
console.error('Database initialization failed:', error); console.error('Database initialization failed:', err);
setError(error.message || 'Database initialization failed'); setError(err.message || 'Database initialization failed');
setIsReady(false); setIsReady(false);
} }
}; };
// Show loading while database initializes
if (!isReady && !error) { if (!isReady && !error) {
return ( return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}> <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#fff' }}>
<ActivityIndicator size="large" color="#007AFF" /> <ActivityIndicator size="large" color="#1a73e8" />
</View> </View>
); );
} }
// Show error if database initialization fails
if (error) { if (error) {
return ( return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', paddingHorizontal: 20 }}> <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', paddingHorizontal: 20, backgroundColor: '#fff' }}>
<Text style={{ fontSize: 16, color: '#333', marginBottom: 12, textAlign: 'center' }}> <Text style={{ fontSize: 16, color: '#333', marginBottom: 12, textAlign: 'center' }}>
{error} {error}
</Text> </Text>
<TouchableOpacity <TouchableOpacity
onPress={initializeApp} onPress={initializeApp}
style={{ backgroundColor: '#007AFF', paddingHorizontal: 16, paddingVertical: 10, borderRadius: 6 }} style={{ backgroundColor: '#1a73e8', paddingHorizontal: 16, paddingVertical: 10, borderRadius: 6 }}
> >
<Text style={{ color: '#fff', fontWeight: '600' }}>Retry</Text> <Text style={{ color: '#fff', fontWeight: '600' }}>Retry</Text>
</TouchableOpacity> </TouchableOpacity>
@@ -121,7 +147,7 @@ export default function App() {
<NavigationContainer> <NavigationContainer>
<AppNavigator /> <AppNavigator />
</NavigationContainer> </NavigationContainer>
<StatusBar barStyle="dark-content" backgroundColor="#f5f5f5" /> <StatusBar style="dark" backgroundColor="#fff" />
</> </>
); );
} }
-8072
View File
File diff suppressed because it is too large. Load diff
+9 -14
View File
@@ -7,33 +7,28 @@
"android": "expo start --android", "android": "expo start --android",
"ios": "expo start --ios", "ios": "expo start --ios",
"web": "expo start --web", "web": "expo start --web",
"backend": "node backend/server.js", "backend": "node ServerFiles/server.js"
"backend-dev": "nodemon backend/server.js"
}, },
"dependencies": { "dependencies": {
"@react-native-community/masked-view": "^0.1.11", "@react-navigation/bottom-tabs": "^6.5.20",
"@react-navigation/native": "^6.1.18", "@react-navigation/native": "^6.1.18",
"@react-navigation/native-stack": "^6.9.26", "@react-navigation/native-stack": "^6.9.26",
"@react-navigation/stack": "^6.4.1", "axios": "^1.7.9",
"axios": "^1.13.5",
"expo": "~54.0.33", "expo": "~54.0.33",
"expo-auth-session": "^7.0.10", "expo-auth-session": "~7.0.10",
"expo-crypto": "^15.0.8", "expo-crypto": "~15.0.8",
"expo-file-system": "~19.0.21", "expo-file-system": "~19.0.21",
"expo-image-picker": "~17.0.10", "expo-image-picker": "~17.0.10",
"expo-linking": "^8.0.11", "expo-linking": "~8.0.11",
"expo-media-library": "~18.2.1", "expo-media-library": "~18.2.1",
"expo-secure-store": "^15.0.8", "expo-secure-store": "~15.0.8",
"expo-sqlite": "~16.0.10", "expo-sqlite": "~16.0.10",
"expo-status-bar": "~3.0.9", "expo-status-bar": "~3.0.9",
"expo-web-browser": "^15.0.10", "expo-web-browser": "~15.0.10",
"react": "19.1.0", "react": "19.1.0",
"react-native": "0.81.5", "react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0",
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0", "react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0", "react-native-screens": "~4.16.0"
"ssh2-sftp-client": "^12.0.1"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.29.0" "@babel/core": "^7.29.0"
+18
View File
@@ -0,0 +1,18 @@
/**
* iCloud Photos API - Placeholder
*
* Apple does not provide a public REST API for iCloud Photos.
* This file is reserved for future integration if Apple provides API access
* or if a third-party solution becomes available.
*/
const iCloudPhotosAPI = () => {
return {
listMediaItems: async () => ({ isSuccess: false, message: 'iCloud API not available' }),
getMediaItem: async () => ({ isSuccess: false, message: 'iCloud API not available' }),
getAlbums: async () => ({ isSuccess: false, message: 'iCloud API not available' }),
};
};
export default iCloudPhotosAPI;
+155 -132
View File
@@ -1,53 +1,66 @@
import axios from 'axios';
/** /**
* NAS API - Direct Connection Handler * NAS API - HTTP Client for SFTP Backend
* *
* Handles NAS/SFTP connections directly from the React Native app * Connects to the Node.js backend server (ServerFiles/server.js)
* Note: Since React Native cannot use SSH2 directly, this provides * which handles all actual SSH/SFTP operations.
* credential management and placeholder methods. For production,
* consider using a WebDAV server on your NAS instead of SFTP.
*/ */
// Default backend URL - update this to match your server address
const DEFAULT_BACKEND_URL = 'http://192.168.1.100:3001';
class NASAPI { class NASAPI {
constructor() { constructor(backendUrl = DEFAULT_BACKEND_URL) {
this.connection = null; this.backendUrl = backendUrl;
this.credentials = null; this.connectionToken = null;
this.isConnected = false; this.isConnected = false;
this.credentials = null;
} }
/** /**
* Test NAS connection * Connect to NAS via the backend server
* Stores credentials for later use * The backend handles the actual SSH connection
*/ */
async connect(host, port, username, password, photoFolder = '/Photos') { async connect(host, port, username, password, photoFolder = '/Photos') {
try { try {
// Validate credentials
if (!host || !username || !password) { if (!host || !username || !password) {
throw new Error('Missing required connection details'); throw new Error('Host, username, and password are required');
} }
// Store credentials securely // Store credentials locally
this.credentials = { this.credentials = { host, port: port || 22, username, photoFolder };
// Try to connect via backend
try {
const response = await axios.post(
`${this.backendUrl}/api/sftp/connect`,
{
host, host,
port: port || 22, port: port || 22,
username, username,
password, // In production, encrypt this password,
photoFolder, },
connectedAt: new Date().toISOString(), { timeout: 10000 }
}; );
if (response.data?.success) {
this.connectionToken = response.data.connectionToken;
this.isConnected = true; this.isConnected = true;
console.log(`NAS connected via backend: ${host}:${port}`);
return { success: true, message: `Connected to ${host}` };
}
} catch (backendErr) {
// Backend not available - store credentials for when it becomes available
console.log('Backend not available, storing NAS credentials locally');
}
console.log(`✓ NAS credentials stored for ${host}:${port}`); // Even without backend, store config so user can proceed
this.isConnected = true;
return { return {
success: true, success: true,
message: `NAS configured: ${host}`, message: `NAS configured: ${host} (backend connection pending)`,
connectionData: { connectionData: this.credentials,
host,
port,
username,
photoFolder,
},
}; };
} catch (error) { } catch (error) {
console.error('NAS connection error:', error); console.error('NAS connection error:', error);
@@ -56,147 +69,157 @@ class NASAPI {
} }
/** /**
* Disconnect from NAS * Get authorization headers for backend requests
*/ */
async disconnect() { getHeaders() {
this.connection = null; if (!this.connectionToken) return {};
this.credentials = null; return { Authorization: `Bearer ${this.connectionToken}` };
this.isConnected = false;
return {
success: true,
message: 'Disconnected from NAS',
};
} }
/** /**
* List files in NAS directory * List directory contents on the NAS
* Note: Placeholder - In production, use WebDAV or backend server
*/ */
async listDirectory(path = '/') { async listDirectory(path = '/') {
if (!this.isConnected) { if (!this.connectionToken) {
throw new Error('Not connected to NAS'); return { path, files: [], message: 'Backend not connected' };
}
try {
const response = await axios.post(
`${this.backendUrl}/api/sftp/list`,
{ path },
{ headers: this.getHeaders(), timeout: 15000 }
);
return response.data;
} catch (error) {
console.error('List directory error:', error.message);
throw new Error('Failed to list directory: ' + error.message);
} }
console.log(`📁 Listing directory: ${path}`);
// Placeholder response
return {
path,
files: [],
message: 'NAS file listing requires WebDAV or backend server',
};
} }
/** /**
* Get photos from NAS * Get photos from designated NAS folder
* Note: Placeholder - In production, use WebDAV or backend server
*/ */
async getPhotos(folder = null) { async getPhotos(folder = null) {
if (!this.isConnected) { const targetFolder = folder || this.credentials?.photoFolder || '/Photos';
throw new Error('Not connected to NAS');
if (!this.connectionToken) {
return { mediaItems: [], folder: targetFolder, message: 'Backend not connected' };
} }
const targetFolder = folder || this.credentials?.photoFolder || '/Photos'; try {
console.log(`📷 Getting photos from: ${targetFolder}`); const listing = await this.listDirectory(targetFolder);
const photoExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.heic'];
const videoExtensions = ['.mp4', '.mov', '.avi', '.mkv', '.webm'];
// Placeholder response const mediaItems = (listing.files || [])
return { .filter((file) => {
mediaItems: [], const ext = (file.name || '').toLowerCase().split('.').pop();
folder: targetFolder, return photoExtensions.includes('.' + ext) || videoExtensions.includes('.' + ext);
message: 'NAS photo retrieval requires WebDAV or backend server', })
}; .map((file) => ({
source_service: 'NAS',
source_id: `nas_${file.name}`,
title: file.name,
remote_path: `${targetFolder}/${file.name}`,
media_type: videoExtensions.includes('.' + (file.name || '').toLowerCase().split('.').pop()) ? 'video' : 'photo',
created_date: file.modifyTime,
size: file.size,
}));
return { mediaItems, folder: targetFolder };
} catch (error) {
console.error('Get photos error:', error.message);
return { mediaItems: [], folder: targetFolder };
}
} }
/** /**
* Upload file to NAS * Upload file to NAS
* Note: Placeholder - In production, use WebDAV or backend server
*/ */
async uploadFile(localPath, remotePath) { async uploadFile(localPath, remotePath) {
if (!this.isConnected) { if (!this.connectionToken) {
throw new Error('Not connected to NAS'); throw new Error('Backend not connected - cannot upload');
} }
try {
const formData = new FormData();
formData.append('remotePath', remotePath);
formData.append('file', { uri: localPath, name: remotePath.split('/').pop(), type: 'application/octet-stream' });
console.log(`⬆️ Upload: ${localPath} -> ${remotePath}`); const response = await axios.post(
`${this.backendUrl}/api/sftp/upload`,
return { formData,
success: false, { headers: { ...this.getHeaders(), 'Content-Type': 'multipart/form-data' }, timeout: 60000 }
message: 'NAS file upload requires WebDAV or backend server', );
}; return response.data;
} catch (error) {
console.error('Upload error:', error.message);
throw new Error('Upload failed: ' + error.message);
} }
/**
* Download file from NAS
* Note: Placeholder - In production, use WebDAV or backend server
*/
async downloadFile(remotePath, localPath) {
if (!this.isConnected) {
throw new Error('Not connected to NAS');
}
console.log(`⬇️ Download: ${remotePath} -> ${localPath}`);
return {
success: false,
message: 'NAS file download requires WebDAV or backend server',
};
}
/**
* Delete file from NAS
*/
async deleteFile(remotePath) {
if (!this.isConnected) {
throw new Error('Not connected to NAS');
}
console.log(`🗑️ Delete: ${remotePath}`);
return {
success: false,
message: 'NAS file deletion requires WebDAV or backend server',
};
} }
/** /**
* Create directory on NAS * Create directory on NAS
*/ */
async createDirectory(remotePath) { async createDirectory(remotePath) {
if (!this.isConnected) { if (!this.connectionToken) {
throw new Error('Not connected to NAS'); throw new Error('Backend not connected');
}
try {
const response = await axios.post(
`${this.backendUrl}/api/sftp/mkdir`,
{ path: remotePath },
{ headers: this.getHeaders(), timeout: 10000 }
);
return response.data;
} catch (error) {
console.error('Create directory error:', error.message);
throw error;
} }
console.log(`📁 Create directory: ${remotePath}`);
return {
success: false,
message: 'NAS directory creation requires WebDAV or backend server',
};
} }
/** /**
* Get connection status * Delete file from NAS
*/ */
async deleteFile(remotePath) {
if (!this.connectionToken) {
throw new Error('Backend not connected');
}
try {
const response = await axios.post(
`${this.backendUrl}/api/sftp/delete`,
{ path: remotePath },
{ headers: this.getHeaders(), timeout: 10000 }
);
return response.data;
} catch (error) {
console.error('Delete error:', error.message);
throw error;
}
}
/**
* Disconnect from NAS
*/
async disconnect() {
if (this.connectionToken) {
try {
await axios.post(
`${this.backendUrl}/api/sftp/disconnect`,
{},
{ headers: this.getHeaders(), timeout: 5000 }
);
} catch (err) {
// Ignore disconnect errors
}
}
this.connectionToken = null;
this.isConnected = false;
this.credentials = null;
return { success: true };
}
getConnectionInfo() { getConnectionInfo() {
if (!this.isConnected || !this.credentials) { return this.credentials ? { ...this.credentials, isConnected: this.isConnected } : null;
return null;
}
return {
host: this.credentials.host,
port: this.credentials.port,
username: this.credentials.username,
photoFolder: this.credentials.photoFolder,
connectedAt: this.credentials.connectedAt,
isConnected: this.isConnected,
};
}
/**
* Check if connected
*/
isConnectedToNAS() {
return this.isConnected;
} }
} }
+45 -125
View File
@@ -2,80 +2,68 @@ import { AuthServiceBase } from './AuthServiceBase';
/** /**
* Google Photos Authentication Service * Google Photos Authentication Service
* Handles OAuth2 authentication with Google Photos API *
* Uses placeholder tokens for now.
* To enable real Google OAuth, set up a Google Cloud project with Photos Library API,
* create OAuth 2.0 credentials, and replace GOOGLE_CLIENT_ID below.
*
* Real OAuth flow uses expo-auth-session + expo-web-browser.
*/ */
// TODO: Replace with your real Google Cloud OAuth Client ID
const GOOGLE_CLIENT_ID = 'YOUR_GOOGLE_CLIENT_ID.apps.googleusercontent.com';
class GoogleAUTH extends AuthServiceBase { class GoogleAUTH extends AuthServiceBase {
constructor() { constructor() {
super(); super();
this.accessToken = null; this.accessToken = null;
this.refreshToken = null; this.refreshToken = null;
this.expiresAt = null; this.expiresAt = null;
this.email = null;
} }
/** /**
* Authenticate with Google OAuth * Authenticate with Google
* In a real app, you would use Google Sign-In library * Currently uses placeholder tokens. Replace with real OAuth when client ID is configured.
*/ */
async authenticate(authCode) { async authenticate() {
try { try {
// Validate input if (GOOGLE_CLIENT_ID === 'YOUR_GOOGLE_CLIENT_ID.apps.googleusercontent.com') {
if (!authCode) { // Placeholder mode - no real client ID configured
throw new Error('Authorization code is required'); console.log('Google Auth: Using placeholder mode (no client ID configured)');
} this.accessToken = 'placeholder_google_token_' + Date.now();
this.refreshToken = 'placeholder_refresh_' + Date.now();
// TODO: Implement actual Google OAuth flow this.expiresAt = Date.now() + 3600000;
// This would typically involve: this.email = 'google-user';
// 1. Initiating Google Sign-In
// 2. Getting the authorization code
// 3. Exchanging it for access and refresh tokens
console.log('Authenticating with Google...');
// Placeholder: In production, exchange authCode for tokens
this.accessToken = 'google_access_token_placeholder';
this.refreshToken = 'google_refresh_token_placeholder';
this.expiresAt = Date.now() + 3600000; // 1 hour from now
return { return {
success: true, success: true,
message: 'Google authentication successful',
accessToken: this.accessToken, accessToken: this.accessToken,
refreshToken: this.refreshToken,
email: this.email,
}; };
}
// 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');
} catch (error) { } catch (error) {
console.error('Google authentication error:', error); console.error('Google authentication error:', error);
throw error; throw error;
} }
} }
/**
* Get current access token
*/
async getAccessToken() { async getAccessToken() {
if (this.accessToken && this.expiresAt > Date.now()) { if (this.accessToken && this.expiresAt > Date.now()) return this.accessToken;
return this.accessToken; throw new Error('No valid Google access token');
}
// If token is expired, refresh it
if (this.refreshToken) {
return await this.refreshToken();
}
throw new Error('No valid access token available');
} }
/** async refreshAccessToken() {
* Refresh the access token using refresh token
*/
async refreshToken() {
try { try {
if (!this.refreshToken) {
throw new Error('No refresh token available');
}
// TODO: Implement actual token refresh with Google API
console.log('Refreshing Google access token...'); console.log('Refreshing Google access token...');
this.accessToken = 'refreshed_google_token_' + Date.now();
// Placeholder: In production, call Google token endpoint
this.accessToken = 'new_google_access_token_placeholder';
this.expiresAt = Date.now() + 3600000; this.expiresAt = Date.now() + 3600000;
return this.accessToken; return this.accessToken;
} catch (error) { } catch (error) {
console.error('Token refresh error:', error); console.error('Token refresh error:', error);
@@ -83,111 +71,43 @@ class GoogleAUTH extends AuthServiceBase {
} }
} }
/**
* Revoke access and clear tokens
*/
async revokeAccess() { async revokeAccess() {
try {
// TODO: Implement actual revocation with Google API
console.log('Revoking Google access...');
this.accessToken = null; this.accessToken = null;
this.refreshToken = null; this.refreshToken = null;
this.expiresAt = null; this.expiresAt = null;
this.email = null;
return { return { success: true };
success: true,
message: 'Google access revoked',
};
} catch (error) {
console.error('Revoke error:', error);
throw error;
}
} }
/**
* Get user info from Google
*/
async getUserInfo() { async getUserInfo() {
try {
const token = await this.getAccessToken();
// TODO: Implement actual API call to Google People API
console.log('Fetching user info from Google...');
// Placeholder user info
return { return {
id: 'google_user_id_placeholder', id: 'google_user',
email: 'user@gmail.com', email: this.email || 'unknown',
name: 'Google User', name: 'Google User',
photoUrl: 'https://via.placeholder.com/150',
}; };
} catch (error) {
console.error('Get user info error:', error);
throw error;
}
} }
/** async getPhotos(pageSize = 50) {
* Get media list from Google Photos
*/
async getPhotos(pageSize = 50, pageToken = null) {
try { try {
const token = await this.getAccessToken(); await this.getAccessToken();
// In production: call Google Photos API
// TODO: Implement actual API call to Google Photos API // https://photoslibrary.googleapis.com/v1/mediaItems
console.log('Fetching photos from Google Photos...'); return { mediaItems: [], nextPageToken: null };
// Placeholder response
return {
mediaItems: [],
nextPageToken: null,
};
} catch (error) { } catch (error) {
console.error('Get photos error:', error); console.error('Get photos error:', error);
throw error; throw error;
} }
} }
/** async getAlbums() {
* Get albums from Google Photos
*/
async getAlbums(pageSize = 50, pageToken = null) {
try { try {
const token = await this.getAccessToken(); await this.getAccessToken();
return { albums: [], nextPageToken: null };
// TODO: Implement actual API call to Google Photos API
console.log('Fetching albums from Google Photos...');
return {
albums: [],
nextPageToken: null,
};
} catch (error) { } catch (error) {
console.error('Get albums error:', error); console.error('Get albums error:', error);
throw error; throw error;
} }
} }
/**
* Search photos by date range
*/
async searchPhotos(filters = {}) {
try {
const token = await this.getAccessToken();
// TODO: Implement actual API call with filters
console.log('Searching photos with filters:', filters);
return {
mediaItems: [],
nextPageToken: null,
};
} catch (error) {
console.error('Search photos error:', error);
throw error;
}
}
} }
export default GoogleAUTH; export default GoogleAUTH;
+16 -158
View File
@@ -1,192 +1,50 @@
import { AuthServiceBase } from './AuthServiceBase'; import { AuthServiceBase } from './AuthServiceBase';
/** /**
* iOS iCloud Authentication Service * iCloud Photos Authentication Service
* Handles authentication with Apple iCloud Photos (placeholder implementation) * Placeholder - Apple does not provide a public iCloud Photos API.
* This is kept for future integration if/when Apple provides API access.
*/ */
class IOSAUTH extends AuthServiceBase { class IOSAUTH extends AuthServiceBase {
constructor() { constructor() {
super(); super();
this.accessToken = null; this.accessToken = null;
this.refreshToken = null;
this.expiresAt = null;
} }
/** async authenticate() {
* Authenticate with iCloud console.log('iCloud Auth: Placeholder - no Apple API available');
* Placeholder - actual implementation would use Apple's authentication this.accessToken = 'icloud_placeholder_' + Date.now();
*/
async authenticate(username, password) {
try {
// Validate input
if (!username || !password) {
throw new Error('Username and password are required');
}
// TODO: Implement actual Apple iCloud authentication
// This would require:
// 1. Apple Sign-In framework
// 2. iCloud session management
// 3. Secure credential storage
console.log('Authenticating with iCloud...');
// Placeholder: In production, authenticate with Apple servers
this.accessToken = 'icloud_access_token_placeholder';
this.refreshToken = 'icloud_refresh_token_placeholder';
this.expiresAt = Date.now() + 3600000; // 1 hour from now
return { return {
success: true, success: true,
message: 'iCloud authentication successful',
accessToken: this.accessToken, accessToken: this.accessToken,
message: 'iCloud placeholder - full integration pending',
}; };
} catch (error) {
console.error('iCloud authentication error:', error);
throw error;
}
} }
/**
* Get current access token
*/
async getAccessToken() { async getAccessToken() {
if (this.accessToken && this.expiresAt > Date.now()) { if (this.accessToken) return this.accessToken;
return this.accessToken; throw new Error('Not authenticated with iCloud');
}
// If token is expired, refresh it
if (this.refreshToken) {
return await this.refreshToken();
}
throw new Error('No valid access token available');
} }
/**
* Refresh the access token
*/
async refreshToken() { async refreshToken() {
try { return this.authenticate();
if (!this.refreshToken) {
throw new Error('No refresh token available');
} }
// TODO: Implement actual token refresh with iCloud API
console.log('Refreshing iCloud access token...');
// Placeholder
this.accessToken = 'new_icloud_access_token_placeholder';
this.expiresAt = Date.now() + 3600000;
return this.accessToken;
} catch (error) {
console.error('Token refresh error:', error);
throw error;
}
}
/**
* Revoke access and clear tokens
*/
async revokeAccess() { async revokeAccess() {
try {
// TODO: Implement actual revocation with Apple iCloud
console.log('Revoking iCloud access...');
this.accessToken = null; this.accessToken = null;
this.refreshToken = null; return { success: true };
this.expiresAt = null;
return {
success: true,
message: 'iCloud access revoked',
};
} catch (error) {
console.error('Revoke error:', error);
throw error;
}
} }
/**
* Get user info from iCloud
*/
async getUserInfo() { async getUserInfo() {
try { return { id: 'icloud_placeholder', name: 'iCloud User' };
const token = await this.getAccessToken();
// TODO: Implement actual API call to iCloud
console.log('Fetching user info from iCloud...');
// Placeholder user info
return {
id: 'icloud_user_id_placeholder',
email: 'user@icloud.com',
name: 'iCloud User',
photoUrl: 'https://via.placeholder.com/150',
};
} catch (error) {
console.error('Get user info error:', error);
throw error;
}
} }
/** async getPhotos() {
* Get photos from iCloud Photos return { mediaItems: [], nextPageToken: null };
*/
async getPhotos(pageSize = 50, pageToken = null) {
try {
const token = await this.getAccessToken();
// TODO: Implement actual API call to iCloud Photos API
console.log('Fetching photos from iCloud Photos...');
// Placeholder response
return {
mediaItems: [],
nextPageToken: null,
};
} catch (error) {
console.error('Get photos error:', error);
throw error;
}
} }
/** async getAlbums() {
* Get albums from iCloud Photos return { albums: [], nextPageToken: null };
*/
async getAlbums(pageSize = 50, pageToken = null) {
try {
const token = await this.getAccessToken();
// TODO: Implement actual API call to iCloud Photos API
console.log('Fetching albums from iCloud Photos...');
return {
albums: [],
nextPageToken: null,
};
} catch (error) {
console.error('Get albums error:', error);
throw error;
}
}
/**
* Search photos in iCloud
*/
async searchPhotos(filters = {}) {
try {
const token = await this.getAccessToken();
// TODO: Implement actual search with filters
console.log('Searching photos in iCloud with filters:', filters);
return {
mediaItems: [],
nextPageToken: null,
};
} catch (error) {
console.error('Search photos error:', error);
throw error;
}
} }
} }
View File
Whitespace-only changes.
View File
Whitespace-only changes.
+210
View File
@@ -0,0 +1,210 @@
import React, { useState, useCallback } from 'react';
import {
StyleSheet, View, Text, FlatList, TouchableOpacity,
Dimensions, Image, ActivityIndicator, RefreshControl,
} from 'react-native';
import { useFocusEffect } from '@react-navigation/native';
import DatabaseService from '../services/DatabaseService';
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 userId = route?.params?.userId;
const [albums, setAlbums] = useState([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [selectedAlbum, setSelectedAlbum] = useState(null);
const [albumMedia, setAlbumMedia] = useState([]);
useFocusEffect(
useCallback(() => {
if (userId) loadAlbums();
}, [userId])
);
const loadAlbums = async () => {
try {
const accounts = await DatabaseService.getLinkedAccounts(userId);
const albumList = [];
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),
icon: getServiceIcon(acc.service_type),
count: media.length,
coverImage: media.length > 0 ? media[0].local_path : null,
media,
});
}
// 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);
} catch (err) {
console.error('Error loading albums:', 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) {
return (
<View style={styles.container}>
<View style={styles.albumHeader}>
<TouchableOpacity onPress={() => { setSelectedAlbum(null); setAlbumMedia([]); }}>
<Text style={styles.backArrow}>←</Text>
</TouchableOpacity>
<View style={styles.albumHeaderInfo}>
<Text style={styles.albumHeaderTitle}>{selectedAlbum.name}</Text>
<Text style={styles.albumHeaderCount}>{selectedAlbum.count} items</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>
</View>
) : (
<FlatList
data={albumMedia}
renderItem={renderMediaItem}
keyExtractor={(item, i) => item.id?.toString() || i.toString()}
numColumns={3}
contentContainerStyle={styles.gridContent}
/>
)}
</View>
);
}
// Albums grid view
return (
<View style={styles.container}>
<View style={styles.header}>
<Text style={styles.headerTitle}>Albums</Text>
</View>
{albums.length === 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>
</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(); }} />}
/>
)}
</View>
);
};
const gridItemSize = (width - 6) / 3;
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#fff' },
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,
},
albumCoverImage: { width: '100%', height: '100%' },
albumCoverPlaceholder: {
width: '100%', height: '100%', justifyContent: 'center', alignItems: 'center', backgroundColor: '#e8eaed',
},
albumName: { fontSize: 14, fontWeight: '600', color: '#202124', paddingHorizontal: 2 },
albumCount: { fontSize: 12, color: '#5f6368', paddingHorizontal: 2, marginTop: 2 },
// Album detail
albumHeader: {
flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16,
paddingTop: 52, paddingBottom: 12, gap: 12,
},
backArrow: { fontSize: 24, color: '#202124' },
albumHeaderInfo: { flex: 1 },
albumHeaderTitle: { fontSize: 20, fontWeight: '700', color: '#202124' },
albumHeaderCount: { fontSize: 12, color: '#5f6368' },
gridContent: { paddingBottom: 20 },
gridItem: { width: gridItemSize, height: gridItemSize, margin: 1, backgroundColor: '#f1f3f4' },
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' },
});
export default AlbumsScreen;
+218 -664
View File
File diff suppressed because it is too large. Load diff
+48 -42
View File
@@ -13,26 +13,29 @@ import {
import DatabaseService from '../services/DatabaseService'; import DatabaseService from '../services/DatabaseService';
const LoginScreen = ({ navigation }) => { const LoginScreen = ({ navigation }) => {
const [email, setEmail] = useState(''); const [identifier, setIdentifier] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const handleLogin = async () => { const handleLogin = async () => {
if (!email.trim() || !password.trim()) { if (!identifier.trim() || !password.trim()) {
Alert.alert('Error', 'Please enter both email and password'); Alert.alert('Error', 'Please enter your username/email and password');
return; return;
} }
setLoading(true); setLoading(true);
try { try {
await DatabaseService.initDatabase(); const user = await DatabaseService.loginUser(identifier.trim(), password);
const user = await DatabaseService.loginUser(email, password);
if (user) { if (user) {
// Successfully logged in - navigate to home // Check if user has completed onboarding (has linked accounts)
navigation.replace('Onboarding', { userId: user.id }); const accounts = await DatabaseService.getLinkedAccounts(user.id);
if (accounts.length > 0) {
navigation.replace('MainTabs', { userId: user.id });
} else { } else {
Alert.alert('Error', 'Invalid email or password'); navigation.replace('Onboarding', { userId: user.id });
}
} else {
Alert.alert('Error', 'Invalid username/email or password');
} }
} catch (error) { } catch (error) {
console.error('Login error:', error); console.error('Login error:', error);
@@ -49,32 +52,33 @@ const LoginScreen = ({ navigation }) => {
> >
<View style={styles.content}> <View style={styles.content}>
<View style={styles.headerContainer}> <View style={styles.headerContainer}>
<Text style={styles.appIcon}>📸</Text>
<Text style={styles.title}>Photo Library</Text> <Text style={styles.title}>Photo Library</Text>
<Text style={styles.subtitle}>Login to your account</Text> <Text style={styles.subtitle}>Sign in to your account</Text>
</View> </View>
<View style={styles.inputContainer}> <View style={styles.inputContainer}>
<Text style={styles.label}>Email</Text> <Text style={styles.label}>Username or Email</Text>
<TextInput <TextInput
style={styles.input} style={styles.input}
placeholder="Enter your email" placeholder="Enter username or email"
placeholderTextColor="#999" placeholderTextColor="#9aa0a6"
value={email} value={identifier}
onChangeText={setEmail} onChangeText={setIdentifier}
editable={!loading} editable={!loading}
keyboardType="email-address"
autoCapitalize="none" autoCapitalize="none"
autoCorrect={false}
/> />
<Text style={styles.label}>Password</Text> <Text style={styles.label}>Password</Text>
<TextInput <TextInput
style={styles.input} style={styles.input}
placeholder="Enter your password" placeholder="Enter your password"
placeholderTextColor="#999" placeholderTextColor="#9aa0a6"
value={password} value={password}
onChangeText={setPassword} onChangeText={setPassword}
editable={!loading} editable={!loading}
secureTextEntry={true} secureTextEntry
autoCapitalize="none" autoCapitalize="none"
/> />
</View> </View>
@@ -87,7 +91,7 @@ const LoginScreen = ({ navigation }) => {
{loading ? ( {loading ? (
<ActivityIndicator color="#fff" /> <ActivityIndicator color="#fff" />
) : ( ) : (
<Text style={styles.buttonText}>Login</Text> <Text style={styles.buttonText}>Sign In</Text>
)} )}
</TouchableOpacity> </TouchableOpacity>
@@ -97,7 +101,7 @@ const LoginScreen = ({ navigation }) => {
onPress={() => navigation.navigate('Register')} onPress={() => navigation.navigate('Register')}
disabled={loading} disabled={loading}
> >
<Text style={styles.registerLink}>Register here</Text> <Text style={styles.registerLink}>Create one</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>
@@ -108,53 +112,56 @@ const LoginScreen = ({ navigation }) => {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
backgroundColor: '#f5f5f5', backgroundColor: '#fff',
}, },
content: { content: {
flex: 1, flex: 1,
justifyContent: 'center', justifyContent: 'center',
paddingHorizontal: 20, paddingHorizontal: 24,
paddingVertical: 40, paddingVertical: 40,
}, },
headerContainer: { headerContainer: {
marginBottom: 40, marginBottom: 40,
alignItems: 'center', alignItems: 'center',
}, },
appIcon: {
fontSize: 56,
marginBottom: 16,
},
title: { title: {
fontSize: 32, fontSize: 28,
fontWeight: 'bold', fontWeight: '700',
color: '#333', color: '#202124',
marginBottom: 10, marginBottom: 8,
}, },
subtitle: { subtitle: {
fontSize: 16, fontSize: 15,
color: '#999', color: '#5f6368',
}, },
inputContainer: { inputContainer: {
marginBottom: 30, marginBottom: 24,
}, },
label: { label: {
fontSize: 14, fontSize: 13,
fontWeight: '600', fontWeight: '600',
color: '#333', color: '#3c4043',
marginBottom: 8, marginBottom: 6,
marginTop: 16, marginTop: 16,
}, },
input: { input: {
borderWidth: 1, borderWidth: 1,
borderColor: '#ddd', borderColor: '#dadce0',
borderRadius: 8, borderRadius: 8,
paddingHorizontal: 16, paddingHorizontal: 14,
paddingVertical: 12, paddingVertical: 12,
fontSize: 16, fontSize: 15,
backgroundColor: '#fff', backgroundColor: '#fff',
color: '#333', color: '#202124',
}, },
loginButton: { loginButton: {
backgroundColor: '#007AFF', backgroundColor: '#1a73e8',
borderRadius: 8, borderRadius: 8,
paddingVertical: 14, paddingVertical: 14,
paddingHorizontal: 20,
alignItems: 'center', alignItems: 'center',
marginBottom: 20, marginBottom: 20,
}, },
@@ -163,7 +170,7 @@ const styles = StyleSheet.create({
}, },
buttonText: { buttonText: {
color: '#fff', color: '#fff',
fontSize: 16, fontSize: 15,
fontWeight: '600', fontWeight: '600',
}, },
registerContainer: { registerContainer: {
@@ -172,15 +179,14 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
}, },
registerText: { registerText: {
color: '#666', color: '#5f6368',
fontSize: 14, fontSize: 14,
}, },
registerLink: { registerLink: {
color: '#007AFF', color: '#1a73e8',
fontSize: 14, fontSize: 14,
fontWeight: '600', fontWeight: '600',
}, },
}); });
export default LoginScreen; export default LoginScreen;
+264 -450
View File
@@ -12,7 +12,6 @@ import {
} from 'react-native'; } from 'react-native';
import DatabaseService from '../services/DatabaseService'; import DatabaseService from '../services/DatabaseService';
import GoogleAUTH from '../AUTH/GoogleAUTH'; import GoogleAUTH from '../AUTH/GoogleAUTH';
import IOSAUTH from '../AUTH/IOSAUTH';
import NASAUTH from '../AUTH/NASAUTH'; import NASAUTH from '../AUTH/NASAUTH';
const OnboardingScreen = ({ navigation, route }) => { const OnboardingScreen = ({ navigation, route }) => {
@@ -20,41 +19,37 @@ const OnboardingScreen = ({ navigation, route }) => {
const [currentStep, setCurrentStep] = useState(0); const [currentStep, setCurrentStep] = useState(0);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
// Service selections // Service toggles
const [googleEnabled, setGoogleEnabled] = useState(false); const [googleEnabled, setGoogleEnabled] = useState(false);
const [iosEnabled, setIosEnabled] = useState(false); const [iosEnabled, setIosEnabled] = useState(false);
const [nasEnabled, setNasEnabled] = useState(false); const [nasEnabled, setNasEnabled] = useState(false);
// NAS configuration // Service connection status
const [googleConnected, setGoogleConnected] = useState(false);
const [iosConnected, setIosConnected] = useState(false);
const [nasConnected, setNasConnected] = useState(false);
// NAS config
const [nasHost, setNasHost] = useState(''); const [nasHost, setNasHost] = useState('');
const [nasPort, setNasPort] = useState('22'); const [nasPort, setNasPort] = useState('22');
const [nasUsername, setNasUsername] = useState(''); const [nasUsername, setNasUsername] = useState('');
const [nasPassword, setNasPassword] = useState(''); const [nasPassword, setNasPassword] = useState('');
const [nasPhotoFolder, setNasPhotoFolder] = useState('/home/user/photos'); const [nasPhotoFolder, setNasPhotoFolder] = useState('/home/user/photos');
// Default service selection // Default service
const [defaultService, setDefaultService] = useState(null); const [defaultService, setDefaultService] = useState(null);
const steps = [ const steps = [
{ { title: 'Choose Services', description: 'Select which photo services to connect (1-3)' },
title: 'Select Services', { title: 'Connect Services', description: 'Set up each selected service' },
description: 'Choose which services to enable (you can enable 1-3)', { title: 'Default Upload', description: 'Choose where new photos upload by default' },
},
{
title: 'Configure Services',
description: 'Set up the enabled services',
},
{
title: 'Choose Default Service',
description: 'Select which service to use for uploads by default',
},
]; ];
const getEnabledServices = () => { const getEnabledServices = () => {
const services = []; const services = [];
if (googleEnabled) services.push('Google Photos'); if (googleEnabled) services.push({ name: 'Google Photos', type: 'GOOGLE_PHOTOS' });
if (iosEnabled) services.push('iCloud Photos'); if (iosEnabled) services.push({ name: 'iCloud Photos', type: 'ICLOUD_PHOTOS' });
if (nasEnabled) services.push('NAS'); if (nasEnabled) services.push({ name: 'NAS Storage', type: 'NAS' });
return services; return services;
}; };
@@ -62,21 +57,18 @@ const OnboardingScreen = ({ navigation, route }) => {
try { try {
setLoading(true); setLoading(true);
const googleAuth = new GoogleAUTH(); const googleAuth = new GoogleAUTH();
// In a real app, this would trigger OAuth flow const result = await googleAuth.authenticate();
const result = await googleAuth.authenticate('auth_code');
await DatabaseService.linkAccount( await DatabaseService.linkAccount(userId, 'GOOGLE_PHOTOS', {
userId,
'GOOGLE_PHOTOS',
{
accessToken: result.accessToken, accessToken: result.accessToken,
email: 'user@gmail.com', refreshToken: result.refreshToken || null,
}, email: result.email || 'google-user',
defaultService === 'GOOGLE_PHOTOS' });
);
setGoogleConnected(true);
Alert.alert('Success', 'Google Photos connected!'); Alert.alert('Success', 'Google Photos connected!');
} catch (error) { } catch (error) {
console.error('Google auth error:', error);
Alert.alert('Error', 'Failed to connect Google Photos: ' + error.message); Alert.alert('Error', 'Failed to connect Google Photos: ' + error.message);
} finally { } finally {
setLoading(false); setLoading(false);
@@ -86,57 +78,50 @@ const OnboardingScreen = ({ navigation, route }) => {
const handleIosAuth = async () => { const handleIosAuth = async () => {
try { try {
setLoading(true); setLoading(true);
const iosAuth = new IOSAUTH(); // Placeholder - iCloud API not yet available
const result = await iosAuth.authenticate('icloud_user', 'icloud_password'); await DatabaseService.linkAccount(userId, 'ICLOUD_PHOTOS', {
status: 'placeholder',
note: 'iCloud integration pending Apple API access',
});
await DatabaseService.linkAccount( setIosConnected(true);
userId, Alert.alert('Info', 'iCloud Photos registered. Full integration coming soon.');
'ICLOUD_PHOTOS',
{
accessToken: result.accessToken,
email: 'user@icloud.com',
},
defaultService === 'ICLOUD_PHOTOS'
);
Alert.alert('Success', 'iCloud Photos connected!');
} catch (error) { } catch (error) {
Alert.alert('Error', 'Failed to connect iCloud Photos: ' + error.message); console.error('iCloud auth error:', error);
Alert.alert('Error', 'Failed to set up iCloud: ' + error.message);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
const handleNasAuth = async () => { const handleNasAuth = async () => {
if (!nasHost || !nasUsername || !nasPassword) { if (!nasHost.trim() || !nasUsername.trim() || !nasPassword.trim()) {
Alert.alert('Error', 'Please fill in all NAS connection details'); Alert.alert('Error', 'Please fill in all NAS connection fields');
return; return;
} }
try { try {
setLoading(true); setLoading(true);
const nasAuth = new NASAUTH(); const nasAuth = new NASAUTH();
const result = await nasAuth.authenticate( await nasAuth.authenticate(
nasHost, nasHost.trim(),
parseInt(nasPort) || 22, parseInt(nasPort) || 22,
nasUsername, nasUsername.trim(),
nasPassword nasPassword,
nasPhotoFolder.trim()
); );
await DatabaseService.linkAccount( await DatabaseService.linkAccount(userId, 'NAS', {
userId, host: nasHost.trim(),
'NAS', port: parseInt(nasPort) || 22,
{ username: nasUsername.trim(),
host: nasHost, photoFolder: nasPhotoFolder.trim(),
port: nasPort, });
username: nasUsername,
photoFolder: nasPhotoFolder,
},
defaultService === 'NAS'
);
setNasConnected(true);
Alert.alert('Success', 'NAS connected!'); Alert.alert('Success', 'NAS connected!');
} catch (error) { } catch (error) {
console.error('NAS auth error:', error);
Alert.alert('Error', 'Failed to connect NAS: ' + error.message); Alert.alert('Error', 'Failed to connect NAS: ' + error.message);
} finally { } finally {
setLoading(false); setLoading(false);
@@ -145,49 +130,37 @@ const OnboardingScreen = ({ navigation, route }) => {
const handleNext = async () => { const handleNext = async () => {
if (currentStep === 0) { if (currentStep === 0) {
const enabledCount = getEnabledServices().length; if (getEnabledServices().length === 0) {
if (enabledCount === 0) {
Alert.alert('Error', 'Please select at least one service'); Alert.alert('Error', 'Please select at least one service');
return; return;
} }
if (enabledCount > 3) {
Alert.alert('Error', 'You can enable a maximum of 3 services');
return;
}
setCurrentStep(1); setCurrentStep(1);
} else if (currentStep === 1) { } else if (currentStep === 1) {
// Configure services // Check at least one service is connected
try { const anyConnected =
setLoading(true); (googleEnabled && googleConnected) ||
if (googleEnabled) await handleGoogleAuth(); (iosEnabled && iosConnected) ||
if (iosEnabled) await handleIosAuth(); (nasEnabled && nasConnected);
if (nasEnabled) await handleNasAuth();
setCurrentStep(2); if (!anyConnected) {
} catch (error) { Alert.alert('Error', 'Please connect at least one service before continuing');
console.error('Configuration error:', error); return;
} finally {
setLoading(false);
} }
setCurrentStep(2);
} else if (currentStep === 2) { } else if (currentStep === 2) {
// Complete onboarding
if (!defaultService) { if (!defaultService) {
Alert.alert('Error', 'Please select a default service'); Alert.alert('Error', 'Please select a default upload service');
return; return;
} }
try { try {
setLoading(true); setLoading(true);
// Set default service
const accounts = await DatabaseService.getLinkedAccounts(userId); const accounts = await DatabaseService.getLinkedAccounts(userId);
const defaultAccount = accounts.find( const defaultAccount = accounts.find((a) => a.service_type === defaultService);
(acc) => acc.service_type === defaultService
);
if (defaultAccount) { if (defaultAccount) {
await DatabaseService.setDefaultService(userId, defaultAccount.id); await DatabaseService.setDefaultService(userId, defaultAccount.id);
} }
navigation.replace('MainTabs', { userId });
// Navigate to home screen
navigation.replace('Home', { userId });
} catch (error) { } catch (error) {
Alert.alert('Error', 'Failed to complete setup: ' + error.message); Alert.alert('Error', 'Failed to complete setup: ' + error.message);
} finally { } finally {
@@ -196,192 +169,184 @@ const OnboardingScreen = ({ navigation, route }) => {
} }
}; };
const handleBack = () => {
if (currentStep > 0) {
setCurrentStep(currentStep - 1);
}
};
return ( return (
<View style={styles.container}> <View style={styles.container}>
<ScrollView style={styles.content} showsVerticalScrollIndicator={false}> {/* Progress bar */}
<View style={styles.header}> <View style={styles.progressBar}>
<Text style={styles.stepIndicator}> {steps.map((_, i) => (
Step {currentStep + 1} of {steps.length} <View key={i} style={[styles.progressDot, i <= currentStep && styles.progressDotActive]} />
</Text> ))}
<Text style={styles.title}>{steps[currentStep].title}</Text>
<Text style={styles.description}>
{steps[currentStep].description}
</Text>
</View> </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>
</View>
{/* Step 0: Select services */}
{currentStep === 0 && ( {currentStep === 0 && (
<View style={styles.servicesContainer}> <View style={styles.cardsContainer}>
<ServiceCard <ServiceToggleCard
icon="📷"
name="Google Photos" name="Google Photos"
description="Access your Google Photos library"
enabled={googleEnabled} enabled={googleEnabled}
onToggle={() => setGoogleEnabled(!googleEnabled)} onToggle={() => setGoogleEnabled(!googleEnabled)}
icon="📷"
description="Connect to your Google Photos account"
/> />
<ServiceCard <ServiceToggleCard
icon="🍎"
name="iCloud Photos" name="iCloud Photos"
description="Access your iCloud photo library (placeholder)"
enabled={iosEnabled} enabled={iosEnabled}
onToggle={() => setIosEnabled(!iosEnabled)} onToggle={() => setIosEnabled(!iosEnabled)}
icon="🍎"
description="Connect to your iCloud Photos account"
/> />
<ServiceCard <ServiceToggleCard
name="Network Storage (NAS)" icon="💾"
name="NAS Storage"
description="Connect to your Ubuntu server via SFTP"
enabled={nasEnabled} enabled={nasEnabled}
onToggle={() => setNasEnabled(!nasEnabled)} onToggle={() => setNasEnabled(!nasEnabled)}
icon="💾"
description="Connect to your Ubuntu NAS via SFTP"
/> />
</View> </View>
)} )}
{/* Step 1: Configure each service */}
{currentStep === 1 && ( {currentStep === 1 && (
<View style={styles.configContainer}> <View style={styles.cardsContainer}>
{googleEnabled && ( {googleEnabled && (
<ServiceConfigCard <View style={styles.configCard}>
title="Google Photos" <View style={styles.configHeader}>
<Text style={styles.configIcon}>📷</Text>
<Text style={styles.configTitle}>Google Photos</Text>
{googleConnected && <Text style={styles.connectedBadge}>✓ Connected</Text>}
</View>
{!googleConnected && (
<TouchableOpacity
style={[styles.authButton, loading && styles.buttonDisabled]}
onPress={handleGoogleAuth} onPress={handleGoogleAuth}
loading={loading} disabled={loading}
> >
<Text style={styles.configText}> {loading ? <ActivityIndicator color="#fff" /> : (
Tap to authenticate with your Google account <Text style={styles.authButtonText}>Sign in with Google</Text>
</Text> )}
</ServiceConfigCard> </TouchableOpacity>
)}
</View>
)} )}
{iosEnabled && ( {iosEnabled && (
<ServiceConfigCard <View style={styles.configCard}>
title="iCloud Photos" <View style={styles.configHeader}>
onPress={handleIosAuth} <Text style={styles.configIcon}>🍎</Text>
loading={loading} <Text style={styles.configTitle}>iCloud Photos</Text>
> {iosConnected && <Text style={styles.connectedBadge}>✓ Connected</Text>}
<Text style={styles.configText}> </View>
Tap to authenticate with your Apple account {!iosConnected && (
</Text>
</ServiceConfigCard>
)}
{nasEnabled && (
<ServiceConfigCard title="Network Storage (NAS)" loading={loading}>
<Text style={styles.configLabel}>Server Address</Text>
<TextInput
style={styles.configInput}
placeholder="192.168.1.100 or nas.example.com"
value={nasHost}
onChangeText={setNasHost}
editable={!loading}
placeholderTextColor="#999"
/>
<Text style={styles.configLabel}>Port</Text>
<TextInput
style={styles.configInput}
placeholder="22"
value={nasPort}
onChangeText={setNasPort}
editable={!loading}
keyboardType="numeric"
placeholderTextColor="#999"
/>
<Text style={styles.configLabel}>Username</Text>
<TextInput
style={styles.configInput}
placeholder="SSH username"
value={nasUsername}
onChangeText={setNasUsername}
editable={!loading}
autoCapitalize="none"
placeholderTextColor="#999"
/>
<Text style={styles.configLabel}>Password</Text>
<TextInput
style={styles.configInput}
placeholder="SSH password"
value={nasPassword}
onChangeText={setNasPassword}
editable={!loading}
secureTextEntry
autoCapitalize="none"
placeholderTextColor="#999"
/>
<Text style={styles.configLabel}>Photos Folder Path</Text>
<TextInput
style={styles.configInput}
placeholder="/home/user/photos"
value={nasPhotoFolder}
onChangeText={setNasPhotoFolder}
editable={!loading}
placeholderTextColor="#999"
/>
<TouchableOpacity <TouchableOpacity
style={[styles.testButton, loading && styles.buttonDisabled]} style={[styles.authButton, { backgroundColor: '#333' }, loading && styles.buttonDisabled]}
onPress={handleNasAuth} onPress={handleIosAuth}
disabled={loading} disabled={loading}
> >
{loading ? ( {loading ? <ActivityIndicator color="#fff" /> : (
<ActivityIndicator color="#fff" /> <Text style={styles.authButtonText}>Connect iCloud</Text>
) : (
<Text style={styles.buttonText}>Test Connection</Text>
)} )}
</TouchableOpacity> </TouchableOpacity>
</ServiceConfigCard>
)} )}
</View> </View>
)} )}
{nasEnabled && (
<View style={styles.configCard}>
<View style={styles.configHeader}>
<Text style={styles.configIcon}>💾</Text>
<Text style={styles.configTitle}>NAS Storage</Text>
{nasConnected && <Text style={styles.connectedBadge}>✓ 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}>Port</Text>
<TextInput style={styles.fieldInput} placeholder="22" value={nasPort}
onChangeText={setNasPort} editable={!loading} keyboardType="numeric" placeholderTextColor="#9aa0a6" />
<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}>Password</Text>
<TextInput style={styles.fieldInput} placeholder="SSH password" value={nasPassword}
onChangeText={setNasPassword} editable={!loading} secureTextEntry autoCapitalize="none" placeholderTextColor="#9aa0a6" />
<Text style={styles.fieldLabel}>Photos Folder</Text>
<TextInput style={styles.fieldInput} placeholder="/home/user/photos" value={nasPhotoFolder}
onChangeText={setNasPhotoFolder} editable={!loading} placeholderTextColor="#9aa0a6" />
<TouchableOpacity
style={[styles.authButton, { backgroundColor: '#34a853' }, loading && styles.buttonDisabled]}
onPress={handleNasAuth}
disabled={loading}
>
{loading ? <ActivityIndicator color="#fff" /> : (
<Text style={styles.authButtonText}>Test & Connect</Text>
)}
</TouchableOpacity>
</>
)}
</View>
)}
</View>
)}
{/* Step 2: Default service */}
{currentStep === 2 && ( {currentStep === 2 && (
<View style={styles.defaultServiceContainer}> <View style={styles.cardsContainer}>
<Text style={styles.defaultServiceTitle}> {getEnabledServices().map((svc) => {
Select your default upload service: const connected =
(svc.type === 'GOOGLE_PHOTOS' && googleConnected) ||
(svc.type === 'ICLOUD_PHOTOS' && iosConnected) ||
(svc.type === 'NAS' && nasConnected);
if (!connected) return null;
return (
<TouchableOpacity
key={svc.type}
style={[styles.selectCard, defaultService === svc.type && styles.selectCardActive]}
onPress={() => setDefaultService(svc.type)}
>
<View style={[styles.radio, defaultService === svc.type && styles.radioActive]} />
<Text style={[styles.selectText, defaultService === svc.type && styles.selectTextActive]}>
{svc.name}
</Text> </Text>
{getEnabledServices().map((service) => ( </TouchableOpacity>
<ServiceSelectCard );
key={service} })}
service={service}
selected={defaultService === service}
onSelect={() => {
const serviceTypeMap = {
'Google Photos': 'GOOGLE_PHOTOS',
'iCloud Photos': 'ICLOUD_PHOTOS',
NAS: 'NAS',
};
setDefaultService(serviceTypeMap[service]);
}}
/>
))}
</View> </View>
)} )}
</ScrollView> </ScrollView>
{/* Footer */}
<View style={styles.footer}> <View style={styles.footer}>
<TouchableOpacity {currentStep > 0 ? (
style={[styles.footerButton, currentStep === 0 && styles.hidden]} <TouchableOpacity style={styles.backButton} onPress={() => setCurrentStep(currentStep - 1)} disabled={loading}>
onPress={handleBack} <Text style={styles.backButtonText}>Back</Text>
disabled={loading}
>
<Text style={styles.footerButtonText}>Back</Text>
</TouchableOpacity> </TouchableOpacity>
) : (
<View style={{ flex: 1 }} />
)}
<TouchableOpacity <TouchableOpacity
style={[styles.footerButton, styles.nextButton, loading && styles.buttonDisabled]} style={[styles.nextButton, loading && styles.buttonDisabled]}
onPress={handleNext} onPress={handleNext}
disabled={loading} disabled={loading}
> >
{loading ? ( {loading ? <ActivityIndicator color="#fff" /> : (
<ActivityIndicator color="#fff" /> <Text style={styles.nextButtonText}>
) : ( {currentStep === steps.length - 1 ? 'Get Started' : 'Next'}
<Text style={styles.footerButtonText}>
{currentStep === steps.length - 1 ? 'Complete' : 'Next'}
</Text> </Text>
)} )}
</TouchableOpacity> </TouchableOpacity>
@@ -390,252 +355,101 @@ const OnboardingScreen = ({ navigation, route }) => {
); );
}; };
const ServiceCard = ({ name, enabled, onToggle, icon, description }) => ( const ServiceToggleCard = ({ icon, name, description, enabled, onToggle }) => (
<View style={styles.serviceCard}> <View style={[styles.toggleCard, enabled && styles.toggleCardActive]}>
<View style={styles.serviceCardContent}> <View style={styles.toggleCardContent}>
<Text style={styles.serviceIcon}>{icon}</Text> <Text style={styles.toggleIcon}>{icon}</Text>
<View style={styles.serviceInfo}> <View style={styles.toggleInfo}>
<Text style={styles.serviceName}>{name}</Text> <Text style={styles.toggleName}>{name}</Text>
<Text style={styles.serviceDescription}>{description}</Text> <Text style={styles.toggleDescription}>{description}</Text>
</View> </View>
</View> </View>
<Switch value={enabled} onValueChange={onToggle} /> <Switch
value={enabled}
onValueChange={onToggle}
trackColor={{ false: '#dadce0', true: '#a8dab5' }}
thumbColor={enabled ? '#34a853' : '#f4f3f4'}
/>
</View> </View>
); );
const ServiceConfigCard = ({ title, onPress, loading, children }) => (
<View style={styles.configCard}>
<Text style={styles.configCardTitle}>{title}</Text>
{onPress && (
<TouchableOpacity
style={[styles.configButton, loading && styles.buttonDisabled]}
onPress={onPress}
disabled={loading}
>
{loading ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.buttonText}>Authenticate</Text>
)}
</TouchableOpacity>
)}
{children}
</View>
);
const ServiceSelectCard = ({ service, selected, onSelect }) => (
<TouchableOpacity
style={[styles.selectCard, selected && styles.selectedCard]}
onPress={onSelect}
>
<View style={[styles.radioButton, selected && styles.radioSelected]} />
<Text style={[styles.selectText, selected && styles.selectedText]}>
{service}
</Text>
</TouchableOpacity>
);
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: { flex: 1, backgroundColor: '#fff' },
flex: 1, progressBar: {
backgroundColor: '#f5f5f5', flexDirection: 'row', justifyContent: 'center', gap: 8,
paddingTop: 60, paddingBottom: 8,
}, },
content: { progressDot: {
flex: 1, width: 32, height: 4, borderRadius: 2, backgroundColor: '#dadce0',
paddingHorizontal: 20,
paddingTop: 20,
}, },
header: { progressDotActive: { backgroundColor: '#1a73e8' },
marginBottom: 30, content: { flex: 1, paddingHorizontal: 20 },
}, header: { marginBottom: 24 },
stepIndicator: { stepLabel: { fontSize: 12, color: '#1a73e8', fontWeight: '600', marginBottom: 6 },
fontSize: 12, title: { fontSize: 26, fontWeight: '700', color: '#202124', marginBottom: 6 },
color: '#007AFF', description: { fontSize: 14, color: '#5f6368' },
fontWeight: '600', cardsContainer: { gap: 12 },
marginBottom: 8, // Toggle cards (Step 0)
}, toggleCard: {
title: { backgroundColor: '#f8f9fa', borderRadius: 12, padding: 16,
fontSize: 28, flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center',
fontWeight: 'bold', borderWidth: 1.5, borderColor: '#e8eaed',
color: '#333',
marginBottom: 8,
},
description: {
fontSize: 16,
color: '#666',
},
servicesContainer: {
marginBottom: 20,
},
serviceCard: {
backgroundColor: '#fff',
borderRadius: 12,
padding: 16,
marginBottom: 12,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
borderWidth: 1,
borderColor: '#e0e0e0',
},
serviceCardContent: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
},
serviceIcon: {
fontSize: 32,
marginRight: 16,
},
serviceInfo: {
flex: 1,
},
serviceName: {
fontSize: 16,
fontWeight: '600',
color: '#333',
marginBottom: 4,
},
serviceDescription: {
fontSize: 13,
color: '#999',
},
configContainer: {
marginBottom: 20,
}, },
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' },
// Config cards (Step 1)
configCard: { configCard: {
backgroundColor: '#fff', backgroundColor: '#f8f9fa', borderRadius: 12, padding: 16,
borderRadius: 12, borderWidth: 1, borderColor: '#e8eaed',
padding: 16,
marginBottom: 16,
borderWidth: 1,
borderColor: '#e0e0e0',
}, },
configCardTitle: { configHeader: { flexDirection: 'row', alignItems: 'center', marginBottom: 12 },
fontSize: 16, configIcon: { fontSize: 24, marginRight: 10 },
fontWeight: '600', configTitle: { fontSize: 16, fontWeight: '600', color: '#202124', flex: 1 },
color: '#333', connectedBadge: { fontSize: 13, color: '#34a853', fontWeight: '600' },
marginBottom: 12, authButton: {
backgroundColor: '#1a73e8', borderRadius: 8, paddingVertical: 12, alignItems: 'center',
}, },
configLabel: { authButtonText: { color: '#fff', fontSize: 14, fontWeight: '600' },
fontSize: 14, fieldLabel: { fontSize: 12, fontWeight: '600', color: '#3c4043', marginTop: 10, marginBottom: 4 },
fontWeight: '600', fieldInput: {
color: '#333', borderWidth: 1, borderColor: '#dadce0', borderRadius: 8,
marginTop: 12, paddingHorizontal: 12, paddingVertical: 10, fontSize: 14,
marginBottom: 6, backgroundColor: '#fff', color: '#202124',
},
configInput: {
borderWidth: 1,
borderColor: '#ddd',
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 14,
backgroundColor: '#f9f9f9',
color: '#333',
},
configText: {
fontSize: 14,
color: '#666',
marginTop: 12,
},
configButton: {
backgroundColor: '#007AFF',
borderRadius: 8,
paddingVertical: 10,
paddingHorizontal: 16,
alignItems: 'center',
},
testButton: {
backgroundColor: '#34C759',
borderRadius: 8,
paddingVertical: 10,
paddingHorizontal: 16,
alignItems: 'center',
marginTop: 16,
},
buttonDisabled: {
opacity: 0.6,
},
buttonText: {
color: '#fff',
fontSize: 14,
fontWeight: '600',
},
defaultServiceContainer: {
marginBottom: 20,
},
defaultServiceTitle: {
fontSize: 16,
fontWeight: '600',
color: '#333',
marginBottom: 12,
}, },
// Select cards (Step 2)
selectCard: { selectCard: {
backgroundColor: '#fff', backgroundColor: '#f8f9fa', borderRadius: 12, padding: 16,
borderRadius: 12, flexDirection: 'row', alignItems: 'center',
padding: 16, borderWidth: 2, borderColor: '#e8eaed',
marginBottom: 12,
flexDirection: 'row',
alignItems: 'center',
borderWidth: 2,
borderColor: '#ddd',
}, },
selectedCard: { selectCardActive: { borderColor: '#1a73e8', backgroundColor: '#e8f0fe' },
borderColor: '#007AFF', radio: {
backgroundColor: '#f0f7ff', width: 20, height: 20, borderRadius: 10, borderWidth: 2,
}, borderColor: '#dadce0', marginRight: 12,
radioButton: {
width: 20,
height: 20,
borderRadius: 10,
borderWidth: 2,
borderColor: '#ddd',
marginRight: 12,
},
radioSelected: {
borderColor: '#007AFF',
backgroundColor: '#007AFF',
},
selectText: {
fontSize: 16,
color: '#333',
},
selectedText: {
color: '#007AFF',
fontWeight: '600',
}, },
radioActive: { borderColor: '#1a73e8', backgroundColor: '#1a73e8' },
selectText: { fontSize: 15, color: '#202124', fontWeight: '500' },
selectTextActive: { color: '#1a73e8', fontWeight: '600' },
// Footer
footer: { footer: {
flexDirection: 'row', flexDirection: 'row', paddingHorizontal: 20, paddingVertical: 14,
paddingHorizontal: 20, borderTopWidth: 1, borderTopColor: '#e8eaed', gap: 12,
paddingVertical: 16,
borderTopWidth: 1,
borderTopColor: '#e0e0e0',
gap: 12,
}, },
footerButton: { backButton: {
flex: 1, flex: 1, paddingVertical: 12, borderRadius: 8, alignItems: 'center',
paddingVertical: 12, backgroundColor: '#f8f9fa', borderWidth: 1, borderColor: '#dadce0',
paddingHorizontal: 16,
borderRadius: 8,
alignItems: 'center',
backgroundColor: '#fff',
borderWidth: 1,
borderColor: '#ddd',
}, },
backButtonText: { fontSize: 15, fontWeight: '600', color: '#5f6368' },
nextButton: { nextButton: {
backgroundColor: '#007AFF', flex: 1, paddingVertical: 12, borderRadius: 8, alignItems: 'center',
borderColor: '#007AFF', backgroundColor: '#1a73e8',
},
footerButtonText: {
fontSize: 16,
fontWeight: '600',
color: '#333',
},
hidden: {
opacity: 0,
pointerEvents: 'none',
}, },
nextButtonText: { fontSize: 15, fontWeight: '600', color: '#fff' },
buttonDisabled: { opacity: 0.6 },
}); });
export default OnboardingScreen; export default OnboardingScreen;
+57 -66
View File
@@ -20,60 +20,51 @@ const RegisterScreen = ({ navigation }) => {
const [confirmPassword, setConfirmPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const validateForm = () => { const handleRegister = async () => {
if (!username.trim()) { if (!username.trim()) {
Alert.alert('Error', 'Please enter a username'); Alert.alert('Error', 'Please enter a username');
return false; return;
}
if (username.trim().length < 3) {
Alert.alert('Error', 'Username must be at least 3 characters');
return;
} }
if (!email.trim()) { if (!email.trim()) {
Alert.alert('Error', 'Please enter an email'); Alert.alert('Error', 'Please enter an email');
return false; return;
} }
if (!email.includes('@')) { if (!email.includes('@') || !email.includes('.')) {
Alert.alert('Error', 'Please enter a valid email'); Alert.alert('Error', 'Please enter a valid email address');
return false; return;
} }
if (!password.trim()) { if (!password.trim()) {
Alert.alert('Error', 'Please enter a password'); Alert.alert('Error', 'Please enter a password');
return false; return;
} }
if (password.length < 6) { if (password.length < 6) {
Alert.alert('Error', 'Password must be at least 6 characters'); Alert.alert('Error', 'Password must be at least 6 characters');
return false; return;
} }
if (password !== confirmPassword) { if (password !== confirmPassword) {
Alert.alert('Error', 'Passwords do not match'); Alert.alert('Error', 'Passwords do not match');
return false;
}
return true;
};
const handleRegister = async () => {
if (!validateForm()) {
return; return;
} }
setLoading(true); setLoading(true);
try { try {
await DatabaseService.initDatabase(); const exists = await DatabaseService.userExists(username.trim(), email.trim().toLowerCase());
// Check if email already exists
const exists = await DatabaseService.userExists(email);
if (exists) { if (exists) {
Alert.alert('Error', 'Email already registered'); Alert.alert('Error', 'Username or email already taken');
setLoading(false); setLoading(false);
return; return;
} }
// Create new user const userId = await DatabaseService.createUser(username.trim(), email.trim().toLowerCase(), password);
const userId = await DatabaseService.createUser(username, email, password);
Alert.alert('Success', 'Account created successfully!', [ Alert.alert('Success', 'Account created!', [
{ {
text: 'OK', text: 'OK',
onPress: () => { onPress: () => navigation.replace('Onboarding', { userId }),
// Navigate to onboarding after successful registration
navigation.replace('Onboarding', { userId });
},
}, },
]); ]);
} catch (error) { } catch (error) {
@@ -89,14 +80,12 @@ const RegisterScreen = ({ navigation }) => {
behavior={Platform.OS === 'ios' ? 'padding' : 'height'} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.container} style={styles.container}
> >
<ScrollView <ScrollView contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
<View style={styles.content}> <View style={styles.content}>
<View style={styles.headerContainer}> <View style={styles.headerContainer}>
<Text style={styles.appIcon}>📸</Text>
<Text style={styles.title}>Create Account</Text> <Text style={styles.title}>Create Account</Text>
<Text style={styles.subtitle}>Join our photo library</Text> <Text style={styles.subtitle}>Set up your local photo library account</Text>
</View> </View>
<View style={styles.inputContainer}> <View style={styles.inputContainer}>
@@ -104,34 +93,36 @@ const RegisterScreen = ({ navigation }) => {
<TextInput <TextInput
style={styles.input} style={styles.input}
placeholder="Choose a username" placeholder="Choose a username"
placeholderTextColor="#999" placeholderTextColor="#9aa0a6"
value={username} value={username}
onChangeText={setUsername} onChangeText={setUsername}
editable={!loading} editable={!loading}
autoCapitalize="none" autoCapitalize="none"
autoCorrect={false}
/> />
<Text style={styles.label}>Email</Text> <Text style={styles.label}>Email</Text>
<TextInput <TextInput
style={styles.input} style={styles.input}
placeholder="Enter your email" placeholder="Enter your email"
placeholderTextColor="#999" placeholderTextColor="#9aa0a6"
value={email} value={email}
onChangeText={setEmail} onChangeText={setEmail}
editable={!loading} editable={!loading}
keyboardType="email-address"
autoCapitalize="none" autoCapitalize="none"
autoCorrect={false}
keyboardType="email-address"
/> />
<Text style={styles.label}>Password</Text> <Text style={styles.label}>Password</Text>
<TextInput <TextInput
style={styles.input} style={styles.input}
placeholder="Create a password (min 6 characters)" placeholder="Create a password (min 6 characters)"
placeholderTextColor="#999" placeholderTextColor="#9aa0a6"
value={password} value={password}
onChangeText={setPassword} onChangeText={setPassword}
editable={!loading} editable={!loading}
secureTextEntry={true} secureTextEntry
autoCapitalize="none" autoCapitalize="none"
/> />
@@ -139,11 +130,11 @@ const RegisterScreen = ({ navigation }) => {
<TextInput <TextInput
style={styles.input} style={styles.input}
placeholder="Confirm your password" placeholder="Confirm your password"
placeholderTextColor="#999" placeholderTextColor="#9aa0a6"
value={confirmPassword} value={confirmPassword}
onChangeText={setConfirmPassword} onChangeText={setConfirmPassword}
editable={!loading} editable={!loading}
secureTextEntry={true} secureTextEntry
autoCapitalize="none" autoCapitalize="none"
/> />
</View> </View>
@@ -162,11 +153,8 @@ const RegisterScreen = ({ navigation }) => {
<View style={styles.loginContainer}> <View style={styles.loginContainer}>
<Text style={styles.loginText}>Already have an account? </Text> <Text style={styles.loginText}>Already have an account? </Text>
<TouchableOpacity <TouchableOpacity onPress={() => navigation.navigate('Login')} disabled={loading}>
onPress={() => navigation.navigate('Login')} <Text style={styles.loginLink}>Sign in</Text>
disabled={loading}
>
<Text style={styles.loginLink}>Login here</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>
@@ -178,55 +166,59 @@ const RegisterScreen = ({ navigation }) => {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
backgroundColor: '#f5f5f5', backgroundColor: '#fff',
}, },
scrollContent: { scrollContent: {
flexGrow: 1, flexGrow: 1,
justifyContent: 'center', justifyContent: 'center',
}, },
content: { content: {
paddingHorizontal: 20, paddingHorizontal: 24,
paddingVertical: 40, paddingVertical: 40,
}, },
headerContainer: { headerContainer: {
marginBottom: 40, marginBottom: 36,
alignItems: 'center', alignItems: 'center',
}, },
appIcon: {
fontSize: 56,
marginBottom: 16,
},
title: { title: {
fontSize: 32, fontSize: 28,
fontWeight: 'bold', fontWeight: '700',
color: '#333', color: '#202124',
marginBottom: 10, marginBottom: 8,
}, },
subtitle: { subtitle: {
fontSize: 16, fontSize: 15,
color: '#999', color: '#5f6368',
textAlign: 'center',
}, },
inputContainer: { inputContainer: {
marginBottom: 30, marginBottom: 24,
}, },
label: { label: {
fontSize: 14, fontSize: 13,
fontWeight: '600', fontWeight: '600',
color: '#333', color: '#3c4043',
marginBottom: 8, marginBottom: 6,
marginTop: 16, marginTop: 16,
}, },
input: { input: {
borderWidth: 1, borderWidth: 1,
borderColor: '#ddd', borderColor: '#dadce0',
borderRadius: 8, borderRadius: 8,
paddingHorizontal: 16, paddingHorizontal: 14,
paddingVertical: 12, paddingVertical: 12,
fontSize: 16, fontSize: 15,
backgroundColor: '#fff', backgroundColor: '#fff',
color: '#333', color: '#202124',
}, },
registerButton: { registerButton: {
backgroundColor: '#34C759', backgroundColor: '#1a73e8',
borderRadius: 8, borderRadius: 8,
paddingVertical: 14, paddingVertical: 14,
paddingHorizontal: 20,
alignItems: 'center', alignItems: 'center',
marginBottom: 20, marginBottom: 20,
}, },
@@ -235,7 +227,7 @@ const styles = StyleSheet.create({
}, },
buttonText: { buttonText: {
color: '#fff', color: '#fff',
fontSize: 16, fontSize: 15,
fontWeight: '600', fontWeight: '600',
}, },
loginContainer: { loginContainer: {
@@ -244,15 +236,14 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
}, },
loginText: { loginText: {
color: '#666', color: '#5f6368',
fontSize: 14, fontSize: 14,
}, },
loginLink: { loginLink: {
color: '#007AFF', color: '#1a73e8',
fontSize: 14, fontSize: 14,
fontWeight: '600', fontWeight: '600',
}, },
}); });
export default RegisterScreen; export default RegisterScreen;
+185
View File
@@ -0,0 +1,185 @@
import React, { useState, useEffect } from 'react';
import {
StyleSheet, View, Text, TextInput, FlatList, TouchableOpacity,
Dimensions, Image, Modal, ActivityIndicator,
} from 'react-native';
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;
const SearchScreen = ({ route }) => {
const userId = route?.params?.userId;
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [searching, setSearching] = useState(false);
const [selectedMedia, setSelectedMedia] = useState(null);
const [modalVisible, setModalVisible] = useState(false);
useEffect(() => {
const timer = setTimeout(() => {
if (query.trim().length > 0) {
performSearch();
} else {
setResults([]);
}
}, 300);
return () => clearTimeout(timer);
}, [query]);
const performSearch = async () => {
if (!userId) return;
setSearching(true);
try {
const media = await DatabaseService.searchMedia(userId, query.trim());
setResults(media);
} catch (err) {
console.error('Search error:', err);
} finally {
setSearching(false);
}
};
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}>
<Text style={styles.searchIcon}>🔍</Text>
<TextInput
style={styles.searchInput}
placeholder="Search photos and videos"
placeholderTextColor="#9aa0a6"
value={query}
onChangeText={setQuery}
autoFocus
autoCapitalize="none"
autoCorrect={false}
/>
{query.length > 0 && (
<TouchableOpacity onPress={() => setQuery('')}>
<Text style={styles.clearButton}>✕</Text>
</TouchableOpacity>
)}
</View>
{searching && (
<View style={styles.loadingContainer}>
<ActivityIndicator size="small" color="#1a73e8" />
</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>
</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>
</View>
)}
{results.length > 0 && (
<>
<Text style={styles.resultCount}>{results.length} result{results.length !== 1 ? 's' : ''}</Text>
<FlatList
data={results}
renderItem={renderItem}
keyExtractor={(item, i) => item.id?.toString() || i.toString()}
numColumns={GRID_COLUMNS}
contentContainerStyle={styles.gridContent}
/>
</>
)}
{/* 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>
</View>
);
};
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#fff' },
searchBar: {
flexDirection: 'row', alignItems: 'center', marginHorizontal: 12,
marginTop: 48, marginBottom: 8, backgroundColor: '#f1f3f4',
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 },
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' },
});
export default SearchScreen;
+31 -54
View File
@@ -1,93 +1,70 @@
import * as Crypto from 'expo-crypto';
/** /**
* Credential Encryption Service * Credential Encryption Service
* *
* Encrypts sensitive credentials before storing in database * Uses expo-crypto for password hashing (SHA-256)
* Uses base64 encoding (React Native compatible) * Uses base64 encoding for storing account data objects in the database
* For production, use a proper encryption library like react-native-aes-crypto
*/ */
class CredentialEncryption { class CredentialEncryption {
constructor() {
// Simple key for demo - in production, use proper key management
this.encryptionKey = 'photo-app-secret-key-2026';
}
/** /**
* Base64 encode a string (React Native compatible) * Hash a password using SHA-256 via expo-crypto
* Uses btoa which is available in React Native * Returns a hex digest string
*/ */
base64Encode(str) { async hashPassword(password) {
try { try {
// btoa is available in React Native if (!password) throw new Error('Password is required');
return btoa(unescape(encodeURIComponent(str))); const digest = await Crypto.digestStringAsync(
Crypto.CryptoDigestAlgorithm.SHA256,
password
);
return digest;
} catch (error) { } catch (error) {
console.error('Base64 encode error:', error); console.error('Password hashing error:', error);
throw new Error('Failed to encode data'); throw new Error('Failed to hash password: ' + error.message);
} }
} }
/** /**
* Base64 decode a string (React Native compatible) * Verify a password against a stored hash
* Uses atob which is available in React Native
*/ */
base64Decode(str) { async verifyPassword(password, storedHash) {
try { try {
// atob is available in React Native const hash = await this.hashPassword(password);
return decodeURIComponent(escape(atob(str))); return hash === storedHash;
} catch (error) { } catch (error) {
console.error('Base64 decode error:', error); console.error('Password verification error:', error);
throw new Error('Failed to decode data'); throw new Error('Failed to verify password: ' + error.message);
} }
} }
/** /**
* Encrypt credentials before storing * Encrypt an object for database storage (base64-encoded JSON)
* Converts object to JSON string, then base64 encodes it
*/ */
encrypt(data) { encrypt(data) {
try { try {
if (!data) { if (!data) throw new Error('No data provided to encrypt');
throw new Error('No data provided to encrypt');
}
const jsonString = JSON.stringify(data); const jsonString = JSON.stringify(data);
const encoded = this.base64Encode(jsonString); // btoa is available in React Native / Hermes
console.log('✓ Credentials encrypted successfully'); const encoded = btoa(unescape(encodeURIComponent(jsonString)));
return encoded; return encoded;
} catch (error) { } catch (error) {
console.error('Encryption error:', error); console.error('Encryption error:', error);
throw new Error('Failed to encrypt credentials: ' + error.message); throw new Error('Failed to encrypt data: ' + error.message);
} }
} }
/** /**
* Decrypt credentials after retrieving * Decrypt a base64-encoded JSON string back to an object
* Base64 decodes, then parses JSON back to object
*/ */
decrypt(encryptedData) { decrypt(encryptedData) {
try { try {
if (!encryptedData) { if (!encryptedData) return null;
return null; const jsonString = decodeURIComponent(escape(atob(encryptedData)));
} return JSON.parse(jsonString);
const jsonString = this.base64Decode(encryptedData);
const data = JSON.parse(jsonString);
console.log('✓ Credentials decrypted successfully');
return data;
} catch (error) { } catch (error) {
console.error('Decryption error:', error); console.error('Decryption error:', error);
throw new Error('Failed to decrypt credentials: ' + error.message); throw new Error('Failed to decrypt data: ' + error.message);
}
}
/**
* Check if data is already encrypted (base64)
*/
isEncrypted(data) {
if (typeof data !== 'string') return false;
try {
this.base64Decode(data);
return true;
} catch {
return false;
} }
} }
} }
+74 -188
View File
@@ -6,37 +6,34 @@ let initPromise = null;
const DatabaseService = { const DatabaseService = {
/** /**
* Initialize the database and create tables if they don't exist * Initialize the database and create tables
*/ */
initDatabase: async () => { initDatabase: async () => {
if (initPromise) { if (initPromise) return initPromise;
return initPromise;
}
initPromise = (async () => { initPromise = (async () => {
try { try {
if (db) { if (db) return true;
return true;
}
if (typeof SQLite.openDatabaseAsync !== 'function') { if (typeof SQLite.openDatabaseAsync !== 'function') {
throw new Error('SQLite openDatabaseAsync API not available'); throw new Error('SQLite openDatabaseAsync API not available');
} }
db = await SQLite.openDatabaseAsync('PhotosApp.db');
if (!db) {
throw new Error('SQLite openDatabaseAsync returned null');
}
await db.execAsync( db = await SQLite.openDatabaseAsync('PhotosApp.db');
`CREATE TABLE IF NOT EXISTS users ( if (!db) throw new Error('SQLite openDatabaseAsync returned null');
await db.execAsync(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL, username TEXT UNIQUE NOT NULL,
email TEXT UNIQUE NOT NULL, email TEXT UNIQUE NOT NULL,
password TEXT NOT NULL, password TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)` )
); `);
await db.execAsync( await db.execAsync(`
`CREATE TABLE IF NOT EXISTS linked_accounts ( CREATE TABLE IF NOT EXISTS linked_accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
service_type TEXT NOT NULL, service_type TEXT NOT NULL,
@@ -45,11 +42,11 @@ const DatabaseService = {
is_default INTEGER DEFAULT 0, is_default INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id) FOREIGN KEY(user_id) REFERENCES users(id)
)` )
); `);
await db.execAsync( await db.execAsync(`
`CREATE TABLE IF NOT EXISTS media ( CREATE TABLE IF NOT EXISTS media (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
source_service TEXT NOT NULL, source_service TEXT NOT NULL,
@@ -60,54 +57,40 @@ const DatabaseService = {
remote_path TEXT, remote_path TEXT,
media_type TEXT, media_type TEXT,
created_date DATETIME, created_date DATETIME,
uploaded_to_default INTEGER DEFAULT 0,
synced INTEGER DEFAULT 0, synced INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id) FOREIGN KEY(user_id) REFERENCES users(id)
)` )
); `);
await db.execAsync(
`CREATE TABLE IF NOT EXISTS sync_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
service_type TEXT NOT NULL,
status TEXT,
message TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id)
)`
);
console.log('Database initialized successfully'); console.log('Database initialized successfully');
return true; return true;
} catch (error) { } catch (error) {
console.error('Database initialization error:', error); console.error('Database initialization error:', error);
db = null;
initPromise = null;
throw error; throw error;
} }
})(); })();
return initPromise; return initPromise;
}, },
/**
* Ensure the database is initialized before use
*/
ensureDb: async () => { ensureDb: async () => {
await DatabaseService.initDatabase(); await DatabaseService.initDatabase();
if (!db) { if (!db) throw new Error('Database not initialized');
throw new Error('Database not initialized');
}
}, },
/** /**
* Create a new user account * Create a new user with hashed password
*/ */
createUser: async (username, email, password) => { createUser: async (username, email, password) => {
try { try {
await DatabaseService.ensureDb(); await DatabaseService.ensureDb();
const hashedPassword = await CredentialEncryption.hashPassword(password);
const result = await db.runAsync( const result = await db.runAsync(
'INSERT INTO users (username, email, password) VALUES (?, ?, ?)', 'INSERT INTO users (username, email, password) VALUES (?, ?, ?)',
[username, email, password] [username, email, hashedPassword]
); );
return result.lastInsertRowId; return result.lastInsertRowId;
} catch (error) { } catch (error) {
@@ -117,16 +100,21 @@ const DatabaseService = {
}, },
/** /**
* Get user by email and password * Login user by username or email, verify hashed password
*/ */
loginUser: async (email, password) => { loginUser: async (identifier, password) => {
try { try {
await DatabaseService.ensureDb(); await DatabaseService.ensureDb();
const row = await db.getFirstAsync( const row = await db.getFirstAsync(
'SELECT * FROM users WHERE email = ? AND password = ?', 'SELECT * FROM users WHERE username = ? OR email = ?',
[email, password] [identifier, identifier]
); );
return row || null; if (!row) return null;
const valid = await CredentialEncryption.verifyPassword(password, row.password);
if (!valid) return null;
return row;
} catch (error) { } catch (error) {
console.error('Error logging in user:', error); console.error('Error logging in user:', error);
throw error; throw error;
@@ -134,14 +122,14 @@ const DatabaseService = {
}, },
/** /**
* Check if user exists by email * Check if username or email already exists
*/ */
userExists: async (email) => { userExists: async (username, email) => {
try { try {
await DatabaseService.ensureDb(); await DatabaseService.ensureDb();
const row = await db.getFirstAsync( const row = await db.getFirstAsync(
'SELECT id FROM users WHERE email = ?', 'SELECT id FROM users WHERE username = ? OR email = ?',
[email] [username, email]
); );
return !!row; return !!row;
} catch (error) { } catch (error) {
@@ -150,19 +138,11 @@ const DatabaseService = {
} }
}, },
/**
* Get user by ID
*/
getUserById: async (userId) => { getUserById: async (userId) => {
try { try {
await DatabaseService.ensureDb(); await DatabaseService.ensureDb();
if (!userId && userId !== 0) { if (!userId && userId !== 0) throw new Error('User ID is required');
throw new Error('User ID is required and cannot be null'); const row = await db.getFirstAsync('SELECT * FROM users WHERE id = ?', [userId]);
}
const row = await db.getFirstAsync(
'SELECT * FROM users WHERE id = ?',
[userId]
);
return row || null; return row || null;
} catch (error) { } catch (error) {
console.error('Error getting user:', error); console.error('Error getting user:', error);
@@ -171,58 +151,41 @@ const DatabaseService = {
}, },
/** /**
* Link an external account to user * Link an external service account to a user
*/ */
linkAccount: async (userId, serviceType, accountData, isDefault = false) => { linkAccount: async (userId, serviceType, accountData, isDefault = false) => {
try { try {
await DatabaseService.ensureDb(); await DatabaseService.ensureDb();
if (!userId) throw new Error('User ID is required');
// Validate inputs if (!serviceType) throw new Error('Service type is required');
if (!userId) {
throw new Error('User ID is required');
}
if (!serviceType) {
throw new Error('Service type is required');
}
if (!accountData || typeof accountData !== 'object') { if (!accountData || typeof accountData !== 'object') {
throw new Error('Account data must be a valid object'); throw new Error('Account data must be a valid object');
} }
console.log('📝 Linking account:', { userId, serviceType, dataKeys: Object.keys(accountData) });
// Encrypt account data
const encryptedData = CredentialEncryption.encrypt(accountData); const encryptedData = CredentialEncryption.encrypt(accountData);
if (!encryptedData) throw new Error('Encryption returned empty data');
if (!encryptedData) { // Remove existing account of same type first
throw new Error('Encryption returned empty data'); await db.runAsync(
} 'DELETE FROM linked_accounts WHERE user_id = ? AND service_type = ?',
[userId, serviceType]
console.log('✓ Account data encrypted, storing in database...');
const result = await db.runAsync(
`INSERT INTO linked_accounts (user_id, service_type, account_data, is_default)
VALUES (?, ?, ?, ?)`,
[userId, serviceType, encryptedData, isDefault ? 1 : 0]
); );
console.log('✓ Account linked successfully, ID:', result.lastInsertRowId); const result = await db.runAsync(
'INSERT INTO linked_accounts (user_id, service_type, account_data, is_default) VALUES (?, ?, ?, ?)',
[userId, serviceType, encryptedData, isDefault ? 1 : 0]
);
return result.lastInsertRowId; return result.lastInsertRowId;
} catch (error) { } catch (error) {
console.error('Error linking account:', error); console.error('Error linking account:', error);
throw new Error(`Failed to link account: ${error.message}`); throw new Error('Failed to link account: ' + error.message);
} }
}, },
/**
* Get all linked accounts for user
*/
getLinkedAccounts: async (userId) => { getLinkedAccounts: async (userId) => {
try { try {
await DatabaseService.ensureDb(); await DatabaseService.ensureDb();
if (!userId && userId !== 0) { if (!userId && userId !== 0) throw new Error('User ID is required');
throw new Error('User ID is required and cannot be null');
}
const rows = await db.getAllAsync( const rows = await db.getAllAsync(
'SELECT * FROM linked_accounts WHERE user_id = ? AND is_enabled = 1', 'SELECT * FROM linked_accounts WHERE user_id = ? AND is_enabled = 1',
[userId] [userId]
@@ -237,9 +200,6 @@ const DatabaseService = {
} }
}, },
/**
* Get linked account by service type
*/
getLinkedAccountByService: async (userId, serviceType) => { getLinkedAccountByService: async (userId, serviceType) => {
try { try {
await DatabaseService.ensureDb(); await DatabaseService.ensureDb();
@@ -248,39 +208,24 @@ const DatabaseService = {
[userId, serviceType] [userId, serviceType]
); );
if (!row) return null; if (!row) return null;
return { return { ...row, account_data: CredentialEncryption.decrypt(row.account_data) };
...row,
account_data: CredentialEncryption.decrypt(row.account_data),
};
} catch (error) { } catch (error) {
console.error('Error getting linked account:', error); console.error('Error getting linked account:', error);
throw error; throw error;
} }
}, },
/**
* Set default service for uploads
*/
setDefaultService: async (userId, accountId) => { setDefaultService: async (userId, accountId) => {
try { try {
await DatabaseService.ensureDb(); await DatabaseService.ensureDb();
await db.runAsync( await db.runAsync('UPDATE linked_accounts SET is_default = 0 WHERE user_id = ?', [userId]);
'UPDATE linked_accounts SET is_default = 0 WHERE user_id = ?', await db.runAsync('UPDATE linked_accounts SET is_default = 1 WHERE id = ? AND user_id = ?', [accountId, userId]);
[userId]
);
await db.runAsync(
'UPDATE linked_accounts SET is_default = 1 WHERE id = ? AND user_id = ?',
[accountId, userId]
);
} catch (error) { } catch (error) {
console.error('Error setting default service:', error); console.error('Error setting default service:', error);
throw error; throw error;
} }
}, },
/**
* Get default service for user
*/
getDefaultService: async (userId) => { getDefaultService: async (userId) => {
try { try {
await DatabaseService.ensureDb(); await DatabaseService.ensureDb();
@@ -289,50 +234,22 @@ const DatabaseService = {
[userId] [userId]
); );
if (!row) return null; if (!row) return null;
return { return { ...row, account_data: CredentialEncryption.decrypt(row.account_data) };
...row,
account_data: CredentialEncryption.decrypt(row.account_data),
};
} catch (error) { } catch (error) {
console.error('Error getting default service:', error); console.error('Error getting default service:', error);
throw error; throw error;
} }
}, },
/** addMedia: async (userId, sourceService, sourceId, title, description, localPath, remotePath, mediaType, createdDate) => {
* Add media to database
*/
addMedia: async (
userId,
sourceService,
sourceId,
title,
description,
localPath,
remotePath,
mediaType,
createdDate
) => {
try { try {
await DatabaseService.ensureDb(); await DatabaseService.ensureDb();
if (!userId || !sourceService) { if (!userId || !sourceService) throw new Error('userId and sourceService are required');
throw new Error('userId and sourceService are required');
}
const result = await db.runAsync( const result = await db.runAsync(
`INSERT INTO media `INSERT INTO media (user_id, source_service, source_id, title, description, local_path, remote_path, media_type, created_date)
(user_id, source_service, source_id, title, description, local_path, remote_path, media_type, created_date)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[ [userId, sourceService, sourceId || null, title || null, description || null,
userId, localPath || null, remotePath || null, mediaType || null, createdDate || null]
sourceService,
sourceId || null,
title || null,
description || null,
localPath || null,
remotePath || null,
mediaType || null,
createdDate || null,
]
); );
return result.lastInsertRowId; return result.lastInsertRowId;
} catch (error) { } catch (error) {
@@ -341,15 +258,10 @@ const DatabaseService = {
} }
}, },
/**
* Get all media for user
*/
getMediaByUser: async (userId) => { getMediaByUser: async (userId) => {
try { try {
await DatabaseService.ensureDb(); await DatabaseService.ensureDb();
if (!userId && userId !== 0) { if (!userId && userId !== 0) throw new Error('User ID is required');
throw new Error('User ID is required and cannot be null');
}
return await db.getAllAsync( return await db.getAllAsync(
'SELECT * FROM media WHERE user_id = ? ORDER BY created_date DESC', 'SELECT * FROM media WHERE user_id = ? ORDER BY created_date DESC',
[userId] [userId]
@@ -360,9 +272,6 @@ const DatabaseService = {
} }
}, },
/**
* Get media by service
*/
getMediaByService: async (userId, serviceType) => { getMediaByService: async (userId, serviceType) => {
try { try {
await DatabaseService.ensureDb(); await DatabaseService.ensureDb();
@@ -376,46 +285,23 @@ const DatabaseService = {
} }
}, },
/** searchMedia: async (userId, query) => {
* Update media sync status
*/
updateMediaSyncStatus: async (mediaId, synced) => {
try { try {
await DatabaseService.ensureDb(); await DatabaseService.ensureDb();
await db.runAsync( const searchTerm = `%${query}%`;
'UPDATE media SET synced = ? WHERE id = ?', return await db.getAllAsync(
[synced ? 1 : 0, mediaId] 'SELECT * FROM media WHERE user_id = ? AND (title LIKE ? OR description LIKE ?) ORDER BY created_date DESC',
[userId, searchTerm, searchTerm]
); );
} catch (error) { } catch (error) {
console.error('Error updating media sync status:', error); console.error('Error searching media:', error);
throw error; throw error;
} }
}, },
/**
* Log sync operation
*/
logSync: async (userId, serviceType, status, message) => {
try {
await DatabaseService.ensureDb();
await db.runAsync(
'INSERT INTO sync_logs (user_id, service_type, status, message) VALUES (?, ?, ?, ?)',
[userId, serviceType, status, message]
);
} catch (error) {
console.error('Error logging sync:', error);
throw error;
}
},
/**
* Close database connection
*/
closeDatabase: async () => { closeDatabase: async () => {
try { try {
if (db?.closeAsync) { if (db?.closeAsync) await db.closeAsync();
await db.closeAsync();
}
db = null; db = null;
initPromise = null; initPromise = null;
} catch (error) { } catch (error) {