import 'dart:io'; import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; import '../../../../core/logger.dart'; import '../../../../core/service_locator.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 { const ImmichScreen({super.key}); @override State createState() => _ImmichScreenState(); } class _ImmichScreenState extends State { List _assets = []; bool _isLoading = false; String? _errorMessage; final ImagePicker _imagePicker = ImagePicker(); bool _isUploading = false; Set _selectedAssetIds = {}; // Track selected assets for deletion bool _isSelectionMode = false; @override void initState() { super.initState(); _loadAssets(); } /// Loads assets from cache first, then fetches from API. Future _loadAssets({bool forceRefresh = false}) async { if (ServiceLocator.instance.immichService == null) { setState(() { _errorMessage = 'Immich service not available'; _isLoading = false; }); return; } setState(() { _isLoading = !forceRefresh; _errorMessage = null; }); try { // First, try to load cached assets if (!forceRefresh) { final cachedAssets = await ServiceLocator.instance.immichService!.getCachedAssets(); if (cachedAssets.isNotEmpty) { setState(() { _assets = cachedAssets; _isLoading = false; }); // Still fetch from API in background to update cache _fetchFromApi(); return; } } // Fetch from API await _fetchFromApi(); } catch (e) { setState(() { _errorMessage = 'Failed to load assets: ${e.toString()}'; _isLoading = false; }); } } /// Fetches assets from Immich API. Future _fetchFromApi() async { try { final assets = await ServiceLocator.instance.immichService!.fetchAssets(limit: 100); setState(() { _assets = assets; _isLoading = false; }); } catch (e) { setState(() { _errorMessage = 'Failed to fetch from Immich: ${e.toString()}'; _isLoading = false; }); } } /// Gets the thumbnail URL for an asset with proper headers. String _getThumbnailUrl(ImmichAsset asset) { return ServiceLocator.instance.immichService!.getThumbnailUrl(asset.id); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text(_isSelectionMode ? '${_selectedAssetIds.length} selected' : 'Immich Media'), actions: [ if (_isSelectionMode) ...[ IconButton( icon: const Icon(Icons.delete), onPressed: _selectedAssetIds.isEmpty ? null : _deleteSelectedAssets, tooltip: 'Delete Selected', color: Colors.red, ), IconButton( icon: const Icon(Icons.close), onPressed: _exitSelectionMode, tooltip: 'Cancel Selection', ), ] else ...[ IconButton( icon: const Icon(Icons.photo_library), onPressed: _pickAndUploadImages, tooltip: 'Upload from Gallery', ), IconButton( icon: const Icon(Icons.info_outline), onPressed: _testServerConnection, tooltip: 'Test Server Connection', ), if (_assets.isNotEmpty) IconButton( icon: const Icon(Icons.refresh), onPressed: () => _loadAssets(forceRefresh: true), tooltip: 'Refresh', ), ], ], ), body: _buildBody(), ); } 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(), ); } if (_errorMessage != null && _assets.isEmpty) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Icon( Icons.error_outline, size: 64, color: Colors.red, ), const SizedBox(height: 16), Text( _errorMessage!, style: const TextStyle(color: Colors.red), textAlign: TextAlign.center, ), const SizedBox(height: 16), ElevatedButton( onPressed: () => _loadAssets(forceRefresh: true), child: const Text('Retry'), ), ], ), ); } if (_assets.isEmpty) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Icon( Icons.photo_library_outlined, size: 64, color: Colors.grey, ), const SizedBox(height: 16), const Text( 'No images found', style: TextStyle(fontSize: 18), ), const SizedBox(height: 8), const Text( 'Pull down to refresh or upload images to Immich', style: TextStyle(color: Colors.grey), textAlign: TextAlign.center, ), ], ), ); } return RefreshIndicator( onRefresh: () => _loadAssets(forceRefresh: true), child: GridView.builder( padding: const EdgeInsets.all(8), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 3, crossAxisSpacing: 8, mainAxisSpacing: 8, childAspectRatio: 1, ), itemCount: _assets.length, itemBuilder: (context, index) { final asset = _assets[index]; return _buildImageTile(asset); }, ), ); } Widget _buildImageTile(ImmichAsset asset) { final thumbnailUrl = _getThumbnailUrl(asset); final isSelected = _selectedAssetIds.contains(asset.id); return GestureDetector( onLongPress: () { if (!_isSelectionMode) { setState(() { _isSelectionMode = true; _selectedAssetIds.add(asset.id); }); } }, onTap: () { if (_isSelectionMode) { setState(() { if (isSelected) { _selectedAssetIds.remove(asset.id); if (_selectedAssetIds.isEmpty) { _isSelectionMode = false; } } else { _selectedAssetIds.add(asset.id); } }); } else { _showImageDetails(asset); } }, child: Stack( children: [ Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(8), color: Colors.grey[300], border: isSelected ? Border.all(color: Colors.blue, width: 3) : null, ), child: ClipRRect( borderRadius: BorderRadius.circular(8), child: _buildImageWidget(thumbnailUrl, asset), ), ), if (isSelected) Positioned( top: 8, right: 8, child: Container( decoration: const BoxDecoration( color: Colors.blue, shape: BoxShape.circle, ), padding: const EdgeInsets.all(4), child: const Icon( Icons.check_circle, color: Colors.white, size: 24, ), ), ), ], ), ); } Widget _buildImageWidget(String url, ImmichAsset asset) { // Use FutureBuilder to fetch image bytes via ImmichService with proper auth return FutureBuilder( future: ServiceLocator.instance.immichService?.fetchImageBytes(asset.id, isThumbnail: true), builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return const Center( child: CircularProgressIndicator(), ); } if (snapshot.hasError || !snapshot.hasData || snapshot.data == null) { return Container( color: Colors.grey[200], child: const Icon( Icons.broken_image, color: Colors.grey, ), ); } return Image.memory( snapshot.data!, fit: BoxFit.cover, ); }, ); } void _showImageDetails(ImmichAsset asset) { showDialog( context: context, builder: (context) => Dialog( child: Column( mainAxisSize: MainAxisSize.min, children: [ AppBar( title: Text(asset.fileName), automaticallyImplyLeading: false, actions: [ IconButton( icon: const Icon(Icons.close), onPressed: () => Navigator.of(context).pop(), ), ], ), Expanded( child: FutureBuilder( future: ServiceLocator.instance.immichService ?.fetchImageBytes(asset.id, isThumbnail: false), builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return const Center( child: CircularProgressIndicator(), ); } if (snapshot.hasError || !snapshot.hasData || snapshot.data == null) { return const Center( child: Text('Failed to load image'), ); } return Image.memory( snapshot.data!, fit: BoxFit.contain, ); }, ), ), Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('File: ${asset.fileName}'), Text('Size: ${_formatFileSize(asset.fileSize)}'), if (asset.width != null && asset.height != null) Text('Dimensions: ${asset.width}x${asset.height}'), Text('Date: ${_formatDate(asset.createdAt)}'), ], ), ), ], ), ), ); } String _formatFileSize(int bytes) { if (bytes < 1024) return '$bytes B'; if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; } String _formatDate(DateTime date) { return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; } /// Tests the connection to Immich server by calling /api/server/about. Future _testServerConnection() async { if (ServiceLocator.instance.immichService == null) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Immich service not available'), backgroundColor: Colors.red, ), ); } return; } showDialog( context: context, barrierDismissible: false, builder: (context) => const Center( child: CircularProgressIndicator(), ), ); try { final serverInfo = await ServiceLocator.instance.immichService!.getServerInfo(); if (!mounted) return; Navigator.of(context).pop(); // Close loading dialog showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Server Info'), content: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( 'GET /api/server/about', style: Theme.of(context).textTheme.titleSmall, ), const SizedBox(height: 16), Text( _formatServerInfo(serverInfo), style: Theme.of(context).textTheme.bodyMedium, ), ], ), ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: const Text('Close'), ), ], ), ); } catch (e) { if (!mounted) return; Navigator.of(context).pop(); // Close loading dialog showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Connection Test Failed'), content: Text( 'Error: ${e.toString()}', style: const TextStyle(color: Colors.red), ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: const Text('Close'), ), ], ), ); } } void _exitSelectionMode() { setState(() { _isSelectionMode = false; _selectedAssetIds.clear(); }); } Future _deleteSelectedAssets() async { if (_selectedAssetIds.isEmpty || ServiceLocator.instance.immichService == null) { return; } // Show confirmation dialog final confirmed = await showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Delete Assets'), content: Text( 'Are you sure you want to delete ${_selectedAssetIds.length} asset(s)? This action cannot be undone.', ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(false), child: const Text('Cancel'), ), TextButton( onPressed: () => Navigator.of(context).pop(true), style: TextButton.styleFrom(foregroundColor: Colors.red), child: const Text('Delete'), ), ], ), ); if (confirmed != true) return; setState(() { _isLoading = true; }); try { final assetIdsToDelete = _selectedAssetIds.toList(); await ServiceLocator.instance.immichService!.deleteAssets(assetIdsToDelete); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Successfully deleted ${assetIdsToDelete.length} asset(s)'), backgroundColor: Colors.green, ), ); // Remove deleted assets from local list setState(() { _assets.removeWhere((asset) => assetIdsToDelete.contains(asset.id)); _selectedAssetIds.clear(); _isSelectionMode = false; }); // Refresh the list to ensure consistency await _loadAssets(forceRefresh: true); } } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Failed to delete assets: ${e.toString()}'), backgroundColor: Colors.red, ), ); } } finally { if (mounted) { setState(() { _isLoading = false; }); } } } /// 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:'); value.forEach((subKey, subValue) { buffer.writeln(' $subKey: $subValue'); }); } else { buffer.writeln('$key: $value'); } }); return buffer.toString(); } /// Opens image picker and uploads selected images to Immich. Future _pickAndUploadImages() async { if (ServiceLocator.instance.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 Logger.error('Image picker error: $pickError', pickError, 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); Logger.debug('Uploading file: ${pickedFile.name}'); final uploadResponse = await ServiceLocator.instance.immichService!.uploadImage(file); Logger.info('Upload successful for ${pickedFile.name}: ${uploadResponse.id}'); successCount++; } catch (e, stackTrace) { Logger.error('Upload failed for ${pickedFile.name}: $e', e, 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 Logger.debug('Refreshing asset list after upload'); await _loadAssets(forceRefresh: true); Logger.debug('Asset list refreshed, current count: ${_assets.length}'); } } catch (e, stackTrace) { setState(() { _isUploading = false; }); if (mounted) { // Log the full error for debugging Logger.error('Image picker error: $e', e, 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, }