diff --git a/lib/data/immich/immich_service.dart b/lib/data/immich/immich_service.dart index bf93089..83f5f02 100644 --- a/lib/data/immich/immich_service.dart +++ b/lib/data/immich/immich_service.dart @@ -2,6 +2,8 @@ import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; import 'package:dio/dio.dart'; +import 'package:path/path.dart' as path; +import 'package:path_provider/path_provider.dart'; import '../../core/logger.dart'; import '../../core/exceptions/immich_exception.dart'; import '../local/local_storage_service.dart'; @@ -30,6 +32,9 @@ class ImmichService { /// Immich API key for authentication. final String _apiKey; + /// Cache directory for storing fetched images. + Directory? _imageCacheDirectory; + /// Creates an [ImmichService] instance. /// /// [baseUrl] - Immich server base URL (e.g., 'https://immich.example.com'). @@ -467,9 +472,37 @@ class ImmichService { /// Gets the base URL for Immich API. String get baseUrl => _baseUrl; - /// Fetches image bytes for an asset. + /// Initializes the image cache directory. + /// Should be called after LocalStorageService is initialized. + Future _ensureImageCacheDirectory() async { + if (_imageCacheDirectory != null) return; + + try { + // Use the same cache directory as LocalStorageService if available + // Otherwise create a separate Immich image cache directory + final appDir = await getApplicationDocumentsDirectory(); + _imageCacheDirectory = Directory(path.join(appDir.path, 'immich_image_cache')); + + if (!await _imageCacheDirectory!.exists()) { + await _imageCacheDirectory!.create(recursive: true); + } + } catch (e) { + Logger.warning('Failed to initialize image cache directory: $e'); + // Continue without cache - images will be fetched fresh each time + } + } + + /// Gets the cache file path for an image. + String _getCacheFilePath(String assetId, bool isThumbnail) { + if (_imageCacheDirectory == null) return ''; + final cacheKey = '${assetId}_${isThumbnail ? 'thumb' : 'full'}'; + return path.join(_imageCacheDirectory!.path, cacheKey); + } + + /// Fetches image bytes for an asset with local caching. /// /// Uses GET /api/assets/{id}/thumbnail for thumbnails or GET /api/assets/{id}/original for full images. + /// Images are cached locally for offline access and faster loading. /// /// [assetId] - The unique identifier of the asset (from metadata response). /// [isThumbnail] - Whether to fetch thumbnail (true) or original image (false). Default: true. @@ -479,6 +512,26 @@ class ImmichService { /// Throws [ImmichException] if fetch fails. Future fetchImageBytes(String assetId, {bool isThumbnail = true}) async { + // Ensure cache directory is initialized + await _ensureImageCacheDirectory(); + + // Check cache first + final cacheFilePath = _getCacheFilePath(assetId, isThumbnail); + if (cacheFilePath.isNotEmpty) { + final cacheFile = File(cacheFilePath); + if (await cacheFile.exists()) { + try { + final cachedBytes = await cacheFile.readAsBytes(); + Logger.debug('Loaded image from cache: $assetId (${isThumbnail ? 'thumb' : 'full'})'); + return Uint8List.fromList(cachedBytes); + } catch (e) { + Logger.warning('Failed to read cached image, fetching fresh: $e'); + // Continue to fetch from network if cache read fails + } + } + } + + // Cache miss - fetch from Immich try { // Use correct endpoint based on thumbnail vs original final endpoint = isThumbnail @@ -499,7 +552,21 @@ class ImmichService { ); } - return Uint8List.fromList(response.data ?? []); + final imageBytes = Uint8List.fromList(response.data ?? []); + + // Cache the fetched image + if (cacheFilePath.isNotEmpty && imageBytes.isNotEmpty) { + try { + final cacheFile = File(cacheFilePath); + await cacheFile.writeAsBytes(imageBytes); + Logger.debug('Cached image: $assetId (${isThumbnail ? 'thumb' : 'full'})'); + } catch (e) { + Logger.warning('Failed to cache image (non-fatal): $e'); + // Don't fail the request if caching fails + } + } + + return imageBytes; } on DioException catch (e) { final statusCode = e.response?.statusCode; final errorData = e.response?.data; diff --git a/lib/data/recipes/recipe_service.dart b/lib/data/recipes/recipe_service.dart index 4269a5b..b9a77be 100644 --- a/lib/data/recipes/recipe_service.dart +++ b/lib/data/recipes/recipe_service.dart @@ -403,7 +403,7 @@ class RecipeService { /// /// Throws [Exception] if retrieval fails. Future> getFavouriteRecipes() async { - _ensureInitialized(); + await _ensureInitializedOrReinitialize(); try { final List> maps = await _db!.query( 'recipes', @@ -413,6 +413,23 @@ class RecipeService { return maps.map((map) => RecipeModel.fromMap(map)).toList(); } catch (e) { + // If error is about database being closed, try to reinitialize once + final errorStr = e.toString().toLowerCase(); + if (errorStr.contains('database_closed')) { + try { + Logger.warning('Database was closed, attempting to reinitialize...'); + _db = null; + await _ensureInitializedOrReinitialize(); + final List> maps = await _db!.query( + 'recipes', + where: 'is_favourite = 1 AND is_deleted = 0', + orderBy: 'created_at DESC', + ); + return maps.map((map) => RecipeModel.fromMap(map)).toList(); + } catch (retryError) { + throw Exception('Failed to get favourite recipes: $retryError'); + } + } throw Exception('Failed to get favourite recipes: $e'); } } diff --git a/lib/ui/add_recipe/add_recipe_screen.dart b/lib/ui/add_recipe/add_recipe_screen.dart index ee70c9b..418038f 100644 --- a/lib/ui/add_recipe/add_recipe_screen.dart +++ b/lib/ui/add_recipe/add_recipe_screen.dart @@ -6,15 +6,16 @@ import '../../core/service_locator.dart'; import '../../core/logger.dart'; import '../../data/recipes/recipe_service.dart'; import '../../data/recipes/models/recipe_model.dart'; -import '../shared/primary_app_bar.dart'; /// Add Recipe screen for creating new recipes. class AddRecipeScreen extends StatefulWidget { final RecipeModel? recipe; // For editing existing recipes + final bool viewMode; // If true, display in view-only mode const AddRecipeScreen({ super.key, this.recipe, + this.viewMode = false, }); @override @@ -288,8 +289,28 @@ class _AddRecipeScreenState extends State { @override Widget build(BuildContext context) { return Scaffold( - appBar: PrimaryAppBar( - title: widget.recipe != null ? 'Edit Recipe' : 'Add Recipe', + appBar: AppBar( + title: Text( + widget.viewMode + ? 'View Recipe' + : (widget.recipe != null ? 'Edit Recipe' : 'Add Recipe'), + ), + actions: [ + // Edit button in view mode + if (widget.viewMode) + IconButton( + icon: const Icon(Icons.edit), + onPressed: () { + // Navigate to edit mode + Navigator.of(context).pushReplacement( + MaterialPageRoute( + builder: (context) => AddRecipeScreen(recipe: widget.recipe), + ), + ); + }, + tooltip: 'Edit', + ), + ], ), body: Form( key: _formKey, @@ -299,23 +320,29 @@ class _AddRecipeScreenState extends State { // Title field TextFormField( controller: _titleController, + enabled: !widget.viewMode, + readOnly: widget.viewMode, decoration: const InputDecoration( labelText: 'Title *', hintText: 'Enter recipe title', border: OutlineInputBorder(), ), - validator: (value) { - if (value == null || value.trim().isEmpty) { - return 'Title is required'; - } - return null; - }, + validator: widget.viewMode + ? null + : (value) { + if (value == null || value.trim().isEmpty) { + return 'Title is required'; + } + return null; + }, ), const SizedBox(height: 16), // Description field TextFormField( controller: _descriptionController, + enabled: !widget.viewMode, + readOnly: widget.viewMode, decoration: const InputDecoration( labelText: 'Description', hintText: 'Enter recipe description', @@ -328,6 +355,8 @@ class _AddRecipeScreenState extends State { // Tags field TextFormField( controller: _tagsController, + enabled: !widget.viewMode, + readOnly: widget.viewMode, decoration: const InputDecoration( labelText: 'Tags', hintText: 'Enter tags separated by commas', @@ -360,11 +389,13 @@ class _AddRecipeScreenState extends State { color: index < _rating ? Colors.amber : Colors.grey, size: 32, ), - onPressed: () { - setState(() { - _rating = index + 1; - }); - }, + onPressed: widget.viewMode + ? null + : () { + setState(() { + _rating = index + 1; + }); + }, ); }), ), @@ -379,11 +410,13 @@ class _AddRecipeScreenState extends State { child: SwitchListTile( title: const Text('Favourite'), value: _isFavourite, - onChanged: (value) { - setState(() { - _isFavourite = value; - }); - }, + onChanged: widget.viewMode + ? null + : (value) { + setState(() { + _isFavourite = value; + }); + }, ), ), const SizedBox(height: 16), @@ -405,11 +438,12 @@ class _AddRecipeScreenState extends State { fontWeight: FontWeight.bold, ), ), - ElevatedButton.icon( - onPressed: _isUploading ? null : _pickImages, - icon: const Icon(Icons.add_photo_alternate), - label: const Text('Add Images'), - ), + if (!widget.viewMode) + ElevatedButton.icon( + onPressed: _isUploading ? null : _pickImages, + icon: const Icon(Icons.add_photo_alternate), + label: const Text('Add Images'), + ), ], ), const SizedBox(height: 8), @@ -449,15 +483,16 @@ class _AddRecipeScreenState extends State { child: _buildImagePreview(url), ), ), - Positioned( - top: 4, - right: 4, - child: IconButton( - icon: const Icon(Icons.close, size: 20), - color: Colors.red, - onPressed: () => _removeImage(index), + if (!widget.viewMode) + Positioned( + top: 4, + right: 4, + child: IconButton( + icon: const Icon(Icons.close, size: 20), + color: Colors.red, + onPressed: () => _removeImage(index), + ), ), - ), ], ); }).toList(), @@ -492,33 +527,34 @@ class _AddRecipeScreenState extends State { ), const SizedBox(height: 16), - // Save and Cancel buttons - Row( - children: [ - Expanded( - child: OutlinedButton( - onPressed: _isSaving ? null : () => Navigator.of(context).pop(), - child: const Text('Cancel'), + // Save and Cancel buttons (only show in edit/add mode) + if (!widget.viewMode) + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: _isSaving ? null : () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), ), - ), - const SizedBox(width: 16), - Expanded( - child: ElevatedButton( - onPressed: _isSaving ? null : _saveRecipe, - child: _isSaving - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation(Colors.white), - ), - ) - : Text(widget.recipe != null ? 'Update' : 'Save'), + const SizedBox(width: 16), + Expanded( + child: ElevatedButton( + onPressed: _isSaving ? null : _saveRecipe, + child: _isSaving + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : Text(widget.recipe != null ? 'Update' : 'Save'), + ), ), - ), - ], - ), + ], + ), ], ), ), diff --git a/lib/ui/favourites/favourites_screen.dart b/lib/ui/favourites/favourites_screen.dart index b088346..d04dc28 100644 --- a/lib/ui/favourites/favourites_screen.dart +++ b/lib/ui/favourites/favourites_screen.dart @@ -1,90 +1,797 @@ +import 'dart:typed_data'; import 'package:flutter/material.dart'; import '../../core/service_locator.dart'; -import '../shared/primary_app_bar.dart'; +import '../../core/logger.dart'; +import '../../data/recipes/recipe_service.dart'; +import '../../data/recipes/models/recipe_model.dart'; +import '../add_recipe/add_recipe_screen.dart'; /// Favourites screen displaying user's favorite recipes. -class FavouritesScreen extends StatelessWidget { +class FavouritesScreen extends StatefulWidget { const FavouritesScreen({super.key}); @override - Widget build(BuildContext context) { - // Check if user is logged in + State createState() => _FavouritesScreenState(); +} + +class _FavouritesScreenState extends State { + List _recipes = []; + bool _isLoading = false; + String? _errorMessage; + RecipeService? _recipeService; + bool _wasLoggedIn = false; + bool _isCompactMode = false; + + @override + void initState() { + super.initState(); + _initializeService(); + _checkLoginState(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _checkLoginState(); + if (_recipeService != null) { + _loadFavourites(); + } + } + + @override + void didUpdateWidget(FavouritesScreen oldWidget) { + super.didUpdateWidget(oldWidget); + _checkLoginState(); + if (_recipeService != null) { + _loadFavourites(); + } + } + + void _checkLoginState() { final sessionService = ServiceLocator.instance.sessionService; final isLoggedIn = sessionService?.isLoggedIn ?? false; + if (isLoggedIn != _wasLoggedIn) { + _wasLoggedIn = isLoggedIn; + if (mounted) { + setState(() {}); + if (isLoggedIn && _recipeService != null) { + _loadFavourites(); + } + } + } + } + + Future _initializeService() async { + try { + _recipeService = ServiceLocator.instance.recipeService; + + if (_recipeService == null) { + throw Exception('RecipeService not available in ServiceLocator'); + } + + _loadFavourites(); + } catch (e) { + Logger.error('Failed to initialize RecipeService', e); + if (mounted) { + setState(() { + _errorMessage = 'Failed to initialize recipe service: $e'; + _isLoading = false; + }); + } + } + } + + Future _loadFavourites() async { + if (_recipeService == null) return; + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final recipes = await _recipeService!.getFavouriteRecipes(); + Logger.info('FavouritesScreen: Loaded ${recipes.length} favourite recipe(s)'); + if (mounted) { + setState(() { + _recipes = recipes; + _isLoading = false; + }); + } + } catch (e) { + Logger.error('Failed to load favourites', e); + if (mounted) { + setState(() { + _errorMessage = 'Failed to load favourites: $e'; + _isLoading = false; + }); + } + } + } + + Future _deleteRecipe(RecipeModel recipe) async { + if (_recipeService == null) return; + + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Delete Recipe'), + content: Text('Are you sure you want to delete "${recipe.title}"?'), + 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; + + try { + await _recipeService!.deleteRecipe(recipe.id); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Recipe deleted successfully'), + backgroundColor: Colors.green, + ), + ); + _loadFavourites(); + } + } catch (e) { + Logger.error('Failed to delete recipe', e); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to delete recipe: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } + + void _viewRecipe(RecipeModel recipe) async { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => AddRecipeScreen(recipe: recipe, viewMode: true), + ), + ); + // Reload favourites after viewing (in case favorite was toggled) + if (mounted) { + _loadFavourites(); + } + } + + void _editRecipe(RecipeModel recipe) async { + final result = await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => AddRecipeScreen(recipe: recipe), + ), + ); + if (result == true || mounted) { + _loadFavourites(); + } + } + + Future _toggleFavorite(RecipeModel recipe) async { + if (_recipeService == null) return; + + try { + final updatedRecipe = recipe.copyWith(isFavourite: !recipe.isFavourite); + await _recipeService!.updateRecipe(updatedRecipe); + if (mounted) { + _loadFavourites(); + } + } catch (e) { + Logger.error('Failed to toggle favorite', e); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to update favorite: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } + + @override + Widget build(BuildContext context) { return Scaffold( - appBar: PrimaryAppBar(title: 'Favourites'), - body: isLoggedIn ? _buildLoggedInContent() : _buildLoginPrompt(context), + appBar: AppBar( + title: const Text('Favourites'), + actions: [ + // View mode toggle icons + IconButton( + icon: Icon( + _isCompactMode ? Icons.view_list : Icons.view_list_outlined, + color: _isCompactMode ? Theme.of(context).primaryColor : Colors.grey, + ), + onPressed: () { + setState(() { + _isCompactMode = true; + }); + }, + tooltip: 'List View', + ), + IconButton( + icon: Icon( + !_isCompactMode ? Icons.grid_view : Icons.grid_view_outlined, + color: !_isCompactMode ? Theme.of(context).primaryColor : Colors.grey, + ), + onPressed: () { + setState(() { + _isCompactMode = false; + }); + }, + tooltip: 'Grid View', + ), + ], + ), + body: _buildBody(), ); } - - Widget _buildLoginPrompt(BuildContext context) { - return Center( - child: Padding( - padding: const EdgeInsets.all(24), + + Widget _buildBody() { + final sessionService = ServiceLocator.instance.sessionService; + final isLoggedIn = sessionService?.isLoggedIn ?? false; + + if (!isLoggedIn) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.lock_outline, size: 64, color: Colors.grey.shade400), + const SizedBox(height: 16), + Text( + 'Please log in to view favourites', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: Colors.grey.shade700, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'Favourites are associated with your user account', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Colors.grey.shade600, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } + + if (_isLoading) { + return const Center( + child: CircularProgressIndicator(), + ); + } + + if (_errorMessage != null) { + return Center( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.error_outline, size: 64, color: Colors.red.shade300), + const SizedBox(height: 16), + Text( + _errorMessage!, + style: TextStyle(color: Colors.red.shade700), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _loadFavourites, + child: const Text('Retry'), + ), + ], + ), + ), + ); + } + + if (_recipes.isEmpty) { + return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.lock_outline, size: 64, color: Colors.grey.shade400), + Icon(Icons.favorite_outline, size: 64, color: Colors.grey.shade400), const SizedBox(height: 16), Text( - 'Please log in to view favourites', - style: Theme.of(context).textTheme.titleLarge?.copyWith( - color: Colors.grey.shade700, + 'No favourites yet', + style: TextStyle( + fontSize: 18, + color: Colors.grey.shade600, ), - textAlign: TextAlign.center, ), const SizedBox(height: 8), Text( - 'Favourites are associated with your user account', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Colors.grey.shade600, + 'Tap the heart icon on recipes to add them to favourites', + style: TextStyle( + fontSize: 14, + color: Colors.grey.shade500, ), textAlign: TextAlign.center, ), - const SizedBox(height: 24), - ElevatedButton.icon( - onPressed: () { - // Navigate to User/Session tab (index 3 in bottom nav) - // This is handled by the parent MainNavigationScaffold - }, - icon: const Icon(Icons.person), - label: const Text('Go to Login'), - ), ], ), + ); + } + + return RefreshIndicator( + onRefresh: _loadFavourites, + child: ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: _recipes.length, + itemBuilder: (context, index) { + final recipe = _recipes[index]; + return _RecipeCard( + recipe: recipe, + isCompact: _isCompactMode, + onTap: () => _viewRecipe(recipe), + onFavorite: () => _toggleFavorite(recipe), + onEdit: () => _editRecipe(recipe), + onDelete: () => _deleteRecipe(recipe), + ); + }, ), ); } - - Widget _buildLoggedInContent() { - return const Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.favorite_outline, - size: 64, - color: Colors.grey, - ), - SizedBox(height: 16), - Text( - 'Favourites Screen', - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: Colors.grey, +} + +/// Card widget for displaying a recipe (reused from RecipesScreen). +/// This is a copy of the _RecipeCard from recipes_screen.dart to avoid making it public. +class _RecipeCard extends StatelessWidget { + final RecipeModel recipe; + final bool isCompact; + final VoidCallback onTap; + final VoidCallback onFavorite; + final VoidCallback onEdit; + final VoidCallback onDelete; + + const _RecipeCard({ + required this.recipe, + required this.isCompact, + required this.onTap, + required this.onFavorite, + required this.onEdit, + required this.onDelete, + }); + + /// Builds an image widget using ImmichService for authenticated access. + Widget _buildRecipeImage(String imageUrl) { + final immichService = ServiceLocator.instance.immichService; + + final assetIdMatch = RegExp(r'/api/assets/([^/]+)/').firstMatch(imageUrl); + + if (assetIdMatch != null && immichService != null) { + final assetId = assetIdMatch.group(1); + if (assetId != null) { + return FutureBuilder( + future: immichService.fetchImageBytes(assetId, isThumbnail: true), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return Container( + color: Colors.grey.shade200, + child: const Center( + child: CircularProgressIndicator(), + ), + ); + } + + if (snapshot.hasError || !snapshot.hasData || snapshot.data == null) { + return Container( + color: Colors.grey.shade200, + child: const Icon(Icons.broken_image, size: 48), + ); + } + + return Image.memory( + snapshot.data!, + fit: BoxFit.cover, + ); + }, + ); + } + } + + return Image.network( + imageUrl, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + color: Colors.grey.shade200, + child: const Icon(Icons.broken_image, size: 48), + ); + }, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) return child; + return Container( + color: Colors.grey.shade200, + child: Center( + child: CircularProgressIndicator( + value: loadingProgress.expectedTotalBytes != null + ? loadingProgress.cumulativeBytesLoaded / + loadingProgress.expectedTotalBytes! + : null, ), ), - SizedBox(height: 8), - Text( - 'Your favorite recipes will appear here', - style: TextStyle( - fontSize: 16, - color: Colors.grey, - ), + ); + }, + ); + } + + /// Builds a mosaic-style image grid for multiple images. + Widget _buildMosaicImages(List imageUrls) { + if (imageUrls.isEmpty) return const SizedBox.shrink(); + if (imageUrls.length == 1) { + return ClipRRect( + borderRadius: const BorderRadius.vertical(top: Radius.circular(12)), + child: AspectRatio( + aspectRatio: 16 / 9, + child: _buildRecipeImage(imageUrls.first), + ), + ); + } + + return ClipRRect( + borderRadius: const BorderRadius.vertical(top: Radius.circular(12)), + child: LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth; + final height = width * 0.6; + + if (imageUrls.length == 2) { + return SizedBox( + height: height, + child: Row( + children: [ + Expanded( + child: ClipRect( + child: _buildRecipeImage(imageUrls[0]), + ), + ), + Expanded( + child: ClipRect( + child: _buildRecipeImage(imageUrls[1]), + ), + ), + ], + ), + ); + } else if (imageUrls.length == 3) { + return SizedBox( + height: height, + child: Row( + children: [ + Expanded( + flex: 2, + child: ClipRect( + child: _buildRecipeImage(imageUrls[0]), + ), + ), + Expanded( + child: Column( + children: [ + Expanded( + child: ClipRect( + child: _buildRecipeImage(imageUrls[1]), + ), + ), + Expanded( + child: ClipRect( + child: _buildRecipeImage(imageUrls[2]), + ), + ), + ], + ), + ), + ], + ), + ); + } else { + return SizedBox( + height: height, + child: GridView.builder( + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + padding: EdgeInsets.zero, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + crossAxisSpacing: 0, + mainAxisSpacing: 0, + childAspectRatio: 1.0, + ), + itemCount: imageUrls.length > 4 ? 4 : imageUrls.length, + itemBuilder: (context, index) { + return ClipRect( + child: Stack( + fit: StackFit.expand, + children: [ + _buildRecipeImage(imageUrls[index]), + if (index == 3 && imageUrls.length > 4) + Container( + color: Colors.black.withOpacity(0.5), + child: Center( + child: Text( + '+${imageUrls.length - 4}', + style: const TextStyle( + color: Colors.white, + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ], + ), + ); + }, + ), + ); + } + }, + ), + ); + } + + @override + Widget build(BuildContext context) { + if (isCompact) { + return _buildCompactCard(context); + } else { + return _buildExpandedCard(context); + } + } + + Widget _buildCompactCard(BuildContext context) { + return Card( + margin: const EdgeInsets.only(bottom: 8), + elevation: 1, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.all(8), + child: Row( + children: [ + if (recipe.imageUrls.isNotEmpty) + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: SizedBox( + width: 80, + height: 80, + child: _buildRecipeImage(recipe.imageUrls.first), + ), + ), + if (recipe.imageUrls.isNotEmpty) const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Expanded( + child: Text( + recipe.title, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (recipe.rating > 0) + Row( + mainAxisSize: MainAxisSize.min, + children: List.generate(5, (index) { + return Icon( + index < recipe.rating + ? Icons.star + : Icons.star_border, + size: 12, + color: index < recipe.rating + ? Colors.amber + : Colors.grey, + ); + }), + ), + ], + ), + if (recipe.tags.isNotEmpty) ...[ + const SizedBox(height: 4), + Wrap( + spacing: 4, + runSpacing: 4, + children: recipe.tags.take(2).map((tag) { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + tag, + style: const TextStyle(fontSize: 10), + ), + ); + }).toList(), + ), + ], + ], + ), + ), + IconButton( + icon: Icon( + recipe.isFavourite ? Icons.favorite : Icons.favorite_border, + size: 20, + color: recipe.isFavourite ? Colors.red : Colors.grey, + ), + onPressed: onFavorite, + tooltip: recipe.isFavourite ? 'Remove from favorites' : 'Add to favorites', + ), + PopupMenuButton( + icon: const Icon(Icons.more_vert, size: 20), + itemBuilder: (context) => [ + PopupMenuItem( + child: const Row( + children: [ + Icon(Icons.edit, size: 18), + SizedBox(width: 8), + Text('Edit'), + ], + ), + onTap: () => Future.delayed( + const Duration(milliseconds: 100), + onEdit, + ), + ), + PopupMenuItem( + child: const Row( + children: [ + Icon(Icons.delete, size: 18, color: Colors.red), + SizedBox(width: 8), + Text('Delete', style: TextStyle(color: Colors.red)), + ], + ), + onTap: () => Future.delayed( + const Duration(milliseconds: 100), + onDelete, + ), + ), + ], + ), + ], ), - ], + ), + ), + ); + } + + Widget _buildExpandedCard(BuildContext context) { + return Card( + margin: const EdgeInsets.only(bottom: 16), + elevation: 2, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (recipe.imageUrls.isNotEmpty) + _buildMosaicImages(recipe.imageUrls), + Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + recipe.title, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ), + if (recipe.rating > 0) + Row( + children: List.generate(5, (index) { + return Icon( + index < recipe.rating + ? Icons.star + : Icons.star_border, + size: 16, + color: index < recipe.rating + ? Colors.amber + : Colors.grey, + ); + }), + ), + ], + ), + if (recipe.description != null && recipe.description!.isNotEmpty) ...[ + const SizedBox(height: 8), + Text( + recipe.description!, + style: TextStyle( + fontSize: 14, + color: Colors.grey.shade700, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + if (recipe.tags.isNotEmpty) ...[ + const SizedBox(height: 8), + Wrap( + spacing: 4, + runSpacing: 4, + children: recipe.tags.map((tag) { + return Chip( + label: Text( + tag, + style: const TextStyle(fontSize: 12), + ), + padding: EdgeInsets.zero, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + ); + }).toList(), + ), + ], + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + IconButton( + icon: Icon( + recipe.isFavourite ? Icons.favorite : Icons.favorite_border, + color: recipe.isFavourite ? Colors.red : Colors.grey, + ), + onPressed: onFavorite, + tooltip: recipe.isFavourite ? 'Remove from favorites' : 'Add to favorites', + ), + IconButton( + icon: const Icon(Icons.edit), + onPressed: onEdit, + tooltip: 'Edit', + ), + IconButton( + icon: const Icon(Icons.delete), + onPressed: onDelete, + tooltip: 'Delete', + color: Colors.red, + ), + ], + ), + ], + ), + ), + ], + ), ), ); } diff --git a/lib/ui/navigation/main_navigation_scaffold.dart b/lib/ui/navigation/main_navigation_scaffold.dart index 260b7b4..c0996e6 100644 --- a/lib/ui/navigation/main_navigation_scaffold.dart +++ b/lib/ui/navigation/main_navigation_scaffold.dart @@ -19,7 +19,6 @@ class MainNavigationScaffold extends StatefulWidget { class _MainNavigationScaffoldState extends State { int _currentIndex = 0; bool _wasLoggedIn = false; - int _recipesRefreshTrigger = 0; bool get _isLoggedIn => ServiceLocator.instance.sessionService?.isLoggedIn ?? false; @@ -83,14 +82,6 @@ class _MainNavigationScaffoldState extends State { setState(() { _currentIndex = index; }); - - // If tapping Recipes tab, trigger refresh from Nostr - if (index == 1 && isLoggedIn) { - // Increment refresh trigger to cause widget rebuild and trigger refresh - setState(() { - _recipesRefreshTrigger++; - }); - } } void _onAddRecipePressed(BuildContext context) async { @@ -128,11 +119,11 @@ class _MainNavigationScaffoldState extends State { return const HomeScreen(); case 1: // Recipes - only show if logged in, otherwise show login prompt - // Use a key that changes when refresh is triggered to force widget update + // Use a key based on login state to force rebuild when login changes return RecipesScreen( key: _isLoggedIn - ? ValueKey('recipes_${_isLoggedIn}_$_recipesRefreshTrigger') - : ValueKey('recipes_logged_out'), + ? const ValueKey('recipes_logged_in') + : const ValueKey('recipes_logged_out'), ); case 2: // Favourites - only show if logged in, otherwise show login prompt diff --git a/lib/ui/recipes/recipes_screen.dart b/lib/ui/recipes/recipes_screen.dart index 30ffe1a..a135069 100644 --- a/lib/ui/recipes/recipes_screen.dart +++ b/lib/ui/recipes/recipes_screen.dart @@ -4,7 +4,6 @@ import '../../core/service_locator.dart'; import '../../core/logger.dart'; import '../../data/recipes/recipe_service.dart'; import '../../data/recipes/models/recipe_model.dart'; -import '../shared/primary_app_bar.dart'; import '../add_recipe/add_recipe_screen.dart'; /// Recipes screen displaying user's recipe collection. @@ -29,6 +28,7 @@ class _RecipesScreenState extends State { String? _errorMessage; RecipeService? _recipeService; bool _wasLoggedIn = false; + bool _isCompactMode = false; @override void initState() { @@ -56,10 +56,10 @@ class _RecipesScreenState extends State { // Check if login state changed _checkLoginState(); - // When widget is updated (e.g., when tab becomes active), refresh from Nostr - // This ensures we get the latest recipes when switching to this tab + // Offline-first: Only reload from local DB when widget updates + // Nostr fetch happens only on explicit refresh (pull-to-refresh or tab tap) if (_recipeService != null) { - _loadRecipes(fetchFromNostr: true); + _loadRecipes(fetchFromNostr: false); } } @@ -96,8 +96,9 @@ class _RecipesScreenState extends State { // The database should already be initialized by SessionService on login // If not initialized, we'll get an error when trying to use it, which is fine - // Load recipes (will fetch from Nostr in didChangeDependencies) - _loadRecipes(); + // Load recipes from local DB only (offline-first) + // Nostr fetch only happens on pull-to-refresh + _loadRecipes(fetchFromNostr: false); } catch (e) { Logger.error('Failed to initialize RecipeService', e); if (mounted) { @@ -213,6 +214,18 @@ class _RecipesScreenState extends State { } } + void _viewRecipe(RecipeModel recipe) async { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => AddRecipeScreen(recipe: recipe, viewMode: true), + ), + ); + // Reload recipes after viewing (in case favorite was toggled) + if (mounted) { + _loadRecipes(); + } + } + void _editRecipe(RecipeModel recipe) async { final result = await Navigator.of(context).push( MaterialPageRoute( @@ -225,10 +238,61 @@ class _RecipesScreenState extends State { } } + Future _toggleFavorite(RecipeModel recipe) async { + if (_recipeService == null) return; + + try { + final updatedRecipe = recipe.copyWith(isFavourite: !recipe.isFavourite); + await _recipeService!.updateRecipe(updatedRecipe); + if (mounted) { + _loadRecipes(); + } + } catch (e) { + Logger.error('Failed to toggle favorite', e); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to update favorite: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } + @override Widget build(BuildContext context) { return Scaffold( - appBar: PrimaryAppBar(title: 'Recipes'), + appBar: AppBar( + title: const Text('Recipes'), + actions: [ + // View mode toggle icons + IconButton( + icon: Icon( + _isCompactMode ? Icons.view_list : Icons.view_list_outlined, + color: _isCompactMode ? Theme.of(context).primaryColor : Colors.grey, + ), + onPressed: () { + setState(() { + _isCompactMode = true; + }); + }, + tooltip: 'List View', + ), + IconButton( + icon: Icon( + !_isCompactMode ? Icons.grid_view : Icons.grid_view_outlined, + color: !_isCompactMode ? Theme.of(context).primaryColor : Colors.grey, + ), + onPressed: () { + setState(() { + _isCompactMode = false; + }); + }, + tooltip: 'Grid View', + ), + ], + ), body: _buildBody(), ); } @@ -345,6 +409,9 @@ class _RecipesScreenState extends State { final recipe = _recipes[index]; return _RecipeCard( recipe: recipe, + isCompact: _isCompactMode, + onTap: () => _viewRecipe(recipe), + onFavorite: () => _toggleFavorite(recipe), onEdit: () => _editRecipe(recipe), onDelete: () => _deleteRecipe(recipe), ); @@ -357,11 +424,17 @@ class _RecipesScreenState extends State { /// Card widget for displaying a recipe. class _RecipeCard extends StatelessWidget { final RecipeModel recipe; + final bool isCompact; + final VoidCallback onTap; + final VoidCallback onFavorite; final VoidCallback onEdit; final VoidCallback onDelete; const _RecipeCard({ required this.recipe, + required this.isCompact, + required this.onTap, + required this.onFavorite, required this.onEdit, required this.onDelete, }); @@ -432,48 +505,279 @@ class _RecipeCard extends StatelessWidget { ); } + /// Builds a mosaic-style image grid for multiple images. + Widget _buildMosaicImages(List imageUrls) { + if (imageUrls.isEmpty) return const SizedBox.shrink(); + if (imageUrls.length == 1) { + return ClipRRect( + borderRadius: const BorderRadius.vertical(top: Radius.circular(12)), + child: AspectRatio( + aspectRatio: 16 / 9, + child: _buildRecipeImage(imageUrls.first), + ), + ); + } + + // Mosaic layout for multiple images - no gaps between images + return ClipRRect( + borderRadius: const BorderRadius.vertical(top: Radius.circular(12)), + child: LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth; + final height = width * 0.6; // 3:5 aspect ratio for mosaic + + if (imageUrls.length == 2) { + // Two images side by side - no gap + return SizedBox( + height: height, + child: Row( + children: [ + Expanded( + child: ClipRect( + child: _buildRecipeImage(imageUrls[0]), + ), + ), + Expanded( + child: ClipRect( + child: _buildRecipeImage(imageUrls[1]), + ), + ), + ], + ), + ); + } else if (imageUrls.length == 3) { + // One large on left, two stacked on right - no gap + return SizedBox( + height: height, + child: Row( + children: [ + Expanded( + flex: 2, + child: ClipRect( + child: _buildRecipeImage(imageUrls[0]), + ), + ), + Expanded( + child: Column( + children: [ + Expanded( + child: ClipRect( + child: _buildRecipeImage(imageUrls[1]), + ), + ), + Expanded( + child: ClipRect( + child: _buildRecipeImage(imageUrls[2]), + ), + ), + ], + ), + ), + ], + ), + ); + } else { + // 4+ images: 2x2 grid (or more) - no gap + return SizedBox( + height: height, + child: GridView.builder( + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + padding: EdgeInsets.zero, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + crossAxisSpacing: 0, + mainAxisSpacing: 0, + childAspectRatio: 1.0, + ), + itemCount: imageUrls.length > 4 ? 4 : imageUrls.length, + itemBuilder: (context, index) { + return ClipRect( + child: Stack( + fit: StackFit.expand, + children: [ + _buildRecipeImage(imageUrls[index]), + if (index == 3 && imageUrls.length > 4) + Container( + color: Colors.black.withOpacity(0.5), + child: Center( + child: Text( + '+${imageUrls.length - 4}', + style: const TextStyle( + color: Colors.white, + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ], + ), + ); + }, + ), + ); + } + }, + ), + ); + } + @override Widget build(BuildContext context) { + if (isCompact) { + return _buildCompactCard(context); + } else { + return _buildExpandedCard(context); + } + } + + Widget _buildCompactCard(BuildContext context) { + return Card( + margin: const EdgeInsets.only(bottom: 8), + elevation: 1, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.all(8), + child: Row( + children: [ + // Compact image + if (recipe.imageUrls.isNotEmpty) + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: SizedBox( + width: 80, + height: 80, + child: _buildRecipeImage(recipe.imageUrls.first), + ), + ), + if (recipe.imageUrls.isNotEmpty) const SizedBox(width: 12), + // Compact content + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Expanded( + child: Text( + recipe.title, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (recipe.rating > 0) + Row( + mainAxisSize: MainAxisSize.min, + children: List.generate(5, (index) { + return Icon( + index < recipe.rating + ? Icons.star + : Icons.star_border, + size: 12, + color: index < recipe.rating + ? Colors.amber + : Colors.grey, + ); + }), + ), + ], + ), + if (recipe.tags.isNotEmpty) ...[ + const SizedBox(height: 4), + Wrap( + spacing: 4, + runSpacing: 4, + children: recipe.tags.take(2).map((tag) { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + tag, + style: const TextStyle(fontSize: 10), + ), + ); + }).toList(), + ), + ], + ], + ), + ), + // Favorite button + IconButton( + icon: Icon( + recipe.isFavourite ? Icons.favorite : Icons.favorite_border, + size: 20, + color: recipe.isFavourite ? Colors.red : Colors.grey, + ), + onPressed: onFavorite, + tooltip: recipe.isFavourite ? 'Remove from favorites' : 'Add to favorites', + ), + // Compact actions + PopupMenuButton( + icon: const Icon(Icons.more_vert, size: 20), + itemBuilder: (context) => [ + PopupMenuItem( + child: const Row( + children: [ + Icon(Icons.edit, size: 18), + SizedBox(width: 8), + Text('Edit'), + ], + ), + onTap: () => Future.delayed( + const Duration(milliseconds: 100), + onEdit, + ), + ), + PopupMenuItem( + child: const Row( + children: [ + Icon(Icons.delete, size: 18, color: Colors.red), + SizedBox(width: 8), + Text('Delete', style: TextStyle(color: Colors.red)), + ], + ), + onTap: () => Future.delayed( + const Duration(milliseconds: 100), + onDelete, + ), + ), + ], + ), + ], + ), + ), + ), + ); + } + + Widget _buildExpandedCard(BuildContext context) { return Card( margin: const EdgeInsets.only(bottom: 16), elevation: 2, child: InkWell( - onTap: onEdit, + onTap: onTap, borderRadius: BorderRadius.circular(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Image section + // Image section with mosaic for multiple images if (recipe.imageUrls.isNotEmpty) - ClipRRect( - borderRadius: const BorderRadius.vertical(top: Radius.circular(12)), - child: AspectRatio( - aspectRatio: 16 / 9, - child: Stack( - fit: StackFit.expand, - children: [ - _buildRecipeImage(recipe.imageUrls.first), - if (recipe.isFavourite) - Positioned( - top: 8, - right: 8, - child: Container( - padding: const EdgeInsets.all(6), - decoration: BoxDecoration( - color: Colors.red.withOpacity(0.8), - shape: BoxShape.circle, - ), - child: const Icon( - Icons.favorite, - color: Colors.white, - size: 20, - ), - ), - ), - ], - ), - ), - ), + _buildMosaicImages(recipe.imageUrls), // Content section Padding( padding: const EdgeInsets.all(16), @@ -541,6 +845,14 @@ class _RecipeCard extends StatelessWidget { Row( mainAxisAlignment: MainAxisAlignment.end, children: [ + IconButton( + icon: Icon( + recipe.isFavourite ? Icons.favorite : Icons.favorite_border, + color: recipe.isFavourite ? Colors.red : Colors.grey, + ), + onPressed: onFavorite, + tooltip: recipe.isFavourite ? 'Remove from favorites' : 'Add to favorites', + ), IconButton( icon: const Icon(Icons.edit), onPressed: onEdit, diff --git a/lib/ui/session/session_screen.dart b/lib/ui/session/session_screen.dart index 065fc3c..d11138c 100644 --- a/lib/ui/session/session_screen.dart +++ b/lib/ui/session/session_screen.dart @@ -3,7 +3,7 @@ import '../../core/logger.dart'; import '../../core/service_locator.dart'; import '../../data/nostr/nostr_service.dart'; import '../../data/nostr/models/nostr_keypair.dart'; -import '../shared/primary_app_bar.dart'; +import '../navigation/app_router.dart'; /// Screen for user session management (login/logout). class SessionScreen extends StatefulWidget { @@ -295,8 +295,17 @@ class _SessionScreenState extends State { final currentUser = ServiceLocator.instance.sessionService?.currentUser; return Scaffold( - appBar: PrimaryAppBar( - title: 'User', + appBar: AppBar( + title: const Text('User'), + actions: [ + IconButton( + icon: const Icon(Icons.settings), + tooltip: 'Relay Management', + onPressed: () { + Navigator.pushNamed(context, AppRoutes.relayManagement); + }, + ), + ], ), body: _isLoading ? const Center(child: CircularProgressIndicator()) diff --git a/lib/ui/shared/primary_app_bar.dart b/lib/ui/shared/primary_app_bar.dart index 1d8b1f0..d1cf09c 100644 --- a/lib/ui/shared/primary_app_bar.dart +++ b/lib/ui/shared/primary_app_bar.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import '../navigation/app_router.dart'; /// Primary AppBar widget with settings icon for all main screens. class PrimaryAppBar extends StatelessWidget implements PreferredSizeWidget { @@ -14,15 +13,6 @@ class PrimaryAppBar extends StatelessWidget implements PreferredSizeWidget { Widget build(BuildContext context) { return AppBar( title: Text(title), - actions: [ - IconButton( - icon: const Icon(Icons.settings), - tooltip: 'Relay Management', - onPressed: () { - Navigator.pushNamed(context, AppRoutes.relayManagement); - }, - ), - ], ); }