bookmarks feature

master
gitea 2 months ago
parent 1e5f75efcc
commit 72aedb6c40

@ -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<String, dynamic> 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<String, dynamic> toMap() {
return {
'id': id,
'name': name,
'color': color,
'created_at': createdAt,
'updated_at': updatedAt,
};
}
factory BookmarkCategory.fromJson(Map<String, dynamic> 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<String, dynamic> 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)';
}
}

@ -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<void> _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<void> _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<BookmarkCategory> 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<List<BookmarkCategory>> getAllBookmarkCategories() async {
await _ensureInitializedOrReinitialize();
try {
final List<Map<String, dynamic>> 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<void> 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<void> 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<void> 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<void> 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<List<BookmarkCategory>> getCategoriesForRecipe(String recipeId) async {
await _ensureInitializedOrReinitialize();
try {
final List<Map<String, dynamic>> 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<List<RecipeModel>> getRecipesByCategory(String categoryId) async {
await _ensureInitializedOrReinitialize();
try {
final List<Map<String, dynamic>> 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<bool> isRecipeBookmarked(String recipeId) async {
await _ensureInitializedOrReinitialize();
try {
final List<Map<String, dynamic>> 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)

@ -207,6 +207,7 @@ class _AddRecipeScreenState extends State<AddRecipeScreen> {
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<AddRecipeScreen> {
? 'Recipe updated successfully'
: 'Recipe added successfully'),
backgroundColor: Colors.green,
duration: const Duration(seconds: 1),
),
);

@ -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<BookmarksScreen> createState() => _BookmarksScreenState();
}
class _BookmarksScreenState extends State<BookmarksScreen> {
List<BookmarkCategory> _categories = [];
Map<String, List<RecipeModel>> _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<void> _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<void> _loadBookmarks() async {
if (_recipeService == null) return;
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
final categories = await _recipeService!.getAllBookmarkCategories();
final recipesMap = <String, List<RecipeModel>>{};
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<String> 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<MainNavigationScaffoldState>();
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<int> 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<Uint8List?>(
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,
);
}
}

@ -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<FavouritesScreen> {
const SnackBar(
content: Text('Recipe deleted successfully'),
backgroundColor: Colors.green,
duration: Duration(seconds: 1),
),
);
_loadFavourites();
@ -210,6 +212,15 @@ class _FavouritesScreenState extends State<FavouritesScreen> {
appBar: AppBar(
title: const Text('Favourites'),
actions: [
// User icon
IconButton(
icon: const Icon(Icons.person),
tooltip: 'User',
onPressed: () {
final scaffold = context.findAncestorStateOfType<MainNavigationScaffoldState>();
scaffold?.navigateToUser();
},
),
// View mode toggle icons
IconButton(
icon: Icon(

@ -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<MainNavigationScaffold> createState() => _MainNavigationScaffoldState();
State<MainNavigationScaffold> createState() => MainNavigationScaffoldState();
}
class _MainNavigationScaffoldState extends State<MainNavigationScaffold> {
class MainNavigationScaffoldState extends State<MainNavigationScaffold> {
int _currentIndex = 0;
bool _wasLoggedIn = false;
@ -70,8 +71,8 @@ class _MainNavigationScaffoldState extends State<MainNavigationScaffold> {
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<MainNavigationScaffold> {
});
}
/// 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<MainNavigationScaffold> {
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<MainNavigationScaffold> {
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<MainNavigationScaffold> {
// 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<MainNavigationScaffold> {
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<MainNavigationScaffold> {
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<MainNavigationScaffold> {
),
),
),
// 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),
),
],
),

@ -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<BookmarkCategory> currentCategories;
const BookmarkDialog({
super.key,
required this.recipeId,
required this.currentCategories,
});
@override
State<BookmarkDialog> createState() => _BookmarkDialogState();
}
class _BookmarkDialogState extends State<BookmarkDialog> {
final TextEditingController _categoryNameController = TextEditingController();
List<BookmarkCategory> _allCategories = [];
bool _isLoading = false;
bool _isCreating = false;
@override
void initState() {
super.initState();
_loadCategories();
}
@override
void dispose() {
_categoryNameController.dispose();
super.dispose();
}
Future<void> _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<void> _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<void> _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'),
),
],
);
}
}

@ -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<RecipesScreen> {
const SnackBar(
content: Text('Recipe deleted successfully'),
backgroundColor: Colors.green,
duration: Duration(seconds: 1),
),
);
_loadRecipes();
@ -300,6 +304,44 @@ class _RecipesScreenState extends State<RecipesScreen> {
}
}
Future<void> _showBookmarkDialog(RecipeModel recipe) async {
try {
final recipeService = _recipeService;
if (recipeService == null) return;
final currentCategories = await recipeService.getCategoriesForRecipe(recipe.id);
final result = await showDialog<BookmarkCategory>(
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<RecipesScreen> {
: 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<MainNavigationScaffoldState>();
scaffold?.navigateToUser();
},
),
IconButton(
icon: Icon(_isSearching ? Icons.close : Icons.search),
onPressed: () {
@ -451,6 +503,7 @@ class _RecipesScreenState extends State<RecipesScreen> {
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<bool> _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<bool>(
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<bool>(
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

@ -282,7 +282,7 @@ class _RelayManagementScreenState extends State<RelayManagementScreen> {
const SnackBar(
content: Text('Relay added and connected successfully'),
backgroundColor: Colors.green,
duration: Duration(seconds: 2),
duration: Duration(seconds: 1),
),
);
} else {

@ -272,7 +272,7 @@ class _SessionScreenState extends State<SessionScreen> {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Session data refreshed'),
duration: Duration(seconds: 2),
duration: Duration(seconds: 1),
),
);
}

@ -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<MainNavigationScaffoldState>();
scaffold?.navigateToUser();
},
),
],
);
}

@ -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);

Loading…
Cancel
Save

Powered by TurnKey Linux.