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 { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { ActivityIndicator, View, Text, TouchableOpacity } from 'react-native';
import DatabaseService from './src/services/DatabaseService';
import LoginScreen from './src/screens/LoginScreen';
import RegisterScreen from './src/screens/RegisterScreen';
import OnboardingScreen from './src/screens/OnboardingScreen';
import HomeScreen from './src/screens/HomeScreen';
import SearchScreen from './src/screens/SearchScreen';
import AlbumsScreen from './src/screens/AlbumsScreen';
const Stack = createNativeStackNavigator();
const Tab = createBottomTabNavigator();
/**
* App.js - Navigation Controller Only
*
* 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
* MainTabs - Bottom tab navigator for logged-in users
* Tabs: Home (Library), Search, Albums
*/
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() {
return (
<Stack.Navigator
screenOptions={{
headerShown: false,
cardStyle: { backgroundColor: '#f5f5f5' },
}}
>
{/* Authentication Stack - User not logged in */}
<Stack.Screen
name="Login"
component={LoginScreen}
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.Screen name="Login" component={LoginScreen} />
<Stack.Screen name="Register" component={RegisterScreen} />
<Stack.Screen name="Onboarding" component={OnboardingScreen} />
<Stack.Screen name="MainTabs" component={MainTabs} />
</Stack.Navigator>
);
}
@@ -74,41 +106,35 @@ export default function App() {
initializeApp();
}, []);
/**
* Initialize database - only purpose of App.js useEffect
* All other logic belongs in screen components
*/
const initializeApp = async () => {
try {
setError(null);
await DatabaseService.initDatabase();
setIsReady(true);
} catch (error) {
console.error('Database initialization failed:', error);
setError(error.message || 'Database initialization failed');
} catch (err) {
console.error('Database initialization failed:', err);
setError(err.message || 'Database initialization failed');
setIsReady(false);
}
};
// Show loading while database initializes
if (!isReady && !error) {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<ActivityIndicator size="large" color="#007AFF" />
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#fff' }}>
<ActivityIndicator size="large" color="#1a73e8" />
</View>
);
}
// Show error if database initialization fails
if (error) {
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' }}>
{error}
</Text>
<TouchableOpacity
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>
</TouchableOpacity>
@@ -121,7 +147,7 @@ export default function App() {
<NavigationContainer>
<AppNavigator />
</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",
"ios": "expo start --ios",
"web": "expo start --web",
"backend": "node backend/server.js",
"backend-dev": "nodemon backend/server.js"
"backend": "node ServerFiles/server.js"
},
"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-stack": "^6.9.26",
"@react-navigation/stack": "^6.4.1",
"axios": "^1.13.5",
"axios": "^1.7.9",
"expo": "~54.0.33",
"expo-auth-session": "^7.0.10",
"expo-crypto": "^15.0.8",
"expo-auth-session": "~7.0.10",
"expo-crypto": "~15.0.8",
"expo-file-system": "~19.0.21",
"expo-image-picker": "~17.0.10",
"expo-linking": "^8.0.11",
"expo-linking": "~8.0.11",
"expo-media-library": "~18.2.1",
"expo-secure-store": "^15.0.8",
"expo-secure-store": "~15.0.8",
"expo-sqlite": "~16.0.10",
"expo-status-bar": "~3.0.9",
"expo-web-browser": "^15.0.10",
"expo-web-browser": "~15.0.10",
"react": "19.1.0",
"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-screens": "~4.16.0",
"ssh2-sftp-client": "^12.0.1"
"react-native-screens": "~4.16.0"
},
"devDependencies": {
"@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
* Note: Since React Native cannot use SSH2 directly, this provides
* credential management and placeholder methods. For production,
* consider using a WebDAV server on your NAS instead of SFTP.
* Connects to the Node.js backend server (ServerFiles/server.js)
* which handles all actual SSH/SFTP operations.
*/
// Default backend URL - update this to match your server address
const DEFAULT_BACKEND_URL = 'http://192.168.1.100:3001';
class NASAPI {
constructor() {
this.connection = null;
this.credentials = null;
constructor(backendUrl = DEFAULT_BACKEND_URL) {
this.backendUrl = backendUrl;
this.connectionToken = null;
this.isConnected = false;
this.credentials = null;
}
/**
* Test NAS connection
* Stores credentials for later use
* Connect to NAS via the backend server
* The backend handles the actual SSH connection
*/
async connect(host, port, username, password, photoFolder = '/Photos') {
try {
// Validate credentials
if (!host || !username || !password) {
throw new Error('Missing required connection details');
throw new Error('Host, username, and password are required');
}
// Store credentials securely
this.credentials = {
// Store credentials locally
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,
port: port || 22,
username,
password, // In production, encrypt this
photoFolder,
connectedAt: new Date().toISOString(),
};
password,
},
{ timeout: 10000 }
);
if (response.data?.success) {
this.connectionToken = response.data.connectionToken;
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 {
success: true,
message: `NAS configured: ${host}`,
connectionData: {
host,
port,
username,
photoFolder,
},
message: `NAS configured: ${host} (backend connection pending)`,
connectionData: this.credentials,
};
} catch (error) {
console.error('NAS connection error:', error);
@@ -56,147 +69,157 @@ class NASAPI {
}
/**
* Disconnect from NAS
* Get authorization headers for backend requests
*/
async disconnect() {
this.connection = null;
this.credentials = null;
this.isConnected = false;
return {
success: true,
message: 'Disconnected from NAS',
};
getHeaders() {
if (!this.connectionToken) return {};
return { Authorization: `Bearer ${this.connectionToken}` };
}
/**
* List files in NAS directory
* Note: Placeholder - In production, use WebDAV or backend server
* List directory contents on the NAS
*/
async listDirectory(path = '/') {
if (!this.isConnected) {
throw new Error('Not connected to NAS');
if (!this.connectionToken) {
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
* Note: Placeholder - In production, use WebDAV or backend server
* Get photos from designated NAS folder
*/
async getPhotos(folder = null) {
if (!this.isConnected) {
throw new Error('Not connected to NAS');
const targetFolder = folder || this.credentials?.photoFolder || '/Photos';
if (!this.connectionToken) {
return { mediaItems: [], folder: targetFolder, message: 'Backend not connected' };
}
const targetFolder = folder || this.credentials?.photoFolder || '/Photos';
console.log(`📷 Getting photos from: ${targetFolder}`);
try {
const listing = await this.listDirectory(targetFolder);
const photoExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.heic'];
const videoExtensions = ['.mp4', '.mov', '.avi', '.mkv', '.webm'];
// Placeholder response
return {
mediaItems: [],
folder: targetFolder,
message: 'NAS photo retrieval requires WebDAV or backend server',
};
const mediaItems = (listing.files || [])
.filter((file) => {
const ext = (file.name || '').toLowerCase().split('.').pop();
return photoExtensions.includes('.' + ext) || videoExtensions.includes('.' + ext);
})
.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
* Note: Placeholder - In production, use WebDAV or backend server
*/
async uploadFile(localPath, remotePath) {
if (!this.isConnected) {
throw new Error('Not connected to NAS');
if (!this.connectionToken) {
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}`);
return {
success: false,
message: 'NAS file upload requires WebDAV or backend server',
};
const response = await axios.post(
`${this.backendUrl}/api/sftp/upload`,
formData,
{ headers: { ...this.getHeaders(), 'Content-Type': 'multipart/form-data' }, timeout: 60000 }
);
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
*/
async createDirectory(remotePath) {
if (!this.isConnected) {
throw new Error('Not connected to NAS');
if (!this.connectionToken) {
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() {
if (!this.isConnected || !this.credentials) {
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;
return this.credentials ? { ...this.credentials, isConnected: this.isConnected } : null;
}
}
+45 -125
View File
@@ -2,80 +2,68 @@ import { AuthServiceBase } from './AuthServiceBase';
/**
* 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 {
constructor() {
super();
this.accessToken = null;
this.refreshToken = null;
this.expiresAt = null;
this.email = null;
}
/**
* Authenticate with Google OAuth
* In a real app, you would use Google Sign-In library
* Authenticate with Google
* Currently uses placeholder tokens. Replace with real OAuth when client ID is configured.
*/
async authenticate(authCode) {
async authenticate() {
try {
// Validate input
if (!authCode) {
throw new Error('Authorization code is required');
}
// TODO: Implement actual Google OAuth flow
// This would typically involve:
// 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
if (GOOGLE_CLIENT_ID === 'YOUR_GOOGLE_CLIENT_ID.apps.googleusercontent.com') {
// Placeholder mode - no real client ID configured
console.log('Google Auth: Using placeholder mode (no client ID configured)');
this.accessToken = 'placeholder_google_token_' + Date.now();
this.refreshToken = 'placeholder_refresh_' + Date.now();
this.expiresAt = Date.now() + 3600000;
this.email = 'google-user';
return {
success: true,
message: 'Google authentication successful',
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) {
console.error('Google authentication error:', error);
throw error;
}
}
/**
* Get current access token
*/
async getAccessToken() {
if (this.accessToken && this.expiresAt > Date.now()) {
return this.accessToken;
}
// If token is expired, refresh it
if (this.refreshToken) {
return await this.refreshToken();
}
throw new Error('No valid access token available');
if (this.accessToken && this.expiresAt > Date.now()) return this.accessToken;
throw new Error('No valid Google access token');
}
/**
* Refresh the access token using refresh token
*/
async refreshToken() {
async refreshAccessToken() {
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...');
// Placeholder: In production, call Google token endpoint
this.accessToken = 'new_google_access_token_placeholder';
this.accessToken = 'refreshed_google_token_' + Date.now();
this.expiresAt = Date.now() + 3600000;
return this.accessToken;
} catch (error) {
console.error('Token refresh error:', error);
@@ -83,111 +71,43 @@ class GoogleAUTH extends AuthServiceBase {
}
}
/**
* Revoke access and clear tokens
*/
async revokeAccess() {
try {
// TODO: Implement actual revocation with Google API
console.log('Revoking Google access...');
this.accessToken = null;
this.refreshToken = null;
this.expiresAt = null;
return {
success: true,
message: 'Google access revoked',
};
} catch (error) {
console.error('Revoke error:', error);
throw error;
}
this.email = null;
return { success: true };
}
/**
* Get user info from Google
*/
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 {
id: 'google_user_id_placeholder',
email: 'user@gmail.com',
id: 'google_user',
email: this.email || 'unknown',
name: 'Google User',
photoUrl: 'https://via.placeholder.com/150',
};
} catch (error) {
console.error('Get user info error:', error);
throw error;
}
}
/**
* Get media list from Google Photos
*/
async getPhotos(pageSize = 50, pageToken = null) {
async getPhotos(pageSize = 50) {
try {
const token = await this.getAccessToken();
// TODO: Implement actual API call to Google Photos API
console.log('Fetching photos from Google Photos...');
// Placeholder response
return {
mediaItems: [],
nextPageToken: null,
};
await this.getAccessToken();
// In production: call Google Photos API
// https://photoslibrary.googleapis.com/v1/mediaItems
return { mediaItems: [], nextPageToken: null };
} catch (error) {
console.error('Get photos error:', error);
throw error;
}
}
/**
* Get albums from Google Photos
*/
async getAlbums(pageSize = 50, pageToken = null) {
async getAlbums() {
try {
const token = await this.getAccessToken();
// TODO: Implement actual API call to Google Photos API
console.log('Fetching albums from Google Photos...');
return {
albums: [],
nextPageToken: null,
};
await this.getAccessToken();
return { albums: [], nextPageToken: null };
} catch (error) {
console.error('Get albums error:', 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;
+16 -158
View File
@@ -1,192 +1,50 @@
import { AuthServiceBase } from './AuthServiceBase';
/**
* iOS iCloud Authentication Service
* Handles authentication with Apple iCloud Photos (placeholder implementation)
* iCloud Photos Authentication Service
* 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 {
constructor() {
super();
this.accessToken = null;
this.refreshToken = null;
this.expiresAt = null;
}
/**
* Authenticate with iCloud
* Placeholder - actual implementation would use Apple's authentication
*/
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
async authenticate() {
console.log('iCloud Auth: Placeholder - no Apple API available');
this.accessToken = 'icloud_placeholder_' + Date.now();
return {
success: true,
message: 'iCloud authentication successful',
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() {
if (this.accessToken && this.expiresAt > Date.now()) {
return this.accessToken;
}
// If token is expired, refresh it
if (this.refreshToken) {
return await this.refreshToken();
}
throw new Error('No valid access token available');
if (this.accessToken) return this.accessToken;
throw new Error('Not authenticated with iCloud');
}
/**
* Refresh the access token
*/
async refreshToken() {
try {
if (!this.refreshToken) {
throw new Error('No refresh token available');
return this.authenticate();
}
// 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() {
try {
// TODO: Implement actual revocation with Apple iCloud
console.log('Revoking iCloud access...');
this.accessToken = null;
this.refreshToken = null;
this.expiresAt = null;
return {
success: true,
message: 'iCloud access revoked',
};
} catch (error) {
console.error('Revoke error:', error);
throw error;
}
return { success: true };
}
/**
* Get user info from iCloud
*/
async getUserInfo() {
try {
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;
}
return { id: 'icloud_placeholder', name: 'iCloud User' };
}
/**
* Get photos from iCloud Photos
*/
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 getPhotos() {
return { mediaItems: [], nextPageToken: null };
}
/**
* Get albums from iCloud Photos
*/
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;
}
async getAlbums() {
return { albums: [], nextPageToken: null };
}
}
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';
const LoginScreen = ({ navigation }) => {
const [email, setEmail] = useState('');
const [identifier, setIdentifier] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const handleLogin = async () => {
if (!email.trim() || !password.trim()) {
Alert.alert('Error', 'Please enter both email and password');
if (!identifier.trim() || !password.trim()) {
Alert.alert('Error', 'Please enter your username/email and password');
return;
}
setLoading(true);
try {
await DatabaseService.initDatabase();
const user = await DatabaseService.loginUser(email, password);
const user = await DatabaseService.loginUser(identifier.trim(), password);
if (user) {
// Successfully logged in - navigate to home
navigation.replace('Onboarding', { userId: user.id });
// Check if user has completed onboarding (has linked accounts)
const accounts = await DatabaseService.getLinkedAccounts(user.id);
if (accounts.length > 0) {
navigation.replace('MainTabs', { userId: user.id });
} 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) {
console.error('Login error:', error);
@@ -49,32 +52,33 @@ const LoginScreen = ({ navigation }) => {
>
<View style={styles.content}>
<View style={styles.headerContainer}>
<Text style={styles.appIcon}>📸</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 style={styles.inputContainer}>
<Text style={styles.label}>Email</Text>
<Text style={styles.label}>Username or Email</Text>
<TextInput
style={styles.input}
placeholder="Enter your email"
placeholderTextColor="#999"
value={email}
onChangeText={setEmail}
placeholder="Enter username or email"
placeholderTextColor="#9aa0a6"
value={identifier}
onChangeText={setIdentifier}
editable={!loading}
keyboardType="email-address"
autoCapitalize="none"
autoCorrect={false}
/>
<Text style={styles.label}>Password</Text>
<TextInput
style={styles.input}
placeholder="Enter your password"
placeholderTextColor="#999"
placeholderTextColor="#9aa0a6"
value={password}
onChangeText={setPassword}
editable={!loading}
secureTextEntry={true}
secureTextEntry
autoCapitalize="none"
/>
</View>
@@ -87,7 +91,7 @@ const LoginScreen = ({ navigation }) => {
{loading ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.buttonText}>Login</Text>
<Text style={styles.buttonText}>Sign In</Text>
)}
</TouchableOpacity>
@@ -97,7 +101,7 @@ const LoginScreen = ({ navigation }) => {
onPress={() => navigation.navigate('Register')}
disabled={loading}
>
<Text style={styles.registerLink}>Register here</Text>
<Text style={styles.registerLink}>Create one</Text>
</TouchableOpacity>
</View>
</View>
@@ -108,53 +112,56 @@ const LoginScreen = ({ navigation }) => {
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
backgroundColor: '#fff',
},
content: {
flex: 1,
justifyContent: 'center',
paddingHorizontal: 20,
paddingHorizontal: 24,
paddingVertical: 40,
},
headerContainer: {
marginBottom: 40,
alignItems: 'center',
},
appIcon: {
fontSize: 56,
marginBottom: 16,
},
title: {
fontSize: 32,
fontWeight: 'bold',
color: '#333',
marginBottom: 10,
fontSize: 28,
fontWeight: '700',
color: '#202124',
marginBottom: 8,
},
subtitle: {
fontSize: 16,
color: '#999',
fontSize: 15,
color: '#5f6368',
},
inputContainer: {
marginBottom: 30,
marginBottom: 24,
},
label: {
fontSize: 14,
fontSize: 13,
fontWeight: '600',
color: '#333',
marginBottom: 8,
color: '#3c4043',
marginBottom: 6,
marginTop: 16,
},
input: {
borderWidth: 1,
borderColor: '#ddd',
borderColor: '#dadce0',
borderRadius: 8,
paddingHorizontal: 16,
paddingHorizontal: 14,
paddingVertical: 12,
fontSize: 16,
fontSize: 15,
backgroundColor: '#fff',
color: '#333',
color: '#202124',
},
loginButton: {
backgroundColor: '#007AFF',
backgroundColor: '#1a73e8',
borderRadius: 8,
paddingVertical: 14,
paddingHorizontal: 20,
alignItems: 'center',
marginBottom: 20,
},
@@ -163,7 +170,7 @@ const styles = StyleSheet.create({
},
buttonText: {
color: '#fff',
fontSize: 16,
fontSize: 15,
fontWeight: '600',
},
registerContainer: {
@@ -172,15 +179,14 @@ const styles = StyleSheet.create({
alignItems: 'center',
},
registerText: {
color: '#666',
color: '#5f6368',
fontSize: 14,
},
registerLink: {
color: '#007AFF',
color: '#1a73e8',
fontSize: 14,
fontWeight: '600',
},
});
export default LoginScreen;
+264 -450
View File
@@ -12,7 +12,6 @@ import {
} from 'react-native';
import DatabaseService from '../services/DatabaseService';
import GoogleAUTH from '../AUTH/GoogleAUTH';
import IOSAUTH from '../AUTH/IOSAUTH';
import NASAUTH from '../AUTH/NASAUTH';
const OnboardingScreen = ({ navigation, route }) => {
@@ -20,41 +19,37 @@ const OnboardingScreen = ({ navigation, route }) => {
const [currentStep, setCurrentStep] = useState(0);
const [loading, setLoading] = useState(false);
// Service selections
// Service toggles
const [googleEnabled, setGoogleEnabled] = useState(false);
const [iosEnabled, setIosEnabled] = 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 [nasPort, setNasPort] = useState('22');
const [nasUsername, setNasUsername] = useState('');
const [nasPassword, setNasPassword] = useState('');
const [nasPhotoFolder, setNasPhotoFolder] = useState('/home/user/photos');
// Default service selection
// Default service
const [defaultService, setDefaultService] = useState(null);
const steps = [
{
title: 'Select Services',
description: 'Choose which services to enable (you can enable 1-3)',
},
{
title: 'Configure Services',
description: 'Set up the enabled services',
},
{
title: 'Choose Default Service',
description: 'Select which service to use for uploads by default',
},
{ title: 'Choose Services', description: 'Select which photo services to connect (1-3)' },
{ title: 'Connect Services', description: 'Set up each selected service' },
{ title: 'Default Upload', description: 'Choose where new photos upload by default' },
];
const getEnabledServices = () => {
const services = [];
if (googleEnabled) services.push('Google Photos');
if (iosEnabled) services.push('iCloud Photos');
if (nasEnabled) services.push('NAS');
if (googleEnabled) services.push({ name: 'Google Photos', type: 'GOOGLE_PHOTOS' });
if (iosEnabled) services.push({ name: 'iCloud Photos', type: 'ICLOUD_PHOTOS' });
if (nasEnabled) services.push({ name: 'NAS Storage', type: 'NAS' });
return services;
};
@@ -62,21 +57,18 @@ const OnboardingScreen = ({ navigation, route }) => {
try {
setLoading(true);
const googleAuth = new GoogleAUTH();
// In a real app, this would trigger OAuth flow
const result = await googleAuth.authenticate('auth_code');
const result = await googleAuth.authenticate();
await DatabaseService.linkAccount(
userId,
'GOOGLE_PHOTOS',
{
await DatabaseService.linkAccount(userId, 'GOOGLE_PHOTOS', {
accessToken: result.accessToken,
email: 'user@gmail.com',
},
defaultService === 'GOOGLE_PHOTOS'
);
refreshToken: result.refreshToken || null,
email: result.email || 'google-user',
});
setGoogleConnected(true);
Alert.alert('Success', 'Google Photos connected!');
} catch (error) {
console.error('Google auth error:', error);
Alert.alert('Error', 'Failed to connect Google Photos: ' + error.message);
} finally {
setLoading(false);
@@ -86,57 +78,50 @@ const OnboardingScreen = ({ navigation, route }) => {
const handleIosAuth = async () => {
try {
setLoading(true);
const iosAuth = new IOSAUTH();
const result = await iosAuth.authenticate('icloud_user', 'icloud_password');
// Placeholder - iCloud API not yet available
await DatabaseService.linkAccount(userId, 'ICLOUD_PHOTOS', {
status: 'placeholder',
note: 'iCloud integration pending Apple API access',
});
await DatabaseService.linkAccount(
userId,
'ICLOUD_PHOTOS',
{
accessToken: result.accessToken,
email: 'user@icloud.com',
},
defaultService === 'ICLOUD_PHOTOS'
);
Alert.alert('Success', 'iCloud Photos connected!');
setIosConnected(true);
Alert.alert('Info', 'iCloud Photos registered. Full integration coming soon.');
} 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 {
setLoading(false);
}
};
const handleNasAuth = async () => {
if (!nasHost || !nasUsername || !nasPassword) {
Alert.alert('Error', 'Please fill in all NAS connection details');
if (!nasHost.trim() || !nasUsername.trim() || !nasPassword.trim()) {
Alert.alert('Error', 'Please fill in all NAS connection fields');
return;
}
try {
setLoading(true);
const nasAuth = new NASAUTH();
const result = await nasAuth.authenticate(
nasHost,
await nasAuth.authenticate(
nasHost.trim(),
parseInt(nasPort) || 22,
nasUsername,
nasPassword
nasUsername.trim(),
nasPassword,
nasPhotoFolder.trim()
);
await DatabaseService.linkAccount(
userId,
'NAS',
{
host: nasHost,
port: nasPort,
username: nasUsername,
photoFolder: nasPhotoFolder,
},
defaultService === 'NAS'
);
await DatabaseService.linkAccount(userId, 'NAS', {
host: nasHost.trim(),
port: parseInt(nasPort) || 22,
username: nasUsername.trim(),
photoFolder: nasPhotoFolder.trim(),
});
setNasConnected(true);
Alert.alert('Success', 'NAS connected!');
} catch (error) {
console.error('NAS auth error:', error);
Alert.alert('Error', 'Failed to connect NAS: ' + error.message);
} finally {
setLoading(false);
@@ -145,49 +130,37 @@ const OnboardingScreen = ({ navigation, route }) => {
const handleNext = async () => {
if (currentStep === 0) {
const enabledCount = getEnabledServices().length;
if (enabledCount === 0) {
if (getEnabledServices().length === 0) {
Alert.alert('Error', 'Please select at least one service');
return;
}
if (enabledCount > 3) {
Alert.alert('Error', 'You can enable a maximum of 3 services');
return;
}
setCurrentStep(1);
} else if (currentStep === 1) {
// Configure services
try {
setLoading(true);
if (googleEnabled) await handleGoogleAuth();
if (iosEnabled) await handleIosAuth();
if (nasEnabled) await handleNasAuth();
setCurrentStep(2);
} catch (error) {
console.error('Configuration error:', error);
} finally {
setLoading(false);
// Check at least one service is connected
const anyConnected =
(googleEnabled && googleConnected) ||
(iosEnabled && iosConnected) ||
(nasEnabled && nasConnected);
if (!anyConnected) {
Alert.alert('Error', 'Please connect at least one service before continuing');
return;
}
setCurrentStep(2);
} else if (currentStep === 2) {
// Complete onboarding
if (!defaultService) {
Alert.alert('Error', 'Please select a default service');
Alert.alert('Error', 'Please select a default upload service');
return;
}
try {
setLoading(true);
// Set default service
const accounts = await DatabaseService.getLinkedAccounts(userId);
const defaultAccount = accounts.find(
(acc) => acc.service_type === defaultService
);
const defaultAccount = accounts.find((a) => a.service_type === defaultService);
if (defaultAccount) {
await DatabaseService.setDefaultService(userId, defaultAccount.id);
}
// Navigate to home screen
navigation.replace('Home', { userId });
navigation.replace('MainTabs', { userId });
} catch (error) {
Alert.alert('Error', 'Failed to complete setup: ' + error.message);
} finally {
@@ -196,192 +169,184 @@ const OnboardingScreen = ({ navigation, route }) => {
}
};
const handleBack = () => {
if (currentStep > 0) {
setCurrentStep(currentStep - 1);
}
};
return (
<View style={styles.container}>
<ScrollView style={styles.content} showsVerticalScrollIndicator={false}>
<View style={styles.header}>
<Text style={styles.stepIndicator}>
Step {currentStep + 1} of {steps.length}
</Text>
<Text style={styles.title}>{steps[currentStep].title}</Text>
<Text style={styles.description}>
{steps[currentStep].description}
</Text>
{/* Progress bar */}
<View style={styles.progressBar}>
{steps.map((_, i) => (
<View key={i} style={[styles.progressDot, i <= currentStep && styles.progressDotActive]} />
))}
</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 && (
<View style={styles.servicesContainer}>
<ServiceCard
<View style={styles.cardsContainer}>
<ServiceToggleCard
icon="📷"
name="Google Photos"
description="Access your Google Photos library"
enabled={googleEnabled}
onToggle={() => setGoogleEnabled(!googleEnabled)}
icon="📷"
description="Connect to your Google Photos account"
/>
<ServiceCard
<ServiceToggleCard
icon="🍎"
name="iCloud Photos"
description="Access your iCloud photo library (placeholder)"
enabled={iosEnabled}
onToggle={() => setIosEnabled(!iosEnabled)}
icon="🍎"
description="Connect to your iCloud Photos account"
/>
<ServiceCard
name="Network Storage (NAS)"
<ServiceToggleCard
icon="💾"
name="NAS Storage"
description="Connect to your Ubuntu server via SFTP"
enabled={nasEnabled}
onToggle={() => setNasEnabled(!nasEnabled)}
icon="💾"
description="Connect to your Ubuntu NAS via SFTP"
/>
</View>
)}
{/* Step 1: Configure each service */}
{currentStep === 1 && (
<View style={styles.configContainer}>
<View style={styles.cardsContainer}>
{googleEnabled && (
<ServiceConfigCard
title="Google Photos"
<View style={styles.configCard}>
<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}
loading={loading}
disabled={loading}
>
<Text style={styles.configText}>
Tap to authenticate with your Google account
</Text>
</ServiceConfigCard>
{loading ? <ActivityIndicator color="#fff" /> : (
<Text style={styles.authButtonText}>Sign in with Google</Text>
)}
</TouchableOpacity>
)}
</View>
)}
{iosEnabled && (
<ServiceConfigCard
title="iCloud Photos"
onPress={handleIosAuth}
loading={loading}
>
<Text style={styles.configText}>
Tap to authenticate with your Apple account
</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"
/>
<View style={styles.configCard}>
<View style={styles.configHeader}>
<Text style={styles.configIcon}>🍎</Text>
<Text style={styles.configTitle}>iCloud Photos</Text>
{iosConnected && <Text style={styles.connectedBadge}>✓ Connected</Text>}
</View>
{!iosConnected && (
<TouchableOpacity
style={[styles.testButton, loading && styles.buttonDisabled]}
onPress={handleNasAuth}
style={[styles.authButton, { backgroundColor: '#333' }, loading && styles.buttonDisabled]}
onPress={handleIosAuth}
disabled={loading}
>
{loading ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.buttonText}>Test Connection</Text>
{loading ? <ActivityIndicator color="#fff" /> : (
<Text style={styles.authButtonText}>Connect iCloud</Text>
)}
</TouchableOpacity>
</ServiceConfigCard>
)}
</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 && (
<View style={styles.defaultServiceContainer}>
<Text style={styles.defaultServiceTitle}>
Select your default upload service:
<View style={styles.cardsContainer}>
{getEnabledServices().map((svc) => {
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>
{getEnabledServices().map((service) => (
<ServiceSelectCard
key={service}
service={service}
selected={defaultService === service}
onSelect={() => {
const serviceTypeMap = {
'Google Photos': 'GOOGLE_PHOTOS',
'iCloud Photos': 'ICLOUD_PHOTOS',
NAS: 'NAS',
};
setDefaultService(serviceTypeMap[service]);
}}
/>
))}
</TouchableOpacity>
);
})}
</View>
)}
</ScrollView>
{/* Footer */}
<View style={styles.footer}>
<TouchableOpacity
style={[styles.footerButton, currentStep === 0 && styles.hidden]}
onPress={handleBack}
disabled={loading}
>
<Text style={styles.footerButtonText}>Back</Text>
{currentStep > 0 ? (
<TouchableOpacity style={styles.backButton} onPress={() => setCurrentStep(currentStep - 1)} disabled={loading}>
<Text style={styles.backButtonText}>Back</Text>
</TouchableOpacity>
) : (
<View style={{ flex: 1 }} />
)}
<TouchableOpacity
style={[styles.footerButton, styles.nextButton, loading && styles.buttonDisabled]}
style={[styles.nextButton, loading && styles.buttonDisabled]}
onPress={handleNext}
disabled={loading}
>
{loading ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.footerButtonText}>
{currentStep === steps.length - 1 ? 'Complete' : 'Next'}
{loading ? <ActivityIndicator color="#fff" /> : (
<Text style={styles.nextButtonText}>
{currentStep === steps.length - 1 ? 'Get Started' : 'Next'}
</Text>
)}
</TouchableOpacity>
@@ -390,252 +355,101 @@ const OnboardingScreen = ({ navigation, route }) => {
);
};
const ServiceCard = ({ name, enabled, onToggle, icon, description }) => (
<View style={styles.serviceCard}>
<View style={styles.serviceCardContent}>
<Text style={styles.serviceIcon}>{icon}</Text>
<View style={styles.serviceInfo}>
<Text style={styles.serviceName}>{name}</Text>
<Text style={styles.serviceDescription}>{description}</Text>
const ServiceToggleCard = ({ icon, name, description, enabled, onToggle }) => (
<View style={[styles.toggleCard, enabled && styles.toggleCardActive]}>
<View style={styles.toggleCardContent}>
<Text style={styles.toggleIcon}>{icon}</Text>
<View style={styles.toggleInfo}>
<Text style={styles.toggleName}>{name}</Text>
<Text style={styles.toggleDescription}>{description}</Text>
</View>
</View>
<Switch value={enabled} onValueChange={onToggle} />
<Switch
value={enabled}
onValueChange={onToggle}
trackColor={{ false: '#dadce0', true: '#a8dab5' }}
thumbColor={enabled ? '#34a853' : '#f4f3f4'}
/>
</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({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
container: { flex: 1, backgroundColor: '#fff' },
progressBar: {
flexDirection: 'row', justifyContent: 'center', gap: 8,
paddingTop: 60, paddingBottom: 8,
},
content: {
flex: 1,
paddingHorizontal: 20,
paddingTop: 20,
progressDot: {
width: 32, height: 4, borderRadius: 2, backgroundColor: '#dadce0',
},
header: {
marginBottom: 30,
},
stepIndicator: {
fontSize: 12,
color: '#007AFF',
fontWeight: '600',
marginBottom: 8,
},
title: {
fontSize: 28,
fontWeight: 'bold',
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,
progressDotActive: { backgroundColor: '#1a73e8' },
content: { flex: 1, paddingHorizontal: 20 },
header: { marginBottom: 24 },
stepLabel: { fontSize: 12, color: '#1a73e8', fontWeight: '600', marginBottom: 6 },
title: { fontSize: 26, fontWeight: '700', color: '#202124', marginBottom: 6 },
description: { fontSize: 14, color: '#5f6368' },
cardsContainer: { gap: 12 },
// Toggle cards (Step 0)
toggleCard: {
backgroundColor: '#f8f9fa', borderRadius: 12, padding: 16,
flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center',
borderWidth: 1.5, borderColor: '#e8eaed',
},
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: {
backgroundColor: '#fff',
borderRadius: 12,
padding: 16,
marginBottom: 16,
borderWidth: 1,
borderColor: '#e0e0e0',
backgroundColor: '#f8f9fa', borderRadius: 12, padding: 16,
borderWidth: 1, borderColor: '#e8eaed',
},
configCardTitle: {
fontSize: 16,
fontWeight: '600',
color: '#333',
marginBottom: 12,
configHeader: { flexDirection: 'row', alignItems: 'center', marginBottom: 12 },
configIcon: { fontSize: 24, marginRight: 10 },
configTitle: { fontSize: 16, fontWeight: '600', color: '#202124', flex: 1 },
connectedBadge: { fontSize: 13, color: '#34a853', fontWeight: '600' },
authButton: {
backgroundColor: '#1a73e8', borderRadius: 8, paddingVertical: 12, alignItems: 'center',
},
configLabel: {
fontSize: 14,
fontWeight: '600',
color: '#333',
marginTop: 12,
marginBottom: 6,
},
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,
authButtonText: { color: '#fff', fontSize: 14, fontWeight: '600' },
fieldLabel: { fontSize: 12, fontWeight: '600', color: '#3c4043', marginTop: 10, marginBottom: 4 },
fieldInput: {
borderWidth: 1, borderColor: '#dadce0', borderRadius: 8,
paddingHorizontal: 12, paddingVertical: 10, fontSize: 14,
backgroundColor: '#fff', color: '#202124',
},
// Select cards (Step 2)
selectCard: {
backgroundColor: '#fff',
borderRadius: 12,
padding: 16,
marginBottom: 12,
flexDirection: 'row',
alignItems: 'center',
borderWidth: 2,
borderColor: '#ddd',
backgroundColor: '#f8f9fa', borderRadius: 12, padding: 16,
flexDirection: 'row', alignItems: 'center',
borderWidth: 2, borderColor: '#e8eaed',
},
selectedCard: {
borderColor: '#007AFF',
backgroundColor: '#f0f7ff',
},
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',
selectCardActive: { borderColor: '#1a73e8', backgroundColor: '#e8f0fe' },
radio: {
width: 20, height: 20, borderRadius: 10, borderWidth: 2,
borderColor: '#dadce0', marginRight: 12,
},
radioActive: { borderColor: '#1a73e8', backgroundColor: '#1a73e8' },
selectText: { fontSize: 15, color: '#202124', fontWeight: '500' },
selectTextActive: { color: '#1a73e8', fontWeight: '600' },
// Footer
footer: {
flexDirection: 'row',
paddingHorizontal: 20,
paddingVertical: 16,
borderTopWidth: 1,
borderTopColor: '#e0e0e0',
gap: 12,
flexDirection: 'row', paddingHorizontal: 20, paddingVertical: 14,
borderTopWidth: 1, borderTopColor: '#e8eaed', gap: 12,
},
footerButton: {
flex: 1,
paddingVertical: 12,
paddingHorizontal: 16,
borderRadius: 8,
alignItems: 'center',
backgroundColor: '#fff',
borderWidth: 1,
borderColor: '#ddd',
backButton: {
flex: 1, paddingVertical: 12, borderRadius: 8, alignItems: 'center',
backgroundColor: '#f8f9fa', borderWidth: 1, borderColor: '#dadce0',
},
backButtonText: { fontSize: 15, fontWeight: '600', color: '#5f6368' },
nextButton: {
backgroundColor: '#007AFF',
borderColor: '#007AFF',
},
footerButtonText: {
fontSize: 16,
fontWeight: '600',
color: '#333',
},
hidden: {
opacity: 0,
pointerEvents: 'none',
flex: 1, paddingVertical: 12, borderRadius: 8, alignItems: 'center',
backgroundColor: '#1a73e8',
},
nextButtonText: { fontSize: 15, fontWeight: '600', color: '#fff' },
buttonDisabled: { opacity: 0.6 },
});
export default OnboardingScreen;
+57 -66
View File
@@ -20,60 +20,51 @@ const RegisterScreen = ({ navigation }) => {
const [confirmPassword, setConfirmPassword] = useState('');
const [loading, setLoading] = useState(false);
const validateForm = () => {
const handleRegister = async () => {
if (!username.trim()) {
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()) {
Alert.alert('Error', 'Please enter an email');
return false;
return;
}
if (!email.includes('@')) {
Alert.alert('Error', 'Please enter a valid email');
return false;
if (!email.includes('@') || !email.includes('.')) {
Alert.alert('Error', 'Please enter a valid email address');
return;
}
if (!password.trim()) {
Alert.alert('Error', 'Please enter a password');
return false;
return;
}
if (password.length < 6) {
Alert.alert('Error', 'Password must be at least 6 characters');
return false;
return;
}
if (password !== confirmPassword) {
Alert.alert('Error', 'Passwords do not match');
return false;
}
return true;
};
const handleRegister = async () => {
if (!validateForm()) {
return;
}
setLoading(true);
try {
await DatabaseService.initDatabase();
// Check if email already exists
const exists = await DatabaseService.userExists(email);
const exists = await DatabaseService.userExists(username.trim(), email.trim().toLowerCase());
if (exists) {
Alert.alert('Error', 'Email already registered');
Alert.alert('Error', 'Username or email already taken');
setLoading(false);
return;
}
// Create new user
const userId = await DatabaseService.createUser(username, email, password);
const userId = await DatabaseService.createUser(username.trim(), email.trim().toLowerCase(), password);
Alert.alert('Success', 'Account created successfully!', [
Alert.alert('Success', 'Account created!', [
{
text: 'OK',
onPress: () => {
// Navigate to onboarding after successful registration
navigation.replace('Onboarding', { userId });
},
onPress: () => navigation.replace('Onboarding', { userId }),
},
]);
} catch (error) {
@@ -89,14 +80,12 @@ const RegisterScreen = ({ navigation }) => {
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.container}
>
<ScrollView
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
<ScrollView contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
<View style={styles.content}>
<View style={styles.headerContainer}>
<Text style={styles.appIcon}>📸</Text>
<Text style={styles.title}>Create Account</Text>
<Text style={styles.subtitle}>Join our photo library</Text>
<Text style={styles.subtitle}>Set up your local photo library account</Text>
</View>
<View style={styles.inputContainer}>
@@ -104,34 +93,36 @@ const RegisterScreen = ({ navigation }) => {
<TextInput
style={styles.input}
placeholder="Choose a username"
placeholderTextColor="#999"
placeholderTextColor="#9aa0a6"
value={username}
onChangeText={setUsername}
editable={!loading}
autoCapitalize="none"
autoCorrect={false}
/>
<Text style={styles.label}>Email</Text>
<TextInput
style={styles.input}
placeholder="Enter your email"
placeholderTextColor="#999"
placeholderTextColor="#9aa0a6"
value={email}
onChangeText={setEmail}
editable={!loading}
keyboardType="email-address"
autoCapitalize="none"
autoCorrect={false}
keyboardType="email-address"
/>
<Text style={styles.label}>Password</Text>
<TextInput
style={styles.input}
placeholder="Create a password (min 6 characters)"
placeholderTextColor="#999"
placeholderTextColor="#9aa0a6"
value={password}
onChangeText={setPassword}
editable={!loading}
secureTextEntry={true}
secureTextEntry
autoCapitalize="none"
/>
@@ -139,11 +130,11 @@ const RegisterScreen = ({ navigation }) => {
<TextInput
style={styles.input}
placeholder="Confirm your password"
placeholderTextColor="#999"
placeholderTextColor="#9aa0a6"
value={confirmPassword}
onChangeText={setConfirmPassword}
editable={!loading}
secureTextEntry={true}
secureTextEntry
autoCapitalize="none"
/>
</View>
@@ -162,11 +153,8 @@ const RegisterScreen = ({ navigation }) => {
<View style={styles.loginContainer}>
<Text style={styles.loginText}>Already have an account? </Text>
<TouchableOpacity
onPress={() => navigation.navigate('Login')}
disabled={loading}
>
<Text style={styles.loginLink}>Login here</Text>
<TouchableOpacity onPress={() => navigation.navigate('Login')} disabled={loading}>
<Text style={styles.loginLink}>Sign in</Text>
</TouchableOpacity>
</View>
</View>
@@ -178,55 +166,59 @@ const RegisterScreen = ({ navigation }) => {
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
backgroundColor: '#fff',
},
scrollContent: {
flexGrow: 1,
justifyContent: 'center',
},
content: {
paddingHorizontal: 20,
paddingHorizontal: 24,
paddingVertical: 40,
},
headerContainer: {
marginBottom: 40,
marginBottom: 36,
alignItems: 'center',
},
appIcon: {
fontSize: 56,
marginBottom: 16,
},
title: {
fontSize: 32,
fontWeight: 'bold',
color: '#333',
marginBottom: 10,
fontSize: 28,
fontWeight: '700',
color: '#202124',
marginBottom: 8,
},
subtitle: {
fontSize: 16,
color: '#999',
fontSize: 15,
color: '#5f6368',
textAlign: 'center',
},
inputContainer: {
marginBottom: 30,
marginBottom: 24,
},
label: {
fontSize: 14,
fontSize: 13,
fontWeight: '600',
color: '#333',
marginBottom: 8,
color: '#3c4043',
marginBottom: 6,
marginTop: 16,
},
input: {
borderWidth: 1,
borderColor: '#ddd',
borderColor: '#dadce0',
borderRadius: 8,
paddingHorizontal: 16,
paddingHorizontal: 14,
paddingVertical: 12,
fontSize: 16,
fontSize: 15,
backgroundColor: '#fff',
color: '#333',
color: '#202124',
},
registerButton: {
backgroundColor: '#34C759',
backgroundColor: '#1a73e8',
borderRadius: 8,
paddingVertical: 14,
paddingHorizontal: 20,
alignItems: 'center',
marginBottom: 20,
},
@@ -235,7 +227,7 @@ const styles = StyleSheet.create({
},
buttonText: {
color: '#fff',
fontSize: 16,
fontSize: 15,
fontWeight: '600',
},
loginContainer: {
@@ -244,15 +236,14 @@ const styles = StyleSheet.create({
alignItems: 'center',
},
loginText: {
color: '#666',
color: '#5f6368',
fontSize: 14,
},
loginLink: {
color: '#007AFF',
color: '#1a73e8',
fontSize: 14,
fontWeight: '600',
},
});
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
*
* Encrypts sensitive credentials before storing in database
* Uses base64 encoding (React Native compatible)
* For production, use a proper encryption library like react-native-aes-crypto
* Uses expo-crypto for password hashing (SHA-256)
* Uses base64 encoding for storing account data objects in the database
*/
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)
* Uses btoa which is available in React Native
* Hash a password using SHA-256 via expo-crypto
* Returns a hex digest string
*/
base64Encode(str) {
async hashPassword(password) {
try {
// btoa is available in React Native
return btoa(unescape(encodeURIComponent(str)));
if (!password) throw new Error('Password is required');
const digest = await Crypto.digestStringAsync(
Crypto.CryptoDigestAlgorithm.SHA256,
password
);
return digest;
} catch (error) {
console.error('Base64 encode error:', error);
throw new Error('Failed to encode data');
console.error('Password hashing error:', error);
throw new Error('Failed to hash password: ' + error.message);
}
}
/**
* Base64 decode a string (React Native compatible)
* Uses atob which is available in React Native
* Verify a password against a stored hash
*/
base64Decode(str) {
async verifyPassword(password, storedHash) {
try {
// atob is available in React Native
return decodeURIComponent(escape(atob(str)));
const hash = await this.hashPassword(password);
return hash === storedHash;
} catch (error) {
console.error('Base64 decode error:', error);
throw new Error('Failed to decode data');
console.error('Password verification error:', error);
throw new Error('Failed to verify password: ' + error.message);
}
}
/**
* Encrypt credentials before storing
* Converts object to JSON string, then base64 encodes it
* Encrypt an object for database storage (base64-encoded JSON)
*/
encrypt(data) {
try {
if (!data) {
throw new Error('No data provided to encrypt');
}
if (!data) throw new Error('No data provided to encrypt');
const jsonString = JSON.stringify(data);
const encoded = this.base64Encode(jsonString);
console.log('✓ Credentials encrypted successfully');
// btoa is available in React Native / Hermes
const encoded = btoa(unescape(encodeURIComponent(jsonString)));
return encoded;
} catch (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
* Base64 decodes, then parses JSON back to object
* Decrypt a base64-encoded JSON string back to an object
*/
decrypt(encryptedData) {
try {
if (!encryptedData) {
return null;
}
const jsonString = this.base64Decode(encryptedData);
const data = JSON.parse(jsonString);
console.log('✓ Credentials decrypted successfully');
return data;
if (!encryptedData) return null;
const jsonString = decodeURIComponent(escape(atob(encryptedData)));
return JSON.parse(jsonString);
} catch (error) {
console.error('Decryption error:', error);
throw new Error('Failed to decrypt credentials: ' + 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;
throw new Error('Failed to decrypt data: ' + error.message);
}
}
}
+74 -188
View File
@@ -6,37 +6,34 @@ let initPromise = null;
const DatabaseService = {
/**
* Initialize the database and create tables if they don't exist
* Initialize the database and create tables
*/
initDatabase: async () => {
if (initPromise) {
return initPromise;
}
if (initPromise) return initPromise;
initPromise = (async () => {
try {
if (db) {
return true;
}
if (db) return true;
if (typeof SQLite.openDatabaseAsync !== 'function') {
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(
`CREATE TABLE IF NOT EXISTS users (
db = await SQLite.openDatabaseAsync('PhotosApp.db');
if (!db) throw new Error('SQLite openDatabaseAsync returned null');
await db.execAsync(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
email TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`
);
)
`);
await db.execAsync(
`CREATE TABLE IF NOT EXISTS linked_accounts (
await db.execAsync(`
CREATE TABLE IF NOT EXISTS linked_accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
service_type TEXT NOT NULL,
@@ -45,11 +42,11 @@ const DatabaseService = {
is_default INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id)
)`
);
)
`);
await db.execAsync(
`CREATE TABLE IF NOT EXISTS media (
await db.execAsync(`
CREATE TABLE IF NOT EXISTS media (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
source_service TEXT NOT NULL,
@@ -60,54 +57,40 @@ const DatabaseService = {
remote_path TEXT,
media_type TEXT,
created_date DATETIME,
uploaded_to_default INTEGER DEFAULT 0,
synced INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
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');
return true;
} catch (error) {
console.error('Database initialization error:', error);
db = null;
initPromise = null;
throw error;
}
})();
return initPromise;
},
/**
* Ensure the database is initialized before use
*/
ensureDb: async () => {
await DatabaseService.initDatabase();
if (!db) {
throw new Error('Database not initialized');
}
if (!db) throw new Error('Database not initialized');
},
/**
* Create a new user account
* Create a new user with hashed password
*/
createUser: async (username, email, password) => {
try {
await DatabaseService.ensureDb();
const hashedPassword = await CredentialEncryption.hashPassword(password);
const result = await db.runAsync(
'INSERT INTO users (username, email, password) VALUES (?, ?, ?)',
[username, email, password]
[username, email, hashedPassword]
);
return result.lastInsertRowId;
} 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 {
await DatabaseService.ensureDb();
const row = await db.getFirstAsync(
'SELECT * FROM users WHERE email = ? AND password = ?',
[email, password]
'SELECT * FROM users WHERE username = ? OR email = ?',
[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) {
console.error('Error logging in user:', 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 {
await DatabaseService.ensureDb();
const row = await db.getFirstAsync(
'SELECT id FROM users WHERE email = ?',
[email]
'SELECT id FROM users WHERE username = ? OR email = ?',
[username, email]
);
return !!row;
} catch (error) {
@@ -150,19 +138,11 @@ const DatabaseService = {
}
},
/**
* Get user by ID
*/
getUserById: async (userId) => {
try {
await DatabaseService.ensureDb();
if (!userId && userId !== 0) {
throw new Error('User ID is required and cannot be null');
}
const row = await db.getFirstAsync(
'SELECT * FROM users WHERE id = ?',
[userId]
);
if (!userId && userId !== 0) throw new Error('User ID is required');
const row = await db.getFirstAsync('SELECT * FROM users WHERE id = ?', [userId]);
return row || null;
} catch (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) => {
try {
await DatabaseService.ensureDb();
// Validate inputs
if (!userId) {
throw new Error('User ID is required');
}
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') {
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);
if (!encryptedData) throw new Error('Encryption returned empty data');
if (!encryptedData) {
throw new Error('Encryption returned empty data');
}
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]
// Remove existing account of same type first
await db.runAsync(
'DELETE FROM linked_accounts WHERE user_id = ? AND service_type = ?',
[userId, serviceType]
);
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;
} catch (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) => {
try {
await DatabaseService.ensureDb();
if (!userId && userId !== 0) {
throw new Error('User ID is required and cannot be null');
}
if (!userId && userId !== 0) throw new Error('User ID is required');
const rows = await db.getAllAsync(
'SELECT * FROM linked_accounts WHERE user_id = ? AND is_enabled = 1',
[userId]
@@ -237,9 +200,6 @@ const DatabaseService = {
}
},
/**
* Get linked account by service type
*/
getLinkedAccountByService: async (userId, serviceType) => {
try {
await DatabaseService.ensureDb();
@@ -248,39 +208,24 @@ const DatabaseService = {
[userId, serviceType]
);
if (!row) return null;
return {
...row,
account_data: CredentialEncryption.decrypt(row.account_data),
};
return { ...row, account_data: CredentialEncryption.decrypt(row.account_data) };
} catch (error) {
console.error('Error getting linked account:', error);
throw error;
}
},
/**
* Set default service for uploads
*/
setDefaultService: async (userId, accountId) => {
try {
await DatabaseService.ensureDb();
await db.runAsync(
'UPDATE linked_accounts SET is_default = 0 WHERE user_id = ?',
[userId]
);
await db.runAsync(
'UPDATE linked_accounts SET is_default = 1 WHERE id = ? AND user_id = ?',
[accountId, userId]
);
await db.runAsync('UPDATE linked_accounts SET is_default = 0 WHERE user_id = ?', [userId]);
await db.runAsync('UPDATE linked_accounts SET is_default = 1 WHERE id = ? AND user_id = ?', [accountId, userId]);
} catch (error) {
console.error('Error setting default service:', error);
throw error;
}
},
/**
* Get default service for user
*/
getDefaultService: async (userId) => {
try {
await DatabaseService.ensureDb();
@@ -289,50 +234,22 @@ const DatabaseService = {
[userId]
);
if (!row) return null;
return {
...row,
account_data: CredentialEncryption.decrypt(row.account_data),
};
return { ...row, account_data: CredentialEncryption.decrypt(row.account_data) };
} catch (error) {
console.error('Error getting default service:', error);
throw error;
}
},
/**
* Add media to database
*/
addMedia: async (
userId,
sourceService,
sourceId,
title,
description,
localPath,
remotePath,
mediaType,
createdDate
) => {
addMedia: async (userId, sourceService, sourceId, title, description, localPath, remotePath, mediaType, createdDate) => {
try {
await DatabaseService.ensureDb();
if (!userId || !sourceService) {
throw new Error('userId and sourceService are required');
}
if (!userId || !sourceService) throw new Error('userId and sourceService are required');
const result = await db.runAsync(
`INSERT INTO media
(user_id, source_service, source_id, title, description, local_path, remote_path, media_type, created_date)
`INSERT INTO media (user_id, source_service, source_id, title, description, local_path, remote_path, media_type, created_date)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
userId,
sourceService,
sourceId || null,
title || null,
description || null,
localPath || null,
remotePath || null,
mediaType || null,
createdDate || null,
]
[userId, sourceService, sourceId || null, title || null, description || null,
localPath || null, remotePath || null, mediaType || null, createdDate || null]
);
return result.lastInsertRowId;
} catch (error) {
@@ -341,15 +258,10 @@ const DatabaseService = {
}
},
/**
* Get all media for user
*/
getMediaByUser: async (userId) => {
try {
await DatabaseService.ensureDb();
if (!userId && userId !== 0) {
throw new Error('User ID is required and cannot be null');
}
if (!userId && userId !== 0) throw new Error('User ID is required');
return await db.getAllAsync(
'SELECT * FROM media WHERE user_id = ? ORDER BY created_date DESC',
[userId]
@@ -360,9 +272,6 @@ const DatabaseService = {
}
},
/**
* Get media by service
*/
getMediaByService: async (userId, serviceType) => {
try {
await DatabaseService.ensureDb();
@@ -376,46 +285,23 @@ const DatabaseService = {
}
},
/**
* Update media sync status
*/
updateMediaSyncStatus: async (mediaId, synced) => {
searchMedia: async (userId, query) => {
try {
await DatabaseService.ensureDb();
await db.runAsync(
'UPDATE media SET synced = ? WHERE id = ?',
[synced ? 1 : 0, mediaId]
const searchTerm = `%${query}%`;
return await db.getAllAsync(
'SELECT * FROM media WHERE user_id = ? AND (title LIKE ? OR description LIKE ?) ORDER BY created_date DESC',
[userId, searchTerm, searchTerm]
);
} catch (error) {
console.error('Error updating media sync status:', error);
console.error('Error searching media:', 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 () => {
try {
if (db?.closeAsync) {
await db.closeAsync();
}
if (db?.closeAsync) await db.closeAsync();
db = null;
initPromise = null;
} catch (error) {