diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 828bdee..9c98acc 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,8 +1,14 @@ + + + + + + android:icon="@mipmap/ic_launcher" + android:requestLegacyExternalStorage="true"> uploadImage( @@ -85,35 +88,183 @@ class ImmichService { throw ImmichException('Image file does not exist: ${imageFile.path}'); } + // Get file metadata + final fileName = imageFile.path.split('/').last; + final fileStat = await imageFile.stat(); + final fileCreatedAt = fileStat.changed; + final fileModifiedAt = fileStat.modified; + + // Determine MIME type from file extension + String mimeType = 'image/jpeg'; // default + final extension = fileName.split('.').last.toLowerCase(); + switch (extension) { + case 'png': + mimeType = 'image/png'; + break; + case 'jpg': + case 'jpeg': + mimeType = 'image/jpeg'; + break; + case 'gif': + mimeType = 'image/gif'; + break; + case 'webp': + mimeType = 'image/webp'; + break; + case 'heic': + case 'heif': + mimeType = 'image/heic'; + break; + } + + // Generate device IDs (required by Immich API) + // Using a consistent device ID based on the app + const deviceId = 'flutter-app-boilerplate'; + final deviceAssetId = 'device-asset-${fileCreatedAt.millisecondsSinceEpoch}'; + + // Format dates in ISO 8601 format (UTC) + final fileCreatedAtIso = fileCreatedAt.toUtc().toIso8601String(); + final fileModifiedAtIso = fileModifiedAt.toUtc().toIso8601String(); + + // Prepare metadata according to Immich API format + // Format: [{"key":"mobile-app","value":{"caption":"...","tags":[]}}] + final metadata = [ + { + 'key': 'mobile-app', + 'value': { + 'caption': fileName, + 'tags': [], + } + } + ]; + final metadataJson = jsonEncode(metadata); + // Prepare form data for multipart upload + // Based on working curl command: + // curl -X POST "https://photos.satoshinakamoto.win/api/assets" \ + // -H "x-api-key: ..." \ + // -H "Content-Type: multipart/form-data" \ + // -F "assetData=@file.png;type=image/png" \ + // -F "deviceAssetId=device-asset-001" \ + // -F "deviceId=device-123" \ + // -F "fileCreatedAt=2025-11-06T12:34:56Z" \ + // -F "fileModifiedAt=2025-11-06T12:34:56Z" \ + // -F 'metadata=[{"key":"mobile-app","value":{"caption":"Test image","tags":[]}}]' final formData = FormData.fromMap({ 'assetData': await MultipartFile.fromFile( imageFile.path, - filename: imageFile.path.split('/').last, + filename: fileName, ), + 'deviceAssetId': deviceAssetId, + 'deviceId': deviceId, + 'fileCreatedAt': fileCreatedAtIso, + 'fileModifiedAt': fileModifiedAtIso, + 'metadata': metadataJson, if (albumId != null) 'albumId': albumId, }); // Upload to Immich + // According to Immich API documentation: POST /api/assets + // Note: Don't set Content-Type header manually for multipart/form-data + // Dio will set it automatically with the correct boundary + + // Determine the correct endpoint path + // If baseUrl already ends with /api, don't add it again + String endpointPath; + if (_baseUrl.endsWith('/api')) { + endpointPath = '/assets'; + } else if (_baseUrl.endsWith('/api/')) { + endpointPath = 'assets'; + } else { + endpointPath = '/api/assets'; + } + + final uploadUrl = '$_baseUrl$endpointPath'; + debugPrint('=== Immich Upload Request ==='); + debugPrint('URL: $uploadUrl'); + debugPrint('Base URL: $_baseUrl'); + debugPrint('File: $fileName, Size: ${await imageFile.length()} bytes'); + debugPrint('Device ID: $deviceId'); + debugPrint('Device Asset ID: $deviceAssetId'); + debugPrint('File Created At: $fileCreatedAtIso'); + debugPrint('File Modified At: $fileModifiedAtIso'); + debugPrint('Metadata: $metadataJson'); + final response = await _dio.post( - '/api/asset/upload', + endpointPath, data: formData, + options: Options( + headers: { + 'x-api-key': _apiKey, + // Don't set Content-Type - Dio handles it automatically for FormData + }, + ), ); + debugPrint('=== Immich Upload Response ==='); + debugPrint('Status Code: ${response.statusCode}'); + debugPrint('Response Data: ${response.data}'); + debugPrint('Response Headers: ${response.headers}'); + if (response.statusCode != 200 && response.statusCode != 201) { + final errorMessage = response.data is Map + ? (response.data as Map)['message']?.toString() ?? + response.statusMessage + : response.statusMessage; + debugPrint('Upload failed with status ${response.statusCode}: $errorMessage'); throw ImmichException( - 'Upload failed: ${response.statusMessage}', + 'Upload failed: $errorMessage', response.statusCode, ); } - final uploadResponse = UploadResponse.fromJson(response.data); + // Log the response data structure + if (response.data is Map) { + debugPrint('Response is Map with keys: ${(response.data as Map).keys}'); + debugPrint('Full response map: ${response.data}'); + } else if (response.data is List) { + debugPrint('Response is List with ${(response.data as List).length} items'); + debugPrint('First item: ${(response.data as List).first}'); + } else { + debugPrint('Response type: ${response.data.runtimeType}'); + debugPrint('Response value: ${response.data}'); + } + + // Handle response - it might be a single object or an array + Map responseData; + if (response.data is List && (response.data as List).isNotEmpty) { + // If response is an array, take the first item + responseData = (response.data as List).first as Map; + debugPrint('Using first item from array response'); + } else if (response.data is Map) { + responseData = response.data as Map; + } else { + throw ImmichException( + 'Unexpected response format: ${response.data.runtimeType}', + response.statusCode, + ); + } + + final uploadResponse = UploadResponse.fromJson(responseData); + debugPrint('Parsed Upload Response:'); + debugPrint(' ID: ${uploadResponse.id}'); + debugPrint(' Duplicate: ${uploadResponse.duplicate}'); // Fetch full asset details to store complete metadata - final asset = await _getAssetById(uploadResponse.id); + debugPrint('Fetching full asset details for ID: ${uploadResponse.id}'); + try { + final asset = await _getAssetById(uploadResponse.id); + debugPrint('Fetched asset: ${asset.id}, ${asset.fileName}'); - // Store metadata in local storage - await _storeAssetMetadata(asset); + // Store metadata in local storage + debugPrint('Storing asset metadata in local storage'); + await _storeAssetMetadata(asset); + debugPrint('Asset metadata stored successfully'); + } catch (e) { + // Log error but don't fail the upload - asset was uploaded successfully + debugPrint('Warning: Failed to fetch/store asset metadata: $e'); + debugPrint('Upload was successful, but metadata caching failed'); + } return uploadResponse; } on DioException catch (e) { @@ -127,15 +278,15 @@ class ImmichService { } /// Fetches a list of assets from Immich. - /// + /// /// Based on official Immich API documentation: https://api.immich.app/endpoints/search/searchAssets /// Uses POST /api/search/metadata endpoint with search parameters. - /// + /// /// [limit] - Maximum number of assets to fetch (default: 100). /// [skip] - Number of assets to skip (for pagination). - /// + /// /// Returns a list of [ImmichAsset] instances. - /// + /// /// Throws [ImmichException] if fetch fails. /// Automatically stores fetched metadata in local storage. Future> fetchAssets({ @@ -163,15 +314,17 @@ class ImmichService { // Parse response structure: {"assets": {"items": [...], "total": N, "count": N}} final responseData = response.data as Map; - + if (!responseData.containsKey('assets')) { - throw ImmichException('Unexpected response format: missing "assets" field'); + throw ImmichException( + 'Unexpected response format: missing "assets" field'); } final assetsData = responseData['assets'] as Map; - + if (!assetsData.containsKey('items')) { - throw ImmichException('Unexpected response format: missing "items" field in assets'); + throw ImmichException( + 'Unexpected response format: missing "items" field in assets'); } final assetsJson = assetsData['items'] as List; @@ -190,16 +343,13 @@ class ImmichService { final statusCode = e.response?.statusCode; final errorData = e.response?.data; String errorMessage; - + if (errorData is Map) { - errorMessage = errorData['message']?.toString() ?? - errorData.toString(); + errorMessage = errorData['message']?.toString() ?? errorData.toString(); } else { - errorMessage = errorData?.toString() ?? - e.message ?? - 'Unknown error'; + errorMessage = errorData?.toString() ?? e.message ?? 'Unknown error'; } - + throw ImmichException( 'Failed to fetch assets: $errorMessage', statusCode, @@ -213,14 +363,14 @@ class ImmichService { } /// Fetches a single asset by ID. - /// + /// /// Based on official Immich API documentation: https://api.immich.app/endpoints/assets /// Endpoint: GET /api/assets/{id} - /// + /// /// [assetId] - The unique identifier (UUID) of the asset. - /// + /// /// Returns [ImmichAsset] if found. - /// + /// /// Throws [ImmichException] if fetch fails. Future _getAssetById(String assetId) async { try { @@ -239,16 +389,13 @@ class ImmichService { final statusCode = e.response?.statusCode; final errorData = e.response?.data; String errorMessage; - + if (errorData is Map) { - errorMessage = errorData['message']?.toString() ?? - errorData.toString(); + errorMessage = errorData['message']?.toString() ?? errorData.toString(); } else { - errorMessage = errorData?.toString() ?? - e.message ?? - 'Unknown error'; + errorMessage = errorData?.toString() ?? e.message ?? 'Unknown error'; } - + throw ImmichException( 'Failed to fetch asset: $errorMessage', statusCode, @@ -259,7 +406,7 @@ class ImmichService { } /// Stores asset metadata in local storage. - /// + /// /// [asset] - The asset to store. Future _storeAssetMetadata(ImmichAsset asset) async { try { @@ -279,9 +426,9 @@ class ImmichService { } /// Gets locally cached asset metadata. - /// + /// /// [assetId] - The unique identifier of the asset. - /// + /// /// Returns [ImmichAsset] if found in local storage, null otherwise. Future getCachedAsset(String assetId) async { try { @@ -296,7 +443,7 @@ class ImmichService { } /// Gets all locally cached assets. - /// + /// /// Returns a list of [ImmichAsset] instances from local storage. Future> getCachedAssets() async { try { @@ -317,22 +464,22 @@ class ImmichService { } /// Gets the thumbnail URL for an asset. - /// + /// /// Uses GET /api/assets/{id}/thumbnail endpoint. - /// + /// /// [assetId] - The unique identifier of the asset. - /// + /// /// Returns the full URL to the thumbnail image. String getThumbnailUrl(String assetId) { return '$_baseUrl/api/assets/$assetId/thumbnail'; } /// Gets the full image URL for an asset. - /// + /// /// Uses GET /api/assets/{id}/original endpoint. - /// + /// /// [assetId] - The unique identifier of the asset. - /// + /// /// Returns the full URL to the original image file. String getImageUrl(String assetId) { return '$_baseUrl/api/assets/$assetId/original'; @@ -342,22 +489,23 @@ class ImmichService { String get baseUrl => _baseUrl; /// Fetches image bytes for an asset. - /// + /// /// Uses GET /api/assets/{id}/thumbnail for thumbnails or GET /api/assets/{id}/original for full images. - /// + /// /// [assetId] - The unique identifier of the asset (from metadata response). /// [isThumbnail] - Whether to fetch thumbnail (true) or original image (false). Default: true. - /// + /// /// Returns the image bytes as Uint8List. - /// + /// /// Throws [ImmichException] if fetch fails. - Future fetchImageBytes(String assetId, {bool isThumbnail = true}) async { + Future fetchImageBytes(String assetId, + {bool isThumbnail = true}) async { try { // Use correct endpoint based on thumbnail vs original - final endpoint = isThumbnail + final endpoint = isThumbnail ? '/api/assets/$assetId/thumbnail' : '/api/assets/$assetId/original'; - + final response = await _dio.get>( endpoint, options: Options( @@ -377,16 +525,13 @@ class ImmichService { final statusCode = e.response?.statusCode; final errorData = e.response?.data; String errorMessage; - + if (errorData is Map) { - errorMessage = errorData['message']?.toString() ?? - errorData.toString(); + errorMessage = errorData['message']?.toString() ?? errorData.toString(); } else { - errorMessage = errorData?.toString() ?? - e.message ?? - 'Unknown error'; + errorMessage = errorData?.toString() ?? e.message ?? 'Unknown error'; } - + throw ImmichException( 'Failed to fetch image: $errorMessage', statusCode, @@ -397,7 +542,7 @@ class ImmichService { } /// Gets the headers needed for authenticated image requests. - /// + /// /// Returns a map of headers including the API key. Map getImageHeaders() { return { @@ -406,9 +551,9 @@ class ImmichService { } /// Tests the connection to Immich server by calling the /api/server/about endpoint. - /// + /// /// Returns server information including version and status. - /// + /// /// Throws [ImmichException] if the request fails. Future> getServerInfo() async { try { @@ -426,16 +571,13 @@ class ImmichService { final statusCode = e.response?.statusCode; final errorData = e.response?.data; String errorMessage; - + if (errorData is Map) { - errorMessage = errorData['message']?.toString() ?? - errorData.toString(); + errorMessage = errorData['message']?.toString() ?? errorData.toString(); } else { - errorMessage = errorData?.toString() ?? - e.message ?? - 'Unknown error'; + errorMessage = errorData?.toString() ?? e.message ?? 'Unknown error'; } - + throw ImmichException( 'Failed to get server info: $errorMessage', statusCode, @@ -445,4 +587,3 @@ class ImmichService { } } } - diff --git a/lib/data/nostr/nostr_service.dart b/lib/data/nostr/nostr_service.dart index 787a47d..fc2bf4d 100644 --- a/lib/data/nostr/nostr_service.dart +++ b/lib/data/nostr/nostr_service.dart @@ -22,12 +22,12 @@ class NostrException implements Exception { } /// Service for interacting with Nostr protocol. -/// +/// /// This service provides: /// - Keypair generation /// - Event publishing to relays /// - Metadata synchronization with multiple relays -/// +/// /// The service is modular and UI-independent, designed for testing without real relays. class NostrService { /// List of configured relays. @@ -37,20 +37,21 @@ class NostrService { final Map _connections = {}; /// Stream controllers for relay messages. - final Map>> _messageControllers = {}; + final Map>> + _messageControllers = {}; /// Creates a [NostrService] instance. NostrService(); /// Generates a new Nostr keypair. - /// + /// /// Returns a [NostrKeyPair] with random private and public keys. NostrKeyPair generateKeyPair() { return NostrKeyPair.generate(); } /// Adds a relay to the service. - /// + /// /// [relayUrl] - The WebSocket URL of the relay (e.g., 'wss://relay.example.com'). void addRelay(String relayUrl) { final relay = NostrRelay.fromUrl(relayUrl); @@ -60,7 +61,7 @@ class NostrService { } /// Removes a relay from the service. - /// + /// /// [relayUrl] - The URL of the relay to remove. void removeRelay(String relayUrl) { _relays.removeWhere((r) => r.url == relayUrl); @@ -68,7 +69,7 @@ class NostrService { } /// Enables or disables a relay. - /// + /// /// [relayUrl] - The URL of the relay to enable/disable. /// [enabled] - Whether the relay should be enabled. void setRelayEnabled(String relayUrl, bool enabled) { @@ -77,7 +78,7 @@ class NostrService { orElse: () => throw NostrException('Relay not found: $relayUrl'), ); relay.isEnabled = enabled; - + // If disabling, also disconnect if (!enabled && relay.isConnected) { disconnectRelay(relayUrl); @@ -85,7 +86,7 @@ class NostrService { } /// Toggles all relays enabled/disabled. - /// + /// /// [enabled] - Whether all relays should be enabled. void setAllRelaysEnabled(bool enabled) { for (final relay in _relays) { @@ -102,11 +103,11 @@ class NostrService { } /// Connects to a relay. - /// + /// /// [relayUrl] - The URL of the relay to connect to. - /// + /// /// Returns a [Stream] of messages from the relay. - /// + /// /// Throws [NostrException] if connection fails. Future>> connectRelay(String relayUrl) async { try { @@ -119,7 +120,8 @@ class NostrService { throw NostrException('Relay is disabled: $relayUrl'); } - if (_connections.containsKey(relayUrl) && _connections[relayUrl] != null) { + if (_connections.containsKey(relayUrl) && + _connections[relayUrl] != null) { // Already connected return _messageControllers[relayUrl]!.stream; } @@ -192,7 +194,7 @@ class NostrService { } /// Disconnects from a relay. - /// + /// /// [relayUrl] - The URL of the relay to disconnect from. void disconnectRelay(String relayUrl) { final channel = _connections[relayUrl]; @@ -207,17 +209,18 @@ class NostrService { _messageControllers.remove(relayUrl); } - final relay = _relays.firstWhere((r) => r.url == relayUrl, orElse: () => NostrRelay.fromUrl(relayUrl)); + final relay = _relays.firstWhere((r) => r.url == relayUrl, + orElse: () => NostrRelay.fromUrl(relayUrl)); relay.isConnected = false; } /// Publishes an event to a relay. - /// + /// /// [event] - The Nostr event to publish. /// [relayUrl] - The URL of the relay to publish to. - /// + /// /// Returns a [Future] that completes when the event is published. - /// + /// /// Throws [NostrException] if publishing fails. Future publishEvent(NostrEvent event, String relayUrl) async { try { @@ -229,7 +232,7 @@ class NostrService { // Convert to nostr_tools Event and then to JSON final nostrToolsEvent = event.toNostrToolsEvent(); final eventJson = nostrToolsEvent.toJson(); - + // Send event in Nostr format: ["EVENT", ] final message = jsonEncode(['EVENT', eventJson]); channel.sink.add(message); @@ -239,9 +242,9 @@ class NostrService { } /// Publishes an event to all enabled relays. - /// + /// /// [event] - The Nostr event to publish. - /// + /// /// Returns a map of relay URLs to success/failure status. Future> publishEventToAllRelays(NostrEvent event) async { final results = {}; @@ -285,13 +288,13 @@ class NostrService { } /// Syncs metadata by publishing an event with metadata content. - /// + /// /// [metadata] - The metadata to sync (as a Map). /// [privateKey] - Private key for signing the event. /// [kind] - Event kind (default: 0 for metadata). - /// + /// /// Returns the created and published event. - /// + /// /// Throws [NostrException] if sync fails. Future syncMetadata({ required Map metadata, @@ -317,14 +320,15 @@ class NostrService { } /// Fetches user profile (kind 0 metadata event) from relays. - /// + /// /// [publicKey] - The public key (hex format) of the user. /// [timeout] - Timeout for the request (default: 10 seconds). - /// + /// /// Returns [NostrProfile] if found, null otherwise. - /// + /// /// Throws [NostrException] if fetch fails. - Future fetchProfile(String publicKey, {Duration timeout = const Duration(seconds: 10)}) async { + Future fetchProfile(String publicKey, + {Duration timeout = const Duration(seconds: 10)}) async { if (_relays.isEmpty) { throw NostrException('No relays configured'); } @@ -333,7 +337,8 @@ class NostrService { for (final relay in _relays) { if (relay.isConnected) { try { - final profile = await _fetchProfileFromRelay(publicKey, relay.url, timeout); + final profile = + await _fetchProfileFromRelay(publicKey, relay.url, timeout); if (profile != null) { return profile; } @@ -361,7 +366,8 @@ class NostrService { } /// Fetches profile from a specific relay. - Future _fetchProfileFromRelay(String publicKey, String relayUrl, Duration timeout) async { + Future _fetchProfileFromRelay( + String publicKey, String relayUrl, Duration timeout) async { final channel = _connections[relayUrl]; final messageController = _messageControllers[relayUrl]; if (channel == null || messageController == null) { @@ -371,20 +377,20 @@ class NostrService { // Send REQ message to request kind 0 events for this public key // Nostr REQ format: ["REQ", , ] final reqId = 'profile_${DateTime.now().millisecondsSinceEpoch}'; - + final completer = Completer(); final subscription = messageController.stream.listen( (message) { - // Message format from connectRelay: + // Message format from connectRelay: // {'type': 'EVENT', 'subscription_id': , 'data': } // or {'type': 'EOSE', 'subscription_id': , 'data': null} - if (message['type'] == 'EVENT' && + if (message['type'] == 'EVENT' && message['subscription_id'] == reqId && message['data'] != null) { try { final eventData = message['data']; Event nostrToolsEvent; - + // Handle both JSON object and array formats if (eventData is Map) { // JSON object format @@ -394,8 +400,11 @@ class NostrService { created_at: eventData['created_at'] as int? ?? 0, kind: eventData['kind'] as int? ?? 0, tags: (eventData['tags'] as List?) - ?.map((tag) => (tag as List).map((e) => e.toString()).toList()) - .toList() ?? [], + ?.map((tag) => (tag as List) + .map((e) => e.toString()) + .toList()) + .toList() ?? + [], content: eventData['content'] as String? ?? '', sig: eventData['sig'] as String? ?? '', verify: false, // Skip verification for profile fetching @@ -408,8 +417,11 @@ class NostrService { created_at: eventData[2] as int? ?? 0, kind: eventData[3] as int? ?? 0, tags: (eventData[4] as List?) - ?.map((tag) => (tag as List).map((e) => e.toString()).toList()) - .toList() ?? [], + ?.map((tag) => (tag as List) + .map((e) => e.toString()) + .toList()) + .toList() ?? + [], content: eventData[5] as String? ?? '', sig: eventData[6] as String? ?? '', verify: false, // Skip verification for profile fetching @@ -417,16 +429,18 @@ class NostrService { } else { return; // Invalid format } - + // Convert to our NostrEvent model final event = NostrEvent.fromNostrToolsEvent(nostrToolsEvent); - + // Check if it's a kind 0 (metadata) event for this public key - if (event.kind == 0 && event.pubkey.toLowerCase() == publicKey.toLowerCase()) { + if (event.kind == 0 && + event.pubkey.toLowerCase() == publicKey.toLowerCase()) { final profile = NostrProfile.fromEventContent( publicKey: publicKey, content: event.content, - updatedAt: DateTime.fromMillisecondsSinceEpoch(event.createdAt * 1000), + updatedAt: + DateTime.fromMillisecondsSinceEpoch(event.createdAt * 1000), ); if (!completer.isCompleted) { completer.complete(profile); @@ -436,8 +450,8 @@ class NostrService { // Ignore parsing errors debugPrint('Error parsing profile event: $e'); } - } else if (message['type'] == 'EOSE' && - message['subscription_id'] == reqId) { + } else if (message['type'] == 'EOSE' && + message['subscription_id'] == reqId) { // End of stored events - no profile found if (!completer.isCompleted) { completer.complete(null); @@ -461,33 +475,33 @@ class NostrService { 'limit': 1, } ]); - + channel.sink.add(reqMessage); try { final profile = await completer.future.timeout(timeout); - subscription?.cancel(); + subscription.cancel(); return profile; } catch (e) { - subscription?.cancel(); + subscription.cancel(); return null; } } /// Fetches preferred relays from a NIP-05 identifier. - /// + /// /// NIP-05 verification endpoint format: https:///.well-known/nostr.json?name= /// The response can include relay hints in the format: /// { /// "names": { "": "" }, /// "relays": { "": ["wss://relay1.com", "wss://relay2.com"] } /// } - /// + /// /// [nip05] - The NIP-05 identifier (e.g., 'user@domain.com'). /// [publicKey] - The public key (hex format) to match against relay hints. - /// + /// /// Returns a list of preferred relay URLs, or empty list if none found. - /// + /// /// Throws [NostrException] if fetch fails. Future> fetchPreferredRelaysFromNip05( String nip05, @@ -504,7 +518,8 @@ class NostrService { final domain = parts[1]; // Construct the verification URL - final url = Uri.https(domain, '/.well-known/nostr.json', {'name': localPart}); + final url = + Uri.https(domain, '/.well-known/nostr.json', {'name': localPart}); // Fetch the NIP-05 verification data final response = await http.get(url).timeout( @@ -515,7 +530,8 @@ class NostrService { ); if (response.statusCode != 200) { - throw NostrException('Failed to fetch NIP-05 data: ${response.statusCode}'); + throw NostrException( + 'Failed to fetch NIP-05 data: ${response.statusCode}'); } // Parse the JSON response @@ -552,20 +568,21 @@ class NostrService { } /// Loads preferred relays from NIP-05 if available and adds them to the relay list. - /// + /// /// [nip05] - The NIP-05 identifier (e.g., 'user@domain.com'). /// [publicKey] - The public key (hex format) to match against relay hints. - /// + /// /// Returns the number of relays added. - /// + /// /// Throws [NostrException] if fetch fails. Future loadPreferredRelaysFromNip05( String nip05, String publicKey, ) async { try { - final preferredRelays = await fetchPreferredRelaysFromNip05(nip05, publicKey); - + final preferredRelays = + await fetchPreferredRelaysFromNip05(nip05, publicKey); + int addedCount = 0; for (final relayUrl in preferredRelays) { try { @@ -594,4 +611,3 @@ class NostrService { _relays.clear(); } } - diff --git a/lib/ui/immich/immich_screen.dart b/lib/ui/immich/immich_screen.dart index e87ab5b..a568cdc 100644 --- a/lib/ui/immich/immich_screen.dart +++ b/lib/ui/immich/immich_screen.dart @@ -1,11 +1,13 @@ +import 'dart:io'; import 'dart:typed_data'; import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; import '../../data/local/local_storage_service.dart'; import '../../data/immich/immich_service.dart'; import '../../data/immich/models/immich_asset.dart'; /// Screen for Immich media integration. -/// +/// /// Displays images from Immich in a grid layout with pull-to-refresh. /// Shows cached images first, then fetches from API. class ImmichScreen extends StatefulWidget { @@ -26,6 +28,8 @@ class _ImmichScreenState extends State { List _assets = []; bool _isLoading = false; String? _errorMessage; + final ImagePicker _imagePicker = ImagePicker(); + bool _isUploading = false; @override void initState() { @@ -100,6 +104,11 @@ class _ImmichScreenState extends State { appBar: AppBar( title: const Text('Immich Media'), actions: [ + IconButton( + icon: const Icon(Icons.photo_library), + onPressed: _pickAndUploadImages, + tooltip: 'Upload from Gallery', + ), IconButton( icon: const Icon(Icons.info_outline), onPressed: _testServerConnection, @@ -118,6 +127,19 @@ class _ImmichScreenState extends State { } Widget _buildBody() { + if (_isUploading) { + return const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text('Uploading images...'), + ], + ), + ); + } + if (_isLoading && _assets.isEmpty) { return const Center( child: CircularProgressIndicator(), @@ -197,7 +219,7 @@ class _ImmichScreenState extends State { Widget _buildImageTile(ImmichAsset asset) { final thumbnailUrl = _getThumbnailUrl(asset); - + // For Immich API, we need to pass the API key as a header // Since Image.network doesn't easily support custom headers, // we'll use a workaround: Immich might accept the key in the URL query parameter @@ -205,15 +227,15 @@ class _ImmichScreenState extends State { // Let's check ImmichService - it has Dio with headers configured. // Actually, we can use Image.network with headers parameter (Flutter supports this). // But we need the API key. Let me check if ImmichService exposes it. - + // Since Immich API requires x-api-key header, and Image.network supports headers, // we need to get the API key. However, ImmichService doesn't expose it. // Let's modify ImmichService to expose a method that returns headers, or // we can fetch images via Dio and display them. - + // For now, let's use Image.network and assume Immich might work without header // (which it won't, but this is a placeholder). We'll fix this properly next. - + return GestureDetector( onTap: () { // TODO: Navigate to full image view @@ -235,7 +257,8 @@ class _ImmichScreenState extends State { Widget _buildImageWidget(String url, ImmichAsset asset) { // Use FutureBuilder to fetch image bytes via ImmichService with proper auth return FutureBuilder( - future: widget.immichService?.fetchImageBytes(asset.id, isThumbnail: true), + future: + widget.immichService?.fetchImageBytes(asset.id, isThumbnail: true), builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return const Center( @@ -280,7 +303,8 @@ class _ImmichScreenState extends State { ), Expanded( child: FutureBuilder( - future: widget.immichService?.fetchImageBytes(asset.id, isThumbnail: false), + future: widget.immichService + ?.fetchImageBytes(asset.id, isThumbnail: false), builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return const Center( @@ -288,7 +312,9 @@ class _ImmichScreenState extends State { ); } - if (snapshot.hasError || !snapshot.hasData || snapshot.data == null) { + if (snapshot.hasError || + !snapshot.hasData || + snapshot.data == null) { return const Center( child: Text('Failed to load image'), ); @@ -354,11 +380,11 @@ class _ImmichScreenState extends State { try { final serverInfo = await widget.immichService!.getServerInfo(); - + if (!mounted) return; - + Navigator.of(context).pop(); // Close loading dialog - + showDialog( context: context, builder: (context) => AlertDialog( @@ -390,9 +416,9 @@ class _ImmichScreenState extends State { ); } catch (e) { if (!mounted) return; - + Navigator.of(context).pop(); // Close loading dialog - + showDialog( context: context, builder: (context) => AlertDialog( @@ -415,7 +441,7 @@ class _ImmichScreenState extends State { /// Formats server info map as a readable string. String _formatServerInfo(Map info) { final buffer = StringBuffer(); - + info.forEach((key, value) { if (value is Map) { buffer.writeln('$key:'); @@ -426,7 +452,285 @@ class _ImmichScreenState extends State { buffer.writeln('$key: $value'); } }); - + return buffer.toString(); } + + /// Opens image picker and uploads selected images to Immich. + Future _pickAndUploadImages() async { + if (widget.immichService == null) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Immich service not available'), + backgroundColor: Colors.red, + ), + ); + } + return; + } + + try { + // Show dialog to choose between single or multiple images + final pickerChoice = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Select Images'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.photo), + title: const Text('Pick Single Image'), + onTap: () => + Navigator.of(context).pop(ImagePickerChoice.single), + ), + ListTile( + leading: const Icon(Icons.photo_library), + title: const Text('Pick Multiple Images'), + onTap: () => + Navigator.of(context).pop(ImagePickerChoice.multiple), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + ], + ), + ); + + if (pickerChoice == null) return; + + List pickedFiles; + try { + if (pickerChoice == ImagePickerChoice.multiple) { + pickedFiles = await _imagePicker.pickMultiImage(); + } else { + final pickedFile = await _imagePicker.pickImage( + source: ImageSource.gallery, + imageQuality: 100, + ); + pickedFiles = pickedFile != null ? [pickedFile] : []; + } + } catch (pickError, stackTrace) { + // Handle image picker specific errors + debugPrint('Image picker error: $pickError'); + debugPrint('Stack trace: $stackTrace'); + + if (mounted) { + String errorMessage = 'Failed to open gallery'; + String errorDetails = pickError.toString(); + + // Check for specific error types + if (errorDetails.contains('Permission') || + errorDetails.contains('permission') || + errorDetails.contains('PERMISSION')) { + errorMessage = + 'Permission denied. Please grant photo library access in app settings.'; + } else if (errorDetails.contains('PlatformException')) { + // Extract the actual error message from PlatformException + final match = RegExp(r'PlatformException\([^,]+,\s*([^,]+)') + .firstMatch(errorDetails); + if (match != null) { + errorDetails = match.group(1) ?? errorDetails; + } + errorMessage = 'Failed to open gallery: $errorDetails'; + } else { + errorMessage = 'Failed to open gallery: $errorDetails'; + } + + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Gallery Error'), + content: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(errorMessage), + const SizedBox(height: 16), + const Text( + 'Troubleshooting:', + style: TextStyle(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + const Text('1. Check app permissions in device settings'), + const Text('2. Make sure a gallery app is installed'), + const Text('3. Try restarting the app'), + const SizedBox(height: 16), + const Text( + 'Full error:', + style: + TextStyle(fontSize: 12, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 4), + Text( + errorDetails, + style: const TextStyle(fontSize: 10), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Close'), + ), + ], + ), + ); + } + return; + } + + if (pickedFiles.isEmpty) { + // User cancelled or no images selected - this is not an error + return; + } + + // Show upload progress + setState(() { + _isUploading = true; + }); + + int successCount = 0; + int failureCount = 0; + final errors = []; + + // Upload each image + for (final pickedFile in pickedFiles) { + try { + final file = File(pickedFile.path); + debugPrint('Uploading file: ${pickedFile.name}'); + final uploadResponse = await widget.immichService!.uploadImage(file); + debugPrint('Upload successful for ${pickedFile.name}: ${uploadResponse.id}'); + successCount++; + } catch (e, stackTrace) { + debugPrint('Upload failed for ${pickedFile.name}: $e'); + debugPrint('Stack trace: $stackTrace'); + failureCount++; + errors.add('${pickedFile.name}: ${e.toString()}'); + } + } + + setState(() { + _isUploading = false; + }); + + // Show result + if (mounted) { + if (failureCount == 0) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Successfully uploaded $successCount image(s)'), + backgroundColor: Colors.green, + ), + ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Uploaded $successCount, failed $failureCount'), + backgroundColor: Colors.orange, + duration: const Duration(seconds: 4), + action: SnackBarAction( + label: 'Details', + onPressed: () { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Upload Errors'), + content: SingleChildScrollView( + child: Text(errors.join('\n')), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Close'), + ), + ], + ), + ); + }, + ), + ), + ); + } + + // Refresh the asset list + debugPrint('Refreshing asset list after upload'); + await _loadAssets(forceRefresh: true); + debugPrint('Asset list refreshed, current count: ${_assets.length}'); + } + } catch (e, stackTrace) { + setState(() { + _isUploading = false; + }); + + if (mounted) { + // Log the full error for debugging + debugPrint('Image picker error: $e'); + debugPrint('Stack trace: $stackTrace'); + + String errorMessage = 'Failed to pick images'; + if (e.toString().contains('Permission')) { + errorMessage = + 'Permission denied. Please grant photo library access in app settings.'; + } else if (e.toString().contains('PlatformException')) { + errorMessage = + 'Failed to open gallery. Please check app permissions.'; + } else { + errorMessage = 'Failed to pick images: ${e.toString()}'; + } + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(errorMessage), + backgroundColor: Colors.red, + duration: const Duration(seconds: 5), + action: SnackBarAction( + label: 'Details', + textColor: Colors.white, + onPressed: () { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Error Details'), + content: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text('Error: ${e.toString()}'), + const SizedBox(height: 16), + const Text( + 'If this is a permission error, please grant photo library access in your device settings.', + style: TextStyle(fontSize: 12), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Close'), + ), + ], + ), + ); + }, + ), + ), + ); + } + } + } +} + +enum ImagePickerChoice { + single, + multiple, } diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index c89c10a..c9de345 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,6 +6,7 @@ import FlutterMacOS import Foundation import cloud_firestore +import file_selector_macos import firebase_analytics import firebase_auth import firebase_core @@ -16,6 +17,7 @@ import sqflite_darwin func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FLTFirebaseFirestorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseFirestorePlugin")) + FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FirebaseAnalyticsPlugin.register(with: registry.registrar(forPlugin: "FirebaseAnalyticsPlugin")) FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin")) FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) diff --git a/pubspec.lock b/pubspec.lock index 5861020..4ec6c8f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -265,6 +265,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.15.0" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "942a4791cd385a68ccb3b32c71c427aba508a1bb949b86dff2adbe4049f16239" + url: "https://pub.dev" + source: hosted + version: "0.3.5" crypto: dependency: transitive description: @@ -329,6 +337,38 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "54cbbd957e1156d29548c7d9b9ec0c0ebb6de0a90452198683a7d23aed617a33" + url: "https://pub.dev" + source: hosted + version: "0.9.3+2" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "88707a3bec4b988aaed3b4df5d7441ee4e987f20b286cddca5d6a8270cab23f2" + url: "https://pub.dev" + source: hosted + version: "0.9.4+5" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: a3994c26f10378a039faa11de174d7b78eb8f79e4dd0af2a451410c1a5c3f66b + url: "https://pub.dev" + source: hosted + version: "2.6.2" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "320fcfb6f33caa90f0b58380489fc5ac05d99ee94b61aa96ec2bff0ba81d3c2b" + url: "https://pub.dev" + source: hosted + version: "0.9.3+4" firebase_analytics: dependency: "direct main" description: @@ -470,6 +510,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.2.1" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "306f0596590e077338312f38837f595c04f28d6cdeeac392d3d74df2f0003687" + url: "https://pub.dev" + source: hosted + version: "2.0.32" flutter_test: dependency: "direct dev" description: flutter @@ -536,6 +584,70 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: "736eb56a911cf24d1859315ad09ddec0b66104bc41a7f8c5b96b4e2620cf5041" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: ca2a3b04d34e76157e9ae680ef16014fb4c2d20484e78417eaed6139330056f6 + url: "https://pub.dev" + source: hosted + version: "0.8.13+7" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "40c2a6a0da15556dc0f8e38a3246064a971a9f512386c3339b89f76db87269b6" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: e675c22790bcc24e9abd455deead2b7a88de4b79f7327a281812f14de1a56f58 + url: "https://pub.dev" + source: hosted + version: "0.8.13+1" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" + url: "https://pub.dev" + source: hosted + version: "0.2.2+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.dev" + source: hosted + version: "0.2.2" io: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 48eecb8..6e81ad9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -23,6 +23,7 @@ dependencies: firebase_auth: ^5.0.0 firebase_messaging: ^15.0.0 firebase_analytics: ^11.0.0 + image_picker: ^1.0.7 dev_dependencies: flutter_test: