import 'dart:typed_data'; import 'package:flutter/material.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 { final LocalStorageService? localStorageService; final ImmichService? immichService; const ImmichScreen({ super.key, this.localStorageService, this.immichService, }); @override State createState() => _ImmichScreenState(); } class _ImmichScreenState extends State { List _assets = []; bool _isLoading = false; String? _errorMessage; @override void initState() { super.initState(); _loadAssets(); } /// Loads assets from cache first, then fetches from API. Future _loadAssets({bool forceRefresh = false}) async { if (widget.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 widget.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 widget.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 widget.immichService!.getThumbnailUrl(asset.id); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Immich Media'), actions: [ 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 (_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); // 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 // OR we need to fetch images via Dio and convert to bytes. // 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 _showImageDetails(asset); }, child: Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(8), color: Colors.grey[300], ), child: ClipRRect( borderRadius: BorderRadius.circular(8), child: _buildImageWidget(thumbnailUrl, asset), ), ), ); } 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), 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: widget.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 (widget.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 widget.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'), ), ], ), ); } } /// 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(); } }