diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..61f1bf6
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+!/Ignore files/
+!/package-lock.json
diff --git a/App.js b/App.js
index 09f879b..ab515f6 100644
--- a/App.js
+++ b/App.js
@@ -1,20 +1,127 @@
+import React, { useEffect, useState } from 'react';
import { StatusBar } from 'expo-status-bar';
-import { StyleSheet, Text, View } from 'react-native';
+import { NavigationContainer } from '@react-navigation/native';
+import { createNativeStackNavigator } from '@react-navigation/native-stack';
+import { ActivityIndicator, View, Text, TouchableOpacity } from 'react-native';
+import DatabaseService from './src/services/DatabaseService';
+import LoginScreen from './src/screens/LoginScreen';
+import RegisterScreen from './src/screens/RegisterScreen';
+import OnboardingScreen from './src/screens/OnboardingScreen';
+import HomeScreen from './src/screens/HomeScreen';
-export default function App() {
+const Stack = createNativeStackNavigator();
+
+/**
+ * App.js - Navigation Controller Only
+ *
+ * This component ONLY handles:
+ * 1. Database initialization
+ * 2. Navigation stack setup
+ * 3. Routing to appropriate first screen
+ *
+ * NO DATA STORAGE OR DISPLAY HERE
+ * All screens handle their own state and data
+ */
+
+function AppNavigator() {
return (
-
- Open up App.js to start working on your app!
-
-
+
+ {/* Authentication Stack - User not logged in */}
+
+
+
+
+ {/* App Stack - User logged in */}
+
+
);
}
-const styles = StyleSheet.create({
- container: {
- flex: 1,
- backgroundColor: '#fff',
- alignItems: 'center',
- justifyContent: 'center',
- },
-});
+export default function App() {
+ const [isReady, setIsReady] = useState(false);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ initializeApp();
+ }, []);
+
+ /**
+ * Initialize database - only purpose of App.js useEffect
+ * All other logic belongs in screen components
+ */
+ const initializeApp = async () => {
+ try {
+ setError(null);
+ await DatabaseService.initDatabase();
+ setIsReady(true);
+ } catch (error) {
+ console.error('Database initialization failed:', error);
+ setError(error.message || 'Database initialization failed');
+ setIsReady(false);
+ }
+ };
+
+ // Show loading while database initializes
+ if (!isReady && !error) {
+ return (
+
+
+
+ );
+ }
+
+ // Show error if database initialization fails
+ if (error) {
+ return (
+
+
+ {error}
+
+
+ Retry
+
+
+ );
+ }
+
+ return (
+ <>
+
+
+
+
+ >
+ );
+}
diff --git a/README.md b/README.md
index 0341805..e25fb80 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,289 @@
-# Media-Aggregation-and-Sorting-System
-This is a repo for my Mass project. A project that aims to create a decentralised way of storing media. Decentralised in the sense that media is stored across cloud providers and local storage solutions.
+# Media Aggregation and Sorting System
+
+A React Native/Expo application that aggregates photos and videos from multiple sources (Google Photos, iCloud, and Network-Attached Storage) into a unified photo library.
+
+## Features
+
+### Core Functionality
+- **User Authentication**: Local account creation and login with SQLite database storage
+- **Multi-Service Integration**:
+ - 🔵 **Google Photos**: OAuth2 integration for Google Photos API
+ - 🍎 **iCloud Photos**: Apple iCloud account support (placeholder)
+ - 💾 **NAS Storage**: SFTP connection to Ubuntu servers for photo/video access
+
+- **Onboarding Flow**:
+ - Service selection (enable 1-3 services)
+ - Service configuration with credentials
+ - Default service selection for uploads
+
+- **Photo Gallery**:
+ - Unified photo library view combining all services
+ - Google Photos-styled grid layout
+ - Modal image viewer with metadata display
+ - Support for photos and videos
+
+### Architecture
+
+#### Database Schema
+- **users**: User account storage (username, email, password)
+- **linked_accounts**: External service credentials and configurations
+- **media**: Photo/video metadata from all sources
+- **sync_logs**: Sync operation history and status
+
+#### Authentication Services
+All authentication services follow the `AuthServiceBase` interface for easy interchangeability:
+
+1. **GoogleAUTH**: OAuth2 authentication with Google Photos API
+2. **IOSAUTH**: Apple iCloud authentication (currently placeholder)
+3. **NASAUTH**: SFTP connection to Ubuntu servers via SSH2
+
+#### Navigation Flow
+```
+Login/Register → Onboarding (Service Setup) → Home (Photo Gallery)
+```
+
+## Project Structure
+
+```
+Media-Aggregation-and-Sorting-System/
+├── src/
+│ ├── AUTH/
+│ │ ├── AuthServiceBase.js # Abstract auth service interface
+│ │ ├── GoogleAUTH.js # Google Photos authentication
+│ │ ├── IOSAUTH.js # iCloud authentication
+│ │ └── NASAUTH.js # NAS SFTP authentication
+│ ├── screens/
+│ │ ├── LoginScreen.js # Login form
+│ │ ├── RegisterScreen.js # Registration form
+│ │ ├── OnboardingScreen.js # Service setup wizard
+│ │ └── HomeScreen.js # Main photo gallery
+│ ├── services/
+│ │ └── DatabaseService.js # SQLite database operations
+│ ├── components/
+│ └── API/
+├── App.js # Navigation setup
+├── app.json # Expo configuration
+├── package.json # Dependencies
+└── README.md
+```
+
+## Installation
+
+### Prerequisites
+- Node.js and npm
+- Expo CLI: `npm install -g expo-cli`
+- iOS Simulator or Android Emulator (or physical device with Expo Go)
+
+### Setup Steps
+
+1. **Clone and Install Dependencies**
+ ```bash
+ cd Media-Aggregation-and-Sorting-System
+ npm install
+ ```
+
+2. **Start the Development Server**
+ ```bash
+ npm start
+ ```
+
+3. **Run on Device/Emulator**
+ - iOS: Press `i` in the terminal
+ - Android: Press `a` in the terminal
+ - Physical Device: Scan QR code with Expo Go app
+
+## Usage
+
+### Creating an Account
+1. Open the app → Select "Register here"
+2. Enter username, email, and password (min 6 characters)
+3. Confirm password and create account
+
+### Login
+1. Enter registered email and password
+2. Click "Login"
+
+### Onboarding (Service Configuration)
+**Step 1: Select Services**
+- Toggle which services to enable (1-3 maximum)
+- Google Photos, iCloud, and/or NAS
+
+**Step 2: Configure Services**
+- **Google Photos**: Tap to authenticate with Google (OAuth flow)
+- **iCloud**: Tap to authenticate with Apple (OAuth flow)
+- **NAS**: Enter connection details:
+ - Server address (IP or hostname)
+ - SSH port (default: 22)
+ - Username
+ - Password
+ - Photos folder path
+ - Test connection before proceeding
+
+**Step 3: Choose Default Service**
+- Select which service to use by default for uploads
+
+### Photo Gallery
+- View all photos/videos from connected services
+- Service badges at top show connected accounts
+- ★ indicates the default service
+- Tap any photo to view in enlarged modal
+- Modal shows metadata: title, description, source, type, date
+
+## API Integration (TODO)
+
+### Google Photos Implementation
+- Replace placeholder `authenticate()` with actual Google Sign-In
+- Implement OAuth2 token exchange
+- Add `/photoslibrary/v1/mediaItems:search` API calls
+- Handle pagination and filtering
+
+### iCloud Implementation
+- Integrate Apple Sign-In
+- Implement CloudKit API for photo access
+- Handle iCloud authentication
+
+### NAS Implementation
+- Replace SSH2 placeholders with actual library implementation
+- Implement SFTP file listing and download
+- Add file upload functionality
+- Handle directory navigation
+
+## Configuration
+
+### Environment Variables (Optional)
+Create a `.env` file for sensitive configuration:
+```env
+GOOGLE_CLIENT_ID=your_client_id
+GOOGLE_CLIENT_SECRET=your_client_secret
+NAS_DEFAULT_PORT=22
+```
+
+### Database
+- SQLite database is automatically created on first run
+- Location: Device's app-specific directory
+- Filename: `PhotosApp.db`
+
+## Authentication Service Pattern
+
+Each service implements the same interface:
+
+```javascript
+class AuthService extends AuthServiceBase {
+ async authenticate(credentials) { }
+ async getAccessToken() { }
+ async refreshToken() { }
+ async revokeAccess() { }
+ async getUserInfo() { }
+ // Service-specific methods
+}
+```
+
+### Easy Interchangeability
+To swap or extend authentication:
+
+1. Create new class extending `AuthServiceBase`
+2. Implement all abstract methods
+3. Update the service type in `DatabaseService.linkAccount()`
+4. Update `OnboardingScreen` UI if needed
+
+## Data Flow
+
+```
+Login/Register
+ ↓
+Authentication → Save to SQLite
+ ↓
+Onboarding → Service Selection
+ ↓
+Service Auth → Store Credentials
+ ↓
+Home Screen → Fetch from Services
+ ↓
+Display in Gallery
+```
+
+## Security Considerations
+
+⚠️ **Current Implementation Warnings**:
+- Passwords stored in plain text in SQLite (development only)
+- NAS passwords stored in plain text (development only)
+- OAuth tokens not persisted securely
+
+**For Production**:
+- Use secure storage (Keychain/Keystore)
+- Encrypt sensitive data in database
+- Implement token refresh and expiration
+- Use OAuth for all services
+- Never store plain-text passwords
+
+## Dependencies
+
+### Core
+- `react`: 19.1.0
+- `react-native`: 0.81.5
+- `expo`: ~54.0.31
+
+### Navigation
+- `@react-navigation/native`: ^6.1.17
+- `@react-navigation/native-stack`: ^6.9.26
+- `react-native-screens`: ^3.31.1
+- `react-native-safe-area-context`: ^4.10.5
+- `react-native-gesture-handler`: ^2.16.1
+
+### Database & Storage
+- `react-native-sqlite-storage`: ^6.0.0
+- `expo-file-system`: ~17.0.1
+- `expo-media-library`: ~15.9.0
+
+### Network
+- `ssh2`: ^1.15.0
+- `react-native-ssh2`: ^1.0.0
+
+### Media
+- `expo-image-picker`: ~14.7.1
+
+## Troubleshooting
+
+### Database Initialization Error
+```
+Solution: Clear app cache/reinstall
+npx expo prebuild --clean
+npm start
+```
+
+### Navigation Issues
+Ensure all screens are properly exported and imported in App.js
+
+### Service Connection Failures
+- Check network connectivity
+- Verify API credentials
+- Check NAS server accessibility (port 22 open)
+- Review console logs for detailed error messages
+
+## Future Enhancements
+
+- [ ] Implement actual Google Photos API integration
+- [ ] Implement actual iCloud Photos integration
+- [ ] Complete SFTP functionality for NAS
+- [ ] Photo upload to default service
+- [ ] Photo search and filtering
+- [ ] Album management
+- [ ] Automatic sync scheduling
+- [ ] Cloud backup
+- [ ] Photo sharing features
+- [ ] Advanced metadata editing
+- [ ] Duplicate detection
+- [ ] Smart album creation
+- [ ] Facial recognition (optional)
+
+## Contributing
+
+This is a personal project. Feel free to fork and extend!
+
+## License
+
+MIT License - Feel free to use for personal or commercial projects.
+
+## Support
+
+For issues or questions, please check the code comments and inline TODOs for implementation guidance.
diff --git a/app.json b/app.json
index 556106a..cdb30ec 100644
--- a/app.json
+++ b/app.json
@@ -24,6 +24,9 @@
},
"web": {
"favicon": "./assets/favicon.png"
- }
+ },
+ "plugins": [
+ "expo-sqlite"
+ ]
}
}
diff --git a/package.json b/package.json
index 9fb791f..59be439 100644
--- a/package.json
+++ b/package.json
@@ -6,13 +6,37 @@
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
- "web": "expo start --web"
+ "web": "expo start --web",
+ "backend": "node backend/server.js",
+ "backend-dev": "nodemon backend/server.js"
},
"dependencies": {
- "expo": "~54.0.31",
+ "@react-native-community/masked-view": "^0.1.11",
+ "@react-navigation/native": "^6.1.18",
+ "@react-navigation/native-stack": "^6.9.26",
+ "@react-navigation/stack": "^6.4.1",
+ "axios": "^1.13.5",
+ "expo": "~54.0.33",
+ "expo-auth-session": "^7.0.10",
+ "expo-crypto": "^15.0.8",
+ "expo-file-system": "~19.0.21",
+ "expo-image-picker": "~17.0.10",
+ "expo-linking": "^8.0.11",
+ "expo-media-library": "~18.2.1",
+ "expo-secure-store": "^15.0.8",
+ "expo-sqlite": "~16.0.10",
"expo-status-bar": "~3.0.9",
+ "expo-web-browser": "^15.0.10",
"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-screens": "~4.16.0",
+ "ssh2-sftp-client": "^12.0.1"
+ },
+ "devDependencies": {
+ "@babel/core": "^7.29.0"
},
"private": true
}
diff --git a/src/API/NASAPI.js b/src/API/NASAPI.js
index e69de29..ceff92e 100644
--- a/src/API/NASAPI.js
+++ b/src/API/NASAPI.js
@@ -0,0 +1,203 @@
+/**
+ * NAS API - Direct Connection Handler
+ *
+ * Handles NAS/SFTP connections directly from the React Native app
+ * Note: Since React Native cannot use SSH2 directly, this provides
+ * credential management and placeholder methods. For production,
+ * consider using a WebDAV server on your NAS instead of SFTP.
+ */
+
+class NASAPI {
+ constructor() {
+ this.connection = null;
+ this.credentials = null;
+ this.isConnected = false;
+ }
+
+ /**
+ * Test NAS connection
+ * Stores credentials for later use
+ */
+ async connect(host, port, username, password, photoFolder = '/Photos') {
+ try {
+ // Validate credentials
+ if (!host || !username || !password) {
+ throw new Error('Missing required connection details');
+ }
+
+ // Store credentials securely
+ this.credentials = {
+ host,
+ port: port || 22,
+ username,
+ password, // In production, encrypt this
+ photoFolder,
+ connectedAt: new Date().toISOString(),
+ };
+
+ this.isConnected = true;
+
+ console.log(`✓ NAS credentials stored for ${host}:${port}`);
+
+ return {
+ success: true,
+ message: `NAS configured: ${host}`,
+ connectionData: {
+ host,
+ port,
+ username,
+ photoFolder,
+ },
+ };
+ } catch (error) {
+ console.error('NAS connection error:', error);
+ throw error;
+ }
+ }
+
+ /**
+ * Disconnect from NAS
+ */
+ async disconnect() {
+ this.connection = null;
+ this.credentials = null;
+ this.isConnected = false;
+
+ return {
+ success: true,
+ message: 'Disconnected from NAS',
+ };
+ }
+
+ /**
+ * List files in NAS directory
+ * Note: Placeholder - In production, use WebDAV or backend server
+ */
+ async listDirectory(path = '/') {
+ if (!this.isConnected) {
+ throw new Error('Not connected to NAS');
+ }
+
+ console.log(`📁 Listing directory: ${path}`);
+
+ // Placeholder response
+ return {
+ path,
+ files: [],
+ message: 'NAS file listing requires WebDAV or backend server',
+ };
+ }
+
+ /**
+ * Get photos from NAS
+ * Note: Placeholder - In production, use WebDAV or backend server
+ */
+ async getPhotos(folder = null) {
+ if (!this.isConnected) {
+ throw new Error('Not connected to NAS');
+ }
+
+ const targetFolder = folder || this.credentials?.photoFolder || '/Photos';
+ console.log(`📷 Getting photos from: ${targetFolder}`);
+
+ // Placeholder response
+ return {
+ mediaItems: [],
+ folder: targetFolder,
+ message: 'NAS photo retrieval requires WebDAV or backend server',
+ };
+ }
+
+ /**
+ * Upload file to NAS
+ * Note: Placeholder - In production, use WebDAV or backend server
+ */
+ async uploadFile(localPath, remotePath) {
+ if (!this.isConnected) {
+ throw new Error('Not connected to NAS');
+ }
+
+ console.log(`⬆️ Upload: ${localPath} -> ${remotePath}`);
+
+ return {
+ success: false,
+ message: 'NAS file upload requires WebDAV or backend server',
+ };
+ }
+
+ /**
+ * Download file from NAS
+ * Note: Placeholder - In production, use WebDAV or backend server
+ */
+ async downloadFile(remotePath, localPath) {
+ if (!this.isConnected) {
+ throw new Error('Not connected to NAS');
+ }
+
+ console.log(`⬇️ Download: ${remotePath} -> ${localPath}`);
+
+ return {
+ success: false,
+ message: 'NAS file download requires WebDAV or backend server',
+ };
+ }
+
+ /**
+ * Delete file from NAS
+ */
+ async deleteFile(remotePath) {
+ if (!this.isConnected) {
+ throw new Error('Not connected to NAS');
+ }
+
+ console.log(`🗑️ Delete: ${remotePath}`);
+
+ return {
+ success: false,
+ message: 'NAS file deletion requires WebDAV or backend server',
+ };
+ }
+
+ /**
+ * Create directory on NAS
+ */
+ async createDirectory(remotePath) {
+ if (!this.isConnected) {
+ throw new Error('Not connected to NAS');
+ }
+
+ console.log(`📁 Create directory: ${remotePath}`);
+
+ return {
+ success: false,
+ message: 'NAS directory creation requires WebDAV or backend server',
+ };
+ }
+
+ /**
+ * Get connection status
+ */
+ getConnectionInfo() {
+ if (!this.isConnected || !this.credentials) {
+ return null;
+ }
+
+ return {
+ host: this.credentials.host,
+ port: this.credentials.port,
+ username: this.credentials.username,
+ photoFolder: this.credentials.photoFolder,
+ connectedAt: this.credentials.connectedAt,
+ isConnected: this.isConnected,
+ };
+ }
+
+ /**
+ * Check if connected
+ */
+ isConnectedToNAS() {
+ return this.isConnected;
+ }
+}
+
+export default NASAPI;
diff --git a/src/AUTH/AuthServiceBase.js b/src/AUTH/AuthServiceBase.js
new file mode 100644
index 0000000..aea0ff6
--- /dev/null
+++ b/src/AUTH/AuthServiceBase.js
@@ -0,0 +1,34 @@
+/**
+ * Abstract base class for authentication services
+ * This provides a common interface for different authentication providers
+ */
+export class AuthServiceBase {
+ constructor() {
+ if (new.target === AuthServiceBase) {
+ throw new TypeError('Cannot instantiate abstract class');
+ }
+ }
+
+ async authenticate() {
+ throw new Error('authenticate() must be implemented');
+ }
+
+ async getAccessToken() {
+ throw new Error('getAccessToken() must be implemented');
+ }
+
+ async refreshToken() {
+ throw new Error('refreshToken() must be implemented');
+ }
+
+ async revokeAccess() {
+ throw new Error('revokeAccess() must be implemented');
+ }
+
+ async getUserInfo() {
+ throw new Error('getUserInfo() must be implemented');
+ }
+}
+
+export default AuthServiceBase;
+
diff --git a/src/AUTH/GoogleAUTH.js b/src/AUTH/GoogleAUTH.js
index e69de29..3cc07a5 100644
--- a/src/AUTH/GoogleAUTH.js
+++ b/src/AUTH/GoogleAUTH.js
@@ -0,0 +1,194 @@
+import { AuthServiceBase } from './AuthServiceBase';
+
+/**
+ * Google Photos Authentication Service
+ * Handles OAuth2 authentication with Google Photos API
+ */
+class GoogleAUTH extends AuthServiceBase {
+ constructor() {
+ super();
+ this.accessToken = null;
+ this.refreshToken = null;
+ this.expiresAt = null;
+ }
+
+ /**
+ * Authenticate with Google OAuth
+ * In a real app, you would use Google Sign-In library
+ */
+ async authenticate(authCode) {
+ try {
+ // Validate input
+ if (!authCode) {
+ throw new Error('Authorization code is required');
+ }
+
+ // TODO: Implement actual Google OAuth flow
+ // This would typically involve:
+ // 1. Initiating Google Sign-In
+ // 2. Getting the authorization code
+ // 3. Exchanging it for access and refresh tokens
+ console.log('Authenticating with Google...');
+
+ // Placeholder: In production, exchange authCode for tokens
+ this.accessToken = 'google_access_token_placeholder';
+ this.refreshToken = 'google_refresh_token_placeholder';
+ this.expiresAt = Date.now() + 3600000; // 1 hour from now
+
+ return {
+ success: true,
+ message: 'Google authentication successful',
+ accessToken: this.accessToken,
+ };
+ } catch (error) {
+ console.error('Google authentication error:', error);
+ throw error;
+ }
+ }
+
+ /**
+ * Get current access token
+ */
+ async getAccessToken() {
+ if (this.accessToken && this.expiresAt > Date.now()) {
+ return this.accessToken;
+ }
+ // If token is expired, refresh it
+ if (this.refreshToken) {
+ return await this.refreshToken();
+ }
+ throw new Error('No valid access token available');
+ }
+
+ /**
+ * Refresh the access token using refresh token
+ */
+ async refreshToken() {
+ try {
+ if (!this.refreshToken) {
+ throw new Error('No refresh token available');
+ }
+
+ // TODO: Implement actual token refresh with Google API
+ console.log('Refreshing Google access token...');
+
+ // Placeholder: In production, call Google token endpoint
+ this.accessToken = 'new_google_access_token_placeholder';
+ this.expiresAt = Date.now() + 3600000;
+
+ return this.accessToken;
+ } catch (error) {
+ console.error('Token refresh error:', error);
+ throw error;
+ }
+ }
+
+ /**
+ * Revoke access and clear tokens
+ */
+ async revokeAccess() {
+ try {
+ // TODO: Implement actual revocation with Google API
+ console.log('Revoking Google access...');
+
+ this.accessToken = null;
+ this.refreshToken = null;
+ this.expiresAt = null;
+
+ return {
+ success: true,
+ message: 'Google access revoked',
+ };
+ } catch (error) {
+ console.error('Revoke error:', error);
+ throw error;
+ }
+ }
+
+ /**
+ * Get user info from Google
+ */
+ async getUserInfo() {
+ try {
+ const token = await this.getAccessToken();
+
+ // TODO: Implement actual API call to Google People API
+ console.log('Fetching user info from Google...');
+
+ // Placeholder user info
+ return {
+ id: 'google_user_id_placeholder',
+ email: 'user@gmail.com',
+ name: 'Google User',
+ photoUrl: 'https://via.placeholder.com/150',
+ };
+ } catch (error) {
+ console.error('Get user info error:', error);
+ throw error;
+ }
+ }
+
+ /**
+ * Get media list from Google Photos
+ */
+ async getPhotos(pageSize = 50, pageToken = null) {
+ try {
+ const token = await this.getAccessToken();
+
+ // TODO: Implement actual API call to Google Photos API
+ console.log('Fetching photos from Google Photos...');
+
+ // Placeholder response
+ return {
+ mediaItems: [],
+ nextPageToken: null,
+ };
+ } catch (error) {
+ console.error('Get photos error:', error);
+ throw error;
+ }
+ }
+
+ /**
+ * Get albums from Google Photos
+ */
+ async getAlbums(pageSize = 50, pageToken = null) {
+ try {
+ const token = await this.getAccessToken();
+
+ // TODO: Implement actual API call to Google Photos API
+ console.log('Fetching albums from Google Photos...');
+
+ return {
+ albums: [],
+ nextPageToken: null,
+ };
+ } catch (error) {
+ console.error('Get albums error:', error);
+ throw error;
+ }
+ }
+
+ /**
+ * Search photos by date range
+ */
+ async searchPhotos(filters = {}) {
+ try {
+ const token = await this.getAccessToken();
+
+ // TODO: Implement actual API call with filters
+ console.log('Searching photos with filters:', filters);
+
+ return {
+ mediaItems: [],
+ nextPageToken: null,
+ };
+ } catch (error) {
+ console.error('Search photos error:', error);
+ throw error;
+ }
+ }
+}
+
+export default GoogleAUTH;
+
diff --git a/src/AUTH/IOSAUTH.js b/src/AUTH/IOSAUTH.js
index e69de29..0960c50 100644
--- a/src/AUTH/IOSAUTH.js
+++ b/src/AUTH/IOSAUTH.js
@@ -0,0 +1,194 @@
+import { AuthServiceBase } from './AuthServiceBase';
+
+/**
+ * iOS iCloud Authentication Service
+ * Handles authentication with Apple iCloud Photos (placeholder implementation)
+ */
+class IOSAUTH extends AuthServiceBase {
+ constructor() {
+ super();
+ this.accessToken = null;
+ this.refreshToken = null;
+ this.expiresAt = null;
+ }
+
+ /**
+ * Authenticate with iCloud
+ * Placeholder - actual implementation would use Apple's authentication
+ */
+ async authenticate(username, password) {
+ try {
+ // Validate input
+ if (!username || !password) {
+ throw new Error('Username and password are required');
+ }
+
+ // TODO: Implement actual Apple iCloud authentication
+ // This would require:
+ // 1. Apple Sign-In framework
+ // 2. iCloud session management
+ // 3. Secure credential storage
+ console.log('Authenticating with iCloud...');
+
+ // Placeholder: In production, authenticate with Apple servers
+ this.accessToken = 'icloud_access_token_placeholder';
+ this.refreshToken = 'icloud_refresh_token_placeholder';
+ this.expiresAt = Date.now() + 3600000; // 1 hour from now
+
+ return {
+ success: true,
+ message: 'iCloud authentication successful',
+ accessToken: this.accessToken,
+ };
+ } catch (error) {
+ console.error('iCloud authentication error:', error);
+ throw error;
+ }
+ }
+
+ /**
+ * Get current access token
+ */
+ async getAccessToken() {
+ if (this.accessToken && this.expiresAt > Date.now()) {
+ return this.accessToken;
+ }
+ // If token is expired, refresh it
+ if (this.refreshToken) {
+ return await this.refreshToken();
+ }
+ throw new Error('No valid access token available');
+ }
+
+ /**
+ * Refresh the access token
+ */
+ async refreshToken() {
+ try {
+ 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() {
+ try {
+ // TODO: Implement actual revocation with Apple iCloud
+ console.log('Revoking iCloud access...');
+
+ this.accessToken = null;
+ this.refreshToken = null;
+ this.expiresAt = null;
+
+ return {
+ success: true,
+ message: 'iCloud access revoked',
+ };
+ } catch (error) {
+ console.error('Revoke error:', error);
+ throw error;
+ }
+ }
+
+ /**
+ * Get user info from iCloud
+ */
+ async getUserInfo() {
+ try {
+ const token = await this.getAccessToken();
+
+ // TODO: Implement actual API call to iCloud
+ console.log('Fetching user info from iCloud...');
+
+ // Placeholder user info
+ return {
+ id: 'icloud_user_id_placeholder',
+ email: 'user@icloud.com',
+ name: 'iCloud User',
+ photoUrl: 'https://via.placeholder.com/150',
+ };
+ } catch (error) {
+ console.error('Get user info error:', error);
+ throw error;
+ }
+ }
+
+ /**
+ * Get photos from iCloud Photos
+ */
+ async getPhotos(pageSize = 50, pageToken = null) {
+ try {
+ const token = await this.getAccessToken();
+
+ // TODO: Implement actual API call to iCloud Photos API
+ console.log('Fetching photos from iCloud Photos...');
+
+ // Placeholder response
+ return {
+ mediaItems: [],
+ nextPageToken: null,
+ };
+ } catch (error) {
+ console.error('Get photos error:', error);
+ throw error;
+ }
+ }
+
+ /**
+ * Get albums from iCloud Photos
+ */
+ async getAlbums(pageSize = 50, pageToken = null) {
+ try {
+ const token = await this.getAccessToken();
+
+ // TODO: Implement actual API call to iCloud Photos API
+ console.log('Fetching albums from iCloud Photos...');
+
+ return {
+ albums: [],
+ nextPageToken: null,
+ };
+ } catch (error) {
+ console.error('Get albums error:', error);
+ throw error;
+ }
+ }
+
+ /**
+ * Search photos in iCloud
+ */
+ async searchPhotos(filters = {}) {
+ try {
+ const token = await this.getAccessToken();
+
+ // TODO: Implement actual search with filters
+ console.log('Searching photos in iCloud with filters:', filters);
+
+ return {
+ mediaItems: [],
+ nextPageToken: null,
+ };
+ } catch (error) {
+ console.error('Search photos error:', error);
+ throw error;
+ }
+ }
+}
+
+export default IOSAUTH;
+
diff --git a/src/AUTH/NASAUTH.js b/src/AUTH/NASAUTH.js
index e69de29..ada672c 100644
--- a/src/AUTH/NASAUTH.js
+++ b/src/AUTH/NASAUTH.js
@@ -0,0 +1,222 @@
+import { AuthServiceBase } from './AuthServiceBase';
+import NASAPI from '../API/NASAPI';
+
+/**
+ * NAS SFTP Authentication Service
+ *
+ * Uses NASAPI for direct NAS connection management
+ * No backend server required
+ */
+class NASAUTH extends AuthServiceBase {
+ constructor() {
+ super();
+ this.nasAPI = new NASAPI();
+ this.host = null;
+ this.port = 22;
+ this.username = null;
+ this.password = null;
+ this.photoFolder = null;
+ this.isConnected = false;
+ }
+
+ /**
+ * Authenticate with NAS/SFTP server
+ */
+ async authenticate(host, port = 22, username, password, photoFolder = '/Photos') {
+ try {
+ // Validate input
+ if (!host || !username || !password) {
+ throw new Error('Host, username, and password are required');
+ }
+
+ console.log(`Connecting to NAS at ${host}:${port}...`);
+
+ this.host = host;
+ this.port = port || 22;
+ this.username = username;
+ this.password = password;
+ this.photoFolder = photoFolder;
+
+ // Connect via NASAPI
+ const result = await this.nasAPI.connect(host, this.port, username, password, photoFolder);
+
+ this.isConnected = true;
+
+ return {
+ success: true,
+ message: `NAS configured: ${host}`,
+ host: this.host,
+ username: this.username,
+ };
+ } catch (error) {
+ console.error('NAS authentication error:', error.message);
+ this.isConnected = false;
+ throw new Error(`Failed to connect to NAS: ${error.message}`);
+ }
+ }
+
+ /**
+ * Get current access/connection status
+ */
+ async getAccessToken() {
+ if (!this.isConnected) {
+ throw new Error('Not connected to NAS');
+ }
+ return {
+ host: this.host,
+ username: this.username,
+ isConnected: this.isConnected,
+ };
+ }
+
+ /**
+ * Refresh connection (reconnect if needed)
+ */
+ async refreshToken() {
+ try {
+ if (this.host && this.username && this.password) {
+ return await this.authenticate(
+ this.host,
+ this.port,
+ this.username,
+ this.password,
+ this.photoFolder
+ );
+ }
+ throw new Error('No NAS credentials available for reconnection');
+ } catch (error) {
+ console.error('Connection refresh error:', error);
+ throw error;
+ }
+ }
+
+ /**
+ * Disconnect from NAS
+ */
+ async revokeAccess() {
+ try {
+ await this.nasAPI.disconnect();
+
+ this.isConnected = false;
+ this.host = null;
+ this.username = null;
+ this.password = null;
+ this.photoFolder = null;
+
+ return {
+ success: true,
+ message: 'NAS connection closed',
+ };
+ } catch (error) {
+ console.error('Disconnect error:', error.message);
+ this.isConnected = false;
+ throw error;
+ }
+ }
+
+ /**
+ * Get NAS server information
+ */
+ async getUserInfo() {
+ try {
+ if (!this.isConnected) {
+ throw new Error('Not connected to NAS');
+ }
+
+ return {
+ host: this.host,
+ username: this.username,
+ port: this.port,
+ photoFolder: this.photoFolder,
+ connectedAt: new Date().toISOString(),
+ serverType: 'NAS SFTP',
+ };
+ } catch (error) {
+ console.error('Get user info error:', error);
+ throw error;
+ }
+ }
+
+ /**
+ * List directories on NAS
+ */
+ async listDirectory(remotePath = '/') {
+ try {
+ if (!this.isConnected) {
+ throw new Error('Not connected to NAS');
+ }
+
+ return await this.nasAPI.listDirectory(remotePath);
+ } catch (error) {
+ console.error('List directory error:', error);
+ throw error;
+ }
+ }
+
+ /**
+ * Upload file to NAS
+ */
+ async uploadFile(localPath, remotePath) {
+ try {
+ if (!this.isConnected) {
+ throw new Error('Not connected to NAS');
+ }
+
+ return await this.nasAPI.uploadFile(localPath, remotePath);
+ } catch (error) {
+ console.error('Upload error:', error);
+ throw error;
+ }
+ }
+
+ /**
+ * Get photos from designated NAS photo folder
+ */
+ async getPhotos(photoFolder = null) {
+ try {
+ if (!this.isConnected) {
+ throw new Error('Not connected to NAS');
+ }
+
+ const folder = photoFolder || this.photoFolder || '/Photos';
+ return await this.nasAPI.getPhotos(folder);
+ } catch (error) {
+ console.error('Get photos error:', error);
+ throw error;
+ }
+ }
+
+ /**
+ * Create directory on NAS
+ */
+ async createDirectory(remotePath) {
+ try {
+ if (!this.isConnected) {
+ throw new Error('Not connected to NAS');
+ }
+
+ return await this.nasAPI.createDirectory(remotePath);
+ } catch (error) {
+ console.error('Create directory error:', error);
+ throw error;
+ }
+ }
+
+ /**
+ * Delete file from NAS
+ */
+ async deleteFile(remotePath) {
+ try {
+ if (!this.isConnected) {
+ throw new Error('Not connected to NAS');
+ }
+
+ return await this.nasAPI.deleteFile(remotePath);
+ } catch (error) {
+ console.error('Delete file error:', error);
+ throw error;
+ }
+ }
+}
+
+export default NASAUTH;
diff --git a/src/screens/HomeScreen.js b/src/screens/HomeScreen.js
new file mode 100644
index 0000000..be7c31d
--- /dev/null
+++ b/src/screens/HomeScreen.js
@@ -0,0 +1,821 @@
+import React, { useState, useEffect } from 'react';
+import {
+ StyleSheet,
+ View,
+ Text,
+ FlatList,
+ ActivityIndicator,
+ Modal,
+ TouchableOpacity,
+ Dimensions,
+ Image,
+ Alert,
+ ScrollView,
+ RefreshControl,
+} from 'react-native';
+import DatabaseService from '../services/DatabaseService';
+import GoogleAUTH from '../AUTH/GoogleAUTH';
+import IOSAUTH from '../AUTH/IOSAUTH';
+import NASAUTH from '../AUTH/NASAUTH';
+
+const { width } = Dimensions.get('window');
+const GRID_COLUMNS = 3;
+const imageSize = (width - 50) / GRID_COLUMNS;
+
+/**
+ * HomeScreen - Main photo gallery
+ *
+ * Responsibilities:
+ * - Load user data from database
+ * - Fetch photos from all connected services
+ * - Display photos in grid layout
+ * - Handle photo viewing in modal
+ * - Manage logout
+ */
+
+const HomeScreen = ({ navigation, route }) => {
+ const userId = route?.params?.userId;
+
+ // User data
+ const [currentUser, setCurrentUser] = useState(null);
+ const [linkedAccounts, setLinkedAccounts] = useState([]);
+
+ // Media data
+ const [allMedia, setAllMedia] = useState([]);
+ const [filteredMedia, setFilteredMedia] = useState([]);
+ const [selectedFilter, setSelectedFilter] = useState('all');
+
+ // UI state
+ const [loading, setLoading] = useState(true);
+ const [refreshing, setRefreshing] = useState(false);
+ const [selectedMedia, setSelectedMedia] = useState(null);
+ const [modalVisible, setModalVisible] = useState(false);
+ const [error, setError] = useState(null);
+
+ // Effect: Load user data on mount
+ useEffect(() => {
+ if (!userId) {
+ console.error('No userId provided to HomeScreen');
+ setError('User ID is missing');
+ setLoading(false);
+ return;
+ }
+ loadUserData();
+ }, [userId]);
+
+ // Effect: Load media when user data is ready
+ useEffect(() => {
+ if (currentUser) {
+ loadAllMedia();
+ }
+ }, [currentUser]);
+
+ // Effect: Filter media when filter changes
+ useEffect(() => {
+ filterMedia();
+ }, [selectedFilter, allMedia]);
+
+ /**
+ * Load user and linked accounts from database
+ */
+ const loadUserData = async () => {
+ try {
+ const user = await DatabaseService.getUserById(userId);
+ setCurrentUser(user);
+
+ const accounts = await DatabaseService.getLinkedAccounts(userId);
+ setLinkedAccounts(accounts);
+
+ setError(null);
+ } catch (err) {
+ console.error('Error loading user data:', err);
+ setError('Failed to load user information');
+ Alert.alert('Error', 'Failed to load user information');
+ }
+ };
+
+ /**
+ * Load media from all connected services
+ */
+ const loadAllMedia = async () => {
+ setLoading(true);
+ try {
+ // First, try to get media from database
+ const mediaFromDb = await DatabaseService.getMediaByUser(userId);
+
+ // Then fetch new media from each connected service
+ const newMedia = [];
+
+ // Fetch from Google Photos if enabled
+ const googleAccount = linkedAccounts.find(
+ (acc) => acc.service_type === 'GOOGLE_PHOTOS'
+ );
+ if (googleAccount) {
+ try {
+ const googleAuth = new GoogleAUTH();
+ console.log('Google Photos would be fetched here');
+ } catch (err) {
+ console.error('Google Photos fetch failed:', err);
+ }
+ }
+
+ // Fetch from iCloud if enabled
+ const iosAccount = linkedAccounts.find(
+ (acc) => acc.service_type === 'ICLOUD_PHOTOS'
+ );
+ if (iosAccount) {
+ try {
+ const iosAuth = new IOSAUTH();
+ console.log('iCloud Photos would be fetched here');
+ } catch (err) {
+ console.error('iCloud Photos fetch failed:', err);
+ }
+ }
+
+ // Fetch from NAS if enabled
+ const nasAccount = linkedAccounts.find(
+ (acc) => acc.service_type === 'NAS'
+ );
+ if (nasAccount) {
+ try {
+ const nasAuth = new NASAUTH();
+ const nasData = nasAccount.account_data;
+
+ // Connect to NAS via backend API
+ await nasAuth.authenticate(
+ nasData.host,
+ nasData.port,
+ nasData.username,
+ nasData.password,
+ nasData.photoFolder
+ );
+
+ // Get photos
+ const nasPhotos = await nasAuth.getPhotos(nasData.photoFolder);
+ if (nasPhotos.mediaItems) {
+ newMedia.push(...nasPhotos.mediaItems);
+ }
+
+ // Disconnect
+ await nasAuth.revokeAccess();
+ } catch (err) {
+ console.error('NAS Photos fetch failed:', err);
+ }
+ }
+
+ // Combine database media with newly fetched media
+ const combinedMedia = [...mediaFromDb, ...newMedia];
+
+ // Remove duplicates
+ const uniqueMedia = Array.from(
+ new Map(combinedMedia.map((m) => [m.source_id || m.id || m.path, m])).values()
+ );
+
+ setAllMedia(uniqueMedia);
+ setError(null);
+ } catch (err) {
+ console.error('Error loading media:', err);
+ setError('Failed to load photos');
+ Alert.alert('Error', 'Failed to load photos from services');
+ } finally {
+ setLoading(false);
+ setRefreshing(false);
+ }
+ };
+
+ /**
+ * Filter media based on selected service
+ */
+ const filterMedia = () => {
+ let filtered = allMedia;
+
+ if (selectedFilter !== 'all') {
+ filtered = allMedia.filter(
+ (media) => media.source_service === selectedFilter
+ );
+ }
+
+ // Sort by date (newest first)
+ filtered.sort((a, b) => {
+ const dateA = new Date(a.created_date || a.modifyTime || 0);
+ const dateB = new Date(b.created_date || b.modifyTime || 0);
+ return dateB - dateA;
+ });
+
+ setFilteredMedia(filtered);
+ };
+
+ /**
+ * Handle refresh gesture
+ */
+ const onRefresh = () => {
+ setRefreshing(true);
+ loadAllMedia();
+ };
+
+ /**
+ * Handle logout
+ */
+ const handleLogout = () => {
+ Alert.alert(
+ 'Logout',
+ 'Are you sure you want to logout?',
+ [
+ { text: 'Cancel', style: 'cancel' },
+ {
+ text: 'Logout',
+ style: 'destructive',
+ onPress: () => {
+ navigation.replace('Login');
+ },
+ },
+ ]
+ );
+ };
+
+ /**
+ * Get service display name
+ */
+ const getServiceName = (serviceType) => {
+ const serviceMap = {
+ GOOGLE_PHOTOS: 'Google Photos',
+ ICLOUD_PHOTOS: 'iCloud',
+ NAS: 'NAS',
+ };
+ return serviceMap[serviceType] || serviceType;
+ };
+
+ /**
+ * Get service icon
+ */
+ const getServiceIcon = (serviceType) => {
+ const iconMap = {
+ GOOGLE_PHOTOS: '🔵',
+ ICLOUD_PHOTOS: '🍎',
+ NAS: '💾',
+ };
+ return iconMap[serviceType] || '📱';
+ };
+
+ /**
+ * Render grid item (photo)
+ */
+ const renderMediaItem = ({ item }) => (
+ {
+ setSelectedMedia(item);
+ setModalVisible(true);
+ }}
+ activeOpacity={0.7}
+ >
+ {item.local_path ? (
+
+ ) : (
+
+ 📷
+
+ )}
+
+ {item.media_type === 'video' && (
+
+ ▶
+
+ )}
+
+
+ {getServiceIcon(item.source_service)}
+
+
+ );
+
+ // Loading state
+ if (loading) {
+ return (
+
+
+ Loading your photos...
+
+ );
+ }
+
+ // Error state
+ if (error && filteredMedia.length === 0) {
+ return (
+
+ ⚠️
+ Unable to Load Photos
+ {error}
+
+ Try Again
+
+
+ );
+ }
+
+ // Empty state
+ if (filteredMedia.length === 0) {
+ return (
+
+
+
+ Photo Library
+
+ Welcome, {currentUser?.username}
+
+
+
+ Logout
+
+
+
+
+ 📸
+ No Photos Yet
+
+ Photos from your connected services will appear here
+
+
+
+ );
+ }
+
+ // Main content
+ return (
+
+
+
+ Photo Library
+
+ Welcome, {currentUser?.username}
+
+
+
+ Logout
+
+
+
+ {linkedAccounts.length > 0 && (
+
+ setSelectedFilter('all')}
+ >
+
+ All
+
+
+
+ {linkedAccounts.map((account) => (
+ setSelectedFilter(account.service_type)}
+ >
+
+ {getServiceIcon(account.service_type)}
+
+
+ {getServiceName(account.service_type)}
+
+
+ ))}
+
+ )}
+
+ item.id?.toString() || item.path || Math.random().toString()}
+ numColumns={GRID_COLUMNS}
+ columnWrapperStyle={styles.columnWrapper}
+ contentContainerStyle={styles.gridContent}
+ scrollEnabled={true}
+ refreshControl={
+
+ }
+ />
+
+ setModalVisible(false)}
+ animationType="fade"
+ >
+ setModalVisible(false)}
+ serviceIcon={getServiceIcon(selectedMedia?.source_service)}
+ serviceName={getServiceName(selectedMedia?.source_service)}
+ />
+
+
+ );
+};
+
+/**
+ * MediaDetailModal - Full screen photo viewer
+ */
+const MediaDetailModal = ({ media, onClose, serviceIcon, serviceName }) => {
+ if (!media) return null;
+
+ return (
+
+
+
+
+
+ ✕
+
+
+
+ {media.local_path ? (
+
+ ) : (
+
+ 📷
+
+ )}
+
+
+
+ {media.title || 'Untitled Photo'}
+
+ {media.description && (
+ {media.description}
+ )}
+
+
+
+
+ From
+
+ {serviceIcon}
+ {serviceName}
+
+
+
+ {media.media_type && (
+
+ Type
+
+ {media.media_type === 'video' ? '🎥 Video' : '📷 Photo'}
+
+
+ )}
+
+ {(media.created_date || media.modifyTime) && (
+
+ Date
+
+ {new Date(
+ media.created_date || media.modifyTime
+ ).toLocaleDateString()}
+
+
+ )}
+
+ {media.size && (
+
+ Size
+
+ {(media.size / 1024 / 1024).toFixed(2)} MB
+
+
+ )}
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: '#f5f5f5',
+ },
+ centerContainer: {
+ flex: 1,
+ justifyContent: 'center',
+ alignItems: 'center',
+ paddingHorizontal: 20,
+ },
+ header: {
+ backgroundColor: '#fff',
+ paddingHorizontal: 16,
+ paddingVertical: 16,
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ borderBottomWidth: 1,
+ borderBottomColor: '#f0f0f0',
+ },
+ headerTitle: {
+ fontSize: 28,
+ fontWeight: 'bold',
+ color: '#1a1a1a',
+ },
+ headerSubtitle: {
+ fontSize: 13,
+ color: '#999',
+ marginTop: 2,
+ },
+ logoutButton: {
+ backgroundColor: '#ff3b30',
+ paddingHorizontal: 12,
+ paddingVertical: 8,
+ borderRadius: 6,
+ },
+ logoutText: {
+ color: '#fff',
+ fontSize: 13,
+ fontWeight: '600',
+ },
+ filterScroll: {
+ paddingHorizontal: 12,
+ paddingVertical: 12,
+ gap: 8,
+ },
+ filterButton: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingHorizontal: 12,
+ paddingVertical: 8,
+ backgroundColor: '#fff',
+ borderRadius: 16,
+ borderWidth: 1,
+ borderColor: '#e0e0e0',
+ gap: 6,
+ },
+ filterButtonActive: {
+ backgroundColor: '#007AFF',
+ borderColor: '#007AFF',
+ },
+ filterButtonText: {
+ fontSize: 13,
+ fontWeight: '600',
+ color: '#333',
+ },
+ filterButtonTextActive: {
+ color: '#fff',
+ },
+ filterIcon: {
+ fontSize: 14,
+ },
+ columnWrapper: {
+ gap: 8,
+ paddingHorizontal: 8,
+ marginBottom: 8,
+ },
+ gridContent: {
+ paddingVertical: 8,
+ paddingBottom: 20,
+ },
+ gridItem: {
+ width: imageSize,
+ height: imageSize,
+ borderRadius: 8,
+ overflow: 'hidden',
+ backgroundColor: '#e8e8e8',
+ position: 'relative',
+ },
+ gridImage: {
+ width: '100%',
+ height: '100%',
+ },
+ placeholderImage: {
+ width: '100%',
+ height: '100%',
+ justifyContent: 'center',
+ alignItems: 'center',
+ backgroundColor: '#e8e8e8',
+ },
+ placeholderIcon: {
+ fontSize: 32,
+ },
+ videoOverlay: {
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ backgroundColor: 'rgba(0, 0, 0, 0.3)',
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ playIcon: {
+ color: '#fff',
+ fontSize: 24,
+ fontWeight: 'bold',
+ },
+ serviceBadge: {
+ position: 'absolute',
+ bottom: 6,
+ right: 6,
+ backgroundColor: 'rgba(0, 0, 0, 0.6)',
+ borderRadius: 12,
+ paddingHorizontal: 6,
+ paddingVertical: 3,
+ },
+ badgeIcon: {
+ fontSize: 12,
+ },
+ loadingText: {
+ marginTop: 12,
+ fontSize: 16,
+ color: '#666',
+ },
+ errorIcon: {
+ fontSize: 64,
+ marginBottom: 16,
+ },
+ errorTitle: {
+ fontSize: 20,
+ fontWeight: 'bold',
+ color: '#333',
+ marginBottom: 8,
+ },
+ errorText: {
+ fontSize: 14,
+ color: '#666',
+ marginBottom: 24,
+ textAlign: 'center',
+ },
+ retryButton: {
+ backgroundColor: '#007AFF',
+ paddingHorizontal: 24,
+ paddingVertical: 10,
+ borderRadius: 8,
+ },
+ retryButtonText: {
+ color: '#fff',
+ fontSize: 14,
+ fontWeight: '600',
+ },
+ emptyIcon: {
+ fontSize: 64,
+ marginBottom: 16,
+ },
+ emptyTitle: {
+ fontSize: 20,
+ fontWeight: 'bold',
+ color: '#333',
+ marginBottom: 8,
+ },
+ emptyText: {
+ fontSize: 14,
+ color: '#999',
+ textAlign: 'center',
+ },
+ modalContainer: {
+ flex: 1,
+ backgroundColor: '#000',
+ },
+ modalBackdrop: {
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ },
+ modalContent: {
+ flex: 1,
+ zIndex: 10,
+ },
+ closeButton: {
+ position: 'absolute',
+ top: 12,
+ right: 12,
+ width: 40,
+ height: 40,
+ borderRadius: 20,
+ backgroundColor: 'rgba(0, 0, 0, 0.5)',
+ justifyContent: 'center',
+ alignItems: 'center',
+ zIndex: 20,
+ },
+ closeButtonText: {
+ color: '#fff',
+ fontSize: 24,
+ fontWeight: '600',
+ },
+ imageContainer: {
+ flex: 0.6,
+ backgroundColor: '#000',
+ },
+ imageContent: {
+ justifyContent: 'center',
+ alignItems: 'center',
+ minHeight: '100%',
+ },
+ fullImage: {
+ width: '100%',
+ height: '100%',
+ },
+ placeholderLarge: {
+ width: 200,
+ height: 200,
+ backgroundColor: '#333',
+ borderRadius: 12,
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ placeholderLargeIcon: {
+ fontSize: 64,
+ },
+ infoPanel: {
+ flex: 0.4,
+ backgroundColor: '#fff',
+ borderTopLeftRadius: 16,
+ borderTopRightRadius: 16,
+ padding: 16,
+ paddingBottom: 24,
+ },
+ infoTitle: {
+ fontSize: 18,
+ fontWeight: 'bold',
+ color: '#1a1a1a',
+ marginBottom: 6,
+ },
+ infoDescription: {
+ fontSize: 13,
+ color: '#666',
+ marginBottom: 12,
+ },
+ infoDivider: {
+ height: 1,
+ backgroundColor: '#f0f0f0',
+ marginVertical: 12,
+ },
+ infoRow: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ marginBottom: 10,
+ },
+ infoLabel: {
+ fontSize: 12,
+ fontWeight: '600',
+ color: '#999',
+ },
+ infoValue: {
+ fontSize: 13,
+ color: '#333',
+ fontWeight: '600',
+ },
+ infoServiceBadge: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 6,
+ },
+ infoServiceIcon: {
+ fontSize: 14,
+ },
+ infoServiceName: {
+ fontSize: 13,
+ color: '#333',
+ fontWeight: '600',
+ },
+});
+
+export default HomeScreen;
+
diff --git a/src/screens/LoginScreen.js b/src/screens/LoginScreen.js
index e69de29..ed92609 100644
--- a/src/screens/LoginScreen.js
+++ b/src/screens/LoginScreen.js
@@ -0,0 +1,186 @@
+import React, { useState } from 'react';
+import {
+ StyleSheet,
+ View,
+ Text,
+ TextInput,
+ TouchableOpacity,
+ Alert,
+ ActivityIndicator,
+ KeyboardAvoidingView,
+ Platform,
+} from 'react-native';
+import DatabaseService from '../services/DatabaseService';
+
+const LoginScreen = ({ navigation }) => {
+ const [email, setEmail] = useState('');
+ const [password, setPassword] = useState('');
+ const [loading, setLoading] = useState(false);
+
+ const handleLogin = async () => {
+ if (!email.trim() || !password.trim()) {
+ Alert.alert('Error', 'Please enter both email and password');
+ return;
+ }
+
+ setLoading(true);
+ try {
+ await DatabaseService.initDatabase();
+ const user = await DatabaseService.loginUser(email, password);
+
+ if (user) {
+ // Successfully logged in - navigate to home
+ navigation.replace('Onboarding', { userId: user.id });
+ } else {
+ Alert.alert('Error', 'Invalid email or password');
+ }
+ } catch (error) {
+ console.error('Login error:', error);
+ Alert.alert('Error', 'Failed to login: ' + error.message);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
+ Photo Library
+ Login to your account
+
+
+
+ Email
+
+
+ Password
+
+
+
+
+ {loading ? (
+
+ ) : (
+ Login
+ )}
+
+
+
+ Don't have an account?
+ navigation.navigate('Register')}
+ disabled={loading}
+ >
+ Register here
+
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: '#f5f5f5',
+ },
+ content: {
+ flex: 1,
+ justifyContent: 'center',
+ paddingHorizontal: 20,
+ paddingVertical: 40,
+ },
+ headerContainer: {
+ marginBottom: 40,
+ alignItems: 'center',
+ },
+ title: {
+ fontSize: 32,
+ fontWeight: 'bold',
+ color: '#333',
+ marginBottom: 10,
+ },
+ subtitle: {
+ fontSize: 16,
+ color: '#999',
+ },
+ inputContainer: {
+ marginBottom: 30,
+ },
+ label: {
+ fontSize: 14,
+ fontWeight: '600',
+ color: '#333',
+ marginBottom: 8,
+ marginTop: 16,
+ },
+ input: {
+ borderWidth: 1,
+ borderColor: '#ddd',
+ borderRadius: 8,
+ paddingHorizontal: 16,
+ paddingVertical: 12,
+ fontSize: 16,
+ backgroundColor: '#fff',
+ color: '#333',
+ },
+ loginButton: {
+ backgroundColor: '#007AFF',
+ borderRadius: 8,
+ paddingVertical: 14,
+ paddingHorizontal: 20,
+ alignItems: 'center',
+ marginBottom: 20,
+ },
+ buttonDisabled: {
+ opacity: 0.6,
+ },
+ buttonText: {
+ color: '#fff',
+ fontSize: 16,
+ fontWeight: '600',
+ },
+ registerContainer: {
+ flexDirection: 'row',
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ registerText: {
+ color: '#666',
+ fontSize: 14,
+ },
+ registerLink: {
+ color: '#007AFF',
+ fontSize: 14,
+ fontWeight: '600',
+ },
+});
+
+export default LoginScreen;
+
diff --git a/src/screens/OnboardingScreen.js b/src/screens/OnboardingScreen.js
new file mode 100644
index 0000000..167ee1f
--- /dev/null
+++ b/src/screens/OnboardingScreen.js
@@ -0,0 +1,642 @@
+import React, { useState } from 'react';
+import {
+ StyleSheet,
+ View,
+ Text,
+ ScrollView,
+ TouchableOpacity,
+ Alert,
+ ActivityIndicator,
+ Switch,
+ TextInput,
+} from 'react-native';
+import DatabaseService from '../services/DatabaseService';
+import GoogleAUTH from '../AUTH/GoogleAUTH';
+import IOSAUTH from '../AUTH/IOSAUTH';
+import NASAUTH from '../AUTH/NASAUTH';
+
+const OnboardingScreen = ({ navigation, route }) => {
+ const { userId } = route.params;
+ const [currentStep, setCurrentStep] = useState(0);
+ const [loading, setLoading] = useState(false);
+
+ // Service selections
+ const [googleEnabled, setGoogleEnabled] = useState(false);
+ const [iosEnabled, setIosEnabled] = useState(false);
+ const [nasEnabled, setNasEnabled] = useState(false);
+
+ // NAS configuration
+ const [nasHost, setNasHost] = useState('');
+ const [nasPort, setNasPort] = useState('22');
+ const [nasUsername, setNasUsername] = useState('');
+ const [nasPassword, setNasPassword] = useState('');
+ const [nasPhotoFolder, setNasPhotoFolder] = useState('/home/user/photos');
+
+ // Default service selection
+ const [defaultService, setDefaultService] = useState(null);
+
+ const steps = [
+ {
+ title: 'Select Services',
+ description: 'Choose which services to enable (you can enable 1-3)',
+ },
+ {
+ title: 'Configure Services',
+ description: 'Set up the enabled services',
+ },
+ {
+ title: 'Choose Default Service',
+ description: 'Select which service to use for uploads by default',
+ },
+ ];
+
+ const getEnabledServices = () => {
+ const services = [];
+ if (googleEnabled) services.push('Google Photos');
+ if (iosEnabled) services.push('iCloud Photos');
+ if (nasEnabled) services.push('NAS');
+ return services;
+ };
+
+ const handleGoogleAuth = async () => {
+ try {
+ setLoading(true);
+ const googleAuth = new GoogleAUTH();
+ // In a real app, this would trigger OAuth flow
+ const result = await googleAuth.authenticate('auth_code');
+
+ await DatabaseService.linkAccount(
+ userId,
+ 'GOOGLE_PHOTOS',
+ {
+ accessToken: result.accessToken,
+ email: 'user@gmail.com',
+ },
+ defaultService === 'GOOGLE_PHOTOS'
+ );
+
+ Alert.alert('Success', 'Google Photos connected!');
+ } catch (error) {
+ Alert.alert('Error', 'Failed to connect Google Photos: ' + error.message);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleIosAuth = async () => {
+ try {
+ setLoading(true);
+ const iosAuth = new IOSAUTH();
+ const result = await iosAuth.authenticate('icloud_user', 'icloud_password');
+
+ await DatabaseService.linkAccount(
+ userId,
+ 'ICLOUD_PHOTOS',
+ {
+ accessToken: result.accessToken,
+ email: 'user@icloud.com',
+ },
+ defaultService === 'ICLOUD_PHOTOS'
+ );
+
+ Alert.alert('Success', 'iCloud Photos connected!');
+ } catch (error) {
+ Alert.alert('Error', 'Failed to connect iCloud Photos: ' + error.message);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleNasAuth = async () => {
+ if (!nasHost || !nasUsername || !nasPassword) {
+ Alert.alert('Error', 'Please fill in all NAS connection details');
+ return;
+ }
+
+ try {
+ setLoading(true);
+ const nasAuth = new NASAUTH();
+ const result = await nasAuth.authenticate(
+ nasHost,
+ parseInt(nasPort) || 22,
+ nasUsername,
+ nasPassword
+ );
+
+ await DatabaseService.linkAccount(
+ userId,
+ 'NAS',
+ {
+ host: nasHost,
+ port: nasPort,
+ username: nasUsername,
+ photoFolder: nasPhotoFolder,
+ },
+ defaultService === 'NAS'
+ );
+
+ Alert.alert('Success', 'NAS connected!');
+ } catch (error) {
+ Alert.alert('Error', 'Failed to connect NAS: ' + error.message);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleNext = async () => {
+ if (currentStep === 0) {
+ const enabledCount = getEnabledServices().length;
+ if (enabledCount === 0) {
+ Alert.alert('Error', 'Please select at least one service');
+ return;
+ }
+ if (enabledCount > 3) {
+ Alert.alert('Error', 'You can enable a maximum of 3 services');
+ return;
+ }
+ setCurrentStep(1);
+ } else if (currentStep === 1) {
+ // Configure services
+ try {
+ setLoading(true);
+ if (googleEnabled) await handleGoogleAuth();
+ if (iosEnabled) await handleIosAuth();
+ if (nasEnabled) await handleNasAuth();
+ setCurrentStep(2);
+ } catch (error) {
+ console.error('Configuration error:', error);
+ } finally {
+ setLoading(false);
+ }
+ } else if (currentStep === 2) {
+ // Complete onboarding
+ if (!defaultService) {
+ Alert.alert('Error', 'Please select a default service');
+ return;
+ }
+
+ try {
+ setLoading(true);
+ // Set default service
+ const accounts = await DatabaseService.getLinkedAccounts(userId);
+ const defaultAccount = accounts.find(
+ (acc) => acc.service_type === defaultService
+ );
+ if (defaultAccount) {
+ await DatabaseService.setDefaultService(userId, defaultAccount.id);
+ }
+
+ // Navigate to home screen
+ navigation.replace('Home', { userId });
+ } catch (error) {
+ Alert.alert('Error', 'Failed to complete setup: ' + error.message);
+ } finally {
+ setLoading(false);
+ }
+ }
+ };
+
+ const handleBack = () => {
+ if (currentStep > 0) {
+ setCurrentStep(currentStep - 1);
+ }
+ };
+
+ return (
+
+
+
+
+ Step {currentStep + 1} of {steps.length}
+
+ {steps[currentStep].title}
+
+ {steps[currentStep].description}
+
+
+
+ {currentStep === 0 && (
+
+ setGoogleEnabled(!googleEnabled)}
+ icon="📷"
+ description="Connect to your Google Photos account"
+ />
+ setIosEnabled(!iosEnabled)}
+ icon="🍎"
+ description="Connect to your iCloud Photos account"
+ />
+ setNasEnabled(!nasEnabled)}
+ icon="💾"
+ description="Connect to your Ubuntu NAS via SFTP"
+ />
+
+ )}
+
+ {currentStep === 1 && (
+
+ {googleEnabled && (
+
+
+ Tap to authenticate with your Google account
+
+
+ )}
+
+ {iosEnabled && (
+
+
+ Tap to authenticate with your Apple account
+
+
+ )}
+
+ {nasEnabled && (
+
+ Server Address
+
+
+ Port
+
+
+ Username
+
+
+ Password
+
+
+ Photos Folder Path
+
+
+
+ {loading ? (
+
+ ) : (
+ Test Connection
+ )}
+
+
+ )}
+
+ )}
+
+ {currentStep === 2 && (
+
+
+ Select your default upload service:
+
+ {getEnabledServices().map((service) => (
+ {
+ const serviceTypeMap = {
+ 'Google Photos': 'GOOGLE_PHOTOS',
+ 'iCloud Photos': 'ICLOUD_PHOTOS',
+ NAS: 'NAS',
+ };
+ setDefaultService(serviceTypeMap[service]);
+ }}
+ />
+ ))}
+
+ )}
+
+
+
+
+ Back
+
+
+
+ {loading ? (
+
+ ) : (
+
+ {currentStep === steps.length - 1 ? 'Complete' : 'Next'}
+
+ )}
+
+
+
+ );
+};
+
+const ServiceCard = ({ name, enabled, onToggle, icon, description }) => (
+
+
+ {icon}
+
+ {name}
+ {description}
+
+
+
+
+);
+
+const ServiceConfigCard = ({ title, onPress, loading, children }) => (
+
+ {title}
+ {onPress && (
+
+ {loading ? (
+
+ ) : (
+ Authenticate
+ )}
+
+ )}
+ {children}
+
+);
+
+const ServiceSelectCard = ({ service, selected, onSelect }) => (
+
+
+
+ {service}
+
+
+);
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: '#f5f5f5',
+ },
+ content: {
+ flex: 1,
+ paddingHorizontal: 20,
+ paddingTop: 20,
+ },
+ header: {
+ marginBottom: 30,
+ },
+ stepIndicator: {
+ fontSize: 12,
+ color: '#007AFF',
+ fontWeight: '600',
+ marginBottom: 8,
+ },
+ title: {
+ fontSize: 28,
+ fontWeight: 'bold',
+ color: '#333',
+ marginBottom: 8,
+ },
+ description: {
+ fontSize: 16,
+ color: '#666',
+ },
+ servicesContainer: {
+ marginBottom: 20,
+ },
+ serviceCard: {
+ backgroundColor: '#fff',
+ borderRadius: 12,
+ padding: 16,
+ marginBottom: 12,
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ borderWidth: 1,
+ borderColor: '#e0e0e0',
+ },
+ serviceCardContent: {
+ flex: 1,
+ flexDirection: 'row',
+ alignItems: 'center',
+ },
+ serviceIcon: {
+ fontSize: 32,
+ marginRight: 16,
+ },
+ serviceInfo: {
+ flex: 1,
+ },
+ serviceName: {
+ fontSize: 16,
+ fontWeight: '600',
+ color: '#333',
+ marginBottom: 4,
+ },
+ serviceDescription: {
+ fontSize: 13,
+ color: '#999',
+ },
+ configContainer: {
+ marginBottom: 20,
+ },
+ configCard: {
+ backgroundColor: '#fff',
+ borderRadius: 12,
+ padding: 16,
+ marginBottom: 16,
+ borderWidth: 1,
+ borderColor: '#e0e0e0',
+ },
+ configCardTitle: {
+ fontSize: 16,
+ fontWeight: '600',
+ color: '#333',
+ marginBottom: 12,
+ },
+ configLabel: {
+ fontSize: 14,
+ fontWeight: '600',
+ color: '#333',
+ marginTop: 12,
+ marginBottom: 6,
+ },
+ configInput: {
+ borderWidth: 1,
+ borderColor: '#ddd',
+ borderRadius: 8,
+ paddingHorizontal: 12,
+ paddingVertical: 10,
+ fontSize: 14,
+ backgroundColor: '#f9f9f9',
+ color: '#333',
+ },
+ configText: {
+ fontSize: 14,
+ color: '#666',
+ marginTop: 12,
+ },
+ configButton: {
+ backgroundColor: '#007AFF',
+ borderRadius: 8,
+ paddingVertical: 10,
+ paddingHorizontal: 16,
+ alignItems: 'center',
+ },
+ testButton: {
+ backgroundColor: '#34C759',
+ borderRadius: 8,
+ paddingVertical: 10,
+ paddingHorizontal: 16,
+ alignItems: 'center',
+ marginTop: 16,
+ },
+ buttonDisabled: {
+ opacity: 0.6,
+ },
+ buttonText: {
+ color: '#fff',
+ fontSize: 14,
+ fontWeight: '600',
+ },
+ defaultServiceContainer: {
+ marginBottom: 20,
+ },
+ defaultServiceTitle: {
+ fontSize: 16,
+ fontWeight: '600',
+ color: '#333',
+ marginBottom: 12,
+ },
+ selectCard: {
+ backgroundColor: '#fff',
+ borderRadius: 12,
+ padding: 16,
+ marginBottom: 12,
+ flexDirection: 'row',
+ alignItems: 'center',
+ borderWidth: 2,
+ borderColor: '#ddd',
+ },
+ selectedCard: {
+ borderColor: '#007AFF',
+ backgroundColor: '#f0f7ff',
+ },
+ radioButton: {
+ width: 20,
+ height: 20,
+ borderRadius: 10,
+ borderWidth: 2,
+ borderColor: '#ddd',
+ marginRight: 12,
+ },
+ radioSelected: {
+ borderColor: '#007AFF',
+ backgroundColor: '#007AFF',
+ },
+ selectText: {
+ fontSize: 16,
+ color: '#333',
+ },
+ selectedText: {
+ color: '#007AFF',
+ fontWeight: '600',
+ },
+ footer: {
+ flexDirection: 'row',
+ paddingHorizontal: 20,
+ paddingVertical: 16,
+ borderTopWidth: 1,
+ borderTopColor: '#e0e0e0',
+ gap: 12,
+ },
+ footerButton: {
+ flex: 1,
+ paddingVertical: 12,
+ paddingHorizontal: 16,
+ borderRadius: 8,
+ alignItems: 'center',
+ backgroundColor: '#fff',
+ borderWidth: 1,
+ borderColor: '#ddd',
+ },
+ nextButton: {
+ backgroundColor: '#007AFF',
+ borderColor: '#007AFF',
+ },
+ footerButtonText: {
+ fontSize: 16,
+ fontWeight: '600',
+ color: '#333',
+ },
+ hidden: {
+ opacity: 0,
+ pointerEvents: 'none',
+ },
+});
+
+export default OnboardingScreen;
+
diff --git a/src/screens/RegisterScreen.js b/src/screens/RegisterScreen.js
index e69de29..2929ec3 100644
--- a/src/screens/RegisterScreen.js
+++ b/src/screens/RegisterScreen.js
@@ -0,0 +1,258 @@
+import React, { useState } from 'react';
+import {
+ StyleSheet,
+ View,
+ Text,
+ TextInput,
+ TouchableOpacity,
+ Alert,
+ ActivityIndicator,
+ KeyboardAvoidingView,
+ Platform,
+ ScrollView,
+} from 'react-native';
+import DatabaseService from '../services/DatabaseService';
+
+const RegisterScreen = ({ navigation }) => {
+ const [username, setUsername] = useState('');
+ const [email, setEmail] = useState('');
+ const [password, setPassword] = useState('');
+ const [confirmPassword, setConfirmPassword] = useState('');
+ const [loading, setLoading] = useState(false);
+
+ const validateForm = () => {
+ if (!username.trim()) {
+ Alert.alert('Error', 'Please enter a username');
+ return false;
+ }
+ if (!email.trim()) {
+ Alert.alert('Error', 'Please enter an email');
+ return false;
+ }
+ if (!email.includes('@')) {
+ Alert.alert('Error', 'Please enter a valid email');
+ return false;
+ }
+ if (!password.trim()) {
+ Alert.alert('Error', 'Please enter a password');
+ return false;
+ }
+ if (password.length < 6) {
+ Alert.alert('Error', 'Password must be at least 6 characters');
+ return false;
+ }
+ if (password !== confirmPassword) {
+ Alert.alert('Error', 'Passwords do not match');
+ return false;
+ }
+ return true;
+ };
+
+ const handleRegister = async () => {
+ if (!validateForm()) {
+ return;
+ }
+
+ setLoading(true);
+ try {
+ await DatabaseService.initDatabase();
+ // Check if email already exists
+ const exists = await DatabaseService.userExists(email);
+ if (exists) {
+ Alert.alert('Error', 'Email already registered');
+ setLoading(false);
+ return;
+ }
+
+ // Create new user
+ const userId = await DatabaseService.createUser(username, email, password);
+
+ Alert.alert('Success', 'Account created successfully!', [
+ {
+ text: 'OK',
+ onPress: () => {
+ // Navigate to onboarding after successful registration
+ navigation.replace('Onboarding', { userId });
+ },
+ },
+ ]);
+ } catch (error) {
+ console.error('Registration error:', error);
+ Alert.alert('Error', 'Failed to register: ' + error.message);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
+
+ Create Account
+ Join our photo library
+
+
+
+ Username
+
+
+ Email
+
+
+ Password
+
+
+ Confirm Password
+
+
+
+
+ {loading ? (
+
+ ) : (
+ Create Account
+ )}
+
+
+
+ Already have an account?
+ navigation.navigate('Login')}
+ disabled={loading}
+ >
+ Login here
+
+
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: '#f5f5f5',
+ },
+ scrollContent: {
+ flexGrow: 1,
+ justifyContent: 'center',
+ },
+ content: {
+ paddingHorizontal: 20,
+ paddingVertical: 40,
+ },
+ headerContainer: {
+ marginBottom: 40,
+ alignItems: 'center',
+ },
+ title: {
+ fontSize: 32,
+ fontWeight: 'bold',
+ color: '#333',
+ marginBottom: 10,
+ },
+ subtitle: {
+ fontSize: 16,
+ color: '#999',
+ },
+ inputContainer: {
+ marginBottom: 30,
+ },
+ label: {
+ fontSize: 14,
+ fontWeight: '600',
+ color: '#333',
+ marginBottom: 8,
+ marginTop: 16,
+ },
+ input: {
+ borderWidth: 1,
+ borderColor: '#ddd',
+ borderRadius: 8,
+ paddingHorizontal: 16,
+ paddingVertical: 12,
+ fontSize: 16,
+ backgroundColor: '#fff',
+ color: '#333',
+ },
+ registerButton: {
+ backgroundColor: '#34C759',
+ borderRadius: 8,
+ paddingVertical: 14,
+ paddingHorizontal: 20,
+ alignItems: 'center',
+ marginBottom: 20,
+ },
+ buttonDisabled: {
+ opacity: 0.6,
+ },
+ buttonText: {
+ color: '#fff',
+ fontSize: 16,
+ fontWeight: '600',
+ },
+ loginContainer: {
+ flexDirection: 'row',
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ loginText: {
+ color: '#666',
+ fontSize: 14,
+ },
+ loginLink: {
+ color: '#007AFF',
+ fontSize: 14,
+ fontWeight: '600',
+ },
+});
+
+export default RegisterScreen;
+
diff --git a/src/services/CredentialEncryption.js b/src/services/CredentialEncryption.js
new file mode 100644
index 0000000..3bfbae6
--- /dev/null
+++ b/src/services/CredentialEncryption.js
@@ -0,0 +1,96 @@
+/**
+ * Credential Encryption Service
+ *
+ * Encrypts sensitive credentials before storing in database
+ * Uses base64 encoding (React Native compatible)
+ * For production, use a proper encryption library like react-native-aes-crypto
+ */
+
+class CredentialEncryption {
+ constructor() {
+ // Simple key for demo - in production, use proper key management
+ this.encryptionKey = 'photo-app-secret-key-2026';
+ }
+
+ /**
+ * Base64 encode a string (React Native compatible)
+ * Uses btoa which is available in React Native
+ */
+ base64Encode(str) {
+ try {
+ // btoa is available in React Native
+ return btoa(unescape(encodeURIComponent(str)));
+ } catch (error) {
+ console.error('Base64 encode error:', error);
+ throw new Error('Failed to encode data');
+ }
+ }
+
+ /**
+ * Base64 decode a string (React Native compatible)
+ * Uses atob which is available in React Native
+ */
+ base64Decode(str) {
+ try {
+ // atob is available in React Native
+ return decodeURIComponent(escape(atob(str)));
+ } catch (error) {
+ console.error('Base64 decode error:', error);
+ throw new Error('Failed to decode data');
+ }
+ }
+
+ /**
+ * Encrypt credentials before storing
+ * Converts object to JSON string, then base64 encodes it
+ */
+ encrypt(data) {
+ try {
+ if (!data) {
+ throw new Error('No data provided to encrypt');
+ }
+ const jsonString = JSON.stringify(data);
+ const encoded = this.base64Encode(jsonString);
+ console.log('✓ Credentials encrypted successfully');
+ return encoded;
+ } catch (error) {
+ console.error('Encryption error:', error);
+ throw new Error('Failed to encrypt credentials: ' + error.message);
+ }
+ }
+
+ /**
+ * Decrypt credentials after retrieving
+ * Base64 decodes, then parses JSON back to object
+ */
+ decrypt(encryptedData) {
+ try {
+ if (!encryptedData) {
+ return null;
+ }
+ const jsonString = this.base64Decode(encryptedData);
+ const data = JSON.parse(jsonString);
+ console.log('✓ Credentials decrypted successfully');
+ return data;
+ } catch (error) {
+ console.error('Decryption error:', error);
+ throw new Error('Failed to decrypt credentials: ' + error.message);
+ }
+ }
+
+ /**
+ * Check if data is already encrypted (base64)
+ */
+ isEncrypted(data) {
+ if (typeof data !== 'string') return false;
+ try {
+ this.base64Decode(data);
+ return true;
+ } catch {
+ return false;
+ }
+ }
+}
+
+export default new CredentialEncryption();
+
diff --git a/src/services/DatabaseService.js b/src/services/DatabaseService.js
new file mode 100644
index 0000000..164075c
--- /dev/null
+++ b/src/services/DatabaseService.js
@@ -0,0 +1,428 @@
+import * as SQLite from 'expo-sqlite';
+import CredentialEncryption from './CredentialEncryption';
+
+let db = null;
+let initPromise = null;
+
+const DatabaseService = {
+ /**
+ * Initialize the database and create tables if they don't exist
+ */
+ initDatabase: async () => {
+ if (initPromise) {
+ return initPromise;
+ }
+ initPromise = (async () => {
+ try {
+ if (db) {
+ return true;
+ }
+ if (typeof SQLite.openDatabaseAsync !== 'function') {
+ throw new Error('SQLite openDatabaseAsync API not available');
+ }
+ db = await SQLite.openDatabaseAsync('PhotosApp.db');
+ if (!db) {
+ throw new Error('SQLite openDatabaseAsync returned null');
+ }
+
+ await db.execAsync(
+ `CREATE TABLE IF NOT EXISTS users (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ username TEXT UNIQUE NOT NULL,
+ email TEXT UNIQUE NOT NULL,
+ password TEXT NOT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ )`
+ );
+
+ await db.execAsync(
+ `CREATE TABLE IF NOT EXISTS linked_accounts (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL,
+ service_type TEXT NOT NULL,
+ account_data TEXT NOT NULL,
+ is_enabled INTEGER DEFAULT 1,
+ is_default INTEGER DEFAULT 0,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY(user_id) REFERENCES users(id)
+ )`
+ );
+
+ await db.execAsync(
+ `CREATE TABLE IF NOT EXISTS media (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL,
+ source_service TEXT NOT NULL,
+ source_id TEXT,
+ title TEXT,
+ description TEXT,
+ local_path TEXT,
+ remote_path TEXT,
+ media_type TEXT,
+ created_date DATETIME,
+ uploaded_to_default INTEGER DEFAULT 0,
+ synced INTEGER DEFAULT 0,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY(user_id) REFERENCES users(id)
+ )`
+ );
+
+ await db.execAsync(
+ `CREATE TABLE IF NOT EXISTS sync_logs (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL,
+ service_type TEXT NOT NULL,
+ status TEXT,
+ message TEXT,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY(user_id) REFERENCES users(id)
+ )`
+ );
+
+ console.log('Database initialized successfully');
+ return true;
+ } catch (error) {
+ console.error('Database initialization error:', error);
+ throw error;
+ }
+ })();
+ return initPromise;
+ },
+
+ /**
+ * Ensure the database is initialized before use
+ */
+ ensureDb: async () => {
+ await DatabaseService.initDatabase();
+ if (!db) {
+ throw new Error('Database not initialized');
+ }
+ },
+
+ /**
+ * Create a new user account
+ */
+ createUser: async (username, email, password) => {
+ try {
+ await DatabaseService.ensureDb();
+ const result = await db.runAsync(
+ 'INSERT INTO users (username, email, password) VALUES (?, ?, ?)',
+ [username, email, password]
+ );
+ return result.lastInsertRowId;
+ } catch (error) {
+ console.error('Error creating user:', error);
+ throw error;
+ }
+ },
+
+ /**
+ * Get user by email and password
+ */
+ loginUser: async (email, password) => {
+ try {
+ await DatabaseService.ensureDb();
+ const row = await db.getFirstAsync(
+ 'SELECT * FROM users WHERE email = ? AND password = ?',
+ [email, password]
+ );
+ return row || null;
+ } catch (error) {
+ console.error('Error logging in user:', error);
+ throw error;
+ }
+ },
+
+ /**
+ * Check if user exists by email
+ */
+ userExists: async (email) => {
+ try {
+ await DatabaseService.ensureDb();
+ const row = await db.getFirstAsync(
+ 'SELECT id FROM users WHERE email = ?',
+ [email]
+ );
+ return !!row;
+ } catch (error) {
+ console.error('Error checking if user exists:', error);
+ throw error;
+ }
+ },
+
+ /**
+ * Get user by ID
+ */
+ getUserById: async (userId) => {
+ try {
+ await DatabaseService.ensureDb();
+ if (!userId && userId !== 0) {
+ throw new Error('User ID is required and cannot be null');
+ }
+ const row = await db.getFirstAsync(
+ 'SELECT * FROM users WHERE id = ?',
+ [userId]
+ );
+ return row || null;
+ } catch (error) {
+ console.error('Error getting user:', error);
+ throw error;
+ }
+ },
+
+ /**
+ * Link an external account to user
+ */
+ linkAccount: async (userId, serviceType, accountData, isDefault = false) => {
+ try {
+ await DatabaseService.ensureDb();
+
+ // Validate inputs
+ if (!userId) {
+ throw new Error('User ID is required');
+ }
+ if (!serviceType) {
+ throw new Error('Service type is required');
+ }
+ if (!accountData || typeof accountData !== 'object') {
+ throw new Error('Account data must be a valid object');
+ }
+
+ console.log('📝 Linking account:', { userId, serviceType, dataKeys: Object.keys(accountData) });
+
+ // Encrypt account data
+ const encryptedData = CredentialEncryption.encrypt(accountData);
+
+ if (!encryptedData) {
+ throw new Error('Encryption returned empty data');
+ }
+
+ 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);
+
+ return result.lastInsertRowId;
+ } catch (error) {
+ console.error('Error linking account:', error);
+ throw new Error(`Failed to link account: ${error.message}`);
+ }
+ },
+
+ /**
+ * Get all linked accounts for user
+ */
+ getLinkedAccounts: async (userId) => {
+ try {
+ await DatabaseService.ensureDb();
+ if (!userId && userId !== 0) {
+ throw new Error('User ID is required and cannot be null');
+ }
+ const rows = await db.getAllAsync(
+ 'SELECT * FROM linked_accounts WHERE user_id = ? AND is_enabled = 1',
+ [userId]
+ );
+ return rows.map((row) => ({
+ ...row,
+ account_data: CredentialEncryption.decrypt(row.account_data),
+ }));
+ } catch (error) {
+ console.error('Error getting linked accounts:', error);
+ throw error;
+ }
+ },
+
+ /**
+ * Get linked account by service type
+ */
+ getLinkedAccountByService: async (userId, serviceType) => {
+ try {
+ await DatabaseService.ensureDb();
+ const row = await db.getFirstAsync(
+ 'SELECT * FROM linked_accounts WHERE user_id = ? AND service_type = ? AND is_enabled = 1',
+ [userId, serviceType]
+ );
+ if (!row) return null;
+ return {
+ ...row,
+ account_data: CredentialEncryption.decrypt(row.account_data),
+ };
+ } catch (error) {
+ console.error('Error getting linked account:', error);
+ throw error;
+ }
+ },
+
+ /**
+ * Set default service for uploads
+ */
+ setDefaultService: async (userId, accountId) => {
+ try {
+ await DatabaseService.ensureDb();
+ await db.runAsync(
+ 'UPDATE linked_accounts SET is_default = 0 WHERE user_id = ?',
+ [userId]
+ );
+ await db.runAsync(
+ 'UPDATE linked_accounts SET is_default = 1 WHERE id = ? AND user_id = ?',
+ [accountId, userId]
+ );
+ } catch (error) {
+ console.error('Error setting default service:', error);
+ throw error;
+ }
+ },
+
+ /**
+ * Get default service for user
+ */
+ getDefaultService: async (userId) => {
+ try {
+ await DatabaseService.ensureDb();
+ const row = await db.getFirstAsync(
+ 'SELECT * FROM linked_accounts WHERE user_id = ? AND is_default = 1',
+ [userId]
+ );
+ if (!row) return null;
+ return {
+ ...row,
+ account_data: CredentialEncryption.decrypt(row.account_data),
+ };
+ } catch (error) {
+ console.error('Error getting default service:', error);
+ throw error;
+ }
+ },
+
+ /**
+ * Add media to database
+ */
+ addMedia: async (
+ userId,
+ sourceService,
+ sourceId,
+ title,
+ description,
+ localPath,
+ remotePath,
+ mediaType,
+ createdDate
+ ) => {
+ try {
+ await DatabaseService.ensureDb();
+ if (!userId || !sourceService) {
+ throw new Error('userId and sourceService are required');
+ }
+ const result = await db.runAsync(
+ `INSERT INTO media
+ (user_id, source_service, source_id, title, description, local_path, remote_path, media_type, created_date)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ [
+ userId,
+ sourceService,
+ sourceId || null,
+ title || null,
+ description || null,
+ localPath || null,
+ remotePath || null,
+ mediaType || null,
+ createdDate || null,
+ ]
+ );
+ return result.lastInsertRowId;
+ } catch (error) {
+ console.error('Error adding media:', error);
+ throw error;
+ }
+ },
+
+ /**
+ * Get all media for user
+ */
+ getMediaByUser: async (userId) => {
+ try {
+ await DatabaseService.ensureDb();
+ if (!userId && userId !== 0) {
+ throw new Error('User ID is required and cannot be null');
+ }
+ return await db.getAllAsync(
+ 'SELECT * FROM media WHERE user_id = ? ORDER BY created_date DESC',
+ [userId]
+ );
+ } catch (error) {
+ console.error('Error getting media:', error);
+ throw error;
+ }
+ },
+
+ /**
+ * Get media by service
+ */
+ getMediaByService: async (userId, serviceType) => {
+ try {
+ await DatabaseService.ensureDb();
+ return await db.getAllAsync(
+ 'SELECT * FROM media WHERE user_id = ? AND source_service = ? ORDER BY created_date DESC',
+ [userId, serviceType]
+ );
+ } catch (error) {
+ console.error('Error getting media by service:', error);
+ throw error;
+ }
+ },
+
+ /**
+ * Update media sync status
+ */
+ updateMediaSyncStatus: async (mediaId, synced) => {
+ try {
+ await DatabaseService.ensureDb();
+ await db.runAsync(
+ 'UPDATE media SET synced = ? WHERE id = ?',
+ [synced ? 1 : 0, mediaId]
+ );
+ } catch (error) {
+ console.error('Error updating media sync status:', error);
+ throw error;
+ }
+ },
+
+ /**
+ * Log sync operation
+ */
+ logSync: async (userId, serviceType, status, message) => {
+ try {
+ await DatabaseService.ensureDb();
+ await db.runAsync(
+ 'INSERT INTO sync_logs (user_id, service_type, status, message) VALUES (?, ?, ?, ?)',
+ [userId, serviceType, status, message]
+ );
+ } catch (error) {
+ console.error('Error logging sync:', error);
+ throw error;
+ }
+ },
+
+ /**
+ * Close database connection
+ */
+ closeDatabase: async () => {
+ try {
+ if (db?.closeAsync) {
+ await db.closeAsync();
+ }
+ db = null;
+ initPromise = null;
+ } catch (error) {
+ console.error('Error closing database:', error);
+ }
+ },
+};
+
+export default DatabaseService;
+