diff --git a/lib/core/config/app_strings.dart b/lib/core/config/app_strings.dart index c7481c2..fa046ae 100644 --- a/lib/core/config/app_strings.dart +++ b/lib/core/config/app_strings.dart @@ -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'; @@ -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'; diff --git a/lib/core/config/firestore_constants.dart b/lib/core/config/firestore_constants.dart index 6cd9f89..6414ac5 100644 --- a/lib/core/config/firestore_constants.dart +++ b/lib/core/config/firestore_constants.dart @@ -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"; @@ -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"; } diff --git a/lib/core/router/nav_routes.dart b/lib/core/router/nav_routes.dart index 06d0523..32351d5 100644 --- a/lib/core/router/nav_routes.dart +++ b/lib/core/router/nav_routes.dart @@ -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'; @@ -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 generateRoute(RouteSettings settings) { switch (settings.name) { @@ -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: diff --git a/lib/core/services/network/api_endpoints.dart b/lib/core/services/network/api_endpoints.dart index c58ce89..2c32c98 100644 --- a/lib/core/services/network/api_endpoints.dart +++ b/lib/core/services/network/api_endpoints.dart @@ -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'; diff --git a/lib/core/utils/dependency.dart b/lib/core/utils/dependency.dart index 3ae4c47..4b59453 100644 --- a/lib/core/utils/dependency.dart +++ b/lib/core/utils/dependency.dart @@ -99,7 +99,11 @@ List buildProviders(SharedPreferences prefs) { }, ), Provider(create: (context) => DiscoverRepository()), - Provider(create: (context) => ProductRepository()), + Provider( + create: (context) => ProductRepository( + Provider.of(context, listen: false), + ), + ), Provider( create: (context) { if (useMockData) { diff --git a/lib/features/liked_items/liked_items_page.dart b/lib/features/liked_items/liked_items_page.dart new file mode 100644 index 0000000..4ab5fb3 --- /dev/null +++ b/lib/features/liked_items/liked_items_page.dart @@ -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( + create: (context) => LikedItemsViewModel( + productRepository: context.read(), + productViewModel: context.read(), + )..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( + 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(); + + 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().openView(); + return; + } + + if (index == _profileNavIndex && Navigator.of(context).canPop()) { + Navigator.of(context).pop(); + } + } +} + +class _LikedProductsGrid extends StatelessWidget { + const _LikedProductsGrid({required this.products}); + + final List 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().goToProductPage(product), + onLikePressed: () => context.read().unlikeProduct(product), + ); + }, + ); + } +} + +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().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), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/liked_items/liked_items_view_model.dart b/lib/features/liked_items/liked_items_view_model.dart new file mode 100644 index 0000000..a6d96e2 --- /dev/null +++ b/lib/features/liked_items/liked_items_view_model.dart @@ -0,0 +1,96 @@ +import 'package:flutter/foundation.dart'; +import 'package:cherry_mvp/core/models/product.dart'; +import 'package:cherry_mvp/features/products/product_repository.dart'; +import 'package:cherry_mvp/features/products/product_viewmodel.dart'; + +enum LikedItemsStatus { + initial, + loading, + loaded, + empty, + error, +} + +class LikedItemsViewModel extends ChangeNotifier { + LikedItemsViewModel({ + required this.productRepository, + required this.productViewModel, + }); + + final ProductRepository productRepository; + final ProductViewModel productViewModel; + + LikedItemsStatus _status = LikedItemsStatus.initial; + List _products = const []; + String? _errorMessage; + + LikedItemsStatus get status => _status; + List get products => _products; + String? get errorMessage => _errorMessage; + + Future loadLikedProducts() async { + _status = LikedItemsStatus.loading; + _errorMessage = null; + notifyListeners(); + + final result = await productRepository.fetchLikedProducts(); + + if (!result.isSuccess) { + _products = const []; + _status = LikedItemsStatus.error; + _errorMessage = result.error ?? 'We couldn’t load your liked items.'; + notifyListeners(); + return; + } + + _products = _mergeProducts( + result.value ?? const [], + productViewModel.cachedLikedProducts, + ); + productViewModel.cacheLikedProducts(_products); + _status = _products.isEmpty ? LikedItemsStatus.empty : LikedItemsStatus.loaded; + notifyListeners(); + } + + Future unlikeProduct(Product product) async { + final index = _products.indexWhere((item) => item.id == product.id); + if (index == -1) { + return true; + } + + final result = await productViewModel.setProductLiked(product, false); + if (!result.isSuccess) { + _errorMessage = result.error ?? 'Unable to remove this liked item.'; + notifyListeners(); + return false; + } + + final nextProducts = List.from(_products)..removeAt(index); + _products = List.unmodifiable(nextProducts); + _status = _products.isEmpty ? LikedItemsStatus.empty : LikedItemsStatus.loaded; + _errorMessage = null; + notifyListeners(); + return true; + } + + Future retry() { + return loadLikedProducts(); + } + + List _mergeProducts( + List primaryProducts, + List cachedProducts, + ) { + final products = []; + final seenProductIds = {}; + + for (final product in [...primaryProducts, ...cachedProducts]) { + if (product.id.trim().isEmpty || !seenProductIds.add(product.id)) { + continue; + } + products.add(product); + } + + return List.unmodifiable(products); + } +} diff --git a/lib/features/products/product_card.dart b/lib/features/products/product_card.dart index fc44cfa..cc55710 100644 --- a/lib/features/products/product_card.dart +++ b/lib/features/products/product_card.dart @@ -11,8 +11,14 @@ import 'package:cherry_mvp/features/products/product_viewmodel.dart'; class ProductCard extends StatelessWidget { final Product product; final VoidCallback? onTap; + final VoidCallback? onLikePressed; - const ProductCard({super.key, required this.product, this.onTap}); + const ProductCard({ + super.key, + required this.product, + this.onTap, + this.onLikePressed, + }); static const _sizeAbbreviations = { 'extra small': 'XS', @@ -79,7 +85,9 @@ class ProductCard extends StatelessWidget { borderRadius: BorderRadius.circular(8), image: DecorationImage( image: ImageProviderHelper.getImageProvider( - product.productImages.first, + product.productImages.isNotEmpty + ? product.productImages.first + : AppImages.emptyStateBg, ), fit: BoxFit.cover, ), @@ -90,43 +98,48 @@ class ProductCard extends StatelessWidget { Positioned( bottom: 16, right: 16, - child: GestureDetector( - onTap: () => viewModel.toggleLike(product), - child: Material( - borderRadius: BorderRadius.circular(4), - elevation: 2, - color: Colors.white, - type: MaterialType.button, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 4, - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - isLiked - ? Image.asset( - AppImages.likeHeart, - height: 16, - fit: BoxFit.contain, - color: Colors.red, - ) - : const Icon( - Icons.favorite_outline, - size: 16, - color: AppColors.grey, - ), - const SizedBox(width: 4), - Text( - displayLikes.toString(), - style: Theme.of(context).textTheme.labelSmall?.copyWith( - fontWeight: FontWeight.bold, - color: isLiked ? Colors.red : AppColors.grey, + child: Semantics( + key: ValueKey('product-like-${product.id}'), + button: true, + label: isLiked ? AppStrings.unlikeProduct : AppStrings.likeProduct, + child: GestureDetector( + onTap: onLikePressed ?? () => viewModel.toggleLike(product), + child: Material( + borderRadius: BorderRadius.circular(4), + elevation: 2, + color: Colors.white, + type: MaterialType.button, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 4, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + isLiked + ? Image.asset( + AppImages.likeHeart, + height: 16, + fit: BoxFit.contain, + color: Colors.red, + ) + : const Icon( + Icons.favorite_outline, + size: 16, + color: AppColors.grey, + ), + const SizedBox(width: 4), + Text( + displayLikes.toString(), + style: Theme.of(context).textTheme.labelSmall?.copyWith( + fontWeight: FontWeight.bold, + color: isLiked ? Colors.red : AppColors.grey, + ), ), - ), - ], + ], + ), ), ), ), diff --git a/lib/features/products/product_repository.dart b/lib/features/products/product_repository.dart index a07f20a..d4caf1c 100644 --- a/lib/features/products/product_repository.dart +++ b/lib/features/products/product_repository.dart @@ -1,4 +1,131 @@ +import 'package:cherry_mvp/core/models/product.dart'; +import 'package:cherry_mvp/core/services/network/api_endpoints.dart'; +import 'package:cherry_mvp/core/services/network/api_service.dart'; +import 'package:cherry_mvp/core/utils/result.dart'; class ProductRepository { + ProductRepository(this._apiService); -} \ No newline at end of file + static const int _likedProductsPageLimit = 50; + static const String _loadFailureMessage = 'We couldn’t load your liked items.'; + + final ApiService _apiService; + + Future> likeProduct(Product product) async { + return _setProductLiked(product.id, liked: true); + } + + Future> unlikeProduct(String productId) async { + return _setProductLiked(productId, liked: false); + } + + Future>> fetchLikedProducts() async { + try { + final result = await _apiService.get( + ApiEndpoints.likedProducts, + queryParameters: const {'limit': _likedProductsPageLimit}, + ); + if (!result.isSuccess) { + return Result.failure(result.error ?? _loadFailureMessage); + } + + final response = result.value; + if (_isUnsuccessfulResponse(response)) { + return Result.failure(_loadFailureMessage); + } + + final productList = _extractProductList(response); + if (productList == null) { + return Result.success(const []); + } + + final products = []; + final seenProductIds = {}; + + for (final productData in productList) { + if (productData is! Map) { + continue; + } + + try { + final product = Product.fromJson( + Map.from(productData), + ); + if (seenProductIds.add(product.id)) { + products.add(product); + } + } catch (_) { + continue; + } + } + + return Result.success(products); + } catch (_) { + return Result.failure(_loadFailureMessage); + } + } + + Future> _setProductLiked( + String productId, { + required bool liked, + }) async { + final validationError = _validateProductId(productId); + if (validationError != null) { + return Result.failure(validationError); + } + + final failureMessage = liked + ? 'Unable to save this liked item. Please try again.' + : 'Unable to remove this liked item. Please try again.'; + + try { + final result = await _apiService.post( + ApiEndpoints.productLike(productId), + data: {'like': liked}, + ); + if (!result.isSuccess) { + return Result.failure(result.error ?? failureMessage); + } + + final response = result.value; + if (response is! Map || response['success'] != true) { + return Result.failure(failureMessage); + } + + final responseData = response['data']; + if (responseData is! Map || responseData['liked'] != liked) { + return Result.failure(failureMessage); + } + + return Result.success(null); + } catch (_) { + return Result.failure(failureMessage); + } + } + + bool _isUnsuccessfulResponse(dynamic response) { + return response is Map && response['success'] == false; + } + + List? _extractProductList(dynamic response) { + if (response is! Map) { + return null; + } + + final data = response['data']; + if (data is! Map) { + return null; + } + + final products = data['products']; + return products is List ? products : null; + } + + String? _validateProductId(String productId) { + if (productId.trim().isEmpty || productId.contains('/')) { + return 'Invalid product ID.'; + } + + return null; + } +} diff --git a/lib/features/products/product_viewmodel.dart b/lib/features/products/product_viewmodel.dart index df66563..b853623 100644 --- a/lib/features/products/product_viewmodel.dart +++ b/lib/features/products/product_viewmodel.dart @@ -2,6 +2,7 @@ import 'package:flutter/cupertino.dart'; import 'package:cherry_mvp/core/models/model.dart'; import 'package:cherry_mvp/core/router/nav_provider.dart'; import 'package:cherry_mvp/core/router/nav_routes.dart'; +import 'package:cherry_mvp/core/utils/result.dart'; import 'package:cherry_mvp/features/products/product_repository.dart'; class ProductViewModel extends ChangeNotifier { @@ -9,6 +10,8 @@ class ProductViewModel extends ChangeNotifier { // Centralized tracker: Map final Map _likedProducts = {}; + final Map _likedProductCache = {}; + final Set _pendingLikeUpdates = {}; Product? get product => _product; @@ -22,6 +25,20 @@ class ProductViewModel extends ChangeNotifier { return _likedProducts[productId] ?? false; } + bool isLikeUpdatePending(String productId) { + return _pendingLikeUpdates.contains(productId); + } + + List get cachedLikedProducts { + final products = []; + for (final entry in _likedProductCache.entries) { + if (_likedProducts[entry.key] == true) { + products.add(entry.value); + } + } + return List.unmodifiable(products); + } + // Get dynamic count for a product int getLikesCount(Product product) { bool isLiked = _likedProducts[product.id] ?? false; @@ -33,15 +50,75 @@ class ProductViewModel extends ChangeNotifier { notifyListeners(); } - void toggleLike(Product product) { + Future> toggleLike(Product product) async { final String id = product.id; final bool currentStatus = _likedProducts[id] ?? false; - _likedProducts[id] = !currentStatus; + return setProductLiked(product, !currentStatus); + } + + Future> setProductLiked(Product product, bool liked) async { + final id = product.id; + if (id.trim().isEmpty || _pendingLikeUpdates.contains(id)) { + return Result.failure('Unable to update this liked item.'); + } + + _pendingLikeUpdates.add(id); + notifyListeners(); + + final result = liked ? await productRepository.likeProduct(product) : await productRepository.unlikeProduct(id); + + _pendingLikeUpdates.remove(id); - // In future: productRepository.likeProduct(id, !currentStatus); + if (result.isSuccess) { + _likedProducts[id] = liked; + if (liked) { + _likedProductCache[id] = product; + } else { + _likedProductCache.remove(id); + } + notifyListeners(); + return Result.success(liked); + } notifyListeners(); + return Result.failure(result.error ?? 'Unable to update this liked item.'); + } + + void setCachedLikeState(String productId, bool liked) { + if (productId.trim().isEmpty) { + return; + } + + _likedProducts[productId] = liked; + if (!liked) { + _likedProductCache.remove(productId); + } + notifyListeners(); + } + + void cacheLikedProducts(Iterable products) { + var changed = false; + + for (final product in products) { + if (product.id.trim().isEmpty) { + continue; + } + + if (_likedProducts[product.id] != true) { + _likedProducts[product.id] = true; + changed = true; + } + + if (_likedProductCache[product.id] != product) { + _likedProductCache[product.id] = product; + changed = true; + } + } + + if (changed) { + notifyListeners(); + } } // For the Product Page (the currently active product) diff --git a/lib/features/profile/profile_page.dart b/lib/features/profile/profile_page.dart index 5b8b3cc..9d30181 100644 --- a/lib/features/profile/profile_page.dart +++ b/lib/features/profile/profile_page.dart @@ -62,6 +62,11 @@ class _ProfilePageState extends State { navigator.navigateTo(AppRoutes.settingspage); } + void navigateToLikedItems() { + final navigator = Provider.of(context, listen: false); + navigator.navigateTo(AppRoutes.likedItems); + } + return Scaffold( //profile header appBar: AppBar( @@ -81,7 +86,8 @@ class _ProfilePageState extends State { navigateToSettings(); }, ), - if (FeatureFlags.showProfileShortcutCards || FeatureFlags.showDonorDiscounts) UserOrderDetails(), + if (FeatureFlags.showProfileShortcutCards || FeatureFlags.showDonorDiscounts) + UserOrderDetails(onLikedPressed: navigateToLikedItems), if (FeatureFlags.showImpactSummaries) ...[ SizedBox(height: 16), DonationChart( diff --git a/lib/features/profile/widgets/user_order_details.dart b/lib/features/profile/widgets/user_order_details.dart index 1f437ca..11ca51c 100644 --- a/lib/features/profile/widgets/user_order_details.dart +++ b/lib/features/profile/widgets/user_order_details.dart @@ -5,42 +5,55 @@ import 'package:flutter/material.dart'; class UserOrderDetails extends StatelessWidget { const UserOrderDetails({ super.key, + this.onOrdersPressed, + this.onLikedPressed, + this.onListingsPressed, }); + final VoidCallback? onOrdersPressed; + final VoidCallback? onLikedPressed; + final VoidCallback? onListingsPressed; + @override Widget build(BuildContext context) { return Column( children: [ if (FeatureFlags.showProfileShortcutCards) - SizedBox( - height: 96, - child: Row( - children: [ - Expanded( - child: UserOrderTile( - title: AppStrings.profileUserOrders, - onPressed: () {}, - assetPath: AppImages.profileOrder, - ), - ), - SizedBox(width: 8), - Expanded( - child: UserOrderTile( - title: AppStrings.profileUserLiked, - onPressed: () {}, - assetPath: AppImages.profileLiked, - ), + Column( + children: [ + SizedBox( + height: 96, + child: Row( + children: [ + Expanded( + child: UserOrderTile( + title: AppStrings.profileUserOrders, + onPressed: onOrdersPressed ?? () {}, + assetPath: AppImages.profileOrder, + ), + ), + const SizedBox(width: 8), + Expanded( + child: UserOrderTile( + title: AppStrings.profileUserLiked, + onPressed: onLikedPressed ?? () {}, + assetPath: AppImages.profileLiked, + ), + ), + ], ), - SizedBox(width: 8), - Expanded( - child: UserOrderTile( - title: AppStrings.profileUserListings, - onPressed: () {}, - assetPath: AppImages.profileListings, - ), + ), + const SizedBox(height: 8), + SizedBox( + height: 56, + width: double.infinity, + child: UserOrderTile( + title: AppStrings.profileUserListings, + onPressed: onListingsPressed ?? () {}, + assetPath: AppImages.profileListings, ), - ], - ), + ), + ], ), if (FeatureFlags.showDonorDiscounts) ...[ const SizedBox(height: 16), diff --git a/test/home_screen_mvp_test.dart b/test/home_screen_mvp_test.dart index 87fa90a..cf33ae3 100644 --- a/test/home_screen_mvp_test.dart +++ b/test/home_screen_mvp_test.dart @@ -13,6 +13,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:provider/provider.dart'; +import 'support/unexpected_api_service.dart'; + class _HomeRepositoryStub implements IHomeRepository { _HomeRepositoryStub(this.result); @@ -56,7 +58,9 @@ Future _pumpHomeScreen( ), ChangeNotifierProvider( create: (_) => ProductViewModel( - productRepository: ProductRepository(), + productRepository: ProductRepository( + const UnexpectedApiService(), + ), navigator: navigator, ), ), diff --git a/test/liked_items_page_test.dart b/test/liked_items_page_test.dart new file mode 100644 index 0000000..294df91 --- /dev/null +++ b/test/liked_items_page_test.dart @@ -0,0 +1,187 @@ +import 'package:cherry_mvp/core/config/app_images.dart'; +import 'package:cherry_mvp/core/config/app_strings.dart'; +import 'package:cherry_mvp/core/models/product.dart'; +import 'package:cherry_mvp/core/router/router.dart'; +import 'package:cherry_mvp/core/utils/result.dart'; +import 'package:cherry_mvp/features/liked_items/liked_items_page.dart'; +import 'package:cherry_mvp/features/products/product_card.dart'; +import 'package:cherry_mvp/features/products/product_page.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:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +import 'support/unexpected_api_service.dart'; + +class _FakeProductRepository extends ProductRepository { + _FakeProductRepository({ + required this.fetchResult, + this.unlikeResult, + }) : super(const UnexpectedApiService()); + + Result> fetchResult; + Result? unlikeResult; + int fetchCount = 0; + + @override + Future> likeProduct(Product product) async { + return Result.success(null); + } + + @override + Future>> fetchLikedProducts() async { + fetchCount += 1; + return fetchResult; + } + + @override + Future> unlikeProduct(String productId) async { + return unlikeResult ?? Result.success(null); + } +} + +Product _product({String id = 'liked-product', String name = 'Liked jacket'}) { + return Product( + id: id, + name: name, + description: 'A liked test product', + quality: 'Good', + productImages: const [AppImages.product1], + donation: 6, + price: 7, + securityFee: 1, + likes: 0, + number: 1, + size: 'M', + postageSizeId: 'small', + ); +} + +Future _pumpLikedItemsPage( + WidgetTester tester, { + required _FakeProductRepository repository, +}) async { + final navigator = NavigationProvider(); + final productViewModel = ProductViewModel( + productRepository: repository, + navigator: navigator, + ); + + await tester.pumpWidget( + MultiProvider( + providers: [ + Provider.value(value: navigator), + Provider.value(value: repository), + ChangeNotifierProvider.value(value: productViewModel), + ], + child: MaterialApp( + navigatorKey: navigator.navigatorKey, + onGenerateRoute: AppRoutes.generateRoute, + home: const LikedItemsPage(), + ), + ), + ); +} + +void main() { + testWidgets('LikedItemsPage shows an empty state', (tester) async { + await _pumpLikedItemsPage( + tester, + repository: _FakeProductRepository( + fetchResult: Result.success(const []), + ), + ); + await tester.pump(); + + expect(find.text(AppStrings.likedItemsTitle), findsOneWidget); + expect(find.text(AppStrings.likedItemsEmptyTitle), findsOneWidget); + expect(find.text(AppStrings.likedItemsBrowseProducts), findsOneWidget); + }); + + testWidgets('LikedItemsPage shows an error state and retry action', ( + tester, + ) async { + final repository = _FakeProductRepository( + fetchResult: Result.failure(AppStrings.likedItemsLoadError), + ); + + await _pumpLikedItemsPage(tester, repository: repository); + await tester.pump(); + + expect(find.text(AppStrings.likedItemsLoadError), findsOneWidget); + expect(find.text(AppStrings.retry), findsOneWidget); + + repository.fetchResult = Result.success(const []); + await tester.tap(find.text(AppStrings.retry)); + await tester.pump(); + + expect(repository.fetchCount, 2); + expect(find.text(AppStrings.likedItemsEmptyTitle), findsOneWidget); + }); + + testWidgets('LikedItemsPage displays liked product cards', (tester) async { + await _pumpLikedItemsPage( + tester, + repository: _FakeProductRepository( + fetchResult: Result.success([_product()]), + ), + ); + await tester.pump(); + + expect(find.byType(ProductCard), findsOneWidget); + expect(find.text('Liked jacket'), findsOneWidget); + expect(find.byKey(const ValueKey('product-like-liked-product')), findsOneWidget); + }); + + testWidgets('tapping a liked product opens the existing product page', ( + tester, + ) async { + await _pumpLikedItemsPage( + tester, + repository: _FakeProductRepository( + fetchResult: Result.success([_product()]), + ), + ); + await tester.pump(); + + await tester.tap(find.byType(ProductCard)); + await tester.pumpAndSettle(); + + expect(find.byType(ProductPage), findsOneWidget); + }); + + testWidgets('unliking a product removes its card after success', ( + tester, + ) async { + await _pumpLikedItemsPage( + tester, + repository: _FakeProductRepository( + fetchResult: Result.success([_product()]), + ), + ); + await tester.pump(); + + await tester.tap(find.byKey(const ValueKey('product-like-liked-product'))); + await tester.pumpAndSettle(); + + expect(find.text('Liked jacket'), findsNothing); + expect(find.text(AppStrings.likedItemsEmptyTitle), findsOneWidget); + }); + + testWidgets('failed unlike keeps the product card visible', (tester) async { + await _pumpLikedItemsPage( + tester, + repository: _FakeProductRepository( + fetchResult: Result.success([_product()]), + unlikeResult: Result.failure('Unlike failed'), + ), + ); + await tester.pump(); + + await tester.tap(find.byKey(const ValueKey('product-like-liked-product'))); + await tester.pumpAndSettle(); + + expect(find.text('Liked jacket'), findsOneWidget); + }); +} diff --git a/test/liked_items_view_model_test.dart b/test/liked_items_view_model_test.dart new file mode 100644 index 0000000..8150d1b --- /dev/null +++ b/test/liked_items_view_model_test.dart @@ -0,0 +1,180 @@ +import 'package:cherry_mvp/core/config/app_images.dart'; +import 'package:cherry_mvp/core/models/product.dart'; +import 'package:cherry_mvp/core/router/nav_provider.dart'; +import 'package:cherry_mvp/core/utils/result.dart'; +import 'package:cherry_mvp/features/liked_items/liked_items_view_model.dart'; +import 'package:cherry_mvp/features/products/product_repository.dart'; +import 'package:cherry_mvp/features/products/product_viewmodel.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'support/unexpected_api_service.dart'; + +class _FakeProductRepository extends ProductRepository { + _FakeProductRepository({ + this.fetchResult, + this.unlikeResult, + }) : super(const UnexpectedApiService()); + + Result>? fetchResult; + Result? unlikeResult; + final unlikedIds = []; + + @override + Future> likeProduct(Product product) async { + return Result.success(null); + } + + @override + Future>> fetchLikedProducts() async { + return fetchResult ?? Result.success(const []); + } + + @override + Future> unlikeProduct(String productId) async { + unlikedIds.add(productId); + return unlikeResult ?? Result.success(null); + } +} + +Product _product({String id = 'liked-product'}) { + return Product( + id: id, + name: 'Liked item', + description: 'A liked test product', + quality: 'Good', + productImages: const [AppImages.product1], + donation: 6, + price: 7, + securityFee: 1, + likes: 0, + number: 1, + size: 'M', + postageSizeId: 'small', + ); +} + +void main() { + group('LikedItemsViewModel', () { + test('loads liked products and marks them as liked in shared state', () async { + final repository = _FakeProductRepository( + fetchResult: Result.success([_product()]), + ); + final productViewModel = ProductViewModel( + productRepository: repository, + navigator: NavigationProvider(), + ); + final viewModel = LikedItemsViewModel( + productRepository: repository, + productViewModel: productViewModel, + ); + + await viewModel.loadLikedProducts(); + + expect(viewModel.status, LikedItemsStatus.loaded); + expect(viewModel.products, hasLength(1)); + expect(productViewModel.isProductLiked('liked-product'), isTrue); + }); + + test('uses cached liked products when the API response is empty', () async { + final product = _product(); + final repository = _FakeProductRepository( + fetchResult: Result.success(const []), + ); + final productViewModel = ProductViewModel( + productRepository: repository, + navigator: NavigationProvider(), + )..cacheLikedProducts([product]); + final viewModel = LikedItemsViewModel( + productRepository: repository, + productViewModel: productViewModel, + ); + + await viewModel.loadLikedProducts(); + + expect(viewModel.status, LikedItemsStatus.loaded); + expect(viewModel.products, [product]); + }); + + test('sets empty state when there are no liked products', () async { + final repository = _FakeProductRepository( + fetchResult: Result.success(const []), + ); + final viewModel = LikedItemsViewModel( + productRepository: repository, + productViewModel: ProductViewModel( + productRepository: repository, + navigator: NavigationProvider(), + ), + ); + + await viewModel.loadLikedProducts(); + + expect(viewModel.status, LikedItemsStatus.empty); + expect(viewModel.products, isEmpty); + }); + + test('sets error state when liked products fail to load', () async { + final repository = _FakeProductRepository( + fetchResult: Result.failure('Load failed'), + ); + final viewModel = LikedItemsViewModel( + productRepository: repository, + productViewModel: ProductViewModel( + productRepository: repository, + navigator: NavigationProvider(), + ), + ); + + await viewModel.loadLikedProducts(); + + expect(viewModel.status, LikedItemsStatus.error); + expect(viewModel.errorMessage, 'Load failed'); + }); + + test('unlike removes a product after the repository write succeeds', () async { + final product = _product(); + final repository = _FakeProductRepository( + fetchResult: Result.success([product]), + ); + final productViewModel = ProductViewModel( + productRepository: repository, + navigator: NavigationProvider(), + ); + final viewModel = LikedItemsViewModel( + productRepository: repository, + productViewModel: productViewModel, + ); + + await viewModel.loadLikedProducts(); + final removed = await viewModel.unlikeProduct(product); + + expect(removed, isTrue); + expect(repository.unlikedIds, ['liked-product']); + expect(viewModel.status, LikedItemsStatus.empty); + expect(viewModel.products, isEmpty); + expect(productViewModel.isProductLiked('liked-product'), isFalse); + }); + + test('failed unlike keeps the product visible', () async { + final product = _product(); + final repository = _FakeProductRepository( + fetchResult: Result.success([product]), + unlikeResult: Result.failure('Unlike failed'), + ); + final viewModel = LikedItemsViewModel( + productRepository: repository, + productViewModel: ProductViewModel( + productRepository: repository, + navigator: NavigationProvider(), + ), + ); + + await viewModel.loadLikedProducts(); + final removed = await viewModel.unlikeProduct(product); + + expect(removed, isFalse); + expect(viewModel.products, [product]); + expect(viewModel.errorMessage, 'Unlike failed'); + }); + }); +} diff --git a/test/mvp_profile_stats_test.dart b/test/mvp_profile_stats_test.dart new file mode 100644 index 0000000..2d98bb5 --- /dev/null +++ b/test/mvp_profile_stats_test.dart @@ -0,0 +1,119 @@ +import 'package:cherry_mvp/core/config/app_strings.dart'; +import 'package:cherry_mvp/core/models/user_section.dart'; +import 'package:cherry_mvp/core/router/nav_provider.dart'; +import 'package:cherry_mvp/features/auth/auth_view_model.dart'; +import 'package:cherry_mvp/features/login/login_repository.dart'; +import 'package:cherry_mvp/features/profile/profile_page.dart'; +import 'package:cherry_mvp/features/profile/widgets/user_information_section.dart'; +import 'package:cherry_mvp/features/profile/widgets/user_order_details.dart'; +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:provider/provider.dart'; + +class _MockLoginRepository extends Mock implements LoginRepository {} + +class _MockNavigationProvider extends Mock implements NavigationProvider {} + +class _MockFirebaseAuth extends Mock implements FirebaseAuth {} + +class _MockFirebaseFirestore extends Mock implements FirebaseFirestore {} + +AuthViewModel _authViewModel() { + return AuthViewModel( + loginRepository: _MockLoginRepository(), + navigator: _MockNavigationProvider(), + firebaseAuth: _MockFirebaseAuth(), + firestore: _MockFirebaseFirestore(), + ); +} + +UserInformation _userInformation() { + return UserInformation( + username: 'Profile Stats User', + location: 'Cardiff', + reviewsCount: 0, + followersCount: 34, + followingCount: 12, + rating: 0, + awards: 7, + hasBuyerDiscounts: true, + ); +} + +void main() { + testWidgets('UserInformationSection shows contact rows while hiding placeholder stats', ( + tester, + ) async { + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: _authViewModel(), + child: MaterialApp( + home: Scaffold( + body: UserInformationSection( + userInformationSection: _userInformation(), + onSettingsPressed: () {}, + ), + ), + ), + ), + ); + + expect( + find.textContaining(AppStrings.profileUserInfoSectionFollowing), + findsNothing, + ); + expect( + find.textContaining(AppStrings.profileUserInfoSectionFollowers), + findsNothing, + ); + expect(find.text('Cardiff'), findsOneWidget); + expect( + find.textContaining(AppStrings.profileUserInfoSectionBuyerAwards), + findsNothing, + ); + expect(find.text(AppStrings.profileUserInfoSectionBuyerDiscount), findsNothing); + }); + + testWidgets('UserOrderDetails shows profile shortcut tiles for the MVP', ( + tester, + ) async { + await tester.pumpWidget( + const MaterialApp(home: Scaffold(body: UserOrderDetails())), + ); + + expect(find.text(AppStrings.profileUserOrders), findsOneWidget); + expect(find.text(AppStrings.profileUserLiked), findsOneWidget); + expect(find.text(AppStrings.profileUserListings), findsOneWidget); + expect(find.text(AppStrings.profileUserBuyerDisc), findsNothing); + }); + + testWidgets('ProfilePage shows profile shortcuts while hiding placeholder profile activity cards', ( + tester, + ) async { + tester.view.physicalSize = const Size(1200, 5000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: _authViewModel()), + Provider.value(value: NavigationProvider()), + ], + child: const MaterialApp(home: ProfilePage()), + ), + ); + await tester.pump(); + + expect(find.text(AppStrings.profileUserOrders), findsOneWidget); + expect(find.text(AppStrings.profileUserLiked), findsOneWidget); + expect(find.text(AppStrings.profileUserListings), findsOneWidget); + expect(find.text(AppStrings.profileUserActivityBought), findsNothing); + expect(find.text(AppStrings.profileUserActivitySold), findsNothing); + expect(find.text(AppStrings.profileUserActivityTotal), findsNothing); + }); +} diff --git a/test/product_repository_test.dart b/test/product_repository_test.dart new file mode 100644 index 0000000..d903499 --- /dev/null +++ b/test/product_repository_test.dart @@ -0,0 +1,280 @@ +import 'package:cherry_mvp/core/config/app_images.dart'; +import 'package:cherry_mvp/core/models/product.dart'; +import 'package:cherry_mvp/core/services/network/api_endpoints.dart'; +import 'package:cherry_mvp/core/services/network/api_service.dart'; +import 'package:cherry_mvp/core/utils/result.dart'; +import 'package:cherry_mvp/features/products/product_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _RecordingApiService implements ApiService { + _RecordingApiService({ + this.getValue, + this.getError, + this.postValue, + this.postError, + }); + + final dynamic getValue; + final String? getError; + final dynamic postValue; + final String? postError; + + String? lastGetEndpoint; + Map? lastQueryParameters; + String? lastPostEndpoint; + dynamic lastPostData; + int postCount = 0; + + @override + Future> get( + String endpoint, { + Map? queryParameters, + }) async { + lastGetEndpoint = endpoint; + lastQueryParameters = queryParameters; + if (getError != null) { + return Result.failure(getError); + } + return Result.success(getValue as T); + } + + @override + Future> post(String endpoint, {dynamic data}) async { + postCount += 1; + lastPostEndpoint = endpoint; + lastPostData = data; + if (postError != null) { + return Result.failure(postError); + } + return Result.success(postValue as T); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + group('ProductRepository.fetchLikedProducts', () { + test('uses the authenticated liked-products endpoint and parses products', () async { + final apiService = _RecordingApiService( + getValue: { + 'success': true, + 'data': { + 'products': [_productJson(id: 'liked-product')], + }, + 'meta': { + 'limit': 50, + 'nextCursor': null, + 'hasMore': false, + }, + }, + ); + final repository = ProductRepository(apiService); + + final result = await repository.fetchLikedProducts(); + + expect(result.isSuccess, isTrue); + expect(result.value, hasLength(1)); + expect(result.value!.single.id, 'liked-product'); + expect(apiService.lastGetEndpoint, ApiEndpoints.likedProducts); + expect(apiService.lastQueryParameters, {'limit': 50}); + }); + + test('returns an empty list for an empty API product list', () async { + final repository = ProductRepository( + _RecordingApiService( + getValue: { + 'success': true, + 'data': {'products': []}, + }, + ), + ); + + final result = await repository.fetchLikedProducts(); + + expect(result.isSuccess, isTrue); + expect(result.value, isEmpty); + }); + + test('returns an empty list for a success response without products', () async { + final repository = ProductRepository( + _RecordingApiService( + getValue: { + 'success': true, + 'data': {}, + }, + ), + ); + + final result = await repository.fetchLikedProducts(); + + expect(result.isSuccess, isTrue); + expect(result.value, isEmpty); + }); + + test('skips malformed and duplicate products without failing the page', () async { + final repository = ProductRepository( + _RecordingApiService( + getValue: { + 'success': true, + 'data': { + 'products': [ + _productJson(id: 'valid-product'), + const {'id': 'malformed-product'}, + _productJson(id: 'valid-product'), + 'not-a-product', + ], + }, + }, + ), + ); + + final result = await repository.fetchLikedProducts(); + + expect(result.isSuccess, isTrue); + expect(result.value, hasLength(1)); + expect(result.value!.single.id, 'valid-product'); + }); + + test('propagates API service failures', () async { + final repository = ProductRepository( + _RecordingApiService(getError: 'Network unavailable'), + ); + + final result = await repository.fetchLikedProducts(); + + expect(result.isSuccess, isFalse); + expect(result.error, 'Network unavailable'); + }); + + test('rejects unsuccessful API response envelopes', () async { + final repository = ProductRepository( + _RecordingApiService( + getValue: const { + 'success': false, + 'message': 'Unable to fetch liked products', + }, + ), + ); + + final result = await repository.fetchLikedProducts(); + + expect(result.isSuccess, isFalse); + expect(result.error, isNotEmpty); + }); + }); + + group('ProductRepository like updates', () { + test('likes a product through the API', () async { + final apiService = _RecordingApiService( + postValue: const { + 'success': true, + 'data': {'liked': true}, + }, + ); + final repository = ProductRepository(apiService); + + final result = await repository.likeProduct(_product()); + + expect(result.isSuccess, isTrue); + expect( + apiService.lastPostEndpoint, + ApiEndpoints.productLike('liked-product'), + ); + expect(apiService.lastPostData, {'like': true}); + }); + + test('unlikes a product through the same API endpoint', () async { + final apiService = _RecordingApiService( + postValue: const { + 'success': true, + 'data': {'liked': false}, + }, + ); + final repository = ProductRepository(apiService); + + final result = await repository.unlikeProduct('liked-product'); + + expect(result.isSuccess, isTrue); + expect( + apiService.lastPostEndpoint, + ApiEndpoints.productLike('liked-product'), + ); + expect(apiService.lastPostData, {'like': false}); + }); + + test('does not call the API for an invalid product ID', () async { + final apiService = _RecordingApiService(); + final repository = ProductRepository(apiService); + + final result = await repository.unlikeProduct('invalid/id'); + + expect(result.isSuccess, isFalse); + expect(result.error, 'Invalid product ID.'); + expect(apiService.postCount, 0); + }); + + test('propagates API service failures', () async { + final repository = ProductRepository( + _RecordingApiService(postError: 'Request failed'), + ); + + final result = await repository.likeProduct(_product()); + + expect(result.isSuccess, isFalse); + expect(result.error, 'Request failed'); + }); + + test('rejects a response that does not confirm the requested state', () async { + final repository = ProductRepository( + _RecordingApiService( + postValue: const { + 'success': true, + 'data': {'liked': false}, + }, + ), + ); + + final result = await repository.likeProduct(_product()); + + expect(result.isSuccess, isFalse); + expect(result.error, isNotEmpty); + }); + }); +} + +Product _product() { + return Product( + id: 'liked-product', + name: 'Liked item', + description: 'A liked test product', + quality: 'GOOD', + productImages: const [AppImages.product1], + donation: 7, + price: 7, + securityFee: 1, + likes: 1, + number: 1, + size: 'M', + postageSizeId: 'small', + ); +} + +Map _productJson({required String id}) { + return { + 'id': id, + 'name': 'Liked item', + 'description': 'A liked test product', + 'quality': 'GOOD', + 'product_images': ['https://example.com/liked-item.jpg'], + 'donation': 7, + 'price': 7, + 'securityFee': 1, + 'likes': 1, + 'number': 1, + 'size': 'M', + 'postageSize': 'small', + 'categoryId': 'category-1', + 'charityId': 'charity-1', + }; +} diff --git a/test/support/unexpected_api_service.dart b/test/support/unexpected_api_service.dart new file mode 100644 index 0000000..d79b7be --- /dev/null +++ b/test/support/unexpected_api_service.dart @@ -0,0 +1,10 @@ +import 'package:cherry_mvp/core/services/network/api_service.dart'; + +class UnexpectedApiService implements ApiService { + const UnexpectedApiService(); + + @override + dynamic noSuchMethod(Invocation invocation) { + throw StateError('Unexpected API request: ${invocation.memberName}'); + } +}