From 72aedb6c401632502f0269e5ce16463225417118 Mon Sep 17 00:00:00 2001 From: gitea Date: Tue, 11 Nov 2025 16:43:59 +0100 Subject: [PATCH] bookmarks feature --- .../models/bookmark_category_model.dart | 90 ++++ lib/data/recipes/recipe_service.dart | 289 ++++++++++++- lib/ui/add_recipe/add_recipe_screen.dart | 2 + lib/ui/bookmarks/bookmarks_screen.dart | 406 ++++++++++++++++++ lib/ui/favourites/favourites_screen.dart | 11 + .../navigation/main_navigation_scaffold.dart | 101 +++-- lib/ui/recipes/bookmark_dialog.dart | 227 ++++++++++ lib/ui/recipes/recipes_screen.dart | 109 +++++ .../relay_management_screen.dart | 2 +- lib/ui/session/session_screen.dart | 2 +- lib/ui/shared/primary_app_bar.dart | 14 +- .../main_navigation_scaffold_test.dart | 35 +- 12 files changed, 1233 insertions(+), 55 deletions(-) create mode 100644 lib/data/recipes/models/bookmark_category_model.dart create mode 100644 lib/ui/bookmarks/bookmarks_screen.dart create mode 100644 lib/ui/recipes/bookmark_dialog.dart diff --git a/lib/data/recipes/models/bookmark_category_model.dart b/lib/data/recipes/models/bookmark_category_model.dart new file mode 100644 index 0000000..4d2c380 --- /dev/null +++ b/lib/data/recipes/models/bookmark_category_model.dart @@ -0,0 +1,90 @@ +import 'dart:convert'; + +/// Represents a bookmark category for organizing recipes. +class BookmarkCategory { + /// Unique identifier for the category. + final String id; + + /// Category name. + final String name; + + /// Optional color for the category (as hex string, e.g., "#FF5733"). + final String? color; + + /// Timestamp when category was created (milliseconds since epoch). + final int createdAt; + + /// Timestamp when category was last updated (milliseconds since epoch). + final int updatedAt; + + BookmarkCategory({ + required this.id, + required this.name, + this.color, + int? createdAt, + int? updatedAt, + }) : createdAt = createdAt ?? DateTime.now().millisecondsSinceEpoch, + updatedAt = updatedAt ?? DateTime.now().millisecondsSinceEpoch; + + factory BookmarkCategory.fromMap(Map map) { + return BookmarkCategory( + id: map['id'] as String, + name: map['name'] as String, + color: map['color'] as String?, + createdAt: map['created_at'] as int, + updatedAt: map['updated_at'] as int, + ); + } + + Map toMap() { + return { + 'id': id, + 'name': name, + 'color': color, + 'created_at': createdAt, + 'updated_at': updatedAt, + }; + } + + factory BookmarkCategory.fromJson(Map json) { + return BookmarkCategory( + id: json['id'] as String, + name: json['name'] as String, + color: json['color'] as String?, + createdAt: json['createdAt'] as int? ?? DateTime.now().millisecondsSinceEpoch, + updatedAt: json['updatedAt'] as int? ?? DateTime.now().millisecondsSinceEpoch, + ); + } + + Map toJson() { + return { + 'id': id, + 'name': name, + if (color != null) 'color': color, + 'createdAt': createdAt, + 'updatedAt': updatedAt, + }; + } + + BookmarkCategory copyWith({ + String? id, + String? name, + String? color, + int? createdAt, + int? updatedAt, + }) { + return BookmarkCategory( + id: id ?? this.id, + name: name ?? this.name, + color: color ?? this.color, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + } + + @override + String toString() { + return 'BookmarkCategory(id: $id, name: $name)'; + } +} + diff --git a/lib/data/recipes/recipe_service.dart b/lib/data/recipes/recipe_service.dart index b9a77be..6d215ae 100644 --- a/lib/data/recipes/recipe_service.dart +++ b/lib/data/recipes/recipe_service.dart @@ -10,6 +10,7 @@ import '../nostr/models/nostr_keypair.dart'; import '../../core/logger.dart'; import '../../core/service_locator.dart'; import 'models/recipe_model.dart'; +import 'models/bookmark_category_model.dart'; /// Service for managing recipes with local storage and Nostr sync. class RecipeService { @@ -65,8 +66,9 @@ class RecipeService { // Open database _db = await openDatabase( dbPath, - version: 1, + version: 2, // Incremented for bookmark tables onCreate: _onCreate, + onUpgrade: _onUpgrade, ); // Store the current database path @@ -90,6 +92,7 @@ class RecipeService { /// Creates the recipes table if it doesn't exist. Future _onCreate(Database db, int version) async { + // Recipes table await db.execute(''' CREATE TABLE IF NOT EXISTS recipes ( id TEXT PRIMARY KEY, @@ -106,13 +109,78 @@ class RecipeService { ) '''); - // Create index for faster queries + // Bookmark categories table + await db.execute(''' + CREATE TABLE IF NOT EXISTS bookmark_categories ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + color TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + '''); + + // Junction table for recipe-bookmark relationships + await db.execute(''' + CREATE TABLE IF NOT EXISTS recipe_bookmarks ( + recipe_id TEXT NOT NULL, + category_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + PRIMARY KEY (recipe_id, category_id), + FOREIGN KEY (recipe_id) REFERENCES recipes(id) ON DELETE CASCADE, + FOREIGN KEY (category_id) REFERENCES bookmark_categories(id) ON DELETE CASCADE + ) + '''); + + // Create indexes await db.execute(''' CREATE INDEX IF NOT EXISTS idx_recipes_created_at ON recipes(created_at DESC) '''); await db.execute(''' CREATE INDEX IF NOT EXISTS idx_recipes_is_deleted ON recipes(is_deleted) '''); + await db.execute(''' + CREATE INDEX IF NOT EXISTS idx_recipe_bookmarks_recipe_id ON recipe_bookmarks(recipe_id) + '''); + await db.execute(''' + CREATE INDEX IF NOT EXISTS idx_recipe_bookmarks_category_id ON recipe_bookmarks(category_id) + '''); + } + + /// Handles database migrations when version changes. + Future _onUpgrade(Database db, int oldVersion, int newVersion) async { + if (oldVersion < 2) { + // Migration to version 2: Add bookmark tables + await db.execute(''' + CREATE TABLE IF NOT EXISTS bookmark_categories ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + color TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + '''); + + await db.execute(''' + CREATE TABLE IF NOT EXISTS recipe_bookmarks ( + recipe_id TEXT NOT NULL, + category_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + PRIMARY KEY (recipe_id, category_id), + FOREIGN KEY (recipe_id) REFERENCES recipes(id) ON DELETE CASCADE, + FOREIGN KEY (category_id) REFERENCES bookmark_categories(id) ON DELETE CASCADE + ) + '''); + + await db.execute(''' + CREATE INDEX IF NOT EXISTS idx_recipe_bookmarks_recipe_id ON recipe_bookmarks(recipe_id) + '''); + await db.execute(''' + CREATE INDEX IF NOT EXISTS idx_recipe_bookmarks_category_id ON recipe_bookmarks(category_id) + '''); + + Logger.info('Database migrated from version $oldVersion to $newVersion'); + } } /// Gets the path to the database file. @@ -461,6 +529,223 @@ class RecipeService { } } + // ==================== Bookmark Category Methods ==================== + + /// Creates a new bookmark category. + /// + /// [name] - The name of the category. + /// [color] - Optional color for the category (hex string). + /// + /// Returns the created category. + /// + /// Throws [Exception] if creation fails. + Future createBookmarkCategory({ + required String name, + String? color, + }) async { + await _ensureInitializedOrReinitialize(); + try { + final category = BookmarkCategory( + id: 'category_${DateTime.now().millisecondsSinceEpoch}', + name: name, + color: color, + ); + + await _db!.insert( + 'bookmark_categories', + category.toMap(), + conflictAlgorithm: ConflictAlgorithm.replace, + ); + + Logger.info('Bookmark category created: ${category.id}'); + return category; + } catch (e) { + throw Exception('Failed to create bookmark category: $e'); + } + } + + /// Gets all bookmark categories. + /// + /// Returns a list of all bookmark categories, ordered by name. + /// + /// Throws [Exception] if retrieval fails. + Future> getAllBookmarkCategories() async { + await _ensureInitializedOrReinitialize(); + try { + final List> maps = await _db!.query( + 'bookmark_categories', + orderBy: 'name ASC', + ); + return maps.map((map) => BookmarkCategory.fromMap(map)).toList(); + } catch (e) { + throw Exception('Failed to get bookmark categories: $e'); + } + } + + /// Deletes a bookmark category and removes all recipe associations. + /// + /// [categoryId] - The ID of the category to delete. + /// + /// Throws [Exception] if deletion fails. + Future deleteBookmarkCategory(String categoryId) async { + await _ensureInitializedOrReinitialize(); + try { + // Cascade delete will handle recipe_bookmarks entries + await _db!.delete( + 'bookmark_categories', + where: 'id = ?', + whereArgs: [categoryId], + ); + Logger.info('Bookmark category deleted: $categoryId'); + } catch (e) { + throw Exception('Failed to delete bookmark category: $e'); + } + } + + /// Updates a bookmark category. + /// + /// [category] - The category with updated data. + /// + /// Throws [Exception] if update fails. + Future updateBookmarkCategory(BookmarkCategory category) async { + await _ensureInitializedOrReinitialize(); + try { + final updatedCategory = category.copyWith( + updatedAt: DateTime.now().millisecondsSinceEpoch, + ); + + await _db!.update( + 'bookmark_categories', + updatedCategory.toMap(), + where: 'id = ?', + whereArgs: [category.id], + ); + Logger.info('Bookmark category updated: ${category.id}'); + } catch (e) { + throw Exception('Failed to update bookmark category: $e'); + } + } + + /// Adds a recipe to a bookmark category. + /// + /// [recipeId] - The ID of the recipe to bookmark. + /// [categoryId] - The ID of the category to add the recipe to. + /// + /// Throws [Exception] if operation fails. + Future addRecipeToCategory({ + required String recipeId, + required String categoryId, + }) async { + await _ensureInitializedOrReinitialize(); + try { + await _db!.insert( + 'recipe_bookmarks', + { + 'recipe_id': recipeId, + 'category_id': categoryId, + 'created_at': DateTime.now().millisecondsSinceEpoch, + }, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + Logger.info('Recipe $recipeId added to category $categoryId'); + } catch (e) { + throw Exception('Failed to add recipe to category: $e'); + } + } + + /// Removes a recipe from a bookmark category. + /// + /// [recipeId] - The ID of the recipe to unbookmark. + /// [categoryId] - The ID of the category to remove the recipe from. + /// + /// Throws [Exception] if operation fails. + Future removeRecipeFromCategory({ + required String recipeId, + required String categoryId, + }) async { + await _ensureInitializedOrReinitialize(); + try { + await _db!.delete( + 'recipe_bookmarks', + where: 'recipe_id = ? AND category_id = ?', + whereArgs: [recipeId, categoryId], + ); + Logger.info('Recipe $recipeId removed from category $categoryId'); + } catch (e) { + throw Exception('Failed to remove recipe from category: $e'); + } + } + + /// Gets all categories for a specific recipe. + /// + /// [recipeId] - The ID of the recipe. + /// + /// Returns a list of categories the recipe belongs to. + /// + /// Throws [Exception] if retrieval fails. + Future> getCategoriesForRecipe(String recipeId) async { + await _ensureInitializedOrReinitialize(); + try { + final List> maps = await _db!.rawQuery(''' + SELECT bc.* + FROM bookmark_categories bc + INNER JOIN recipe_bookmarks rb ON bc.id = rb.category_id + WHERE rb.recipe_id = ? + ORDER BY bc.name ASC + ''', [recipeId]); + + return maps.map((map) => BookmarkCategory.fromMap(map)).toList(); + } catch (e) { + throw Exception('Failed to get categories for recipe: $e'); + } + } + + /// Gets all recipes in a specific category. + /// + /// [categoryId] - The ID of the category. + /// + /// Returns a list of recipes in the category, ordered by creation date (newest first). + /// + /// Throws [Exception] if retrieval fails. + Future> getRecipesByCategory(String categoryId) async { + await _ensureInitializedOrReinitialize(); + try { + final List> maps = await _db!.rawQuery(''' + SELECT r.* + FROM recipes r + INNER JOIN recipe_bookmarks rb ON r.id = rb.recipe_id + WHERE rb.category_id = ? AND r.is_deleted = 0 + ORDER BY r.created_at DESC + ''', [categoryId]); + + return maps.map((map) => RecipeModel.fromMap(map)).toList(); + } catch (e) { + throw Exception('Failed to get recipes by category: $e'); + } + } + + /// Checks if a recipe is bookmarked in any category. + /// + /// [recipeId] - The ID of the recipe to check. + /// + /// Returns true if the recipe is in at least one category, false otherwise. + /// + /// Throws [Exception] if check fails. + Future isRecipeBookmarked(String recipeId) async { + await _ensureInitializedOrReinitialize(); + try { + final List> maps = await _db!.query( + 'recipe_bookmarks', + where: 'recipe_id = ?', + whereArgs: [recipeId], + limit: 1, + ); + return maps.isNotEmpty; + } catch (e) { + throw Exception('Failed to check if recipe is bookmarked: $e'); + } + } + /// Fetches recipes from Nostr for a given public key. /// /// Queries kind 30000 events (NIP-33 parameterized replaceable events) diff --git a/lib/ui/add_recipe/add_recipe_screen.dart b/lib/ui/add_recipe/add_recipe_screen.dart index 785702d..7cc9828 100644 --- a/lib/ui/add_recipe/add_recipe_screen.dart +++ b/lib/ui/add_recipe/add_recipe_screen.dart @@ -207,6 +207,7 @@ class _AddRecipeScreenState extends State { SnackBar( content: Text('${uploadedUrls.length} image(s) uploaded successfully'), backgroundColor: Colors.green, + duration: const Duration(seconds: 1), ), ); } @@ -302,6 +303,7 @@ class _AddRecipeScreenState extends State { ? 'Recipe updated successfully' : 'Recipe added successfully'), backgroundColor: Colors.green, + duration: const Duration(seconds: 1), ), ); diff --git a/lib/ui/bookmarks/bookmarks_screen.dart b/lib/ui/bookmarks/bookmarks_screen.dart new file mode 100644 index 0000000..c88299a --- /dev/null +++ b/lib/ui/bookmarks/bookmarks_screen.dart @@ -0,0 +1,406 @@ +import 'dart:typed_data'; +import 'package:flutter/material.dart'; +import '../../core/service_locator.dart'; +import '../../core/logger.dart'; +import '../../data/recipes/recipe_service.dart'; +import '../../data/recipes/models/bookmark_category_model.dart'; +import '../../data/recipes/models/recipe_model.dart'; +import '../add_recipe/add_recipe_screen.dart'; +import '../photo_gallery/photo_gallery_screen.dart'; +import '../navigation/main_navigation_scaffold.dart'; + +/// Bookmarks screen displaying all bookmark categories and their recipes. +class BookmarksScreen extends StatefulWidget { + const BookmarksScreen({super.key}); + + @override + State createState() => _BookmarksScreenState(); +} + +class _BookmarksScreenState extends State { + List _categories = []; + Map> _recipesByCategory = {}; + bool _isLoading = false; + String? _errorMessage; + RecipeService? _recipeService; + bool _wasLoggedIn = false; + + @override + void initState() { + super.initState(); + _checkLoginState(); + _initializeService(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _checkLoginState(); + if (_recipeService != null && _wasLoggedIn) { + _loadBookmarks(); + } + } + + @override + void didUpdateWidget(BookmarksScreen oldWidget) { + super.didUpdateWidget(oldWidget); + _checkLoginState(); + if (_recipeService != null && _wasLoggedIn) { + _loadBookmarks(); + } + } + + void _checkLoginState() { + final sessionService = ServiceLocator.instance.sessionService; + final isLoggedIn = sessionService?.isLoggedIn ?? false; + + if (isLoggedIn != _wasLoggedIn) { + _wasLoggedIn = isLoggedIn; + if (mounted) { + setState(() {}); + if (isLoggedIn && _recipeService != null) { + _loadBookmarks(); + } + } + } + } + + Future _initializeService() async { + try { + _recipeService = ServiceLocator.instance.recipeService; + if (_recipeService == null) { + throw Exception('RecipeService not available in ServiceLocator'); + } + } catch (e) { + Logger.error('Failed to initialize RecipeService', e); + if (mounted) { + setState(() { + _errorMessage = 'Failed to initialize recipe service: $e'; + }); + } + } + } + + Future _loadBookmarks() async { + if (_recipeService == null) return; + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final categories = await _recipeService!.getAllBookmarkCategories(); + final recipesMap = >{}; + + for (final category in categories) { + final recipes = await _recipeService!.getRecipesByCategory(category.id); + recipesMap[category.id] = recipes; + } + + if (mounted) { + setState(() { + _categories = categories; + _recipesByCategory = recipesMap; + _isLoading = false; + }); + } + } catch (e) { + Logger.error('Failed to load bookmarks', e); + if (mounted) { + setState(() { + _isLoading = false; + _errorMessage = 'Failed to load bookmarks: $e'; + }); + } + } + } + + Color _parseColor(String hexColor) { + try { + return Color(int.parse(hexColor.replaceAll('#', '0xFF'))); + } catch (e) { + return Colors.blue; + } + } + + void _viewRecipe(RecipeModel recipe) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => AddRecipeScreen( + recipe: recipe, + viewMode: true, + ), + ), + ).then((_) { + // Reload bookmarks when returning from recipe view + _loadBookmarks(); + }); + } + + void _openPhotoGallery(List imageUrls, int initialIndex) { + if (imageUrls.isEmpty) return; + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => PhotoGalleryScreen( + imageUrls: imageUrls, + initialIndex: initialIndex, + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final isLoggedIn = ServiceLocator.instance.sessionService?.isLoggedIn ?? false; + + if (!isLoggedIn) { + return Scaffold( + appBar: AppBar(title: const Text('Bookmarks')), + body: const Center( + child: Text('Please log in to view bookmarks'), + ), + ); + } + + if (_isLoading) { + return Scaffold( + appBar: AppBar(title: const Text('Bookmarks')), + body: const Center(child: CircularProgressIndicator()), + ); + } + + if (_errorMessage != null) { + return Scaffold( + appBar: AppBar(title: const Text('Bookmarks')), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(_errorMessage!), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _loadBookmarks, + child: const Text('Retry'), + ), + ], + ), + ), + ); + } + + if (_categories.isEmpty) { + return Scaffold( + appBar: AppBar(title: const Text('Bookmarks')), + body: const Center( + child: Text( + 'No bookmark categories yet.\nBookmark a recipe to get started!', + textAlign: TextAlign.center, + ), + ), + ); + } + + return Scaffold( + appBar: AppBar( + title: const Text('Bookmarks'), + actions: [ + IconButton( + icon: const Icon(Icons.person), + tooltip: 'User', + onPressed: () { + final scaffold = context.findAncestorStateOfType(); + scaffold?.navigateToUser(); + }, + ), + ], + ), + body: RefreshIndicator( + onRefresh: _loadBookmarks, + child: ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: _categories.length, + itemBuilder: (context, index) { + final category = _categories[index]; + final recipes = _recipesByCategory[category.id] ?? []; + + return Card( + margin: const EdgeInsets.only(bottom: 16), + child: ExpansionTile( + leading: category.color != null + ? Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: _parseColor(category.color!), + shape: BoxShape.circle, + ), + ) + : const Icon(Icons.bookmark), + title: Text( + category.name, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: Text('${recipes.length} recipe(s)'), + children: recipes.isEmpty + ? [ + const ListTile( + title: Text('No recipes in this category'), + ) + ] + : recipes.map((recipe) { + return _BookmarkRecipeItem( + recipe: recipe, + onTap: () => _viewRecipe(recipe), + onPhotoTap: (index) => _openPhotoGallery( + recipe.imageUrls, + index, + ), + ); + }).toList(), + ), + ); + }, + ), + ), + ); + } +} + +/// Widget for displaying a recipe item in a bookmark category. +class _BookmarkRecipeItem extends StatelessWidget { + final RecipeModel recipe; + final VoidCallback onTap; + final ValueChanged onPhotoTap; + + const _BookmarkRecipeItem({ + required this.recipe, + required this.onTap, + required this.onPhotoTap, + }); + + /// 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: 32), + ); + } + + return Image.memory( + snapshot.data!, + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + ); + }, + ); + } + } + + return Image.network( + imageUrl, + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + errorBuilder: (context, error, stackTrace) { + return Container( + color: Colors.grey.shade200, + child: const Icon(Icons.broken_image, size: 32), + ); + }, + 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, + ), + ), + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + return ListTile( + leading: recipe.imageUrls.isNotEmpty + ? GestureDetector( + onTap: () => onPhotoTap(0), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: SizedBox( + width: 60, + height: 60, + child: _buildRecipeImage(recipe.imageUrls.first), + ), + ), + ) + : Container( + width: 60, + height: 60, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(8), + ), + child: const Icon(Icons.restaurant_menu), + ), + title: Text( + recipe.title, + style: const TextStyle(fontWeight: FontWeight.w500), + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (recipe.description != null && recipe.description!.isNotEmpty) + Text( + recipe.description!, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Row( + children: [ + const Icon(Icons.star, size: 16, color: Colors.amber), + const SizedBox(width: 4), + Text(recipe.rating.toString()), + if (recipe.isFavourite) ...[ + const SizedBox(width: 12), + const Icon(Icons.favorite, size: 16, color: Colors.red), + ], + ], + ), + ], + ), + onTap: onTap, + ); + } +} + diff --git a/lib/ui/favourites/favourites_screen.dart b/lib/ui/favourites/favourites_screen.dart index 8ec694e..c8b59e4 100644 --- a/lib/ui/favourites/favourites_screen.dart +++ b/lib/ui/favourites/favourites_screen.dart @@ -6,6 +6,7 @@ import '../../data/recipes/recipe_service.dart'; import '../../data/recipes/models/recipe_model.dart'; import '../add_recipe/add_recipe_screen.dart'; import '../photo_gallery/photo_gallery_screen.dart'; +import '../navigation/main_navigation_scaffold.dart'; /// Favourites screen displaying user's favorite recipes. class FavouritesScreen extends StatefulWidget { @@ -142,6 +143,7 @@ class _FavouritesScreenState extends State { const SnackBar( content: Text('Recipe deleted successfully'), backgroundColor: Colors.green, + duration: Duration(seconds: 1), ), ); _loadFavourites(); @@ -210,6 +212,15 @@ class _FavouritesScreenState extends State { appBar: AppBar( title: const Text('Favourites'), actions: [ + // User icon + IconButton( + icon: const Icon(Icons.person), + tooltip: 'User', + onPressed: () { + final scaffold = context.findAncestorStateOfType(); + scaffold?.navigateToUser(); + }, + ), // View mode toggle icons IconButton( icon: Icon( diff --git a/lib/ui/navigation/main_navigation_scaffold.dart b/lib/ui/navigation/main_navigation_scaffold.dart index 65beb99..85e2225 100644 --- a/lib/ui/navigation/main_navigation_scaffold.dart +++ b/lib/ui/navigation/main_navigation_scaffold.dart @@ -5,6 +5,7 @@ import '../../core/service_locator.dart'; import '../home/home_screen.dart'; import '../recipes/recipes_screen.dart'; import '../favourites/favourites_screen.dart'; +import '../bookmarks/bookmarks_screen.dart'; import '../session/session_screen.dart'; import 'app_router.dart'; @@ -13,10 +14,10 @@ class MainNavigationScaffold extends StatefulWidget { const MainNavigationScaffold({super.key}); @override - State createState() => _MainNavigationScaffoldState(); + State createState() => MainNavigationScaffoldState(); } -class _MainNavigationScaffoldState extends State { +class MainNavigationScaffoldState extends State { int _currentIndex = 0; bool _wasLoggedIn = false; @@ -70,8 +71,8 @@ class _MainNavigationScaffoldState extends State { void _onItemTapped(int index) { final isLoggedIn = _isLoggedIn; - // Only allow navigation to Recipes (1) and Favourites (2) if logged in - if (!isLoggedIn && (index == 1 || index == 2)) { + // Only allow navigation to Recipes (1), Favourites (2), and Bookmarks (4) if logged in + if (!isLoggedIn && (index == 1 || index == 2 || index == 4)) { // Redirect to User/Session tab to prompt login setState(() { _currentIndex = 3; // User/Session tab @@ -84,6 +85,13 @@ class _MainNavigationScaffoldState extends State { }); } + /// Public method to navigate to User screen from AppBar. + void navigateToUser() { + setState(() { + _currentIndex = 3; // User/Session tab + }); + } + void _onAddRecipePressed(BuildContext context) async { final isLoggedIn = _isLoggedIn; @@ -96,7 +104,7 @@ class _MainNavigationScaffoldState extends State { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Please log in to add recipes'), - duration: Duration(seconds: 2), + duration: Duration(seconds: 1), ), ); return; @@ -131,6 +139,10 @@ class _MainNavigationScaffoldState extends State { return FavouritesScreen(key: ValueKey(_isLoggedIn)); case 3: return SessionScreen(onSessionChanged: _onSessionChanged); + case 4: + // Bookmarks - only show if logged in + // Use a key based on login state to force rebuild when login changes + return BookmarksScreen(key: ValueKey(_isLoggedIn)); default: return const SizedBox(); } @@ -144,8 +156,8 @@ class _MainNavigationScaffoldState extends State { // Check login state in build to trigger rebuilds _checkLoginState(); - // Ensure current index is valid (if logged out, don't allow Recipes/Favourites) - if (!isLoggedIn && (_currentIndex == 1 || _currentIndex == 2)) { + // Ensure current index is valid (if logged out, don't allow Recipes/Favourites/Bookmarks) + if (!isLoggedIn && (_currentIndex == 1 || _currentIndex == 2 || _currentIndex == 4)) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { setState(() { @@ -158,7 +170,7 @@ class _MainNavigationScaffoldState extends State { return Scaffold( body: IndexedStack( index: _currentIndex, - children: List.generate(4, (index) => _buildScreen(index)), + children: List.generate(5, (index) => _buildScreen(index)), ), bottomNavigationBar: _buildCustomBottomNav(context), ); @@ -184,23 +196,31 @@ class _MainNavigationScaffoldState extends State { height: kBottomNavigationBarHeight, padding: const EdgeInsets.symmetric(horizontal: 8), child: Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - // Home - _buildNavItem( - icon: Icons.home, - label: 'Home', - index: 0, - onTap: () => _onItemTapped(0), - ), - // Recipes - only show if logged in - if (isLoggedIn) - _buildNavItem( - icon: Icons.menu_book, - label: 'Recipes', - index: 1, - onTap: () => _onItemTapped(1), + // Left side items + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + // Home + _buildNavItem( + icon: Icons.home, + label: 'Home', + index: 0, + onTap: () => _onItemTapped(0), + ), + // Recipes - only show if logged in + if (isLoggedIn) + _buildNavItem( + icon: Icons.menu_book, + label: 'Recipes', + index: 1, + onTap: () => _onItemTapped(1), + ), + ], ), + ), // Center Add Recipe button - only show if logged in if (isLoggedIn) Container( @@ -221,20 +241,29 @@ class _MainNavigationScaffoldState extends State { ), ), ), - // Favourites - only show if logged in - if (isLoggedIn) - _buildNavItem( - icon: Icons.favorite, - label: 'Favourites', - index: 2, - onTap: () => _onItemTapped(2), + // Right side items + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + // Favourites - only show if logged in + if (isLoggedIn) + _buildNavItem( + icon: Icons.favorite, + label: 'Favourites', + index: 2, + onTap: () => _onItemTapped(2), + ), + // Bookmarks - only show if logged in + if (isLoggedIn) + _buildNavItem( + icon: Icons.bookmark, + label: 'Bookmarks', + index: 4, + onTap: () => _onItemTapped(4), + ), + ], ), - // User - _buildNavItem( - icon: Icons.person, - label: 'User', - index: 3, - onTap: () => _onItemTapped(3), ), ], ), diff --git a/lib/ui/recipes/bookmark_dialog.dart b/lib/ui/recipes/bookmark_dialog.dart new file mode 100644 index 0000000..45b04df --- /dev/null +++ b/lib/ui/recipes/bookmark_dialog.dart @@ -0,0 +1,227 @@ +import 'package:flutter/material.dart'; +import '../../core/service_locator.dart'; +import '../../core/logger.dart'; +import '../../data/recipes/recipe_service.dart'; +import '../../data/recipes/models/bookmark_category_model.dart'; + +/// Dialog for selecting or creating a bookmark category. +class BookmarkDialog extends StatefulWidget { + final String recipeId; + final List currentCategories; + + const BookmarkDialog({ + super.key, + required this.recipeId, + required this.currentCategories, + }); + + @override + State createState() => _BookmarkDialogState(); +} + +class _BookmarkDialogState extends State { + final TextEditingController _categoryNameController = TextEditingController(); + List _allCategories = []; + bool _isLoading = false; + bool _isCreating = false; + + @override + void initState() { + super.initState(); + _loadCategories(); + } + + @override + void dispose() { + _categoryNameController.dispose(); + super.dispose(); + } + + Future _loadCategories() async { + setState(() => _isLoading = true); + try { + final recipeService = ServiceLocator.instance.recipeService; + if (recipeService != null) { + final categories = await recipeService.getAllBookmarkCategories(); + setState(() { + _allCategories = categories; + _isLoading = false; + }); + } + } catch (e) { + Logger.error('Failed to load bookmark categories', e); + setState(() => _isLoading = false); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to load categories: $e')), + ); + } + } + } + + Future _createCategory() async { + final name = _categoryNameController.text.trim(); + if (name.isEmpty) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Category name cannot be empty')), + ); + } + return; + } + + setState(() => _isCreating = true); + try { + final recipeService = ServiceLocator.instance.recipeService; + if (recipeService != null) { + final category = await recipeService.createBookmarkCategory(name: name); + await recipeService.addRecipeToCategory( + recipeId: widget.recipeId, + categoryId: category.id, + ); + if (mounted) { + Navigator.of(context).pop(category); + } + } + } catch (e) { + Logger.error('Failed to create bookmark category', e); + setState(() => _isCreating = false); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to create category: $e')), + ); + } + } + } + + Future _selectCategory(BookmarkCategory category) async { + try { + final recipeService = ServiceLocator.instance.recipeService; + if (recipeService != null) { + final isInCategory = widget.currentCategories + .any((c) => c.id == category.id); + + if (isInCategory) { + // Remove from category + await recipeService.removeRecipeFromCategory( + recipeId: widget.recipeId, + categoryId: category.id, + ); + } else { + // Add to category + await recipeService.addRecipeToCategory( + recipeId: widget.recipeId, + categoryId: category.id, + ); + } + if (mounted) { + Navigator.of(context).pop(category); + } + } + } catch (e) { + Logger.error('Failed to update bookmark', e); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to update bookmark: $e')), + ); + } + } + } + + Color _parseColor(String hexColor) { + try { + return Color(int.parse(hexColor.replaceAll('#', '0xFF'))); + } catch (e) { + return Colors.blue; + } + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Bookmark Recipe'), + content: SizedBox( + width: double.maxFinite, + child: _isLoading + ? const Center(child: CircularProgressIndicator()) + : SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Existing categories + if (_allCategories.isNotEmpty) ...[ + const Text( + 'Add to category:', + style: TextStyle(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 200), + child: ListView.builder( + shrinkWrap: true, + itemCount: _allCategories.length, + itemBuilder: (context, index) { + final category = _allCategories[index]; + final isSelected = widget.currentCategories + .any((c) => c.id == category.id); + return CheckboxListTile( + title: Text(category.name), + value: isSelected, + onChanged: (_) => _selectCategory(category), + secondary: category.color != null + ? Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: _parseColor(category.color!), + shape: BoxShape.circle, + ), + ) + : const Icon(Icons.bookmark), + ); + }, + ), + ), + const SizedBox(height: 16), + ], + // Create new category + const Text( + 'Or create new category:', + style: TextStyle(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + TextField( + controller: _categoryNameController, + decoration: const InputDecoration( + hintText: 'Category name', + border: OutlineInputBorder(), + ), + enabled: !_isCreating, + onSubmitted: (_) => _createCategory(), + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + if (!_isLoading) + TextButton( + onPressed: _isCreating ? null : _createCategory, + child: _isCreating + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Create & Add'), + ), + ], + ); + } +} + diff --git a/lib/ui/recipes/recipes_screen.dart b/lib/ui/recipes/recipes_screen.dart index 682f76f..1740299 100644 --- a/lib/ui/recipes/recipes_screen.dart +++ b/lib/ui/recipes/recipes_screen.dart @@ -7,6 +7,9 @@ import '../../data/recipes/models/recipe_model.dart'; import '../../data/local/models/item.dart'; import '../add_recipe/add_recipe_screen.dart'; import '../photo_gallery/photo_gallery_screen.dart'; +import '../navigation/main_navigation_scaffold.dart'; +import 'bookmark_dialog.dart'; +import '../../data/recipes/models/bookmark_category_model.dart'; /// Recipes screen displaying user's recipe collection. class RecipesScreen extends StatefulWidget { @@ -239,6 +242,7 @@ class _RecipesScreenState extends State { const SnackBar( content: Text('Recipe deleted successfully'), backgroundColor: Colors.green, + duration: Duration(seconds: 1), ), ); _loadRecipes(); @@ -300,6 +304,44 @@ class _RecipesScreenState extends State { } } + Future _showBookmarkDialog(RecipeModel recipe) async { + try { + final recipeService = _recipeService; + if (recipeService == null) return; + + final currentCategories = await recipeService.getCategoriesForRecipe(recipe.id); + + final result = await showDialog( + context: context, + builder: (context) => BookmarkDialog( + recipeId: recipe.id, + currentCategories: currentCategories, + ), + ); + + if (result != null && mounted) { + // Reload recipes to reflect bookmark changes + await _loadRecipes(fetchFromNostr: false); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Bookmark updated'), + duration: Duration(milliseconds: 800), + ), + ); + } + } catch (e) { + Logger.error('Failed to show bookmark dialog', e); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to update bookmark: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } + @override Widget build(BuildContext context) { return Scaffold( @@ -319,6 +361,16 @@ class _RecipesScreenState extends State { : const Text('All Recipes'), elevation: 0, actions: [ + // User icon + IconButton( + icon: const Icon(Icons.person), + tooltip: 'User', + onPressed: () { + // Navigate to User screen + final scaffold = context.findAncestorStateOfType(); + scaffold?.navigateToUser(); + }, + ), IconButton( icon: Icon(_isSearching ? Icons.close : Icons.search), onPressed: () { @@ -451,6 +503,7 @@ class _RecipesScreenState extends State { isMinimal: _isMinimalView, onTap: () => _viewRecipe(recipe), onFavouriteToggle: () => _toggleFavorite(recipe), + onBookmarkToggle: () => _showBookmarkDialog(recipe), ); }, ), @@ -463,14 +516,28 @@ class _RecipeCard extends StatelessWidget { final bool isMinimal; final VoidCallback onTap; final VoidCallback onFavouriteToggle; + final VoidCallback onBookmarkToggle; const _RecipeCard({ required this.recipe, required this.isMinimal, required this.onTap, required this.onFavouriteToggle, + required this.onBookmarkToggle, }); + Future _isRecipeBookmarked(String recipeId) async { + try { + final recipeService = ServiceLocator.instance.recipeService; + if (recipeService != null) { + return await recipeService.isRecipeBookmarked(recipeId); + } + } catch (e) { + Logger.warning('Failed to check bookmark status: $e'); + } + return false; + } + void _openPhotoGallery(BuildContext context, int initialIndex) { if (recipe.imageUrls.isEmpty) return; Navigator.push( @@ -560,6 +627,27 @@ class _RecipeCard extends StatelessWidget { Row( mainAxisAlignment: MainAxisAlignment.end, children: [ + // Bookmark icon + FutureBuilder( + future: _isRecipeBookmarked(recipe.id), + builder: (context, snapshot) { + final isBookmarked = snapshot.data ?? false; + return IconButton( + icon: Icon( + isBookmarked + ? Icons.bookmark + : Icons.bookmark_border, + color: isBookmarked ? Colors.blue : Colors.grey, + size: 24, + ), + onPressed: onBookmarkToggle, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ); + }, + ), + const SizedBox(width: 4), + // Favorite icon IconButton( icon: Icon( recipe.isFavourite @@ -639,6 +727,27 @@ class _RecipeCard extends StatelessWidget { ), ), const SizedBox(width: 12), + // Bookmark icon + FutureBuilder( + future: _isRecipeBookmarked(recipe.id), + builder: (context, snapshot) { + final isBookmarked = snapshot.data ?? false; + return IconButton( + icon: Icon( + isBookmarked + ? Icons.bookmark + : Icons.bookmark_border, + color: isBookmarked ? Colors.blue : Colors.grey[600], + size: 20, + ), + onPressed: onBookmarkToggle, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ); + }, + ), + const SizedBox(width: 4), + // Favorite icon IconButton( icon: Icon( recipe.isFavourite diff --git a/lib/ui/relay_management/relay_management_screen.dart b/lib/ui/relay_management/relay_management_screen.dart index 6288ffe..d4ee793 100644 --- a/lib/ui/relay_management/relay_management_screen.dart +++ b/lib/ui/relay_management/relay_management_screen.dart @@ -282,7 +282,7 @@ class _RelayManagementScreenState extends State { const SnackBar( content: Text('Relay added and connected successfully'), backgroundColor: Colors.green, - duration: Duration(seconds: 2), + duration: Duration(seconds: 1), ), ); } else { diff --git a/lib/ui/session/session_screen.dart b/lib/ui/session/session_screen.dart index d11138c..bca7938 100644 --- a/lib/ui/session/session_screen.dart +++ b/lib/ui/session/session_screen.dart @@ -272,7 +272,7 @@ class _SessionScreenState extends State { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Session data refreshed'), - duration: Duration(seconds: 2), + duration: Duration(seconds: 1), ), ); } diff --git a/lib/ui/shared/primary_app_bar.dart b/lib/ui/shared/primary_app_bar.dart index d1cf09c..559ce6c 100644 --- a/lib/ui/shared/primary_app_bar.dart +++ b/lib/ui/shared/primary_app_bar.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; +import '../navigation/main_navigation_scaffold.dart'; -/// Primary AppBar widget with settings icon for all main screens. +/// Primary AppBar widget with user icon for all main screens. class PrimaryAppBar extends StatelessWidget implements PreferredSizeWidget { final String title; @@ -13,6 +14,17 @@ class PrimaryAppBar extends StatelessWidget implements PreferredSizeWidget { Widget build(BuildContext context) { return AppBar( title: Text(title), + actions: [ + IconButton( + icon: const Icon(Icons.person), + tooltip: 'User', + onPressed: () { + // Navigate to User screen by finding the MainNavigationScaffold + final scaffold = context.findAncestorStateOfType(); + scaffold?.navigateToUser(); + }, + ), + ], ); } diff --git a/test/ui/navigation/main_navigation_scaffold_test.dart b/test/ui/navigation/main_navigation_scaffold_test.dart index d1c0b69..d28d2e9 100644 --- a/test/ui/navigation/main_navigation_scaffold_test.dart +++ b/test/ui/navigation/main_navigation_scaffold_test.dart @@ -98,18 +98,17 @@ void main() { group('MainNavigationScaffold - Navigation', () { testWidgets('displays bottom navigation bar with correct tabs', (WidgetTester tester) async { - // When not logged in, only Home and User tabs are visible + // When not logged in, only Home tab is visible in bottom nav await tester.pumpWidget(createTestWidget()); await tester.pumpAndSettle(); // Check for navigation icons (custom bottom nav, not standard BottomNavigationBar) expect(find.byIcon(Icons.home), findsWidgets); - expect(find.byIcon(Icons.person), findsWidgets); // Check for labels expect(find.text('Home'), findsWidgets); - expect(find.text('User'), findsWidgets); + // User icon is now in AppBar, not bottom nav // Recipes, Favourites, and Add button in bottom nav are hidden when not logged in expect(find.text('Recipes'), findsNothing); expect(find.text('Favourites'), findsNothing); @@ -188,11 +187,13 @@ void main() { await tester.pumpWidget(createTestWidget()); await tester.pumpAndSettle(); - // Tap User tab - final userTab = find.text('User'); - expect(userTab, findsWidgets); - await tester.tap(userTab); - await tester.pumpAndSettle(); + // Tap User icon in AppBar (person icon) + final userIcon = find.byIcon(Icons.person); + expect(userIcon, findsWidgets); + // Tap the first person icon (should be in AppBar) + await tester.tap(userIcon.first); + await tester.pump(); // Initial pump + await tester.pump(const Duration(milliseconds: 100)); // Allow navigation // Verify User screen is shown expect(find.text('User'), findsWidgets); @@ -234,8 +235,10 @@ void main() { await tester.pump(); // Initial pump await tester.pump(const Duration(milliseconds: 100)); // Allow widget to build - // Navigate to User screen (only screen with settings icon) - await tester.tap(find.text('User')); + // Navigate to User screen via AppBar icon (only screen with settings icon) + final userIcon = find.byIcon(Icons.person); + expect(userIcon, findsWidgets); + await tester.tap(userIcon.first); await tester.pump(); // Initial pump await tester.pump(const Duration(milliseconds: 100)); // Allow navigation @@ -253,8 +256,10 @@ void main() { await tester.pump(); // Initial pump await tester.pump(const Duration(milliseconds: 100)); // Allow widget to build - // Navigate to User screen (only screen with settings icon) - await tester.tap(find.text('User')); + // Navigate to User screen via AppBar icon (only screen with settings icon) + final userIcon = find.byIcon(Icons.person); + expect(userIcon, findsWidgets); + await tester.tap(userIcon.first); await tester.pump(); // Initial pump await tester.pump(const Duration(milliseconds: 100)); // Allow navigation @@ -295,8 +300,10 @@ void main() { await tester.pump(); // Initial pump await tester.pump(const Duration(milliseconds: 100)); // Allow widget to build - // Navigate to User screen (only screen with settings icon) - await tester.tap(find.text('User')); + // Navigate to User screen via AppBar icon (only screen with settings icon) + final userIcon = find.byIcon(Icons.person); + expect(userIcon, findsWidgets); + await tester.tap(userIcon.first); await tester.pump(); // Initial pump await tester.pump(const Duration(milliseconds: 100)); // Allow navigation expect(find.byIcon(Icons.settings), findsWidgets);