From 65a34a6215afe7dc22c3028f6ac7206ac7a07752 Mon Sep 17 00:00:00 2001 From: leoG Date: Mon, 9 Sep 2024 21:48:36 -0300 Subject: [PATCH 1/2] feat: added presentation layer to the Superformula Challenge chore: added unit and widget tests --- .fvm/fvm_config.json | 3 +- .fvmrc | 4 + .gitignore | 4 +- .vscode/settings.json | 14 +- README.md | 4 +- documentation.md | 77 +++++++ lib/core/config/strings.dart | 22 ++ lib/core/utils/router_manager.dart | 44 ++++ lib/main.dart | 29 ++- lib/models/restaurant.g.dart | 6 +- .../restaurant_details_page.dart | 50 +++++ .../restaurant_details_data_widget.dart | 116 +++++++++++ .../restaurant_details_favorite_button.dart | 26 +++ ...restaurant_details_review_list_widget.dart | 27 +++ .../restaurant_details_review_widget.dart | 54 +++++ .../restaurant_list/restaurant_list_page.dart | 38 ++++ .../restaurant_list_all_restaurants_tab.dart | 33 +++ ...aurant_list_favorites_restaurants_tab.dart | 22 ++ .../widgets/restaurant_list_card_widget.dart | 92 ++++++++ .../restaurant_list_categories_widget.dart | 26 +++ .../restaurant_list_rating_icons_widget.dart | 26 +++ .../providers/restaurant_provider.dart | 140 +++++++++++++ lib/repositories/yelp_repository.dart | 4 +- pubspec.lock | 196 ++++++++++-------- pubspec.yaml | 2 + test/repository_test.dart | 61 ++++++ test/repository_test.mocks.dart | 72 +++++++ test/unit_test_domain.dart | 117 +++++++++++ test/unit_test_presentation.dart | 79 +++++++ test/widget_test.dart | 61 ++++-- 30 files changed, 1322 insertions(+), 127 deletions(-) create mode 100644 .fvmrc create mode 100644 documentation.md create mode 100644 lib/core/config/strings.dart create mode 100644 lib/core/utils/router_manager.dart create mode 100644 lib/presentation/pages/restaurant_details/restaurant_details_page.dart create mode 100644 lib/presentation/pages/restaurant_details/widgets/restaurant_details_data_widget.dart create mode 100644 lib/presentation/pages/restaurant_details/widgets/restaurant_details_favorite_button.dart create mode 100644 lib/presentation/pages/restaurant_details/widgets/restaurant_details_review_list_widget.dart create mode 100644 lib/presentation/pages/restaurant_details/widgets/restaurant_details_review_widget.dart create mode 100644 lib/presentation/pages/restaurant_list/restaurant_list_page.dart create mode 100644 lib/presentation/pages/restaurant_list/tabs/restaurant_list_all_restaurants_tab.dart create mode 100644 lib/presentation/pages/restaurant_list/tabs/restaurant_list_favorites_restaurants_tab.dart create mode 100644 lib/presentation/pages/restaurant_list/widgets/restaurant_list_card_widget.dart create mode 100644 lib/presentation/pages/restaurant_list/widgets/restaurant_list_categories_widget.dart create mode 100644 lib/presentation/pages/restaurant_list/widgets/restaurant_list_rating_icons_widget.dart create mode 100644 lib/presentation/providers/restaurant_provider.dart create mode 100644 test/repository_test.dart create mode 100644 test/repository_test.mocks.dart create mode 100644 test/unit_test_domain.dart create mode 100644 test/unit_test_presentation.dart diff --git a/.fvm/fvm_config.json b/.fvm/fvm_config.json index 160b5b2..e7b55eb 100644 --- a/.fvm/fvm_config.json +++ b/.fvm/fvm_config.json @@ -1,4 +1,3 @@ { - "flutterSdkVersion": "3.22.3", - "flavors": {} + "flutterSdkVersion": "3.22.3" } \ No newline at end of file diff --git a/.fvmrc b/.fvmrc new file mode 100644 index 0000000..e03e940 --- /dev/null +++ b/.fvmrc @@ -0,0 +1,4 @@ +{ + "flutter": "3.22.3", + "flavors": {} +} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 1be2d87..7040cb0 100644 --- a/.gitignore +++ b/.gitignore @@ -46,4 +46,6 @@ app.*.map.json /android/app/release # fvm -.fvm/flutter_sdk \ No newline at end of file + +# FVM Version Cache +.fvm/ \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index f285aa4..c959187 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,9 +1,9 @@ { - "dart.flutterSdkPath": ".fvm/flutter_sdk", - "search.exclude": { - "**/.fvm": true - }, - "files.watcherExclude": { - "**/.fvm": true - } + "dart.flutterSdkPath": ".fvm/versions/3.22.3", + "search.exclude": { + "**/.fvm": true + }, + "files.watcherExclude": { + "**/.fvm": true + } } \ No newline at end of file diff --git a/README.md b/README.md index ff5ea9c..9dcecf7 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,10 @@ Welcome to Superformula's Coding challenge, we are excited to see what you can b This take home test aims to evaluate your skills in building a Flutter application. We are looking for a well-structured and well-tested application that demonstrates your knowledge of Flutter and the Dart language. -We are not looking for pixel perfect designs, but we are looking for a well-structured application that demonstrates your skills and best practices developing a flutter aplication. We know there are many ways to solve a problem, and we are interested in seeing how you approach this one. If you have any questions, please don't hesitate to ask. +We are not looking for pixel perfect designs, but we are looking for a well-structured application that demonstrates your skills and best practices developing a flutter application. We know there are many ways to solve a problem, and we are interested in seeing how you approach this one. If you have any questions, please don't hesitate to ask. Things we'll be looking on your submission: -- App structure for scallability +- App structure for scalability - Error and optional (?) handling - Widget tree optimization - State management diff --git a/documentation.md b/documentation.md new file mode 100644 index 0000000..ec8ddea --- /dev/null +++ b/documentation.md @@ -0,0 +1,77 @@ +# RestauranTour App Documentation + +## Overview + +The RestaurantTour app connects to the Yelp database to provide a list of restaurants, allowing users to view detailed restaurant information, and favorite restaurants. +The app follows a **Clean Architecture** design pattern, with **Riverpod** for state management, **Dio** for networking, and **Mockito** for API testing. + +## Architecture + +The project follows a **Clean Architecture** design pattern that organizes code distribution in a specific manner, with three primary outer layers as folders. + +### Layers: +1. **Presentation Layer**: + - Manages UI, Widgets, and State Management (using **Riverpod**). + +2. **Domain Layer**: + - Contains business logic, use cases, and entities. In this case, the layer is provided as `models`. + +3. **Data Layer**: + - Manages repositories, networking (using **Dio**), and data sources. In this case, the layer is provided as `repositories`. + +### Additional Folder: + +- **Core**: Contains functionality that may not fit well with the definition of the standard layers. In this project, the following subfolders were added: + - **Config**: Contains a `strings` file, allowing string manipulation to be detached from the app's logic. + - **Utils**: Stores the `router manager` for handling navigation. + +## State Management + +**Riverpod** was selected as the state manager for its ease of use. +In this project, Riverpod handles the state of the restaurant list and the favorites functionality. + +### Example: + +```dart +final restaurantListProvider = FutureProvider>((ref) async { + final repository = ref.watch(restaurantRepositoryProvider); + return repository.getRestaurants(); +}); +``` +## Routing and Navigation + +The app has three pages, and the following routes are used to navigate between them: +```dart +class Routes { + static const String main = '/'; + static const String restaurantList = '/restaurantList'; + static const String restaurantDetails = '/restaurantDetails'; +} +``` + +## Error Handling + +The main source of errors in this app comes from null values in the restaurant list. +We handle these errors at the UI level to prevent crashes. For example: + +```dart +Text(restaurant.price ?? '$$') +``` + +## Testing + +### Unit Tests + +1. **Presentation Layer**: + - Created tests that check whether the toggle favorite functionality correctly adds, contains, and deletes restaurants from the favorites list. + +2. **Domain Layer**: + - Created tests that ensure the fromJson and toJson methods produce the correct object and JSON respectively. + +3. **Data Layer**: + - Created tests that check whether the API call returns a valid list of restaurants. + +### Widget Tests + +The only meaningful widget that changes state based on user interaction is the Favorite icon in the Details Page. +A test was created to check if the icon changes properly every time a tap is performed. \ No newline at end of file diff --git a/lib/core/config/strings.dart b/lib/core/config/strings.dart new file mode 100644 index 0000000..1e8c331 --- /dev/null +++ b/lib/core/config/strings.dart @@ -0,0 +1,22 @@ +class AppStrings { + // General strings + static const appTitle = 'Restaurant App'; + static const loading = 'Loading...'; + static const errorMessage = 'Something went wrong. Please try again later.'; + + // Restaurant List Page strings + // Appbar + static const restaurantListTitle = 'RestauranTour'; + static const restaurantAllTab = 'All Restaurants'; + static const restaurantFavTab = 'My Favorites'; + static const restaurantOpen = 'Open Now'; + static const restaurantClosed = 'Closed'; + + // Restaurant Detail Page strings + static const restaurantDetailTitle = 'Restaurant Details'; + static const restaurantDetailEmpty = 'No details available.'; + static const restaurantDetailLocation = 'Address'; + static const restaurantDetailRating = 'Overall Rating'; + static const restaurantDetailReviews = 'reviews'; + +} \ No newline at end of file diff --git a/lib/core/utils/router_manager.dart b/lib/core/utils/router_manager.dart new file mode 100644 index 0000000..b257dfc --- /dev/null +++ b/lib/core/utils/router_manager.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import 'package:restaurant_tour/presentation/pages/restaurant_details/restaurant_details_page.dart'; + +import '../../main.dart'; +import '../../models/restaurant.dart'; +import '../../presentation/pages/restaurant_list/restaurant_list_page.dart'; +//import '../../presentation/pages/restaurant_details/restaurant_details_page.dart'; + +// Define all the route names here +class Routes { + static const String main = '/'; + static const String restaurantList = '/restaurantList'; + static const String restaurantDetails = '/restaurantDetails'; +} + +class RouterManager { + static Route generateRoute(RouteSettings settings) { + switch (settings.name) { + case Routes.main: + return MaterialPageRoute(builder: (_) => HomePage()); + case Routes.restaurantList: + return MaterialPageRoute(builder: (_) => RestaurantListPage()); + + case Routes.restaurantDetails: + final restaurant = settings.arguments as Restaurant; + return MaterialPageRoute( + builder: (_) => RestaurantDetailsPage(restaurant: restaurant), + ); + + default: + return _errorRoute(); // Error Route for unknown routes + } + } + + // Error route in case of invalid navigation + static Route _errorRoute() { + return MaterialPageRoute( + builder: (_) => Scaffold( + appBar: AppBar(title: const Text('Error')), + body: const Center(child: Text('Page not found')), + ), + ); + } +} \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 3a4af7d..9492b22 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,8 +1,12 @@ import 'package:flutter/material.dart'; -import 'package:restaurant_tour/repositories/yelp_repository.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '/core/utils/router_manager.dart'; +import '/repositories/yelp_repository.dart'; +import 'models/restaurant.dart'; void main() { - runApp(const RestaurantTour()); + runApp(const ProviderScope(child: RestaurantTour())); } class RestaurantTour extends StatelessWidget { @@ -12,7 +16,8 @@ class RestaurantTour extends StatelessWidget { Widget build(BuildContext context) { return const MaterialApp( title: 'Restaurant Tour', - home: HomePage(), + initialRoute: Routes.main, + onGenerateRoute: RouterManager.generateRoute, ); } } @@ -29,20 +34,12 @@ class HomePage extends StatelessWidget { children: [ const Text('Restaurant Tour'), ElevatedButton( - child: const Text('Fetch Restaurants'), + child: const Text('Go To Restaurants List'), onPressed: () async { - final yelpRepo = YelpRepository(); - - try { - final result = await yelpRepo.getRestaurants(); - if (result != null) { - print('Fetched ${result.restaurants!.length} restaurants'); - } else { - print('No restaurants fetched'); - } - } catch (e) { - print('Failed to fetch restaurants: $e'); - } + Navigator.pushNamed( + context, + Routes.restaurantList, + ); }, ), ], diff --git a/lib/models/restaurant.g.dart b/lib/models/restaurant.g.dart index 3ed33f9..dea6677 100644 --- a/lib/models/restaurant.g.dart +++ b/lib/models/restaurant.g.dart @@ -38,15 +38,17 @@ Map _$UserToJson(User instance) => { Review _$ReviewFromJson(Map json) => Review( id: json['id'] as String?, - rating: json['rating'] as int?, + rating: (json['rating'] as num?)?.toInt(), user: json['user'] == null ? null : User.fromJson(json['user'] as Map), + text: json['text'] as String?, ); Map _$ReviewToJson(Review instance) => { 'id': instance.id, 'rating': instance.rating, + 'text': instance.text, 'user': instance.user, }; @@ -95,7 +97,7 @@ Map _$RestaurantToJson(Restaurant instance) => RestaurantQueryResult _$RestaurantQueryResultFromJson( Map json) => RestaurantQueryResult( - total: json['total'] as int?, + total: (json['total'] as num?)?.toInt(), restaurants: (json['business'] as List?) ?.map((e) => Restaurant.fromJson(e as Map)) .toList(), diff --git a/lib/presentation/pages/restaurant_details/restaurant_details_page.dart b/lib/presentation/pages/restaurant_details/restaurant_details_page.dart new file mode 100644 index 0000000..4ef4461 --- /dev/null +++ b/lib/presentation/pages/restaurant_details/restaurant_details_page.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import 'package:restaurant_tour/presentation/pages/restaurant_details/widgets/restaurant_details_favorite_button.dart'; +import '../../../core/config/strings.dart'; +import '../../../models/restaurant.dart'; +import '../restaurant_list/widgets/restaurant_list_categories_widget.dart'; +import 'widgets/restaurant_details_data_widget.dart'; +import 'widgets/restaurant_details_review_list_widget.dart'; + +class RestaurantDetailsPage extends StatelessWidget { + final Restaurant restaurant; + + const RestaurantDetailsPage({super.key, required this.restaurant}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + actions: [ + RestaurantDetailsFavoriteButton(restaurant: restaurant) + ], + centerTitle: true, + title: Text( + restaurant.name ?? 'Unknown Restaurant', + style: const TextStyle( + fontFamily: 'Lora', + ), + ), + ), + body: ListView( + children: [ + // Main photo + SizedBox.square( + dimension: MediaQuery.sizeOf(context).width, + child: Hero( + tag: restaurant.id ?? 'heroImage', + child: Image.network( + restaurant.heroImage, + fit: BoxFit.fitHeight, + ), + ), + ), + // Price/Category/Open-closed row + RestaurantDetailsDataWidget(restaurant: restaurant,), + // Reviews + RestaurantDetailsReviewListWidget(reviews: restaurant.reviews ?? [],), + ], + ), + ); + } +} diff --git a/lib/presentation/pages/restaurant_details/widgets/restaurant_details_data_widget.dart b/lib/presentation/pages/restaurant_details/widgets/restaurant_details_data_widget.dart new file mode 100644 index 0000000..b323e69 --- /dev/null +++ b/lib/presentation/pages/restaurant_details/widgets/restaurant_details_data_widget.dart @@ -0,0 +1,116 @@ +import 'package:flutter/material.dart'; +import '../../../../core/config/strings.dart'; +import '../../../../models/restaurant.dart'; +import '../../../../typography.dart'; +import '../../restaurant_list/widgets/restaurant_list_categories_widget.dart'; +import 'restaurant_details_review_list_widget.dart'; + +class RestaurantDetailsDataWidget extends StatelessWidget { + final Restaurant restaurant; + + const RestaurantDetailsDataWidget({super.key, required this.restaurant}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(24), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + restaurant.price ?? '\$\$', + style: AppTextStyles.openRegularText, + ), + const SizedBox( + width: 10, + ), + // Category + RestaurantListCategoriesWidget( + categories: restaurant.categories!, + ), + const Expanded( + child: SizedBox(), + ), + Row( + children: [ + restaurant.isOpen + ? const Text(AppStrings.restaurantOpen) + : const Text(AppStrings.restaurantClosed), + const SizedBox( + width: 10, + ), + Icon( + Icons.circle, + color: restaurant.isOpen ? Colors.green : Colors.red, + size: 10, + ), + ], + ), + ], + ), + ), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 24), + child: Divider(), + ), + // Address + const Padding( + padding: EdgeInsets.all(24), + child: Text( + AppStrings.restaurantDetailLocation, + style: AppTextStyles.openRegularText, + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 24), + child: Text( + restaurant.location!.formattedAddress!, + style: AppTextStyles.loraRegularTitle, + ), + ), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 24), + child: Divider(), + ), + // Overall Rating + const Padding( + padding: EdgeInsets.all(24), + child: Text( + AppStrings.restaurantDetailRating, + style: AppTextStyles.openRegularText, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Row( + children: [ + Text( + restaurant.rating.toString(), + style: AppTextStyles.loraRegularHeadline, + ), + const Icon( + Icons.star, + color: Colors.yellow, + size: 12, + ), + ], + ), + ), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 24), + child: Divider(), + ), + Padding( + padding: const EdgeInsets.all(24), + child: Text( + '${restaurant.reviews?.length ?? 0} Reviews', + style: AppTextStyles.openRegularText, + ), + ), + ], + ); + } +} diff --git a/lib/presentation/pages/restaurant_details/widgets/restaurant_details_favorite_button.dart b/lib/presentation/pages/restaurant_details/widgets/restaurant_details_favorite_button.dart new file mode 100644 index 0000000..02e546c --- /dev/null +++ b/lib/presentation/pages/restaurant_details/widgets/restaurant_details_favorite_button.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../providers/restaurant_provider.dart'; + +import '../../../../models/restaurant.dart'; + +class RestaurantDetailsFavoriteButton extends ConsumerWidget { + final Restaurant restaurant; + + const RestaurantDetailsFavoriteButton({super.key, required this.restaurant}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final favorites = ref.watch(favoritesListProvider); + final isFavorite = favorites.any((r) => r.id == restaurant.id); + return IconButton( + onPressed: () { + ref.read(favoritesListProvider.notifier).toggleFavorite(restaurant); + }, + icon: Icon( + isFavorite ? Icons.favorite : Icons.favorite_border, + color: isFavorite ? Colors.red : null, + ), + ); + } +} diff --git a/lib/presentation/pages/restaurant_details/widgets/restaurant_details_review_list_widget.dart b/lib/presentation/pages/restaurant_details/widgets/restaurant_details_review_list_widget.dart new file mode 100644 index 0000000..f00ff66 --- /dev/null +++ b/lib/presentation/pages/restaurant_details/widgets/restaurant_details_review_list_widget.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; +import 'package:restaurant_tour/presentation/pages/restaurant_details/widgets/restaurant_details_review_widget.dart'; +import '../../../../core/config/strings.dart'; +import '../../../../models/restaurant.dart'; +import '../../restaurant_list/widgets/restaurant_list_categories_widget.dart'; + +class RestaurantDetailsReviewListWidget extends StatelessWidget { + final List reviews; + + const RestaurantDetailsReviewListWidget({super.key, required this.reviews}); + + @override + Widget build(BuildContext context) { + return ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: reviews.length, + separatorBuilder: (BuildContext context, int index) => const Padding( + padding: EdgeInsets.symmetric(horizontal: 24), + child: Divider(), + ), + itemBuilder: (context, index) { + return RestaurantDetailsReviewsWidget(review: reviews[index]); + }, + ); + } +} diff --git a/lib/presentation/pages/restaurant_details/widgets/restaurant_details_review_widget.dart b/lib/presentation/pages/restaurant_details/widgets/restaurant_details_review_widget.dart new file mode 100644 index 0000000..e146973 --- /dev/null +++ b/lib/presentation/pages/restaurant_details/widgets/restaurant_details_review_widget.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import '../../../../core/config/strings.dart'; +import '../../../../models/restaurant.dart'; +import '../../../../typography.dart'; +import '../../restaurant_list/widgets/restaurant_list_categories_widget.dart'; +import '../../restaurant_list/widgets/restaurant_list_rating_icons_widget.dart'; + +class RestaurantDetailsReviewsWidget extends StatelessWidget { + final Review review; + + const RestaurantDetailsReviewsWidget({super.key, required this.review}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + RestaurantListRatingIconsWidget(rating: review.rating?.round() ?? 0), + Padding( + padding: const EdgeInsets.symmetric(vertical: 24), + child: Text(review.text ?? 'No comment'), + ), + Row( + children: [ + review.user!.imageUrl != null + ? ClipOval( + child: SizedBox.fromSize( + size: const Size.fromRadius(20), + child: Image.network( + review.user!.imageUrl!, + height: 40, + width: 40, + fit: BoxFit.cover, + ), + ), + ) + : const Icon( + Icons.person, + size: 40, + ), + const SizedBox(width: 10,), + Text( + review.user?.name ?? 'Anonymous', + style: AppTextStyles.openRegularText, + ), + ], + ) + ], + ), + ); + } +} diff --git a/lib/presentation/pages/restaurant_list/restaurant_list_page.dart b/lib/presentation/pages/restaurant_list/restaurant_list_page.dart new file mode 100644 index 0000000..88b9150 --- /dev/null +++ b/lib/presentation/pages/restaurant_list/restaurant_list_page.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import '../../../core/config/strings.dart'; +import 'tabs/restaurant_list_all_restaurants_tab.dart'; +import 'tabs/restaurant_list_favorites_restaurants_tab.dart'; + +class RestaurantListPage extends StatelessWidget { + const RestaurantListPage({super.key}); + + @override + Widget build(BuildContext context) { + return DefaultTabController( + length: 2, + child: Scaffold( + appBar: AppBar( + bottom: const TabBar( + tabs: [ + Tab(icon: Text(AppStrings.restaurantAllTab),), + Tab(icon: Text(AppStrings.restaurantFavTab),), + ], + ), + centerTitle: true, + title: const Text( + AppStrings.restaurantListTitle, + style: TextStyle( + fontFamily: 'Lora', + ), + ), + ), + body: const TabBarView( + children: [ + RestaurantListAllRestaurantsWidget(), + RestaurantListFavoritesRestaurantsWidget(), + ], + ), + ), + ); + } +} diff --git a/lib/presentation/pages/restaurant_list/tabs/restaurant_list_all_restaurants_tab.dart b/lib/presentation/pages/restaurant_list/tabs/restaurant_list_all_restaurants_tab.dart new file mode 100644 index 0000000..3f996f6 --- /dev/null +++ b/lib/presentation/pages/restaurant_list/tabs/restaurant_list_all_restaurants_tab.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../providers/restaurant_provider.dart'; +import '../widgets/restaurant_list_card_widget.dart'; + +class RestaurantListAllRestaurantsWidget extends ConsumerWidget { + const RestaurantListAllRestaurantsWidget({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final restaurantAsyncValue = ref.watch(restaurantListProvider); + + return restaurantAsyncValue.when( + data: (restaurants) { + if (restaurants.isEmpty) { + return const Center(child: Text('No restaurants found.')); + } + return ListView.builder( + itemCount: restaurants.length, + itemBuilder: (context, index) { + final restaurant = restaurants[index]; + return RestaurantListCardWidget(restaurant: restaurant); + }, + ); + }, + loading: () => const Center( + child: CircularProgressIndicator(),), // Show a loading spinner + error: (error, stack) => + Center(child: Text('Error: $error')), // Handle errors + ); + } +} diff --git a/lib/presentation/pages/restaurant_list/tabs/restaurant_list_favorites_restaurants_tab.dart b/lib/presentation/pages/restaurant_list/tabs/restaurant_list_favorites_restaurants_tab.dart new file mode 100644 index 0000000..6a9dabd --- /dev/null +++ b/lib/presentation/pages/restaurant_list/tabs/restaurant_list_favorites_restaurants_tab.dart @@ -0,0 +1,22 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../providers/restaurant_provider.dart'; +import '../widgets/restaurant_list_card_widget.dart'; + +class RestaurantListFavoritesRestaurantsWidget extends ConsumerWidget { + const RestaurantListFavoritesRestaurantsWidget({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final restaurants = ref.watch(favoritesListProvider); + + return ListView.builder( + itemCount: restaurants.length, + itemBuilder: (context, index) { + final restaurant = restaurants[index]; + return RestaurantListCardWidget(restaurant: restaurant); + }, + ); + } +} diff --git a/lib/presentation/pages/restaurant_list/widgets/restaurant_list_card_widget.dart b/lib/presentation/pages/restaurant_list/widgets/restaurant_list_card_widget.dart new file mode 100644 index 0000000..c846e06 --- /dev/null +++ b/lib/presentation/pages/restaurant_list/widgets/restaurant_list_card_widget.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; +import 'package:restaurant_tour/models/restaurant.dart'; + +import '../../../../core/config/strings.dart'; +import '../../../../core/utils/router_manager.dart'; +import '../../../../typography.dart'; +import 'restaurant_list_categories_widget.dart'; +import 'restaurant_list_rating_icons_widget.dart'; + +class RestaurantListCardWidget extends StatelessWidget { + final Restaurant restaurant; + + const RestaurantListCardWidget({super.key, required this.restaurant}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(8.0), + child: Card( + child: ListTile( + leading: SizedBox( + height: 88, + width: 88, + child: InkWell( + onTap: () { + Navigator.pushNamed( + context, + Routes.restaurantDetails, + arguments: restaurant, + ); + }, + child: Hero( + tag: restaurant.id ?? 'heroImage', + child: Image.network( + restaurant.heroImage, + fit: BoxFit.fitHeight, + ), + ), + ), + ), + title: Text( + restaurant.name ?? 'Unknown Restaurant', + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: AppTextStyles.loraRegularTitle, + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + // Price + Text(restaurant.price!), + const SizedBox( + width: 10, + ), + // Category + RestaurantListCategoriesWidget( + categories: restaurant.categories ?? [], + ), + ], + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // Rating + RestaurantListRatingIconsWidget(rating: (restaurant.rating ?? 0).round()), + // Open/Closed + Row( + children: [ + restaurant.isOpen + ? const Text(AppStrings.restaurantOpen) + : const Text(AppStrings.restaurantClosed), + const SizedBox( + width: 10, + ), + Icon( + Icons.circle, + color: restaurant.isOpen ? Colors.green : Colors.red, + size: 10, + ), + ], + ), + ], + ) + ], + ), + ), + ), + ); + } +} diff --git a/lib/presentation/pages/restaurant_list/widgets/restaurant_list_categories_widget.dart b/lib/presentation/pages/restaurant_list/widgets/restaurant_list_categories_widget.dart new file mode 100644 index 0000000..3b16a91 --- /dev/null +++ b/lib/presentation/pages/restaurant_list/widgets/restaurant_list_categories_widget.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; + +import '../../../../models/restaurant.dart' as model; +import '../../../../typography.dart'; + +class RestaurantListCategoriesWidget extends StatelessWidget { + final List categories; + + RestaurantListCategoriesWidget({super.key, required this.categories}); + String response = ''; + + @override + Widget build(BuildContext context) { + if (categories == null) { + return const Text(''); + } + for (int i = 0; i < categories.length; i++) { + response += '${categories[i].alias!} '; + } + return Text( + response, + overflow: TextOverflow.ellipsis, + style: AppTextStyles.openRegularText, + ); + } +} diff --git a/lib/presentation/pages/restaurant_list/widgets/restaurant_list_rating_icons_widget.dart b/lib/presentation/pages/restaurant_list/widgets/restaurant_list_rating_icons_widget.dart new file mode 100644 index 0000000..65af197 --- /dev/null +++ b/lib/presentation/pages/restaurant_list/widgets/restaurant_list_rating_icons_widget.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; + +class RestaurantListRatingIconsWidget extends StatelessWidget { + final int rating; + + const RestaurantListRatingIconsWidget({super.key, required this.rating}); + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 15, + width: 60, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: rating, + itemBuilder: (context, index) { + return const Icon( + Icons.star, + color: Colors.yellow, + size: 12, + ); + }, + ), + ); + } +} diff --git a/lib/presentation/providers/restaurant_provider.dart b/lib/presentation/providers/restaurant_provider.dart new file mode 100644 index 0000000..d1fd754 --- /dev/null +++ b/lib/presentation/providers/restaurant_provider.dart @@ -0,0 +1,140 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../repositories/yelp_repository.dart'; // Import your repository +import '../../models/restaurant.dart'; + +// Provider that returns a list of Restaurants +final restaurantListProvider = FutureProvider>((ref) async { + + final yelpRepo = YelpRepository(); + final result = await yelpRepo.getRestaurants(); + if (result != null) { + print('Fetched ${result.restaurants!.length} restaurants'); + List restaurants = List.from(result.restaurants!); + return restaurants; + } else { + print('No restaurants fetched'); + return []; + } +}); + +// StateNotifier for managing favorites list +class FavoritesNotifier extends StateNotifier> { + FavoritesNotifier() : super([]); + + // Add or remove a restaurant from the favorites list + void toggleFavorite(Restaurant restaurant) { + // Check if the restaurant is already in the list + if (state.any((r) => r.id == restaurant.id)) { + // If restaurant is already in the list, remove it + removeFavorite(restaurant); + } else { + // If not in the list, add it + addFavorite(restaurant); + } + } + + // Add a restaurant to favorites + void addFavorite(Restaurant restaurant) { + state = [...state, restaurant]; // Add restaurant to the list + } + + // Remove a restaurant from favorites + void removeFavorite(Restaurant restaurant) { + state = state.where((r) => r.id != restaurant.id).toList(); // Remove restaurant by id + } +} + +// Create a provider for the favorites list +final favoritesListProvider = StateNotifierProvider>((ref) { + return FavoritesNotifier(); +}); + +/*List list = []; + + Restaurant a = Restaurant( + id: '1', + name: 'restaurant a', + price: 'price', + rating: 3.6, + photos: [ + 'https://media.cntraveler.com/photos/654bd5e13892537a8ded0947/master/pass/phy2023.din.oss.restaurant-lr.jpg' + ], + categories: [ + Category(alias: 'italian', title: 'Italian'), + Category(alias: 'pasta', title: 'Pasta') + ], + hours: [const Hours(isOpenNow: true)], + reviews: [ + const Review( + id: "sjZoO8wcK1NeGJFDk5i82Q", + rating: 5, + user: User( + id: "BuBCkWFNT_O2dbSnBZvpoQ", + imageUrl: + "https://s3-media2.fl.yelpcdn.com/photo/v8tbTjYaFvkzh1d7iE-pcQ/o.jpg", + name: "Gina T.", + ), + text: + "I love this place! The food is amazing and the service is great."), + const Review( + id: "okpO9hfpxQXssbTZTKq9hA", + rating: 5, + user: User( + id: "0x9xu_b0Ct_6hG6jaxpztw", + imageUrl: + "https://s3-media3.fl.yelpcdn.com/photo/gjz8X6tqE3e4praK4HfCiA/o.jpg", + name: "Crystal L.", + ), + text: "Greate place to eat", + ), + ], + location: Location( + formattedAddress: '102 Lakeside Ave Seattle, WA 98122', + ), + ); + Restaurant b = Restaurant( + id: '2', + name: 'restaurant b', + price: 'price', + rating: 2.4, + photos: [ + 'https://media.cntraveler.com/photos/6552541546a7d1d8bdf5122d/master/w_1600,c_limit/DSC00781%20copy.jpg' + ], + categories: [ + Category(alias: 'asian', title: 'Asian'), + Category(alias: 'ramen', title: 'Ramen') + ], + hours: [const Hours(isOpenNow: false)], + reviews: [ + const Review( + id: "sjZoO8wcK1NeGJFDk5i82Q", + rating: 5, + user: User( + id: "BuBCkWFNT_O2dbSnBZvpoQ", + imageUrl: + "https://s3-media2.fl.yelpcdn.com/photo/v8tbTjYaFvkzh1d7iE-pcQ/o.jpg", + name: "Gina T.", + ), + text: + "I love this place! The food is amazing and the service is great."), + const Review( + id: "okpO9hfpxQXssbTZTKq9hA", + rating: 5, + user: User( + id: "0x9xu_b0Ct_6hG6jaxpztw", + imageUrl: + "https:///s3-media3.fl.yelpcdn.com/photo/gjz8X6tqE3e4praK4HfCiA/o.jpg", + name: "Crystal L.", + ), + text: "Greate place to eat", + ), + ], + location: Location( + formattedAddress: '102 Lakeside Ave Seattle, WA 98122', + ), + ); + + list.add(a); + list.add(b); + return list;*/ \ No newline at end of file diff --git a/lib/repositories/yelp_repository.dart b/lib/repositories/yelp_repository.dart index 9eab02a..5b1569f 100644 --- a/lib/repositories/yelp_repository.dart +++ b/lib/repositories/yelp_repository.dart @@ -2,7 +2,7 @@ import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; import 'package:restaurant_tour/models/restaurant.dart'; -const _apiKey = ''; +const _apiKey = ''; class YelpRepository { late Dio dio; @@ -75,7 +75,7 @@ class YelpRepository { String _getQuery(int offset) { return ''' query getRestaurants { - search(location: "Las Vegas", limit: 20, offset: $offset) { + search(location: "Las Vegas", limit: 2, offset: $offset) { total business { id diff --git a/pubspec.lock b/pubspec.lock index 27b6e40..d599111 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,26 +5,26 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: ae92f5d747aee634b87f89d9946000c2de774be1d6ac3e58268224348cd0101a + sha256: "0b2f2bd91ba804e53a61d757b986f89f1f9eaed5b11e4b2f5a2468d86d6c9fc7" url: "https://pub.dev" source: hosted - version: "61.0.0" + version: "67.0.0" analyzer: dependency: transitive description: name: analyzer - sha256: ea3d8652bda62982addfd92fdc2d0214e5f82e43325104990d4f4c4a2a313562 + sha256: "37577842a27e4338429a1cbc32679d508836510b056f1eedf0c8d20e39c1383d" url: "https://pub.dev" source: hosted - version: "5.13.0" + version: "6.4.1" args: dependency: transitive description: name: args - sha256: "0bd9a99b6eb96f07af141f0eb53eace8983e8e5aa5de59777aca31684680ef22" + sha256: "7cf60b9f0cc88203c5a190b4cd62a99feea42759a7fa695010eb5de1c0b2252a" url: "https://pub.dev" source: hosted - version: "2.3.0" + version: "2.5.0" async: dependency: transitive description: @@ -45,10 +45,10 @@ packages: dependency: transitive description: name: build - sha256: "3fbda25365741f8251b39f3917fb3c8e286a96fd068a5a242e11c2012d495777" + sha256: "80184af8b6cb3e5c1c4ec6d8544d27711700bc3e6d2efad04238c7b5290889f0" url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.4.1" build_config: dependency: transitive description: @@ -61,18 +61,18 @@ packages: dependency: transitive description: name: build_daemon - sha256: "5f02d73eb2ba16483e693f80bee4f088563a820e47d1027d4cdfe62b5bb43e65" + sha256: "79b2aef6ac2ed00046867ed354c88778c9c0f029df8a20fe10b5436826721ef9" url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "4.0.2" build_resolvers: dependency: transitive description: name: build_resolvers - sha256: "6c4dd11d05d056e76320b828a1db0fc01ccd376922526f8e9d6c796a5adbac20" + sha256: "339086358431fa15d7eca8b6a36e5d783728cf025e559b834f4609a1fcfb7b0a" url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "2.4.2" build_runner: dependency: "direct dev" description: @@ -85,10 +85,10 @@ packages: dependency: transitive description: name: build_runner_core - sha256: f4d6244cc071ba842c296cb1c4ee1b31596b9f924300647ac7a1445493471a3f + sha256: e3c79f69a64bdfcd8a776a3c28db4eb6e3fb5356d013ae5eb2e52007706d5dbe url: "https://pub.dev" source: hosted - version: "7.2.3" + version: "7.3.1" built_collection: dependency: transitive description: @@ -101,10 +101,10 @@ packages: dependency: transitive description: name: built_value - sha256: b6c9911b2d670376918d5b8779bc27e0e612a94ec3ff0343689e991d8d0a3b8a + sha256: c7913a9737ee4007efedaffc968c049fd0f3d0e49109e778edc10de9426005cb url: "https://pub.dev" source: hosted - version: "8.1.4" + version: "8.9.2" characters: dependency: transitive description: @@ -113,22 +113,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" - charcode: - dependency: transitive - description: - name: charcode - sha256: fb98c0f6d12c920a02ee2d998da788bca066ca5f148492b7085ee23372b12306 - url: "https://pub.dev" - source: hosted - version: "1.3.1" checked_yaml: dependency: transitive description: name: checked_yaml - sha256: dd007e4fb8270916820a0d66e24f619266b60773cddd082c6439341645af2659 + sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "2.0.3" clock: dependency: transitive description: @@ -157,42 +149,42 @@ packages: dependency: transitive description: name: convert - sha256: f08428ad63615f96a27e34221c65e1a451439b5f26030f78d790f461c686d65d + sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.1.1" crypto: dependency: transitive description: name: crypto - sha256: cf75650c66c0316274e21d7c43d3dea246273af5955bd94e8184837cd577575c + sha256: ec30d999af904f33454ba22ed9a86162b35e52b44ac4807d1d93c288041d7d27 url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.5" dart_style: dependency: transitive description: name: dart_style - sha256: "1efa911ca7086affd35f463ca2fc1799584fb6aa89883cf0af8e3664d6a02d55" + sha256: "99e066ce75c89d6b29903d788a7bb9369cf754f7b24bf70bf4b6d6d6b26853b9" url: "https://pub.dev" source: hosted - version: "2.3.2" + version: "2.3.6" dio: dependency: "direct main" description: name: dio - sha256: "0dfb6b6a1979dac1c1245e17cef824d7b452ea29bd33d3467269f9bef3715fb0" + sha256: "5598aa796bbf4699afd5c67c0f5f6e2ed542afc956884b9cd58c306966efc260" url: "https://pub.dev" source: hosted - version: "5.6.0" + version: "5.7.0" dio_web_adapter: dependency: transitive description: name: dio_web_adapter - sha256: "36c5b2d79eb17cdae41e974b7a8284fec631651d2a6f39a8a2ff22327e90aeac" + sha256: "33259a9276d6cea88774a0000cfae0d861003497755969c92faa223108620dc8" url: "https://pub.dev" source: hosted - version: "1.0.1" + version: "2.0.0" fake_async: dependency: transitive description: @@ -205,18 +197,18 @@ packages: dependency: transitive description: name: file - sha256: b69516f2c26a5bcac4eee2e32512e1a5205ab312b3536c1c1227b2b942b5f9ad + sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c" url: "https://pub.dev" source: hosted - version: "6.1.2" + version: "7.0.0" fixnum: dependency: transitive description: name: fixnum - sha256: "6a2ef17156f4dc49684f9d99aaf4a93aba8ac49f5eac861755f5730ddf6e2e4e" + sha256: "25517a4deb0c03aa0f32fd12db525856438902d9c16536311e76cdc57b31d7d1" url: "https://pub.dev" source: hosted - version: "1.0.0" + version: "1.1.0" flutter: dependency: "direct main" description: flutter @@ -230,6 +222,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" + flutter_riverpod: + dependency: "direct main" + description: + name: flutter_riverpod + sha256: "0f1974eff5bbe774bf1d870e406fc6f29e3d6f1c46bd9c58e7172ff68a785d7d" + url: "https://pub.dev" + source: hosted + version: "2.5.1" flutter_svg: dependency: "direct main" description: @@ -247,26 +247,26 @@ packages: dependency: transitive description: name: frontend_server_client - sha256: "408e3ca148b31c20282ad6f37ebfa6f4bdc8fede5b74bc2f08d9d92b55db3612" + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 url: "https://pub.dev" source: hosted - version: "3.2.0" + version: "4.0.0" glob: dependency: transitive description: name: glob - sha256: "8321dd2c0ab0683a91a51307fa844c6db4aa8e3981219b78961672aaab434658" + sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63" url: "https://pub.dev" source: hosted - version: "2.0.2" + version: "2.1.2" graphs: dependency: transitive description: name: graphs - sha256: aedc5a15e78fc65a6e23bcd927f24c64dd995062bcd1ca6eda65a3cff92a4d19 + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.3.2" http: dependency: transitive description: @@ -279,34 +279,34 @@ packages: dependency: transitive description: name: http_multi_server - sha256: bfb651625e251a88804ad6d596af01ea903544757906addcb2dcdf088b5ea185 + sha256: "97486f20f9c2f7be8f514851703d0119c3596d14ea63227af6f7a481ef2b2f8b" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.2.1" http_parser: dependency: transitive description: name: http_parser - sha256: e362d639ba3bc07d5a71faebb98cde68c05bfbcfbbb444b60b6f60bb67719185 + sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "4.0.2" io: dependency: transitive description: name: io - sha256: "0d4c73c3653ab85bf696d51a9657604c900a370549196a91f33e4c39af760852" + sha256: "2ec25704aba361659e10e3e5f5d672068d332fc8ac516421d483a11e5cbd061e" url: "https://pub.dev" source: hosted - version: "1.0.3" + version: "1.0.4" js: dependency: transitive description: name: js - sha256: d9bdfd70d828eeb352390f81b18d6a354ef2044aa28ef25682079797fa7cd174 + sha256: c1b2e9b5ea78c45e1a0788d29606ba27dc5f71f019f32ca5140f61ef071838cf url: "https://pub.dev" source: hosted - version: "0.6.3" + version: "0.7.1" json_annotation: dependency: "direct main" description: @@ -359,10 +359,10 @@ packages: dependency: transitive description: name: logging - sha256: "293ae2d49fd79d4c04944c3a26dfd313382d5f52e821ec57119230ae16031ad4" + sha256: "623a88c9594aa774443aa3eb2d41807a48486b5613e67599fb4c41c0ad47c340" url: "https://pub.dev" source: hosted - version: "1.0.2" + version: "1.2.0" matcher: dependency: transitive description: @@ -391,18 +391,26 @@ packages: dependency: transitive description: name: mime - sha256: fd5f81041e6a9fc9b9d7fa2cb8a01123f9f5d5d49136e06cb9dc7d33689529f4 + sha256: "801fd0b26f14a4a58ccb09d5892c3fbdeff209594300a542492cf13fba9d247a" url: "https://pub.dev" source: hosted - version: "1.0.1" + version: "1.0.6" + mockito: + dependency: "direct dev" + description: + name: mockito + sha256: "6841eed20a7befac0ce07df8116c8b8233ed1f4486a7647c7fc5a02ae6163917" + url: "https://pub.dev" + source: hosted + version: "5.4.4" package_config: dependency: transitive description: name: package_config - sha256: a4d5ede5ca9c3d88a2fef1147a078570c861714c806485c596b109819135bc12 + sha256: "1c5b77ccc91e4823a5af61ee74e6b972db1ef98c2ff5a18d3161c982a55448bd" url: "https://pub.dev" source: hosted - version: "2.0.2" + version: "2.1.0" path: dependency: transitive description: @@ -431,42 +439,50 @@ packages: dependency: transitive description: name: pool - sha256: "05955e3de2683e1746222efd14b775df7131139e07695dc8e24650f6b4204504" + sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" url: "https://pub.dev" source: hosted - version: "1.5.0" + version: "1.5.1" pub_semver: dependency: transitive description: name: pub_semver - sha256: b5a5fcc6425ea43704852ba4453ba94b08c2226c63418a260240c3a054579014 + sha256: "40d3ab1bbd474c4c2328c91e3a7df8c6dd629b79ece4c4bd04bee496a224fb0c" url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.1.4" pubspec_parse: dependency: transitive description: name: pubspec_parse - sha256: "3686efe4a4613a4449b1a4ae08670aadbd3376f2e78d93e3f8f0919db02a7256" + sha256: c799b721d79eb6ee6fa56f00c04b472dcd44a30d258fac2174a6ec57302678f8 url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.3.0" + riverpod: + dependency: transitive + description: + name: riverpod + sha256: f21b32ffd26a36555e501b04f4a5dca43ed59e16343f1a30c13632b2351dfa4d + url: "https://pub.dev" + source: hosted + version: "2.5.1" shelf: dependency: transitive description: name: shelf - sha256: c240984c924796e055e831a0a36db23be8cb04f170b26df572931ab36418421d + sha256: ad29c505aee705f41a4d8963641f91ac4cee3c8fad5947e033390a7bd8180fa4 url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.4.1" shelf_web_socket: dependency: transitive description: name: shelf_web_socket - sha256: fd84910bf7d58db109082edf7326b75322b8f186162028482f53dc892f00332d + sha256: "073c147238594ecd0d193f3456a5fe91c4b0abbcc68bf5cd95b36c4e194ac611" url: "https://pub.dev" source: hosted - version: "1.0.1" + version: "2.0.0" sky_engine: dependency: transitive description: flutter @@ -504,6 +520,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.11.1" + state_notifier: + dependency: transitive + description: + name: state_notifier + sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb + url: "https://pub.dev" + source: hosted + version: "1.0.0" stream_channel: dependency: transitive description: @@ -516,10 +540,10 @@ packages: dependency: transitive description: name: stream_transform - sha256: ed464977cb26a1f41537e177e190c67223dbd9f4f683489b6ab2e5d211ec564e + sha256: "14a00e794c7c11aa145a170587321aedce29769c08d7f58b1d141da75e3b1c6f" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.1.0" string_scanner: dependency: transitive description: @@ -548,18 +572,18 @@ packages: dependency: transitive description: name: timing - sha256: c386d07d7f5efc613479a7c4d9d64b03710b03cfaa7e8ad5f2bfb295a1f0dfad + sha256: "70a3b636575d4163c477e6de42f247a23b315ae20e86442bebe32d3cabf61c32" url: "https://pub.dev" source: hosted - version: "1.0.0" + version: "1.0.1" typed_data: dependency: transitive description: name: typed_data - sha256: "53bdf7e979cfbf3e28987552fd72f637e63f3c8724c9e56d9246942dc2fa36ee" + sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.3.2" vector_graphics: dependency: transitive description: @@ -604,10 +628,10 @@ packages: dependency: transitive description: name: watcher - sha256: e42dfcc48f67618344da967b10f62de57e04bae01d9d3af4c2596f3712a88c99 + sha256: "3d2ad6751b3c16cf07c7fca317a1413b3f26530319181b37e3b9039b84fc01d8" url: "https://pub.dev" source: hosted - version: "1.0.1" + version: "1.1.0" web: dependency: transitive description: @@ -616,14 +640,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "3c12d96c0c9a4eec095246debcea7b86c0324f22df69893d538fcc6f1b8cce83" + url: "https://pub.dev" + source: hosted + version: "0.1.6" web_socket_channel: dependency: transitive description: name: web_socket_channel - sha256: "0c2ada1b1aeb2ad031ca81872add6be049b8cb479262c6ad3c4b0f9c24eaab2f" + sha256: "9f187088ed104edd8662ca07af4b124465893caf063ba29758f97af57e61da8f" url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "3.0.1" xml: dependency: transitive description: @@ -636,10 +668,10 @@ packages: dependency: transitive description: name: yaml - sha256: "3cee79b1715110341012d27756d9bae38e650588acd38d3f3c610822e1337ace" + sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" url: "https://pub.dev" source: hosted - version: "3.1.0" + version: "3.1.2" sdks: dart: ">=3.4.0 <4.0.0" flutter: ">=3.19.6" diff --git a/pubspec.yaml b/pubspec.yaml index 4018593..5edce45 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,6 +16,7 @@ dependencies: dio: ^5.6.0 json_annotation: ^4.9.0 flutter_svg: ^2.0.10 + flutter_riverpod: ^2.0.0 dev_dependencies: flutter_test: @@ -23,6 +24,7 @@ dev_dependencies: flutter_lints: ^4.0.0 build_runner: ^2.4.10 json_serializable: ^6.8.0 + mockito: ^5.4.0 flutter: generate: true diff --git a/test/repository_test.dart b/test/repository_test.dart new file mode 100644 index 0000000..b7fb9b1 --- /dev/null +++ b/test/repository_test.dart @@ -0,0 +1,61 @@ +import 'package:mockito/annotations.dart'; +import 'package:restaurant_tour/repositories/yelp_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:restaurant_tour/models/restaurant.dart'; +import 'repository_test.mocks.dart'; + +@GenerateMocks([YelpRepository]) +void main() { + // Create an instance of the mock + late MockYelpRepository mockYelpRepository; + + setUp(() { + mockYelpRepository = MockYelpRepository(); + }); + + group('YelpRepository Tests', () { + test('returns a list of restaurants when API call is successful', () async { + // Create a fake response + final mockRestaurants = RestaurantQueryResult( + total: 2, + restaurants: [ + Restaurant( + id: '1', + name: 'Test Restaurant', + price: '\$\$', + rating: 4.5, + photos: ['https://example.com/photo.jpg'], + categories: [Category(alias: 'italian', title: 'Italian')], + hours: [Hours(isOpenNow: true)], + reviews: [], + location: Location(formattedAddress: '123 Main St'), + ), + Restaurant( + id: '2', + name: 'Another Test Restaurant', + price: '\$\$', + rating: 3.4, + photos: ['https://example.com/photo2.jpg'], + categories: [Category(alias: 'asian', title: 'asian')], + hours: [Hours(isOpenNow: true)], + reviews: [], + location: Location(formattedAddress: '321 Main St'), + ), + ], + ); + + // Stub the mock repository to return the fake data + when(mockYelpRepository.getRestaurants(offset: 0)) + .thenAnswer((_) async => mockRestaurants); + + // Call the method + final result = await mockYelpRepository.getRestaurants(offset: 0); + + // Verify the interactions and results + expect(result?.restaurants?.length, 2); + expect(result?.restaurants?.first.name, 'Test Restaurant'); + }); + + }); +} \ No newline at end of file diff --git a/test/repository_test.mocks.dart b/test/repository_test.mocks.dart new file mode 100644 index 0000000..7b9ecc1 --- /dev/null +++ b/test/repository_test.mocks.dart @@ -0,0 +1,72 @@ +// Mocks generated by Mockito 5.4.4 from annotations +// in restaurant_tour/test/repository_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i4; + +import 'package:dio/dio.dart' as _i2; +import 'package:mockito/mockito.dart' as _i1; +import 'package:restaurant_tour/models/restaurant.dart' as _i5; +import 'package:restaurant_tour/repositories/yelp_repository.dart' as _i3; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class + +class _FakeDio_0 extends _i1.SmartFake implements _i2.Dio { + _FakeDio_0( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +/// A class which mocks [YelpRepository]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockYelpRepository extends _i1.Mock implements _i3.YelpRepository { + MockYelpRepository() { + _i1.throwOnMissingStub(this); + } + + @override + _i2.Dio get dio => (super.noSuchMethod( + Invocation.getter(#dio), + returnValue: _FakeDio_0( + this, + Invocation.getter(#dio), + ), + ) as _i2.Dio); + + @override + set dio(_i2.Dio? _dio) => super.noSuchMethod( + Invocation.setter( + #dio, + _dio, + ), + returnValueForMissingStub: null, + ); + + @override + _i4.Future<_i5.RestaurantQueryResult?> getRestaurants({int? offset = 0}) => + (super.noSuchMethod( + Invocation.method( + #getRestaurants, + [], + {#offset: offset}, + ), + returnValue: _i4.Future<_i5.RestaurantQueryResult?>.value(), + ) as _i4.Future<_i5.RestaurantQueryResult?>); +} diff --git a/test/unit_test_domain.dart b/test/unit_test_domain.dart new file mode 100644 index 0000000..baa7145 --- /dev/null +++ b/test/unit_test_domain.dart @@ -0,0 +1,117 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:restaurant_tour/models/restaurant.dart'; + +void main() { + group('Restaurant Model', () { + test('Category to/from JSON', () { + final category = Category(alias: 'italian', title: 'Italian'); + final json = category.toJson(); + final fromJson = Category.fromJson(json); + + expect(fromJson.alias, equals('italian')); + expect(fromJson.title, equals('Italian')); + }); + + test('Hours to/from JSON', () { + final hours = Hours(isOpenNow: true); + final json = hours.toJson(); + final fromJson = Hours.fromJson(json); + + expect(fromJson.isOpenNow, equals(true)); + }); + + test('User to/from JSON', () { + final user = User(id: '123', imageUrl: 'http://example.com', name: 'John Doe'); + final json = user.toJson(); + final fromJson = User.fromJson(json); + + expect(fromJson.id, equals('123')); + expect(fromJson.imageUrl, equals('http://example.com')); + expect(fromJson.name, equals('John Doe')); + }); + + test('Review to/from JSON', () { + final user = User(id: '123', imageUrl: 'http://example.com', name: 'John Doe'); + final review = Review(id: 'review1', rating: 5, text: 'Great place!', user: user); + final json = review.toJson(); + final fromJson = Review.fromJson(json); + + expect(fromJson.id, equals('review1')); + expect(fromJson.rating, equals(5)); + expect(fromJson.text, equals('Great place!')); + expect(fromJson.user!.id, equals('123')); + }); + + test('Location to/from JSON', () { + final location = Location(formattedAddress: '123 Main St, City, Country'); + final json = location.toJson(); + final fromJson = Location.fromJson(json); + + expect(fromJson.formattedAddress, equals('123 Main St, City, Country')); + }); + + test('Restaurant to/from JSON', () { + final restaurant = Restaurant( + id: 'restaurant1', + name: 'Best Restaurant', + price: '\$\$', + rating: 4.5, + photos: ['https://example.com/photo1.jpg'], + categories: [Category(alias: 'italian', title: 'Italian')], + hours: [Hours(isOpenNow: true)], + reviews: [ + Review(id: 'review1', rating: 5, text: 'Great place!', user: User(id: '123', imageUrl: 'http://example.com', name: 'John Doe')), + ], + location: Location(formattedAddress: '123 Main St, City, Country'), + ); + + final json = restaurant.toJson(); + final fromJson = Restaurant.fromJson(json); + + expect(fromJson.id, equals('restaurant1')); + expect(fromJson.name, equals('Best Restaurant')); + expect(fromJson.price, equals('\$\$')); + expect(fromJson.rating, equals(4.5)); + expect(fromJson.photos!.first, equals('https://example.com/photo1.jpg')); + expect(fromJson.categories!.first.title, equals('Italian')); + expect(fromJson.hours!.first.isOpenNow, equals(true)); + expect(fromJson.reviews!.first.text, equals('Great place!')); + expect(fromJson.location!.formattedAddress, equals('123 Main St, City, Country')); + }); + + test('RestaurantQueryResult to/from JSON', () { + final restaurant = Restaurant( + id: 'restaurant1', + name: 'Best Restaurant', + price: '\$\$', + rating: 4.5, + ); + final queryResult = RestaurantQueryResult( + total: 100, + restaurants: [restaurant], + ); + + final json = queryResult.toJson(); + final fromJson = RestaurantQueryResult.fromJson(json); + + expect(fromJson.total, equals(100)); + expect(fromJson.restaurants!.first.name, equals('Best Restaurant')); + }); + + test('Restaurant utility methods', () { + final restaurant = Restaurant( + id: 'restaurant1', + name: 'Best Restaurant', + price: '\$\$', + rating: 4.5, + categories: [Category(alias: 'italian', title: 'Italian')], + photos: ['https://example.com/photo1.jpg'], + hours: [Hours(isOpenNow: true)], + ); + + expect(restaurant.displayCategory, equals('Italian')); + expect(restaurant.heroImage, equals('https://example.com/photo1.jpg')); + expect(restaurant.isOpen, equals(true)); + }); + }); +} diff --git a/test/unit_test_presentation.dart b/test/unit_test_presentation.dart new file mode 100644 index 0000000..2426f70 --- /dev/null +++ b/test/unit_test_presentation.dart @@ -0,0 +1,79 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:restaurant_tour/models/restaurant.dart'; +import 'package:restaurant_tour/presentation/providers/restaurant_provider.dart'; +import 'package:riverpod/riverpod.dart'; + +void main() { + group('FavoritesNotifier', () { + late FavoritesNotifier favoritesNotifier; + late ProviderContainer container; + + setUp(() { + // Set up the provider container for Riverpod and the FavoritesNotifier instance + container = ProviderContainer(); + favoritesNotifier = container.read(favoritesListProvider.notifier); + }); + + tearDown(() { + // Clean up after each test + container.dispose(); + }); + + test('Initial state should be an empty list', () { + expect(favoritesNotifier.state, isEmpty); + }); + + test('Adding a restaurant to favorites should increase the list count', () { + // Arrange + final restaurant = Restaurant(id: '1', name: 'Best Restaurant'); + + // Act + favoritesNotifier.toggleFavorite(restaurant); + + // Assert + expect(favoritesNotifier.state.length, 1); + expect(favoritesNotifier.state.first, equals(restaurant)); + }); + + test('Removing a restaurant from favorites should decrease the list count', () { + // Arrange + final restaurant = Restaurant(id: '1', name: 'Best Restaurant'); + + // Act - Add the restaurant first + favoritesNotifier.toggleFavorite(restaurant); + + // Act - Remove the restaurant + favoritesNotifier.toggleFavorite(restaurant); + + // Assert + expect(favoritesNotifier.state, isEmpty); + }); + + test('Toggling a favorite twice should leave the list unchanged', () { + // Arrange + final restaurant = Restaurant(id: '1', name: 'Best Restaurant'); + + // Act - Add and then remove the restaurant + favoritesNotifier.toggleFavorite(restaurant); + favoritesNotifier.toggleFavorite(restaurant); + + // Assert + expect(favoritesNotifier.state, isEmpty); + }); + + test('Toggling a second restaurant should maintain both in the list', () { + // Arrange + final restaurant1 = Restaurant(id: '1', name: 'Restaurant 1'); + final restaurant2 = Restaurant(id: '2', name: 'Restaurant 2'); + + // Act - Add both restaurants + favoritesNotifier.toggleFavorite(restaurant1); + favoritesNotifier.toggleFavorite(restaurant2); + + // Assert + expect(favoritesNotifier.state.length, 2); + expect(favoritesNotifier.state.contains(restaurant1), isTrue); + expect(favoritesNotifier.state.contains(restaurant2), isTrue); + }); + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart index b729d48..1bd6e08 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,19 +1,54 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility that Flutter provides. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:restaurant_tour/main.dart'; +import 'package:restaurant_tour/models/restaurant.dart'; +import 'package:restaurant_tour/presentation/pages/restaurant_details/widgets/restaurant_details_favorite_button.dart'; void main() { - testWidgets('Page loads', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const RestaurantTour()); + testWidgets('Tapping favorite button toggles favorite icon', (WidgetTester tester) async { + // Create a full test restaurant + final restaurant = Restaurant(id: '1', name: 'Best Restaurant'); + + // Build the widget within a ProviderScope to simulate Riverpod environment + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + home: Scaffold( + appBar: AppBar( + actions: [ + RestaurantDetailsFavoriteButton(restaurant: restaurant), + ], + centerTitle: true, + title: Text( + restaurant.name!, + style: const TextStyle( + fontFamily: 'Lora', + ), + ), + ), + ), + ), + ), + ); + + // Initially, the IconButton should display Icons.favorite_border + expect(find.byIcon(Icons.favorite_border), findsOneWidget); + expect(find.byIcon(Icons.favorite), findsNothing); + + // Tap the favorite button + await tester.tap(find.byIcon(Icons.favorite_border)); + await tester.pump(); // Rebuild the widget to reflect changes in the state + + // After tapping, the IconButton should display Icons.favorite + expect(find.byIcon(Icons.favorite), findsOneWidget); + expect(find.byIcon(Icons.favorite_border), findsNothing); + + // Tap the favorite button again to remove from favorites + await tester.tap(find.byIcon(Icons.favorite)); + await tester.pump(); // Rebuild the widget to reflect changes in the state - // Verify that tests will run - expect(find.text('Fetch Restaurants'), findsOneWidget); + // After tapping again, it should go back to showing Icons.favorite_border + expect(find.byIcon(Icons.favorite_border), findsOneWidget); + expect(find.byIcon(Icons.favorite), findsNothing); }); } From 45762745f7347d470f18636b3ef0a4c077cba5ae Mon Sep 17 00:00:00 2001 From: leoG Date: Tue, 10 Sep 2024 01:07:13 -0300 Subject: [PATCH 2/2] feat: added persistent storage for the favorite list chore: removed redundant Category widget --- .../restaurant_details_page.dart | 2 - .../restaurant_details_data_widget.dart | 7 +- ...restaurant_details_review_list_widget.dart | 2 - .../restaurant_details_review_widget.dart | 4 +- .../widgets/restaurant_list_card_widget.dart | 14 +- .../restaurant_list_categories_widget.dart | 26 ---- .../providers/restaurant_provider.dart | 135 +++++------------ pubspec.lock | 143 ++++++++++++++++-- pubspec.yaml | 1 + 9 files changed, 183 insertions(+), 151 deletions(-) delete mode 100644 lib/presentation/pages/restaurant_list/widgets/restaurant_list_categories_widget.dart diff --git a/lib/presentation/pages/restaurant_details/restaurant_details_page.dart b/lib/presentation/pages/restaurant_details/restaurant_details_page.dart index 4ef4461..b82b307 100644 --- a/lib/presentation/pages/restaurant_details/restaurant_details_page.dart +++ b/lib/presentation/pages/restaurant_details/restaurant_details_page.dart @@ -1,8 +1,6 @@ import 'package:flutter/material.dart'; import 'package:restaurant_tour/presentation/pages/restaurant_details/widgets/restaurant_details_favorite_button.dart'; -import '../../../core/config/strings.dart'; import '../../../models/restaurant.dart'; -import '../restaurant_list/widgets/restaurant_list_categories_widget.dart'; import 'widgets/restaurant_details_data_widget.dart'; import 'widgets/restaurant_details_review_list_widget.dart'; diff --git a/lib/presentation/pages/restaurant_details/widgets/restaurant_details_data_widget.dart b/lib/presentation/pages/restaurant_details/widgets/restaurant_details_data_widget.dart index b323e69..f070bb4 100644 --- a/lib/presentation/pages/restaurant_details/widgets/restaurant_details_data_widget.dart +++ b/lib/presentation/pages/restaurant_details/widgets/restaurant_details_data_widget.dart @@ -2,8 +2,6 @@ import 'package:flutter/material.dart'; import '../../../../core/config/strings.dart'; import '../../../../models/restaurant.dart'; import '../../../../typography.dart'; -import '../../restaurant_list/widgets/restaurant_list_categories_widget.dart'; -import 'restaurant_details_review_list_widget.dart'; class RestaurantDetailsDataWidget extends StatelessWidget { final Restaurant restaurant; @@ -28,8 +26,9 @@ class RestaurantDetailsDataWidget extends StatelessWidget { width: 10, ), // Category - RestaurantListCategoriesWidget( - categories: restaurant.categories!, + Text( + restaurant.displayCategory, + style: AppTextStyles.openRegularText, ), const Expanded( child: SizedBox(), diff --git a/lib/presentation/pages/restaurant_details/widgets/restaurant_details_review_list_widget.dart b/lib/presentation/pages/restaurant_details/widgets/restaurant_details_review_list_widget.dart index f00ff66..cadef23 100644 --- a/lib/presentation/pages/restaurant_details/widgets/restaurant_details_review_list_widget.dart +++ b/lib/presentation/pages/restaurant_details/widgets/restaurant_details_review_list_widget.dart @@ -1,8 +1,6 @@ import 'package:flutter/material.dart'; import 'package:restaurant_tour/presentation/pages/restaurant_details/widgets/restaurant_details_review_widget.dart'; -import '../../../../core/config/strings.dart'; import '../../../../models/restaurant.dart'; -import '../../restaurant_list/widgets/restaurant_list_categories_widget.dart'; class RestaurantDetailsReviewListWidget extends StatelessWidget { final List reviews; diff --git a/lib/presentation/pages/restaurant_details/widgets/restaurant_details_review_widget.dart b/lib/presentation/pages/restaurant_details/widgets/restaurant_details_review_widget.dart index e146973..304b444 100644 --- a/lib/presentation/pages/restaurant_details/widgets/restaurant_details_review_widget.dart +++ b/lib/presentation/pages/restaurant_details/widgets/restaurant_details_review_widget.dart @@ -1,8 +1,6 @@ import 'package:flutter/material.dart'; -import '../../../../core/config/strings.dart'; import '../../../../models/restaurant.dart'; import '../../../../typography.dart'; -import '../../restaurant_list/widgets/restaurant_list_categories_widget.dart'; import '../../restaurant_list/widgets/restaurant_list_rating_icons_widget.dart'; class RestaurantDetailsReviewsWidget extends StatelessWidget { @@ -24,7 +22,7 @@ class RestaurantDetailsReviewsWidget extends StatelessWidget { ), Row( children: [ - review.user!.imageUrl != null + review.user?.imageUrl != null ? ClipOval( child: SizedBox.fromSize( size: const Size.fromRadius(20), diff --git a/lib/presentation/pages/restaurant_list/widgets/restaurant_list_card_widget.dart b/lib/presentation/pages/restaurant_list/widgets/restaurant_list_card_widget.dart index c846e06..f664d33 100644 --- a/lib/presentation/pages/restaurant_list/widgets/restaurant_list_card_widget.dart +++ b/lib/presentation/pages/restaurant_list/widgets/restaurant_list_card_widget.dart @@ -4,7 +4,6 @@ import 'package:restaurant_tour/models/restaurant.dart'; import '../../../../core/config/strings.dart'; import '../../../../core/utils/router_manager.dart'; import '../../../../typography.dart'; -import 'restaurant_list_categories_widget.dart'; import 'restaurant_list_rating_icons_widget.dart'; class RestaurantListCardWidget extends StatelessWidget { @@ -50,13 +49,17 @@ class RestaurantListCardWidget extends StatelessWidget { Row( children: [ // Price - Text(restaurant.price!), + Text( + restaurant.price ?? '\$\$', + style: AppTextStyles.openRegularText, + ), const SizedBox( width: 10, ), // Category - RestaurantListCategoriesWidget( - categories: restaurant.categories ?? [], + Text( + restaurant.displayCategory, + style: AppTextStyles.openRegularText, ), ], ), @@ -64,7 +67,8 @@ class RestaurantListCardWidget extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ // Rating - RestaurantListRatingIconsWidget(rating: (restaurant.rating ?? 0).round()), + RestaurantListRatingIconsWidget( + rating: (restaurant.rating ?? 0).round()), // Open/Closed Row( children: [ diff --git a/lib/presentation/pages/restaurant_list/widgets/restaurant_list_categories_widget.dart b/lib/presentation/pages/restaurant_list/widgets/restaurant_list_categories_widget.dart deleted file mode 100644 index 3b16a91..0000000 --- a/lib/presentation/pages/restaurant_list/widgets/restaurant_list_categories_widget.dart +++ /dev/null @@ -1,26 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../../../../models/restaurant.dart' as model; -import '../../../../typography.dart'; - -class RestaurantListCategoriesWidget extends StatelessWidget { - final List categories; - - RestaurantListCategoriesWidget({super.key, required this.categories}); - String response = ''; - - @override - Widget build(BuildContext context) { - if (categories == null) { - return const Text(''); - } - for (int i = 0; i < categories.length; i++) { - response += '${categories[i].alias!} '; - } - return Text( - response, - overflow: TextOverflow.ellipsis, - style: AppTextStyles.openRegularText, - ); - } -} diff --git a/lib/presentation/providers/restaurant_provider.dart b/lib/presentation/providers/restaurant_provider.dart index d1fd754..f71de28 100644 --- a/lib/presentation/providers/restaurant_provider.dart +++ b/lib/presentation/providers/restaurant_provider.dart @@ -1,4 +1,7 @@ +import 'dart:convert'; + import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import '../../repositories/yelp_repository.dart'; // Import your repository import '../../models/restaurant.dart'; @@ -20,121 +23,61 @@ final restaurantListProvider = FutureProvider>((ref) async { // StateNotifier for managing favorites list class FavoritesNotifier extends StateNotifier> { - FavoritesNotifier() : super([]); + FavoritesNotifier() : super([]) { + _loadFavorites(); + } + + // Load the favorites from SharedPreferences when the app starts + Future _loadFavorites() async { + final prefs = await SharedPreferences.getInstance(); + final favoriteListJson = prefs.getString('favoriteRestaurants'); + + if (favoriteListJson == null || favoriteListJson.isEmpty) { + state = []; + return; + } + + try { + final List jsonList = jsonDecode(favoriteListJson) as List; + state = jsonList.map((json) => Restaurant.fromJson(json as Map)).toList(); + } catch (e) { + print('Error deserializing favorites: $e'); + state = []; + } + } + + // Save the updated favorites list to SharedPreferences + Future _saveFavorites() async { + final prefs = await SharedPreferences.getInstance(); + final List> jsonList = state.map((restaurant) => restaurant.toJson()).toList(); + final String jsonString = jsonEncode(jsonList); + + await prefs.setString('favoriteRestaurants', jsonString); + } // Add or remove a restaurant from the favorites list void toggleFavorite(Restaurant restaurant) { - // Check if the restaurant is already in the list if (state.any((r) => r.id == restaurant.id)) { - // If restaurant is already in the list, remove it removeFavorite(restaurant); } else { - // If not in the list, add it addFavorite(restaurant); } } // Add a restaurant to favorites void addFavorite(Restaurant restaurant) { - state = [...state, restaurant]; // Add restaurant to the list + state = [...state, restaurant]; + _saveFavorites(); } // Remove a restaurant from favorites void removeFavorite(Restaurant restaurant) { - state = state.where((r) => r.id != restaurant.id).toList(); // Remove restaurant by id + state = state.where((r) => r.id != restaurant.id).toList(); + _saveFavorites(); } } // Create a provider for the favorites list final favoritesListProvider = StateNotifierProvider>((ref) { return FavoritesNotifier(); -}); - -/*List list = []; - - Restaurant a = Restaurant( - id: '1', - name: 'restaurant a', - price: 'price', - rating: 3.6, - photos: [ - 'https://media.cntraveler.com/photos/654bd5e13892537a8ded0947/master/pass/phy2023.din.oss.restaurant-lr.jpg' - ], - categories: [ - Category(alias: 'italian', title: 'Italian'), - Category(alias: 'pasta', title: 'Pasta') - ], - hours: [const Hours(isOpenNow: true)], - reviews: [ - const Review( - id: "sjZoO8wcK1NeGJFDk5i82Q", - rating: 5, - user: User( - id: "BuBCkWFNT_O2dbSnBZvpoQ", - imageUrl: - "https://s3-media2.fl.yelpcdn.com/photo/v8tbTjYaFvkzh1d7iE-pcQ/o.jpg", - name: "Gina T.", - ), - text: - "I love this place! The food is amazing and the service is great."), - const Review( - id: "okpO9hfpxQXssbTZTKq9hA", - rating: 5, - user: User( - id: "0x9xu_b0Ct_6hG6jaxpztw", - imageUrl: - "https://s3-media3.fl.yelpcdn.com/photo/gjz8X6tqE3e4praK4HfCiA/o.jpg", - name: "Crystal L.", - ), - text: "Greate place to eat", - ), - ], - location: Location( - formattedAddress: '102 Lakeside Ave Seattle, WA 98122', - ), - ); - Restaurant b = Restaurant( - id: '2', - name: 'restaurant b', - price: 'price', - rating: 2.4, - photos: [ - 'https://media.cntraveler.com/photos/6552541546a7d1d8bdf5122d/master/w_1600,c_limit/DSC00781%20copy.jpg' - ], - categories: [ - Category(alias: 'asian', title: 'Asian'), - Category(alias: 'ramen', title: 'Ramen') - ], - hours: [const Hours(isOpenNow: false)], - reviews: [ - const Review( - id: "sjZoO8wcK1NeGJFDk5i82Q", - rating: 5, - user: User( - id: "BuBCkWFNT_O2dbSnBZvpoQ", - imageUrl: - "https://s3-media2.fl.yelpcdn.com/photo/v8tbTjYaFvkzh1d7iE-pcQ/o.jpg", - name: "Gina T.", - ), - text: - "I love this place! The food is amazing and the service is great."), - const Review( - id: "okpO9hfpxQXssbTZTKq9hA", - rating: 5, - user: User( - id: "0x9xu_b0Ct_6hG6jaxpztw", - imageUrl: - "https:///s3-media3.fl.yelpcdn.com/photo/gjz8X6tqE3e4praK4HfCiA/o.jpg", - name: "Crystal L.", - ), - text: "Greate place to eat", - ), - ], - location: Location( - formattedAddress: '102 Lakeside Ave Seattle, WA 98122', - ), - ); - - list.add(a); - list.add(b); - return list;*/ \ No newline at end of file +}); \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock index d599111..be835da 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -193,6 +193,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.1" + ffi: + dependency: transitive + description: + name: ffi + sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" + url: "https://pub.dev" + source: hosted + version: "2.1.3" file: dependency: transitive description: @@ -243,6 +251,11 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" frontend_server_client: dependency: transitive description: @@ -327,18 +340,18 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a" + sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05" url: "https://pub.dev" source: hosted - version: "10.0.4" + version: "10.0.5" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8" + sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806" url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "3.0.5" leak_tracker_testing: dependency: transitive description: @@ -375,18 +388,18 @@ packages: dependency: transitive description: name: material_color_utilities - sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.8.0" + version: "0.11.1" meta: dependency: transitive description: name: meta - sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" + sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 url: "https://pub.dev" source: hosted - version: "1.12.0" + version: "1.15.0" mime: dependency: transitive description: @@ -427,6 +440,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" petitparser: dependency: transitive description: @@ -435,6 +472,22 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.2" + platform: + dependency: transitive + description: + name: platform + sha256: "9b71283fc13df574056616011fb138fd3b793ea47cc509c189a6c3fa5f8a1a65" + url: "https://pub.dev" + source: hosted + version: "3.1.5" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" pool: dependency: transitive description: @@ -467,6 +520,62 @@ packages: url: "https://pub.dev" source: hosted version: "2.5.1" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "746e5369a43170c25816cc472ee016d3a66bc13fcf430c0bc41ad7b4b2922051" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "480ba4345773f56acda9abf5f50bd966f581dac5d514e5fc4a18c62976bbba7e" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: c4b35f6cb8f63c147312c054ce7c2254c8066745125264f0c88739c417fc9d9f + url: "https://pub.dev" + source: hosted + version: "2.5.2" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: d2ca4132d3946fec2184261726b355836a82c33d7d5b67af32692aff18a4684e + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" shelf: dependency: transitive description: @@ -564,10 +673,10 @@ packages: dependency: transitive description: name: test_api - sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" + sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb" url: "https://pub.dev" source: hosted - version: "0.7.0" + version: "0.7.2" timing: dependency: transitive description: @@ -620,10 +729,10 @@ packages: dependency: transitive description: name: vm_service - sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" + sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d" url: "https://pub.dev" source: hosted - version: "14.2.1" + version: "14.2.5" watcher: dependency: transitive description: @@ -656,6 +765,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: faea9dee56b520b55a566385b84f2e8de55e7496104adada9962e0bd11bcff1d + url: "https://pub.dev" + source: hosted + version: "1.0.4" xml: dependency: transitive description: @@ -674,4 +791,4 @@ packages: version: "3.1.2" sdks: dart: ">=3.4.0 <4.0.0" - flutter: ">=3.19.6" + flutter: ">=3.22.0" diff --git a/pubspec.yaml b/pubspec.yaml index 5edce45..de132b8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -17,6 +17,7 @@ dependencies: json_annotation: ^4.9.0 flutter_svg: ^2.0.10 flutter_riverpod: ^2.0.0 + shared_preferences: ^2.0.15 dev_dependencies: flutter_test: