Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions lib/core/config/app_strings.dart
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,11 @@ class AppStrings {
static const profileUserLiked = "Liked";
static const profileUserListings = "Listings";
static const profileUserBuyerDisc = "Donor discounts";
static const likedItemsTitle = 'Liked Items ❤️';
static const likedItemsEmptyTitle = 'No likes??? 👀';
static const likedItemsEmptyBody = 'Tap the heart on anything you want to find again.';
static const likedItemsBrowseProducts = 'Browse products';
static const likedItemsLoadError = 'We couldn’t load your liked items.';

// Profile: Donations & Activity
static const profileUserActivityBought = 'Bought';
Expand Down Expand Up @@ -199,6 +204,8 @@ class AppStrings {
static const giveInStyle = 'Give in style';
static const productIncl = 'Incl.';
static const askSeller = 'Ask seller';
static const likeProduct = 'Like product';
static const unlikeProduct = 'Unlike product';

// Checkout
static const checkoutTitle = 'Checkout';
Expand Down
4 changes: 4 additions & 0 deletions lib/core/config/firestore_constants.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
class FirestoreConstants {
static const users = pathUserCollection;
static const pathUserCollection = "users";
static const products = "products";
static const pathReportCollection = "report";
static const firstname = "firstname";
static const username = "username";
Expand All @@ -21,4 +23,6 @@ class FirestoreConstants {
static const lat = "lat";
static const long = "long";
static const lockers = "lockers";
static const productId = "productId";
static const likes = "likes";
}
4 changes: 4 additions & 0 deletions lib/core/router/nav_routes.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import 'package:cherry_mvp/features/donation/donation_page.dart';
import 'package:cherry_mvp/features/donation/postage_size_page.dart';
import 'package:cherry_mvp/features/donation/successful_upload_page.dart';
import 'package:cherry_mvp/features/discover/discover_page.dart';
import 'package:cherry_mvp/features/liked_items/liked_items_page.dart';
import 'package:cherry_mvp/features/login/login_page.dart';
import 'package:cherry_mvp/features/forgot_password/forgot_password_page.dart';
import 'package:cherry_mvp/features/products/product_page.dart';
Expand Down Expand Up @@ -42,6 +43,7 @@ class AppRoutes {
static const String charity = '/charity';
static const String postageSize = '/postageSize';
static const String pickupPointSelector = '/pickupPointSelector';
static const String likedItems = '/liked-items';

static Route<dynamic> generateRoute(RouteSettings settings) {
switch (settings.name) {
Expand All @@ -59,6 +61,8 @@ class AppRoutes {
return MaterialPageRoute(builder: (_) => const PostAuthUsernameGate());
case discover:
return MaterialPageRoute(builder: (_) => DiscoverPage());
case likedItems:
return MaterialPageRoute(builder: (_) => const LikedItemsPage());
case settingspage:
return MaterialPageRoute(builder: (_) => SettingsPage());
case donations:
Expand Down
2 changes: 2 additions & 0 deletions lib/core/services/network/api_endpoints.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ class ApiEndpoints {

static const String products = '$_apiPrefix/products';
static const String productsWithDetails = '$_apiPrefix/products/with-details';
static const String likedProducts = '$products/my-liked-items';
static String productLike(String productId) => '$products/${Uri.encodeComponent(productId)}/like';

// Auth sync
static const String authSync = '$_apiPrefix/auth/sync';
Expand Down
6 changes: 5 additions & 1 deletion lib/core/utils/dependency.dart
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,11 @@ List<SingleChildWidget> buildProviders(SharedPreferences prefs) {
},
),
Provider<DiscoverRepository>(create: (context) => DiscoverRepository()),
Provider<ProductRepository>(create: (context) => ProductRepository()),
Provider<ProductRepository>(
create: (context) => ProductRepository(
Provider.of<ApiService>(context, listen: false),
),
),
Provider<IDonationRepository>(
create: (context) {
if (useMockData) {
Expand Down
222 changes: 222 additions & 0 deletions lib/features/liked_items/liked_items_page.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
import 'package:cherry_mvp/core/config/config.dart';
import 'package:cherry_mvp/core/config/app_spacing.dart';
import 'package:cherry_mvp/core/models/product.dart';
import 'package:cherry_mvp/core/router/router.dart';
import 'package:cherry_mvp/features/donation/donation_page.dart';
import 'package:cherry_mvp/features/home/widgets/bottom_nav_bar.dart';
import 'package:cherry_mvp/features/liked_items/liked_items_view_model.dart';
import 'package:cherry_mvp/features/products/product_card.dart';
import 'package:cherry_mvp/features/products/product_repository.dart';
import 'package:cherry_mvp/features/products/product_viewmodel.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

class LikedItemsPage extends StatelessWidget {
const LikedItemsPage({super.key});

@override
Widget build(BuildContext context) {
return ChangeNotifierProvider<LikedItemsViewModel>(
create: (context) => LikedItemsViewModel(
productRepository: context.read<ProductRepository>(),
productViewModel: context.read<ProductViewModel>(),
)..loadLikedProducts(),
child: const _LikedItemsView(),
);
}
}

class _LikedItemsView extends StatelessWidget {
const _LikedItemsView();

static const int _homeNavIndex = 0;
static const int _inboxNavIndex = 1;
static const int _giveNavIndex = FeatureFlags.showInbox ? 2 : 1;
static const int _searchNavIndex = _giveNavIndex + 1;
static const int _profileNavIndex = _giveNavIndex + (FeatureFlags.showSearchNavigation ? 2 : 1);

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
leading: const BackButton(),
centerTitle: true,
title: const Text(AppStrings.likedItemsTitle),
),
body: SafeArea(
child: Consumer<LikedItemsViewModel>(
builder: (context, viewModel, _) {
switch (viewModel.status) {
case LikedItemsStatus.initial:
case LikedItemsStatus.loading:
return const Center(child: CircularProgressIndicator());
case LikedItemsStatus.empty:
return const _LikedItemsEmptyState();
case LikedItemsStatus.error:
return _LikedItemsErrorState(
message: viewModel.errorMessage ?? AppStrings.likedItemsLoadError,
onRetry: viewModel.retry,
);
case LikedItemsStatus.loaded:
return _LikedProductsGrid(products: viewModel.products);
}
},
),
),
bottomNavigationBar: CherryBottomNavBar(
selectedIndex: _profileNavIndex,
onItemSelected: (index) => _handleNavTap(context, index),
selectedColor: Theme.of(context).colorScheme.primary,
unselectedColor: Theme.of(context).colorScheme.secondary,
),
);
}

void _handleNavTap(BuildContext context, int index) {
final navigator = context.read<NavigationProvider>();

if (index == _homeNavIndex) {
navigator.navigateToAndRemoveUntil(AppRoutes.home, (_) => false);
return;
}

if (FeatureFlags.showInbox && index == _inboxNavIndex) {
navigator.navigateToAndRemoveUntil(AppRoutes.home, (_) => false);
return;
}

if (index == _giveNavIndex) {
showDialog(
context: context,
builder: (context) => const Dialog.fullscreen(child: DonationPage()),
);
return;
}

if (FeatureFlags.showSearchNavigation && index == _searchNavIndex) {
context.read<SearchController>().openView();
return;
}

if (index == _profileNavIndex && Navigator.of(context).canPop()) {
Navigator.of(context).pop();
}
}
}

class _LikedProductsGrid extends StatelessWidget {
const _LikedProductsGrid({required this.products});

final List<Product> products;

@override
Widget build(BuildContext context) {
final size = MediaQuery.sizeOf(context);
final textScale = MediaQuery.textScalerOf(context).scale(1.0).clamp(1.0, 1.4);
final cardWidth = (size.width - 24 - 12) / 2;
final imageHeight = cardWidth / AppSpacing.imageContainerAspectRatio;
final cardHeight = imageHeight + (120.0 * textScale);

return GridView.builder(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 24),
itemCount: products.length,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 24,
crossAxisSpacing: 12,
mainAxisExtent: cardHeight,
),
itemBuilder: (context, index) {
final product = products[index];

return ProductCard(
key: ValueKey(product.id),
product: product,
onTap: () => context.read<ProductViewModel>().goToProductPage(product),
onLikePressed: () => context.read<LikedItemsViewModel>().unlikeProduct(product),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Handle the potential failure when unliking a product.

Currently, if unlikeProduct fails (e.g., due to a network error), it returns false and sets an errorMessage in the view model. However, because the page status remains LikedItemsStatus.loaded, the UI ignores this error silently and leaves the user wondering why the item wasn't removed.

Please await the result and display a SnackBar to inform the user if the action fails.

🐛 Proposed fix
-          onLikePressed: () => context.read<LikedItemsViewModel>().unlikeProduct(product),
+          onLikePressed: () async {
+            final viewModel = context.read<LikedItemsViewModel>();
+            final success = await viewModel.unlikeProduct(product);
+            if (!success && context.mounted) {
+              ScaffoldMessenger.of(context).showSnackBar(
+                SnackBar(
+                  content: Text(viewModel.errorMessage ?? AppStrings.genericError),
+                ),
+              );
+            }
+          },
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
onLikePressed: () => context.read<LikedItemsViewModel>().unlikeProduct(product),
onLikePressed: () async {
final viewModel = context.read<LikedItemsViewModel>();
final success = await viewModel.unlikeProduct(product);
if (!success && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(viewModel.errorMessage ?? AppStrings.genericError),
),
);
}
},
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/features/liked_items/liked_items_page.dart` at line 136, Update the
onLikePressed callback in the liked-items page to await unlikeProduct’s boolean
result, and show a SnackBar when it returns false so the view model’s
errorMessage is surfaced to the user. Preserve the existing successful unlike
behavior and use the page’s current BuildContext and established error-message
access.

);
},
);
}
}

class _LikedItemsEmptyState extends StatelessWidget {
const _LikedItemsEmptyState();

@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.favorite_border,
size: 48,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(height: 16),
Text(
AppStrings.likedItemsEmptyTitle,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
AppStrings.likedItemsEmptyBody,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 24),
FilledButton(
onPressed: () =>
context.read<NavigationProvider>().navigateToAndRemoveUntil(AppRoutes.home, (_) => false),
child: const Text(AppStrings.likedItemsBrowseProducts),
),
],
),
),
);
}
}

class _LikedItemsErrorState extends StatelessWidget {
const _LikedItemsErrorState({
required this.message,
required this.onRetry,
});

final String message;
final VoidCallback onRetry;

@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.error_outline,
size: 48,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(height: 16),
Text(
message,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 24),
OutlinedButton(
onPressed: onRetry,
child: const Text(AppStrings.retry),
),
],
),
),
);
}
}
Loading