mirror of
https://github.com/mudabbir-ahmad/Media-Aggregation-and-Sorting-System.git
synced 2026-10-08 03:40:20 +00:00
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:
1 parent
37511780b7
commit
c1d7139055
17 files changed
+1411
-10020
No files matched your search
@@ -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" />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Generated
-8072
File diff suppressed because it is too large.
Load diff
+9
-14
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Whitespace-only changes.
Whitespace-only changes.
@@ -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
File diff suppressed because it is too large.
Load diff
+48
-42
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
|
||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
Reference in new issue
Block a user