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/.github/workflows/main.yaml b/.github/workflows/main.yaml new file mode 100644 index 0000000..3df90c0 --- /dev/null +++ b/.github/workflows/main.yaml @@ -0,0 +1,21 @@ +name: restaurant_app + +concurrency: + group: $-$ + cancel-in-progress: true + +on: + push: + branches: + - master + pull_request: + branches: + - master + +jobs: + build: + uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/flutter_package.yml@v1 + with: + flutter_channel: stable + min_coverage: 80 + diff --git a/.github/workflows/restaurant_gql__client.yaml b/.github/workflows/restaurant_gql__client.yaml new file mode 100644 index 0000000..ab208ad --- /dev/null +++ b/.github/workflows/restaurant_gql__client.yaml @@ -0,0 +1,20 @@ +name: restaurant_gql_client + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + pull_request: + paths: + - "packages/restaurant_gql_client/**" + - ".github/workflows/restaurant_gql_client.yaml" + branches: + - master + +jobs: + build: + uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/flutter_package.yml@v1 + with: + working_directory: packages/restaurant_gql_client + min_coverage: 80 diff --git a/.github/workflows/restaurant_models.yaml b/.github/workflows/restaurant_models.yaml new file mode 100644 index 0000000..d2085b5 --- /dev/null +++ b/.github/workflows/restaurant_models.yaml @@ -0,0 +1,21 @@ +name: restaurant_models + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + pull_request: + paths: + - "packages/restaurant_models/**" + - ".github/workflows/restaurant_models.yaml" + branches: + - master + +jobs: + build: + uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/flutter_package.yml@v1 + with: + working_directory: packages/restaurant_models + min_coverage: 80 + coverage_excludes: '*.g.dart' diff --git a/.github/workflows/restaurant_repository.yaml b/.github/workflows/restaurant_repository.yaml new file mode 100644 index 0000000..f74a725 --- /dev/null +++ b/.github/workflows/restaurant_repository.yaml @@ -0,0 +1,20 @@ +name: movie_repository + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + pull_request: + paths: + - "packages/restaurant_repository/**" + - ".github/workflows/restaurant_repository.yaml" + branches: + - master + +jobs: + build: + uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/flutter_package.yml@v1 + with: + working_directory: packages/restaurant_repository + min_coverage: 80 diff --git a/.github/workflows/restaurant_ui.yaml b/.github/workflows/restaurant_ui.yaml new file mode 100644 index 0000000..945f5b3 --- /dev/null +++ b/.github/workflows/restaurant_ui.yaml @@ -0,0 +1,20 @@ +name: restaurant_ui + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + pull_request: + paths: + - "packages/restaurant_ui/**" + - ".github/workflows/restaurant_ui.yaml" + branches: + - master + +jobs: + build: + uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/flutter_package.yml@v1 + with: + working_directory: packages/restaurant_ui + min_coverage: 80 diff --git a/.gitignore b/.gitignore index 1be2d87..49afc48 100644 --- a/.gitignore +++ b/.gitignore @@ -46,4 +46,8 @@ app.*.map.json /android/app/release # fvm -.fvm/flutter_sdk \ No newline at end of file + +# FVM Version Cache +.fvm/ + +api_keys.json \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json index 5d0f1d3..8691fbd 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -7,7 +7,11 @@ { "name": "app", "request": "launch", - "type": "dart" + "type": "dart", + "args": [ + "--dart-define-from-file", + "api_keys.json" + ] } ] } \ 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 412d444..9a8fc0a 100644 --- a/README.md +++ b/README.md @@ -1,202 +1,56 @@ -# Restaurant Tour +# Superformula Flutter Test -Welcome to Superformula's Coding challenge, we are excited to see what you can build! +Restaurant from yelp API. -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. +### Installing -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. +1. Clone the repository: -Things we'll be looking on your submission: -- App structure for scalability -- Error and optional (?) handling -- Widget tree optimization -- State management -- Test coverage + ```bash + git clone https://github.com/enzoftware/flutter_test.git + cd flutter_test + ``` -Think of the app you'll be building as the final product, do not over engineer it for possible future features, but do not under engineer it either. We are looking for a balance. We want that the functionalities that you implement are well thought out and implemented. +2. Install the dependencies: -As an example, for the favorites feature you can simply use SharedPreferences, you don't need to use a complex database solution, but we're looking for a solid shared preferences implementation. + ```bash + flutter pub get + ``` +3. Add your API Key: + Create `api_keys.json`, and follow the `api_keys_example.json` to add your TheMovieDB API key: + ```json + { + "API_KEY":"your_api_key" + } + ``` -Be sure to read **all** of this document carefully, and follow the guidelines within. +4. Run the app on an emulator or physical device: -## Vendorized Flutter + ```bash + flutter run --dart-define-from-file api_keys.json + ``` -3. We use [fvm](https://fvm.app/) for managing the flutter version within the project. Using terminal, while being on the test repository, install the tools dependencies by running the following commands: +### Project Structure - ```sh - dart pub global activate fvm - ``` - - The output of the command will ask to add the folder `./pub-cache/bin` to your PATH variables, if you didn't already. If that is the case, add it to your environment variables, and restart the terminal. - - ```sh - export PATH="$PATH":"$HOME/.pub-cache/bin" # Add this to your environment variables - ``` - -4. Install the project's flutter version using `fvm`. - - ```sh - fvm use - ``` - -5. From now on, you will run all the flutter commands with the `fvm` prefix. Get all the projects dependencies. - - ```sh - fvm flutter pub get - ``` - -More information on the approach can be found here: - -> hhttps://fvm.app/docs/getting_started/installation - -From the root directory: - - -### IDE Setup - -
-Use with VSCode -

- -If you're a VScode user link the new Flutter SDK path in your settings -`$projectRoot/.vscode/settings.json` (create if it doesn't exist yet) - -```json -{ - "dart.flutterSdkPath": ".fvm/flutter_sdk" -} +``` bash +/lib + /favorite_restaurants_list #favorite restaurants features + /restaurant_detail # Detail for restaurant + /restaurant_list # Restaurant list +/packages + /restaurant_gql_client # API client to handle Yelp requests + /restaurant_models # Models for restaurant + /restaurant_repository # Repository layer abstracting API logic + /restaurant_ui # Reusable UI components ``` +### Demo -

-
- -
-Use with IntelliJ / Android Studio -

- -Go to `Preferences > Languages & Frameworks > Flutter` and set the Flutter SDK path to `$projectRoot/.fvm/flutter_sdk` - -IntelliJ Settings - -

-
- -## Requirements - -### App Structure - -#### Restaurant List Page - -- Tab Bar - - List of favorites (stored client side) - - List of businesses - - Hero image - - Name - - Price - - Category - - Rating (rounded to the nearest value) - - Open/Closed - -#### Restaurant Detail View - -- Ability to favorite a business -- Name -- Hero image -- Price and category -- Address -- Rating -- Total reviews -- List of reviews - - User name - - Rating - - User image - - Review Text (These are just snippets of the full review, usually like 3-4 lines long) - -#### Misc. - -- Clear documentation on the structure and architecture of your application. -- Clear and logical commit messages. - - We suggest following [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) - -## Test Coverage - -To demonstrate your experience writing different types of tests in Flutter please do the following: - -- We are looking to see how you write tests in Flutter. We are not looking for 100% coverage but we are looking for a good mix of unit and widget tests. -- We are specially looking for you to cover at least one file for each domain layer (interface, application, repositories, etc). - -Feel free to add more tests as you see fit but the above is the minimum requirement. - -## Design - -- See this [Figma File](https://www.figma.com/file/KsEhQUp66m9yeVkvQ0hSZm/Flutter-Test?node-id=0%3A1) for design information related to the overall look and feel of the application. We do not expect pixel-perfection but would like the application to visually be close to what is specified in the Figma file. - -![List View](screenshots/listview.png) -![Detail View](screenshots/detailview.png) - -## API - -The [Yelp GraphQL API](https://www.yelp.com/developers/graphql/guides/intro) is used as the API for this Application. We have provided the boilerplate of the API requests and backing data models to save you some time. To successfully make a request to the Yelp GraphQL API, please follow these steps: - -1. Please go to https://www.yelp.com/signup and sign up for a developer account. -1. Once signed up, navigate to https://www.yelp.com/developers/v3/manage_app. -1. Create a new app by filling out the required information. -1. Once your app is created, scroll down and join the `Developer Beta`. This allows you to use the GraphQL API. -1. Copy your API Key from your app page and paste it on `line 5` [yelp_repository.dart](app/lib/yelp_repository.dart) replacing the `` with your key. -1. Run the app and tap the `Fetch Restaurants` button. If you see a log like `Fetched x restaurants` you are all set! - -## Technical Requirements - -### State Management - -Please restrict your usage of state management or dependency injection to the following options: - -1. [provider](https://pub.dev/packages/provider) -2. [Riverpod](https://pub.dev/packages/riverpod) -3. [bloc](https://pub.dev/packages/bloc) -4. [get_it](https://pub.dev/packages/get_it)/[get_it_mixins](https://pub.dev/packages/get_it_mixin) -5. [Mobx](https://pub.dev/packages/mobx) - -We ask this because this challenge values consistency and efficiency over ingenuity. Using commonly used libraries ensures that we can review your code in a timely manner and allows us to provide better feedback. - -## Coding Values - -At **Superformula** we strive to build applications that have - -- Consistent architecture -- Extensible, clean code -- Solid testing -- Good security & performance best practices - -### Clear, consistent architecture - -Approach your submission as if it were a real world app. This includes Use any libraries that you would normally choose. - -_Please note: we're interested in your code & the way you solve the problem, not how well you can use a particular library or feature._ - -### Easy to understand - -Writing boring code that is easy to follow is essential at **Superformula**. - -We're interested in your method and how you approach the problem just as much as we're interested in the end result. - -### Solid testing approach - -While the purpose of this challenge is not to gauge whether you can achieve 100% test coverage, we do seek to evaluate whether you know how & what to test. - -## Q&A - -> Where should I send back the result when I'm done? - -Please fork this repo and then send us a pull request to our repo when you think you are done. There is no deadline for this task unless otherwise noted to you directly. - -> What if I have a question? - -Just create a new issue in this repo and we will respond and get back to you quickly. + + + + -## Review -The coding challenge is a take-home test upon which we'll be conducting a thorough code review once complete. The review will consist of meeting some more of our mobile engineers and giving a review of the solution you have designed. Please be prepared to share your screen and run/demo the application to the group. During this process, the engineers will be asking questions. diff --git a/api_keys_example.json b/api_keys_example.json new file mode 100644 index 0000000..876c61c --- /dev/null +++ b/api_keys_example.json @@ -0,0 +1,3 @@ +{ + "API_KEY":"your_api_key" +} \ No newline at end of file diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig index 592ceee..ec97fc6 100644 --- a/ios/Flutter/Debug.xcconfig +++ b/ios/Flutter/Debug.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "Generated.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig index 592ceee..c4855bf 100644 --- a/ios/Flutter/Release.xcconfig +++ b/ios/Flutter/Release.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "Generated.xcconfig" diff --git a/ios/Podfile b/ios/Podfile new file mode 100644 index 0000000..d97f17e --- /dev/null +++ b/ios/Podfile @@ -0,0 +1,44 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '12.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + use_modular_headers! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/lib/favorite_restaurants_list/bloc/favorite_restaurants_bloc.dart b/lib/favorite_restaurants_list/bloc/favorite_restaurants_bloc.dart new file mode 100644 index 0000000..8e86834 --- /dev/null +++ b/lib/favorite_restaurants_list/bloc/favorite_restaurants_bloc.dart @@ -0,0 +1,33 @@ +import 'dart:async'; + +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; +import 'package:restaurant_models/restaurant_models.dart'; +import 'package:restaurant_repository/restaurant_repository.dart'; + +part 'favorite_restaurants_event.dart'; +part 'favorite_restaurants_state.dart'; + +class FavoriteRestaurantsBloc + extends Bloc { + FavoriteRestaurantsBloc(RestaurantRepository repository) + : _repository = repository, + super(const FavoriteRestaurantsLoading()) { + on(_onFetchFavoriteRestaurants); + } + + final RestaurantRepository _repository; + + FutureOr _onFetchFavoriteRestaurants( + FetchFavoriteRestaurants event, + Emitter emit, + ) async { + try { + emit(const FavoriteRestaurantsLoading()); + final response = await _repository.fetchFavoriteRestaurants(); + emit(FavoriteRestaurantsData(restaurants: response)); + } catch (e) { + emit(FavoriteRestaurantsError(message: e.toString())); + } + } +} diff --git a/lib/favorite_restaurants_list/bloc/favorite_restaurants_event.dart b/lib/favorite_restaurants_list/bloc/favorite_restaurants_event.dart new file mode 100644 index 0000000..4203e12 --- /dev/null +++ b/lib/favorite_restaurants_list/bloc/favorite_restaurants_event.dart @@ -0,0 +1,12 @@ +part of 'favorite_restaurants_bloc.dart'; + +sealed class FavoriteRestaurantsEvent extends Equatable { + const FavoriteRestaurantsEvent(); + + @override + List get props => []; +} + +class FetchFavoriteRestaurants extends FavoriteRestaurantsEvent { + const FetchFavoriteRestaurants(); +} diff --git a/lib/favorite_restaurants_list/bloc/favorite_restaurants_state.dart b/lib/favorite_restaurants_list/bloc/favorite_restaurants_state.dart new file mode 100644 index 0000000..a4481cf --- /dev/null +++ b/lib/favorite_restaurants_list/bloc/favorite_restaurants_state.dart @@ -0,0 +1,29 @@ +part of 'favorite_restaurants_bloc.dart'; + +sealed class FavoriteRestaurantsState extends Equatable { + const FavoriteRestaurantsState(); + + @override + List get props => []; +} + +final class FavoriteRestaurantsLoading extends FavoriteRestaurantsState { + const FavoriteRestaurantsLoading(); +} + +final class FavoriteRestaurantsError extends FavoriteRestaurantsState { + const FavoriteRestaurantsError({required this.message}); + final String message; + + @override + List get props => [message]; +} + +final class FavoriteRestaurantsData extends FavoriteRestaurantsState { + const FavoriteRestaurantsData({required this.restaurants}); + + final List restaurants; + + @override + List get props => [restaurants]; +} diff --git a/lib/favorite_restaurants_list/favorite_restaurant_list.dart b/lib/favorite_restaurants_list/favorite_restaurant_list.dart new file mode 100644 index 0000000..dc57760 --- /dev/null +++ b/lib/favorite_restaurants_list/favorite_restaurant_list.dart @@ -0,0 +1,2 @@ +export 'bloc/favorite_restaurants_bloc.dart'; +export 'view/view.dart'; diff --git a/lib/favorite_restaurants_list/view/favorite_restaurants_list_view.dart b/lib/favorite_restaurants_list/view/favorite_restaurants_list_view.dart new file mode 100644 index 0000000..93df8e5 --- /dev/null +++ b/lib/favorite_restaurants_list/view/favorite_restaurants_list_view.dart @@ -0,0 +1,43 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter/material.dart'; +import 'package:restaurant_tour/favorite_restaurants_list/bloc/favorite_restaurants_bloc.dart'; +import 'package:restaurant_ui/restaurant_ui.dart'; + +class FavoriteRestaurantsListView extends StatelessWidget { + const FavoriteRestaurantsListView({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + final state = + context.select((FavoriteRestaurantsBloc bloc) => bloc.state); + if (state is FavoriteRestaurantsLoading) { + return const CircularProgressIndicator(); + } + if (state is FavoriteRestaurantsError) { + return const Text('error'); + } + if (state is FavoriteRestaurantsData) { + final restaurants = state.restaurants; + return ListView.builder( + itemCount: restaurants.length, + itemBuilder: (context, index) { + final restaurant = restaurants[index]; + return RestaurantCard( + tag: restaurant.id ?? '', + title: restaurant.name ?? '', + photoUrl: restaurant.heroImage, + isOpen: restaurant.isOpen, + price: restaurant.price ?? '', + rating: restaurant.rating ?? 0.0, + category: restaurant.displayCategory, + ); + }, + ); + } + return const SizedBox.shrink(); + }, + ); + } +} diff --git a/lib/favorite_restaurants_list/view/view.dart b/lib/favorite_restaurants_list/view/view.dart new file mode 100644 index 0000000..6e72c0b --- /dev/null +++ b/lib/favorite_restaurants_list/view/view.dart @@ -0,0 +1 @@ +export 'favorite_restaurants_list_view.dart'; diff --git a/lib/main.dart b/lib/main.dart index ae7012a..ea6293c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,84 +1,91 @@ -import 'dart:convert'; - import 'package:flutter/material.dart'; -import 'package:http/http.dart' as http; -import 'package:restaurant_tour/models/restaurant.dart'; -import 'package:restaurant_tour/query.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:http/http.dart'; +import 'package:restaurant_gql_client/restaurant_gql_client.dart'; +import 'package:restaurant_repository/restaurant_repository.dart'; +import 'package:restaurant_tour/favorite_restaurants_list/bloc/favorite_restaurants_bloc.dart'; +import 'package:restaurant_tour/favorite_restaurants_list/view/view.dart'; +import 'package:restaurant_tour/restaurant_detail/restaurant_detail.dart'; +import 'package:restaurant_tour/restaurant_list/restaurant_list.dart'; +import 'package:restaurant_ui/restaurant_ui.dart'; +import 'package:shared_preferences/shared_preferences.dart'; -const _apiKey = ''; -const _baseUrl = 'https://api.yelp.com/v3/graphql'; +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + final client = Client(); + const apiKey = String.fromEnvironment('API_KEY'); + const baseUrl = 'https://api.yelp.com/v3/graphql'; + final gqlClient = RestaurantGqlClient(client, baseUrl, apiKey); + final sharedPreferences = await SharedPreferences.getInstance(); + final repository = RestaurantRepository(gqlClient, sharedPreferences); -void main() { - runApp(const RestaurantTour()); + runApp(RestaurantTour(restaurantRepository: repository)); } class RestaurantTour extends StatelessWidget { - const RestaurantTour({super.key}); + const RestaurantTour({super.key, required this.restaurantRepository}); + + final RestaurantRepository restaurantRepository; @override Widget build(BuildContext context) { - return const MaterialApp( - title: 'Restaurant Tour', - home: HomePage(), + return RepositoryProvider.value( + value: restaurantRepository, + child: MultiBlocProvider( + providers: [ + BlocProvider( + create: (context) => RestaurantListBloc( + restaurantRepository, + )..add(const FetchRestaurantList()), + ), + BlocProvider( + create: (context) => FavoriteRestaurantsBloc(restaurantRepository) + ..add(const FetchFavoriteRestaurants()), + ), + BlocProvider( + create: (context) => RestaurantDetailBloc(restaurantRepository), + ), + ], + child: const MaterialApp( + title: 'Restaurant Tour', + home: HomePage(), + ), + ), ); } } -// TODO: Architect code -// This is just a POC of the API integration class HomePage extends StatelessWidget { const HomePage({super.key}); - Future getRestaurants({int offset = 0}) async { - final headers = { - 'Authorization': 'Bearer $_apiKey', - 'Content-Type': 'application/graphql', - }; - - try { - final response = await http.post( - Uri.parse(_baseUrl), - headers: headers, - body: query(offset), - ); - - if (response.statusCode == 200) { - return RestaurantQueryResult.fromJson( - jsonDecode(response.body)['data']['search'], - ); - } else { - print('Failed to load restaurants: ${response.statusCode}'); - return null; - } - } catch (e) { - print('Error fetching restaurants: $e'); - return null; - } - } - @override Widget build(BuildContext context) { - return Scaffold( - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, + return DefaultTabController( + length: 2, + child: Scaffold( + appBar: AppBar( + centerTitle: true, + title: const Text( + 'Restaurant Tour', + style: AppTextStyles.loraRegularHeadline, + ), + bottom: const TabBar( + tabs: [ + Text( + 'All Restaurants', + style: AppTextStyles.loraRegularTitle, + ), + Text( + 'My Favorites', + style: AppTextStyles.loraRegularTitle, + ), + ], + ), + ), + body: const TabBarView( children: [ - const Text('Restaurant Tour'), - ElevatedButton( - child: const Text('Fetch Restaurants'), - onPressed: () async { - try { - final result = await getRestaurants(); - if (result != null) { - print('Fetched ${result.restaurants!.length} restaurants'); - } else { - print('No restaurants fetched'); - } - } catch (e) { - print('Failed to fetch restaurants: $e'); - } - }, - ), + RestaurantListView(), + FavoriteRestaurantsListView(), ], ), ), diff --git a/lib/models/restaurant.dart b/lib/models/restaurant.dart deleted file mode 100644 index 1c7ad2f..0000000 --- a/lib/models/restaurant.dart +++ /dev/null @@ -1,157 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; - -part 'restaurant.g.dart'; - -@JsonSerializable() -class Category { - final String? alias; - final String? title; - - Category({ - this.alias, - this.title, - }); - - factory Category.fromJson(Map json) => - _$CategoryFromJson(json); - - Map toJson() => _$CategoryToJson(this); -} - -@JsonSerializable() -class Hours { - @JsonKey(name: 'is_open_now') - final bool? isOpenNow; - - const Hours({ - this.isOpenNow, - }); - - factory Hours.fromJson(Map json) => _$HoursFromJson(json); - - Map toJson() => _$HoursToJson(this); -} - -@JsonSerializable() -class User { - final String? id; - @JsonKey(name: 'image_url') - final String? imageUrl; - final String? name; - - const User({ - this.id, - this.imageUrl, - this.name, - }); - - factory User.fromJson(Map json) => _$UserFromJson(json); - - Map toJson() => _$UserToJson(this); -} - -@JsonSerializable() -class Review { - final String? id; - final int? rating; - final String? text; - final User? user; - - const Review({ - this.id, - this.rating, - this.user, - this.text, - }); - - factory Review.fromJson(Map json) => _$ReviewFromJson(json); - - Map toJson() => _$ReviewToJson(this); -} - -@JsonSerializable() -class Location { - @JsonKey(name: 'formatted_address') - final String? formattedAddress; - - Location({ - this.formattedAddress, - }); - - factory Location.fromJson(Map json) => - _$LocationFromJson(json); - - Map toJson() => _$LocationToJson(this); -} - -@JsonSerializable() -class Restaurant { - final String? id; - final String? name; - final String? price; - final double? rating; - final List? photos; - final List? categories; - final List? hours; - final List? reviews; - final Location? location; - - const Restaurant({ - this.id, - this.name, - this.price, - this.rating, - this.photos, - this.categories, - this.hours, - this.reviews, - this.location, - }); - - factory Restaurant.fromJson(Map json) => - _$RestaurantFromJson(json); - - Map toJson() => _$RestaurantToJson(this); - - /// Use the first category for the category shown to the user - String get displayCategory { - if (categories != null && categories!.isNotEmpty) { - return categories!.first.title ?? ''; - } - return ''; - } - - /// Use the first image as the image shown to the user - String get heroImage { - if (photos != null && photos!.isNotEmpty) { - return photos!.first; - } - return ''; - } - - /// This logic is probably not correct in all cases but it is ok - /// for this application - bool get isOpen { - if (hours != null && hours!.isNotEmpty) { - return hours!.first.isOpenNow ?? false; - } - return false; - } -} - -@JsonSerializable() -class RestaurantQueryResult { - final int? total; - @JsonKey(name: 'business') - final List? restaurants; - - const RestaurantQueryResult({ - this.total, - this.restaurants, - }); - - factory RestaurantQueryResult.fromJson(Map json) => - _$RestaurantQueryResultFromJson(json); - - Map toJson() => _$RestaurantQueryResultToJson(this); -} diff --git a/lib/query.dart b/lib/query.dart deleted file mode 100644 index 7a8993b..0000000 --- a/lib/query.dart +++ /dev/null @@ -1,34 +0,0 @@ -String query(int offset) => ''' - query getRestaurants { - search(location: "Las Vegas", limit: 20, offset: $offset) { - total - business { - id - name - price - rating - photos - reviews { - id - rating - text - user { - id - image_url - name - } - } - categories { - title - alias - } - hours { - is_open_now - } - location { - formatted_address - } - } - } - } - '''; diff --git a/lib/restaurant_detail/bloc/restaurant_detail_bloc.dart b/lib/restaurant_detail/bloc/restaurant_detail_bloc.dart new file mode 100644 index 0000000..2eb6ddb --- /dev/null +++ b/lib/restaurant_detail/bloc/restaurant_detail_bloc.dart @@ -0,0 +1,43 @@ +import 'dart:async'; + +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; +import 'package:restaurant_models/restaurant_models.dart'; +import 'package:restaurant_repository/restaurant_repository.dart'; + +part 'restaurant_detail_event.dart'; +part 'restaurant_detail_state.dart'; + +class RestaurantDetailBloc + extends Bloc { + RestaurantDetailBloc(RestaurantRepository repository) + : _repository = repository, + super(const RestaurantDetailLoading()) { + on(_onToggleRestaurantFavorite); + on(_onFetchRestaurantIsFavorite); + } + + final RestaurantRepository _repository; + + FutureOr _onToggleRestaurantFavorite( + ToggleRestaurantFavorite event, + Emitter emit, + ) async { + final isFavorite = await _repository.isFavorite(event.restaurant.id ?? ''); + if (isFavorite) { + _repository.deleteFavoriteRestaurant(event.restaurant); + } else { + _repository.saveFavoriteRestaurant(event.restaurant); + } + emit(RestaurantDetailLoaded(isFavorite: !isFavorite)); + } + + FutureOr _onFetchRestaurantIsFavorite( + FetchRestaurantIsFavorite event, + Emitter emit, + ) async { + emit(const RestaurantDetailLoading()); + final isFavorite = await _repository.isFavorite(event.restaurant.id ?? ''); + emit(RestaurantDetailLoaded(isFavorite: isFavorite)); + } +} diff --git a/lib/restaurant_detail/bloc/restaurant_detail_event.dart b/lib/restaurant_detail/bloc/restaurant_detail_event.dart new file mode 100644 index 0000000..4f40a54 --- /dev/null +++ b/lib/restaurant_detail/bloc/restaurant_detail_event.dart @@ -0,0 +1,26 @@ +part of 'restaurant_detail_bloc.dart'; + +sealed class RestaurantDetailEvent extends Equatable { + const RestaurantDetailEvent(); + + @override + List get props => []; +} + +class FetchRestaurantIsFavorite extends RestaurantDetailEvent { + final Restaurant restaurant; + + const FetchRestaurantIsFavorite({required this.restaurant}); + + @override + List get props => [restaurant]; +} + +class ToggleRestaurantFavorite extends RestaurantDetailEvent { + const ToggleRestaurantFavorite({required this.restaurant}); + + final Restaurant restaurant; + + @override + List get props => [restaurant]; +} diff --git a/lib/restaurant_detail/bloc/restaurant_detail_state.dart b/lib/restaurant_detail/bloc/restaurant_detail_state.dart new file mode 100644 index 0000000..de808f8 --- /dev/null +++ b/lib/restaurant_detail/bloc/restaurant_detail_state.dart @@ -0,0 +1,20 @@ +part of 'restaurant_detail_bloc.dart'; + +sealed class RestaurantDetailState extends Equatable { + const RestaurantDetailState(); + @override + List get props => []; +} + +final class RestaurantDetailLoading extends RestaurantDetailState { + const RestaurantDetailLoading(); +} + +final class RestaurantDetailLoaded extends RestaurantDetailState { + final bool isFavorite; + + const RestaurantDetailLoaded({required this.isFavorite}); + + @override + List get props => [isFavorite]; +} diff --git a/lib/restaurant_detail/restaurant_detail.dart b/lib/restaurant_detail/restaurant_detail.dart new file mode 100644 index 0000000..adca00f --- /dev/null +++ b/lib/restaurant_detail/restaurant_detail.dart @@ -0,0 +1,2 @@ +export 'bloc/restaurant_detail_bloc.dart'; +export 'view/view.dart'; diff --git a/lib/restaurant_detail/view/restaurant_detail.dart b/lib/restaurant_detail/view/restaurant_detail.dart new file mode 100644 index 0000000..2e763ce --- /dev/null +++ b/lib/restaurant_detail/view/restaurant_detail.dart @@ -0,0 +1,206 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:restaurant_models/restaurant_models.dart'; +import 'package:restaurant_tour/restaurant_detail/restaurant_detail.dart'; +import 'package:restaurant_ui/restaurant_ui.dart'; + +class RestaurantDetailView extends StatelessWidget { + const RestaurantDetailView({super.key, required this.restaurant}); + + final Restaurant restaurant; + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: context.read() + ..add(FetchRestaurantIsFavorite(restaurant: restaurant)), + child: BlocBuilder( + builder: (context, state) { + if (state is RestaurantDetailLoading) { + return const CircularProgressIndicator(); + } + + if (state is RestaurantDetailLoaded) { + return Scaffold( + appBar: AppBar( + title: Text( + restaurant.name ?? '', + style: AppTextStyles.loraRegularHeadline, + ), + actions: [ + GestureDetector( + onTap: () => context + .read() + .add(ToggleRestaurantFavorite(restaurant: restaurant)), + child: Icon( + state.isFavorite ? Icons.favorite : Icons.favorite_border, + ), + ), + ], + ), + body: SafeArea( + child: CustomScrollView( + slivers: [ + HeaderSection( + imageUrl: restaurant.heroImage, + id: restaurant.id ?? '', + price: restaurant.price ?? '', + category: restaurant.displayCategory, + isOpen: restaurant.isOpen, + ), + AddressSection( + address: restaurant.location?.formattedAddress ?? '', + ), + RatingSection(rating: restaurant.rating ?? 0), + ReviewsSection(reviews: restaurant.reviews ?? []), + ], + ), + ), + ); + } + return const SizedBox.shrink(); + }, + ), + ); + } +} + +class HeaderSection extends StatelessWidget { + const HeaderSection({ + super.key, + required this.imageUrl, + required this.id, + required this.price, + required this.category, + required this.isOpen, + }); + + final String imageUrl; + final String id; + final String price; + final String category; + final bool isOpen; + + @override + Widget build(BuildContext context) { + return SliverToBoxAdapter( + child: Column( + children: [ + Hero( + tag: id, + child: RestaurantNetworkImage( + height: 450, + width: MediaQuery.of(context).size.width, + radius: 0, + photoUrl: imageUrl, + fit: BoxFit.cover, + ), + ), + Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('$price $category'), + RestaurantStatus(isOpen: isOpen), + ], + ), + ), + ], + ), + ); + } +} + +class AddressSection extends StatelessWidget { + const AddressSection({super.key, required this.address}); + + final String address; + + @override + Widget build(BuildContext context) { + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Address', style: AppTextStyles.openRegularTitle), + const SizedBox(height: 16), + Text(address, style: AppTextStyles.openRegularTitleSemiBold), + const SizedBox(height: 16), + const Divider(), + ], + ), + ), + ); + } +} + +class RatingSection extends StatelessWidget { + const RatingSection({super.key, required this.rating}); + + final double rating; + + @override + Widget build(BuildContext context) { + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Overall Rating', + style: AppTextStyles.openRegularTitle, + ), + const SizedBox(height: 16), + Row( + children: [ + Text( + rating.toString(), + style: const TextStyle( + fontFamily: 'Lora', + fontWeight: FontWeight.w700, + fontSize: 30, + ), + ), + const Icon(Icons.star, color: Colors.yellow), + ], + ), + const SizedBox(height: 8), + const Divider(), + ], + ), + ), + ); + } +} + +class ReviewsSection extends StatelessWidget { + const ReviewsSection({ + super.key, + required this.reviews, + }); + + final List reviews; + + @override + Widget build(BuildContext context) { + return SliverList.builder( + itemCount: reviews.length, + itemBuilder: (context, index) { + final review = reviews[index]; + return Padding( + padding: const EdgeInsets.all(16.0), + child: RestaurantReview( + rating: review.rating ?? 0, + review: review.text ?? '', + userName: review.user?.name ?? '', + userPhoto: review.user?.imageUrl ?? '', + ), + ); + }, + ); + } +} diff --git a/lib/restaurant_detail/view/view.dart b/lib/restaurant_detail/view/view.dart new file mode 100644 index 0000000..575ac47 --- /dev/null +++ b/lib/restaurant_detail/view/view.dart @@ -0,0 +1 @@ +export 'restaurant_detail.dart'; diff --git a/lib/restaurant_list/bloc/restaurant_list_bloc.dart b/lib/restaurant_list/bloc/restaurant_list_bloc.dart new file mode 100644 index 0000000..7a14dd2 --- /dev/null +++ b/lib/restaurant_list/bloc/restaurant_list_bloc.dart @@ -0,0 +1,43 @@ +import 'dart:async'; + +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; +import 'package:restaurant_models/restaurant_models.dart'; +import 'package:restaurant_repository/restaurant_repository.dart'; + +part 'restaurant_list_event.dart'; +part 'restaurant_list_state.dart'; + +class RestaurantListBloc + extends Bloc { + RestaurantListBloc(RestaurantRepository repository) + : _repository = repository, + super(const RestaurantListLoading()) { + on(_onFetchRestaurantList); + on(_onGoToRestaurantDetail); + } + + final RestaurantRepository _repository; + + FutureOr _onFetchRestaurantList( + FetchRestaurantList event, + Emitter emit, + ) async { + try { + emit(const RestaurantListLoading()); + final response = await _repository.fetchRestaurants(); + final restaurants = response?.restaurants ?? []; + emit(RestaurantListData(restaurants: restaurants)); + } catch (e) { + emit(RestaurantListError(message: e.toString())); + addError(e); + } + } + + FutureOr _onGoToRestaurantDetail( + GoToRestaurantDetail event, + Emitter emit, + ) { + emit(RestaurantDetail(restaurant: event.restaurant)); + } +} diff --git a/lib/restaurant_list/bloc/restaurant_list_event.dart b/lib/restaurant_list/bloc/restaurant_list_event.dart new file mode 100644 index 0000000..06756d4 --- /dev/null +++ b/lib/restaurant_list/bloc/restaurant_list_event.dart @@ -0,0 +1,20 @@ +part of 'restaurant_list_bloc.dart'; + +sealed class RestaurantListEvent extends Equatable { + const RestaurantListEvent(); + @override + List get props => []; +} + +class FetchRestaurantList extends RestaurantListEvent { + const FetchRestaurantList(); +} + +class GoToRestaurantDetail extends RestaurantListEvent { + const GoToRestaurantDetail({required this.restaurant}); + + final Restaurant restaurant; + + @override + List get props => [restaurant]; +} diff --git a/lib/restaurant_list/bloc/restaurant_list_state.dart b/lib/restaurant_list/bloc/restaurant_list_state.dart new file mode 100644 index 0000000..ba6b9c6 --- /dev/null +++ b/lib/restaurant_list/bloc/restaurant_list_state.dart @@ -0,0 +1,39 @@ +part of 'restaurant_list_bloc.dart'; + +sealed class RestaurantListState extends Equatable { + const RestaurantListState(); + + @override + List get props => []; +} + +final class RestaurantListLoading extends RestaurantListState { + const RestaurantListLoading(); +} + +final class RestaurantListError extends RestaurantListState { + const RestaurantListError({required this.message}); + + final String message; + + @override + List get props => [message]; +} + +final class RestaurantListData extends RestaurantListState { + const RestaurantListData({required this.restaurants}); + + final List restaurants; + + @override + List get props => [restaurants]; +} + +final class RestaurantDetail extends RestaurantListState { + const RestaurantDetail({required this.restaurant}); + + final Restaurant restaurant; + + @override + List get props => [restaurant]; +} diff --git a/lib/restaurant_list/restaurant_list.dart b/lib/restaurant_list/restaurant_list.dart new file mode 100644 index 0000000..f5318b7 --- /dev/null +++ b/lib/restaurant_list/restaurant_list.dart @@ -0,0 +1,2 @@ +export 'bloc/restaurant_list_bloc.dart'; +export 'view/view.dart'; diff --git a/lib/restaurant_list/view/restaurant_list.dart b/lib/restaurant_list/view/restaurant_list.dart new file mode 100644 index 0000000..4443871 --- /dev/null +++ b/lib/restaurant_list/view/restaurant_list.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:restaurant_tour/favorite_restaurants_list/favorite_restaurant_list.dart'; +import 'package:restaurant_tour/restaurant_detail/restaurant_detail.dart'; +import 'package:restaurant_tour/restaurant_list/restaurant_list.dart'; +import 'package:restaurant_ui/restaurant_ui.dart'; + +class RestaurantListView extends StatelessWidget { + const RestaurantListView({super.key}); + + @override + Widget build(BuildContext context) { + return BlocConsumer( + listener: (context, state) { + if (state is RestaurantDetail) { + // Store bloc references before the async operation + final restaurantListBloc = context.read(); + final favoriteRestaurantsBloc = + context.read(); + + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => RestaurantDetailView( + restaurant: state.restaurant, + ), + ), + ).then( + (_) { + // Use the stored bloc references + restaurantListBloc.add(const FetchRestaurantList()); + favoriteRestaurantsBloc.add(const FetchFavoriteRestaurants()); + }, + ); + } + }, + builder: (context, state) { + final state = context.select((RestaurantListBloc bloc) => bloc.state); + if (state is RestaurantListLoading) { + return const Center(child: CircularProgressIndicator()); + } + if (state is RestaurantListError) { + return const Text('error'); + } + if (state is RestaurantListData) { + final restaurants = state.restaurants; + return ListView.builder( + itemCount: restaurants.length, + itemBuilder: (context, index) { + final restaurant = restaurants[index]; + return RestaurantCard( + tag: restaurant.id ?? '', + title: restaurant.name ?? '', + photoUrl: restaurant.heroImage, + isOpen: restaurant.isOpen, + price: restaurant.price ?? '', + rating: restaurant.rating ?? 0.0, + category: restaurant.displayCategory, + onTap: () => context + .read() + .add(GoToRestaurantDetail(restaurant: restaurant)), + ); + }, + ); + } + return const SizedBox.shrink(); + }, + ); + } +} diff --git a/lib/restaurant_list/view/view.dart b/lib/restaurant_list/view/view.dart new file mode 100644 index 0000000..a050a8e --- /dev/null +++ b/lib/restaurant_list/view/view.dart @@ -0,0 +1 @@ +export 'restaurant_list.dart'; diff --git a/lib/typography.dart b/lib/typography.dart deleted file mode 100644 index e165260..0000000 --- a/lib/typography.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'package:flutter/material.dart'; - -class AppTextStyles { - ////----- Lora -----// - static const loraRegularHeadline = TextStyle( - fontFamily: 'Lora', - fontWeight: FontWeight.w700, - fontSize: 18.0, - ); - static const loraRegularTitle = TextStyle( - fontFamily: 'Lora', - fontWeight: FontWeight.w500, - fontSize: 16.0, - ); - - //----- Open Sans -----// - static const openRegularHeadline = TextStyle( - fontFamily: 'OpenSans', - fontWeight: FontWeight.w400, - fontSize: 16.0, - color: Colors.black, - ); - static const openRegularTitleSemiBold = TextStyle( - fontFamily: 'OpenSans', - fontWeight: FontWeight.w600, - fontSize: 14.0, - color: Colors.black, - ); - static const openRegularTitle = TextStyle( - fontFamily: 'OpenSans', - fontWeight: FontWeight.w400, - fontSize: 14.0, - color: Colors.black, - ); - static const openRegularText = TextStyle( - fontFamily: 'OpenSans', - fontWeight: FontWeight.w400, - fontSize: 12.0, - color: Colors.black, - ); - - static const openRegularItalic = TextStyle( - fontFamily: 'OpenSans', - fontWeight: FontWeight.w400, - fontStyle: FontStyle.italic, - fontSize: 12.0, - color: Colors.black, - ); -} diff --git a/packages/restaurant_gql_client/.github/ISSUE_TEMPLATE/bug_report.md b/packages/restaurant_gql_client/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..50a4c7b --- /dev/null +++ b/packages/restaurant_gql_client/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,29 @@ +--- +name: Bug Report +about: Create a report to help us improve +title: "fix: " +labels: bug +--- + +**Description** + +A clear and concise description of what the bug is. + +**Steps To Reproduce** + +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected Behavior** + +A clear and concise description of what you expected to happen. + +**Screenshots** + +If applicable, add screenshots to help explain your problem. + +**Additional Context** + +Add any other context about the problem here. diff --git a/packages/restaurant_gql_client/.github/ISSUE_TEMPLATE/build.md b/packages/restaurant_gql_client/.github/ISSUE_TEMPLATE/build.md new file mode 100644 index 0000000..0cf8e62 --- /dev/null +++ b/packages/restaurant_gql_client/.github/ISSUE_TEMPLATE/build.md @@ -0,0 +1,14 @@ +--- +name: Build System +about: Changes that affect the build system or external dependencies +title: "build: " +labels: build +--- + +**Description** + +Describe what changes need to be done to the build system and why. + +**Requirements** + +- [ ] The build system is passing diff --git a/packages/restaurant_gql_client/.github/ISSUE_TEMPLATE/chore.md b/packages/restaurant_gql_client/.github/ISSUE_TEMPLATE/chore.md new file mode 100644 index 0000000..498ebfd --- /dev/null +++ b/packages/restaurant_gql_client/.github/ISSUE_TEMPLATE/chore.md @@ -0,0 +1,14 @@ +--- +name: Chore +about: Other changes that don't modify src or test files +title: "chore: " +labels: chore +--- + +**Description** + +Clearly describe what change is needed and why. If this changes code then please use another issue type. + +**Requirements** + +- [ ] No functional changes to the code diff --git a/packages/restaurant_gql_client/.github/ISSUE_TEMPLATE/ci.md b/packages/restaurant_gql_client/.github/ISSUE_TEMPLATE/ci.md new file mode 100644 index 0000000..fa2dd9e --- /dev/null +++ b/packages/restaurant_gql_client/.github/ISSUE_TEMPLATE/ci.md @@ -0,0 +1,14 @@ +--- +name: Continuous Integration +about: Changes to the CI configuration files and scripts +title: "ci: " +labels: ci +--- + +**Description** + +Describe what changes need to be done to the ci/cd system and why. + +**Requirements** + +- [ ] The ci system is passing diff --git a/packages/restaurant_gql_client/.github/ISSUE_TEMPLATE/config.yml b/packages/restaurant_gql_client/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..ec4bb38 --- /dev/null +++ b/packages/restaurant_gql_client/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false \ No newline at end of file diff --git a/packages/restaurant_gql_client/.github/ISSUE_TEMPLATE/documentation.md b/packages/restaurant_gql_client/.github/ISSUE_TEMPLATE/documentation.md new file mode 100644 index 0000000..f494a4d --- /dev/null +++ b/packages/restaurant_gql_client/.github/ISSUE_TEMPLATE/documentation.md @@ -0,0 +1,14 @@ +--- +name: Documentation +about: Improve the documentation so all collaborators have a common understanding +title: "docs: " +labels: documentation +--- + +**Description** + +Clearly describe what documentation you are looking to add or improve. + +**Requirements** + +- [ ] Requirements go here diff --git a/packages/restaurant_gql_client/.github/ISSUE_TEMPLATE/feature_request.md b/packages/restaurant_gql_client/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..ddd2fcc --- /dev/null +++ b/packages/restaurant_gql_client/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,18 @@ +--- +name: Feature Request +about: A new feature to be added to the project +title: "feat: " +labels: feature +--- + +**Description** + +Clearly describe what you are looking to add. The more context the better. + +**Requirements** + +- [ ] Checklist of requirements to be fulfilled + +**Additional Context** + +Add any other context or screenshots about the feature request go here. diff --git a/packages/restaurant_gql_client/.github/ISSUE_TEMPLATE/performance.md b/packages/restaurant_gql_client/.github/ISSUE_TEMPLATE/performance.md new file mode 100644 index 0000000..699b8d4 --- /dev/null +++ b/packages/restaurant_gql_client/.github/ISSUE_TEMPLATE/performance.md @@ -0,0 +1,14 @@ +--- +name: Performance Update +about: A code change that improves performance +title: "perf: " +labels: performance +--- + +**Description** + +Clearly describe what code needs to be changed and what the performance impact is going to be. Bonus point's if you can tie this directly to user experience. + +**Requirements** + +- [ ] There is no drop in test coverage. diff --git a/packages/restaurant_gql_client/.github/ISSUE_TEMPLATE/refactor.md b/packages/restaurant_gql_client/.github/ISSUE_TEMPLATE/refactor.md new file mode 100644 index 0000000..1626c57 --- /dev/null +++ b/packages/restaurant_gql_client/.github/ISSUE_TEMPLATE/refactor.md @@ -0,0 +1,14 @@ +--- +name: Refactor +about: A code change that neither fixes a bug nor adds a feature +title: "refactor: " +labels: refactor +--- + +**Description** + +Clearly describe what needs to be refactored and why. Please provide links to related issues (bugs or upcoming features) in order to help prioritize. + +**Requirements** + +- [ ] There is no drop in test coverage. diff --git a/packages/restaurant_gql_client/.github/ISSUE_TEMPLATE/revert.md b/packages/restaurant_gql_client/.github/ISSUE_TEMPLATE/revert.md new file mode 100644 index 0000000..9d121dc --- /dev/null +++ b/packages/restaurant_gql_client/.github/ISSUE_TEMPLATE/revert.md @@ -0,0 +1,16 @@ +--- +name: Revert Commit +about: Reverts a previous commit +title: "revert: " +labels: revert +--- + +**Description** + +Provide a link to a PR/Commit that you are looking to revert and why. + +**Requirements** + +- [ ] Change has been reverted +- [ ] No change in test coverage has happened +- [ ] A new ticket is created for any follow on work that needs to happen diff --git a/packages/restaurant_gql_client/.github/ISSUE_TEMPLATE/style.md b/packages/restaurant_gql_client/.github/ISSUE_TEMPLATE/style.md new file mode 100644 index 0000000..02244a7 --- /dev/null +++ b/packages/restaurant_gql_client/.github/ISSUE_TEMPLATE/style.md @@ -0,0 +1,14 @@ +--- +name: Style Changes +about: Changes that do not affect the meaning of the code (white space, formatting, missing semi-colons, etc) +title: "style: " +labels: style +--- + +**Description** + +Clearly describe what you are looking to change and why. + +**Requirements** + +- [ ] There is no drop in test coverage. diff --git a/packages/restaurant_gql_client/.github/ISSUE_TEMPLATE/test.md b/packages/restaurant_gql_client/.github/ISSUE_TEMPLATE/test.md new file mode 100644 index 0000000..431a7ea --- /dev/null +++ b/packages/restaurant_gql_client/.github/ISSUE_TEMPLATE/test.md @@ -0,0 +1,14 @@ +--- +name: Test +about: Adding missing tests or correcting existing tests +title: "test: " +labels: test +--- + +**Description** + +List out the tests that need to be added or changed. Please also include any information as to why this was not covered in the past. + +**Requirements** + +- [ ] There is no drop in test coverage. diff --git a/packages/restaurant_gql_client/.github/PULL_REQUEST_TEMPLATE.md b/packages/restaurant_gql_client/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..1169936 --- /dev/null +++ b/packages/restaurant_gql_client/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,27 @@ + + +## Status + +**READY/IN DEVELOPMENT/HOLD** + +## Description + + + +## Type of Change + + + +- [ ] โœจ New feature (non-breaking change which adds functionality) +- [ ] ๐Ÿ› ๏ธ Bug fix (non-breaking change which fixes an issue) +- [ ] โŒ Breaking change (fix or feature that would cause existing functionality to change) +- [ ] ๐Ÿงน Code refactor +- [ ] โœ… Build configuration change +- [ ] ๐Ÿ“ Documentation +- [ ] ๐Ÿ—‘๏ธ Chore diff --git a/packages/restaurant_gql_client/.github/cspell.json b/packages/restaurant_gql_client/.github/cspell.json new file mode 100644 index 0000000..89a1367 --- /dev/null +++ b/packages/restaurant_gql_client/.github/cspell.json @@ -0,0 +1,21 @@ +{ + "version": "0.2", + "$schema": "https://raw.githubusercontent.com/streetsidesoftware/cspell/main/cspell.schema.json", + "dictionaries": ["vgv_allowed", "vgv_forbidden"], + "dictionaryDefinitions": [ + { + "name": "vgv_allowed", + "path": "https://raw.githubusercontent.com/verygoodopensource/very_good_dictionaries/main/allowed.txt", + "description": "Allowed VGV Spellings" + }, + { + "name": "vgv_forbidden", + "path": "https://raw.githubusercontent.com/verygoodopensource/very_good_dictionaries/main/forbidden.txt", + "description": "Forbidden VGV Spellings" + } + ], + "useGitignore": true, + "words": [ + "restaurant_gql_client" + ] +} diff --git a/packages/restaurant_gql_client/.github/dependabot.yaml b/packages/restaurant_gql_client/.github/dependabot.yaml new file mode 100644 index 0000000..63b035c --- /dev/null +++ b/packages/restaurant_gql_client/.github/dependabot.yaml @@ -0,0 +1,11 @@ +version: 2 +enable-beta-ecosystems: true +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" + - package-ecosystem: "pub" + directory: "/" + schedule: + interval: "daily" diff --git a/packages/restaurant_gql_client/.github/workflows/main.yaml b/packages/restaurant_gql_client/.github/workflows/main.yaml new file mode 100644 index 0000000..c7146c3 --- /dev/null +++ b/packages/restaurant_gql_client/.github/workflows/main.yaml @@ -0,0 +1,27 @@ +name: ci + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + semantic_pull_request: + uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/semantic_pull_request.yml@v1 + + spell-check: + uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/spell_check.yml@v1 + with: + includes: "**/*.md" + modified_files_only: false + + build: + uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/dart_package.yml@v1 + diff --git a/packages/restaurant_gql_client/.gitignore b/packages/restaurant_gql_client/.gitignore new file mode 100644 index 0000000..526da15 --- /dev/null +++ b/packages/restaurant_gql_client/.gitignore @@ -0,0 +1,7 @@ +# See https://www.dartlang.org/guides/libraries/private-files + +# Files and directories created by pub +.dart_tool/ +.packages +build/ +pubspec.lock \ No newline at end of file diff --git a/packages/restaurant_gql_client/README.md b/packages/restaurant_gql_client/README.md new file mode 100644 index 0000000..ac4ea83 --- /dev/null +++ b/packages/restaurant_gql_client/README.md @@ -0,0 +1,62 @@ +# Restaurant Gql Client + +[![style: very good analysis][very_good_analysis_badge]][very_good_analysis_link] +[![Powered by Mason](https://img.shields.io/endpoint?url=https%3A%2F%2Ftinyurl.com%2Fmason-badge)](https://github.com/felangel/mason) +[![License: MIT][license_badge]][license_link] + +Restaurant gql client + +## Installation ๐Ÿ’ป + +**โ— In order to start using Restaurant Gql Client you must have the [Dart SDK][dart_install_link] installed on your machine.** + +Install via `dart pub add`: + +```sh +dart pub add restaurant_gql_client +``` + +--- + +## Continuous Integration ๐Ÿค– + +Restaurant Gql Client comes with a built-in [GitHub Actions workflow][github_actions_link] powered by [Very Good Workflows][very_good_workflows_link] but you can also add your preferred CI/CD solution. + +Out of the box, on each pull request and push, the CI `formats`, `lints`, and `tests` the code. This ensures the code remains consistent and behaves correctly as you add functionality or make changes. The project uses [Very Good Analysis][very_good_analysis_link] for a strict set of analysis options used by our team. Code coverage is enforced using the [Very Good Workflows][very_good_coverage_link]. + +--- + +## Running Tests ๐Ÿงช + +To run all unit tests: + +```sh +dart pub global activate coverage 1.2.0 +dart test --coverage=coverage +dart pub global run coverage:format_coverage --lcov --in=coverage --out=coverage/lcov.info +``` + +To view the generated coverage report you can use [lcov](https://github.com/linux-test-project/lcov). + +```sh +# Generate Coverage Report +genhtml coverage/lcov.info -o coverage/ + +# Open Coverage Report +open coverage/index.html +``` + +[dart_install_link]: https://dart.dev/get-dart +[github_actions_link]: https://docs.github.com/en/actions/learn-github-actions +[license_badge]: https://img.shields.io/badge/license-MIT-blue.svg +[license_link]: https://opensource.org/licenses/MIT +[logo_black]: https://raw.githubusercontent.com/VGVentures/very_good_brand/main/styles/README/vgv_logo_black.png#gh-light-mode-only +[logo_white]: https://raw.githubusercontent.com/VGVentures/very_good_brand/main/styles/README/vgv_logo_white.png#gh-dark-mode-only +[mason_link]: https://github.com/felangel/mason +[very_good_analysis_badge]: https://img.shields.io/badge/style-very_good_analysis-B22C89.svg +[very_good_analysis_link]: https://pub.dev/packages/very_good_analysis +[very_good_coverage_link]: https://github.com/marketplace/actions/very-good-coverage +[very_good_ventures_link]: https://verygood.ventures +[very_good_ventures_link_light]: https://verygood.ventures#gh-light-mode-only +[very_good_ventures_link_dark]: https://verygood.ventures#gh-dark-mode-only +[very_good_workflows_link]: https://github.com/VeryGoodOpenSource/very_good_workflows diff --git a/packages/restaurant_gql_client/analysis_options.yaml b/packages/restaurant_gql_client/analysis_options.yaml new file mode 100644 index 0000000..faa2dab --- /dev/null +++ b/packages/restaurant_gql_client/analysis_options.yaml @@ -0,0 +1,4 @@ +analyzer: + errors: + argument_type_not_assignable: ignore +include: package:very_good_analysis/analysis_options.6.0.0.yaml diff --git a/packages/restaurant_gql_client/coverage_badge.svg b/packages/restaurant_gql_client/coverage_badge.svg new file mode 100644 index 0000000..499e98c --- /dev/null +++ b/packages/restaurant_gql_client/coverage_badge.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + coverage + coverage + 100% + 100% + + diff --git a/packages/restaurant_gql_client/lib/restaurant_gql_client.dart b/packages/restaurant_gql_client/lib/restaurant_gql_client.dart new file mode 100644 index 0000000..b7aad6a --- /dev/null +++ b/packages/restaurant_gql_client/lib/restaurant_gql_client.dart @@ -0,0 +1,4 @@ +/// Restaurant gql client +library; + +export 'src/restaurant_gql_client.dart'; diff --git a/packages/restaurant_gql_client/lib/src/query/query.dart b/packages/restaurant_gql_client/lib/src/query/query.dart new file mode 100644 index 0000000..f583845 --- /dev/null +++ b/packages/restaurant_gql_client/lib/src/query/query.dart @@ -0,0 +1,80 @@ +/// Generates a GraphQL query string to fetch a list of restaurants. +/// +/// This query retrieves 20 restaurants based on the provided offset from the +/// location "Las Vegas". The query includes essential restaurant details like +/// name, price, rating, photos, categories, hours of operation, location, and +/// reviews, along with the user details who provided the review. +/// +/// The result set includes: +/// - `total`: The total number of restaurants available. +/// - `business`: List of restaurants, with each restaurant containing: +/// - `id`: Unique identifier for the restaurant. +/// - `name`: Name of the restaurant. +/// - `price`: Price level of the restaurant (e.g., $, $$, $$$). +/// - `rating`: The average rating of the restaurant. +/// - `photos`: A list of photo URLs for the restaurant. +/// - `reviews`: A list of reviews with details like: +/// - `id`: Review ID. +/// - `rating`: Rating given by the user. +/// - `text`: Review text. +/// - `user`: The user who posted the review, containing: +/// - `id`: User ID. +/// - `image_url`: User's profile picture URL. +/// - `name`: User's name. +/// - `categories`: List of categories associated with the restaurant, +/// containing: +/// - `title`: Category title. +/// - `alias`: Category alias. +/// - `hours`: Operating hours of the restaurant, containing: +/// - `is_open_now`: Whether the restaurant is currently open. +/// - `location`: The formatted address of the restaurant. +/// +/// The query accepts an `offset` parameter, which is used for paginating +/// the list of restaurants. +/// +/// Example: +/// ```dart +/// final gqlQuery = query(20); // Fetch the next 20 restaurants. +/// print(gqlQuery); +/// ``` +/// +/// [offset]: The offset value for paginated results, allowing for fetching +/// restaurants in batches of 20. +/// +/// Returns: +/// A `String` representing the GraphQL query to fetch restaurants from the +/// specified location with pagination support. +String query(int offset) => ''' + query getRestaurants { + search(location: "Las Vegas", limit: 20, offset: $offset) { + total + business { + id + name + price + rating + photos + reviews { + id + rating + text + user { + id + image_url + name + } + } + categories { + title + alias + } + hours { + is_open_now + } + location { + formatted_address + } + } + } + } + '''; diff --git a/packages/restaurant_gql_client/lib/src/restaurant_gql_client.dart b/packages/restaurant_gql_client/lib/src/restaurant_gql_client.dart new file mode 100644 index 0000000..88a1f12 --- /dev/null +++ b/packages/restaurant_gql_client/lib/src/restaurant_gql_client.dart @@ -0,0 +1,75 @@ +// ignore_for_file: avoid_dynamic_calls + +import 'dart:convert'; +import 'dart:developer'; +import 'package:http/http.dart' as http; +import 'package:restaurant_gql_client/src/query/query.dart'; +import 'package:restaurant_models/restaurant_models.dart'; + +/// {@template api_exception} +/// Custom exception for handling API errors with status codes and messages. +/// {@endtemplate} +class ApiException implements Exception { + /// {@macro api_exception} + ApiException(this.statusCode, this.message); + + /// HTTP status code of the API response. + final int statusCode; + + /// Error message returned or custom message if any. + final String message; + + @override + String toString() => + 'ApiException(statusCode: $statusCode, message: $message)'; +} + +/// {@template restaurant_gql_client} +/// A client for interacting with the restaurant GraphQL API to fetch +/// restaurant data such as reviews, categories, and location. +/// {@endtemplate} +class RestaurantGqlClient { + /// {@macro restaurant_gql_client} + RestaurantGqlClient(http.Client client, String baseUrl, String apiKey) + : _client = client, + _baseUrl = baseUrl, + _apiKey = apiKey; + + final http.Client _client; + final String _baseUrl; + final String _apiKey; + + /// Fetches restaurants from the GraphQL API with an optional [offset] for + /// pagination. + /// + /// Returns a [RestaurantQueryResult] or throws an [ApiException] if the + /// request fails. + Future getRestaurants({int offset = 0}) async { + final headers = { + 'Authorization': 'Bearer $_apiKey', + 'Content-Type': 'application/graphql', + }; + + try { + final response = await _client.post( + Uri.parse(_baseUrl), + headers: headers, + body: query(offset), + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body)['data']['search']; + return RestaurantQueryResult.fromJson(data); + } else { + log( + 'Failed to fetch restaurants: ${response.statusCode},' + ' ${response.body}', + ); + throw ApiException(response.statusCode, 'Failed to load restaurants'); + } + } catch (e) { + log('Error fetching restaurants: $e'); + throw ApiException(500, 'An unexpected error occurred: $e'); + } + } +} diff --git a/packages/restaurant_gql_client/pubspec.yaml b/packages/restaurant_gql_client/pubspec.yaml new file mode 100644 index 0000000..2e29986 --- /dev/null +++ b/packages/restaurant_gql_client/pubspec.yaml @@ -0,0 +1,17 @@ +name: restaurant_gql_client +description: Restaurant gql client +version: 0.1.0+1 +publish_to: none + +environment: + sdk: ^3.4.4 + +dependencies: + http: ^1.2.2 + restaurant_models: + path: ../restaurant_models + +dev_dependencies: + mocktail: ^1.0.4 + test: ^1.25.8 + very_good_analysis: ^6.0.0 diff --git a/packages/restaurant_gql_client/test/src/restaurant_gql_client_test.dart b/packages/restaurant_gql_client/test/src/restaurant_gql_client_test.dart new file mode 100644 index 0000000..94b922c --- /dev/null +++ b/packages/restaurant_gql_client/test/src/restaurant_gql_client_test.dart @@ -0,0 +1,140 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; +import 'package:mocktail/mocktail.dart'; +import 'package:restaurant_gql_client/restaurant_gql_client.dart'; +import 'package:test/test.dart'; + +class MockHttpClient extends Mock implements http.Client {} + +class FakeHttpResponse extends Fake implements http.Response {} + +void main() { + late MockHttpClient mockHttpClient; + late RestaurantGqlClient restaurantGqlClient; + + const baseUrl = 'https://api.yelp.com/v3/graphql'; + const apiKey = 'dummyApiKey'; + + setUpAll(() { + registerFallbackValue(FakeHttpResponse()); + }); + + setUp(() { + mockHttpClient = MockHttpClient(); + restaurantGqlClient = RestaurantGqlClient(mockHttpClient, baseUrl, apiKey); + }); + + group('RestaurantGqlClient', () { + const headers = { + 'Authorization': 'Bearer $apiKey', + 'Content-Type': 'application/graphql', + }; + + test('successfully fetches restaurants', () async { + final mockResponseData = { + 'data': { + 'search': { + 'total': 1, + 'business': [ + { + 'id': 'vHz2RLtfUMVRPFmd7VBEHA', + 'name': 'Test Restaurant', + 'price': r'$$', + 'rating': 4.5, + 'photos': ['https://example.com/photo.jpg'], + 'reviews': [ + { + 'id': 'F88H5ow44AmiwisbrbswPw', + 'rating': 5, + 'text': 'Great food!', + 'user': { + 'id': 'y742Fi1jF_JAqq5sRUlLEw', + 'image_url': 'https://example.com/user.jpg', + 'name': 'John Doe', + }, + }, + ], + 'categories': [ + {'title': 'Italian', 'alias': 'italian'}, + ], + 'hours': [ + {'is_open_now': true}, + ], + 'location': { + 'formatted_address': '123 Main St, City, Country', + }, + } + ], + }, + }, + }; + + final mockResponse = http.Response(jsonEncode(mockResponseData), 200); + + when( + () => mockHttpClient.post( + Uri.parse(baseUrl), + headers: headers, + body: any(named: 'body'), + ), + ).thenAnswer((_) async => mockResponse); + + final result = await restaurantGqlClient.getRestaurants(); + + expect(result?.restaurants?.first.name, 'Test Restaurant'); + expect(result?.restaurants?.first.rating, 4.5); + expect(result?.restaurants?.first.isOpen, true); + expect(result?.restaurants?.first.displayCategory, 'Italian'); + }); + + test('throws ApiException on non-200 status code', () async { + final mockResponse = http.Response('{"error": "Not found"}', 404); + + when( + () => mockHttpClient.post( + Uri.parse(baseUrl), + headers: headers, + body: any(named: 'body'), + ), + ).thenAnswer((_) async => mockResponse); + + expect( + () async => restaurantGqlClient.getRestaurants(), + throwsA( + isA().having( + (e) => e.toString(), + 'toString', + contains( + 'ApiException(statusCode: 404, message: ' + 'Failed to load restaurants)', + ), + ), + ), + ); + }); + + test('throws ApiException on error during request', () async { + when( + () => mockHttpClient.post( + Uri.parse(baseUrl), + headers: headers, + body: any(named: 'body'), + ), + ).thenThrow(Exception('Some error')); + + expect( + () async => restaurantGqlClient.getRestaurants(), + throwsA( + isA().having( + (e) => e.toString(), + 'toString', + contains( + 'ApiException(statusCode: 500, message: An unexpected error ' + 'occurred: Exception: Some error)', + ), + ), + ), + ); + }); + }); +} diff --git a/packages/restaurant_models/.github/ISSUE_TEMPLATE/bug_report.md b/packages/restaurant_models/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..50a4c7b --- /dev/null +++ b/packages/restaurant_models/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,29 @@ +--- +name: Bug Report +about: Create a report to help us improve +title: "fix: " +labels: bug +--- + +**Description** + +A clear and concise description of what the bug is. + +**Steps To Reproduce** + +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected Behavior** + +A clear and concise description of what you expected to happen. + +**Screenshots** + +If applicable, add screenshots to help explain your problem. + +**Additional Context** + +Add any other context about the problem here. diff --git a/packages/restaurant_models/.github/ISSUE_TEMPLATE/build.md b/packages/restaurant_models/.github/ISSUE_TEMPLATE/build.md new file mode 100644 index 0000000..0cf8e62 --- /dev/null +++ b/packages/restaurant_models/.github/ISSUE_TEMPLATE/build.md @@ -0,0 +1,14 @@ +--- +name: Build System +about: Changes that affect the build system or external dependencies +title: "build: " +labels: build +--- + +**Description** + +Describe what changes need to be done to the build system and why. + +**Requirements** + +- [ ] The build system is passing diff --git a/packages/restaurant_models/.github/ISSUE_TEMPLATE/chore.md b/packages/restaurant_models/.github/ISSUE_TEMPLATE/chore.md new file mode 100644 index 0000000..498ebfd --- /dev/null +++ b/packages/restaurant_models/.github/ISSUE_TEMPLATE/chore.md @@ -0,0 +1,14 @@ +--- +name: Chore +about: Other changes that don't modify src or test files +title: "chore: " +labels: chore +--- + +**Description** + +Clearly describe what change is needed and why. If this changes code then please use another issue type. + +**Requirements** + +- [ ] No functional changes to the code diff --git a/packages/restaurant_models/.github/ISSUE_TEMPLATE/ci.md b/packages/restaurant_models/.github/ISSUE_TEMPLATE/ci.md new file mode 100644 index 0000000..fa2dd9e --- /dev/null +++ b/packages/restaurant_models/.github/ISSUE_TEMPLATE/ci.md @@ -0,0 +1,14 @@ +--- +name: Continuous Integration +about: Changes to the CI configuration files and scripts +title: "ci: " +labels: ci +--- + +**Description** + +Describe what changes need to be done to the ci/cd system and why. + +**Requirements** + +- [ ] The ci system is passing diff --git a/packages/restaurant_models/.github/ISSUE_TEMPLATE/config.yml b/packages/restaurant_models/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..ec4bb38 --- /dev/null +++ b/packages/restaurant_models/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false \ No newline at end of file diff --git a/packages/restaurant_models/.github/ISSUE_TEMPLATE/documentation.md b/packages/restaurant_models/.github/ISSUE_TEMPLATE/documentation.md new file mode 100644 index 0000000..f494a4d --- /dev/null +++ b/packages/restaurant_models/.github/ISSUE_TEMPLATE/documentation.md @@ -0,0 +1,14 @@ +--- +name: Documentation +about: Improve the documentation so all collaborators have a common understanding +title: "docs: " +labels: documentation +--- + +**Description** + +Clearly describe what documentation you are looking to add or improve. + +**Requirements** + +- [ ] Requirements go here diff --git a/packages/restaurant_models/.github/ISSUE_TEMPLATE/feature_request.md b/packages/restaurant_models/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..ddd2fcc --- /dev/null +++ b/packages/restaurant_models/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,18 @@ +--- +name: Feature Request +about: A new feature to be added to the project +title: "feat: " +labels: feature +--- + +**Description** + +Clearly describe what you are looking to add. The more context the better. + +**Requirements** + +- [ ] Checklist of requirements to be fulfilled + +**Additional Context** + +Add any other context or screenshots about the feature request go here. diff --git a/packages/restaurant_models/.github/ISSUE_TEMPLATE/performance.md b/packages/restaurant_models/.github/ISSUE_TEMPLATE/performance.md new file mode 100644 index 0000000..699b8d4 --- /dev/null +++ b/packages/restaurant_models/.github/ISSUE_TEMPLATE/performance.md @@ -0,0 +1,14 @@ +--- +name: Performance Update +about: A code change that improves performance +title: "perf: " +labels: performance +--- + +**Description** + +Clearly describe what code needs to be changed and what the performance impact is going to be. Bonus point's if you can tie this directly to user experience. + +**Requirements** + +- [ ] There is no drop in test coverage. diff --git a/packages/restaurant_models/.github/ISSUE_TEMPLATE/refactor.md b/packages/restaurant_models/.github/ISSUE_TEMPLATE/refactor.md new file mode 100644 index 0000000..1626c57 --- /dev/null +++ b/packages/restaurant_models/.github/ISSUE_TEMPLATE/refactor.md @@ -0,0 +1,14 @@ +--- +name: Refactor +about: A code change that neither fixes a bug nor adds a feature +title: "refactor: " +labels: refactor +--- + +**Description** + +Clearly describe what needs to be refactored and why. Please provide links to related issues (bugs or upcoming features) in order to help prioritize. + +**Requirements** + +- [ ] There is no drop in test coverage. diff --git a/packages/restaurant_models/.github/ISSUE_TEMPLATE/revert.md b/packages/restaurant_models/.github/ISSUE_TEMPLATE/revert.md new file mode 100644 index 0000000..9d121dc --- /dev/null +++ b/packages/restaurant_models/.github/ISSUE_TEMPLATE/revert.md @@ -0,0 +1,16 @@ +--- +name: Revert Commit +about: Reverts a previous commit +title: "revert: " +labels: revert +--- + +**Description** + +Provide a link to a PR/Commit that you are looking to revert and why. + +**Requirements** + +- [ ] Change has been reverted +- [ ] No change in test coverage has happened +- [ ] A new ticket is created for any follow on work that needs to happen diff --git a/packages/restaurant_models/.github/ISSUE_TEMPLATE/style.md b/packages/restaurant_models/.github/ISSUE_TEMPLATE/style.md new file mode 100644 index 0000000..02244a7 --- /dev/null +++ b/packages/restaurant_models/.github/ISSUE_TEMPLATE/style.md @@ -0,0 +1,14 @@ +--- +name: Style Changes +about: Changes that do not affect the meaning of the code (white space, formatting, missing semi-colons, etc) +title: "style: " +labels: style +--- + +**Description** + +Clearly describe what you are looking to change and why. + +**Requirements** + +- [ ] There is no drop in test coverage. diff --git a/packages/restaurant_models/.github/ISSUE_TEMPLATE/test.md b/packages/restaurant_models/.github/ISSUE_TEMPLATE/test.md new file mode 100644 index 0000000..431a7ea --- /dev/null +++ b/packages/restaurant_models/.github/ISSUE_TEMPLATE/test.md @@ -0,0 +1,14 @@ +--- +name: Test +about: Adding missing tests or correcting existing tests +title: "test: " +labels: test +--- + +**Description** + +List out the tests that need to be added or changed. Please also include any information as to why this was not covered in the past. + +**Requirements** + +- [ ] There is no drop in test coverage. diff --git a/packages/restaurant_models/.github/PULL_REQUEST_TEMPLATE.md b/packages/restaurant_models/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..1169936 --- /dev/null +++ b/packages/restaurant_models/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,27 @@ + + +## Status + +**READY/IN DEVELOPMENT/HOLD** + +## Description + + + +## Type of Change + + + +- [ ] โœจ New feature (non-breaking change which adds functionality) +- [ ] ๐Ÿ› ๏ธ Bug fix (non-breaking change which fixes an issue) +- [ ] โŒ Breaking change (fix or feature that would cause existing functionality to change) +- [ ] ๐Ÿงน Code refactor +- [ ] โœ… Build configuration change +- [ ] ๐Ÿ“ Documentation +- [ ] ๐Ÿ—‘๏ธ Chore diff --git a/packages/restaurant_models/.github/cspell.json b/packages/restaurant_models/.github/cspell.json new file mode 100644 index 0000000..5f0d03c --- /dev/null +++ b/packages/restaurant_models/.github/cspell.json @@ -0,0 +1,21 @@ +{ + "version": "0.2", + "$schema": "https://raw.githubusercontent.com/streetsidesoftware/cspell/main/cspell.schema.json", + "dictionaries": ["vgv_allowed", "vgv_forbidden"], + "dictionaryDefinitions": [ + { + "name": "vgv_allowed", + "path": "https://raw.githubusercontent.com/verygoodopensource/very_good_dictionaries/main/allowed.txt", + "description": "Allowed VGV Spellings" + }, + { + "name": "vgv_forbidden", + "path": "https://raw.githubusercontent.com/verygoodopensource/very_good_dictionaries/main/forbidden.txt", + "description": "Forbidden VGV Spellings" + } + ], + "useGitignore": true, + "words": [ + "restaurant_models" + ] +} diff --git a/packages/restaurant_models/.github/dependabot.yaml b/packages/restaurant_models/.github/dependabot.yaml new file mode 100644 index 0000000..63b035c --- /dev/null +++ b/packages/restaurant_models/.github/dependabot.yaml @@ -0,0 +1,11 @@ +version: 2 +enable-beta-ecosystems: true +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" + - package-ecosystem: "pub" + directory: "/" + schedule: + interval: "daily" diff --git a/packages/restaurant_models/.github/workflows/main.yaml b/packages/restaurant_models/.github/workflows/main.yaml new file mode 100644 index 0000000..c7146c3 --- /dev/null +++ b/packages/restaurant_models/.github/workflows/main.yaml @@ -0,0 +1,27 @@ +name: ci + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + semantic_pull_request: + uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/semantic_pull_request.yml@v1 + + spell-check: + uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/spell_check.yml@v1 + with: + includes: "**/*.md" + modified_files_only: false + + build: + uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/dart_package.yml@v1 + diff --git a/packages/restaurant_models/.gitignore b/packages/restaurant_models/.gitignore new file mode 100644 index 0000000..526da15 --- /dev/null +++ b/packages/restaurant_models/.gitignore @@ -0,0 +1,7 @@ +# See https://www.dartlang.org/guides/libraries/private-files + +# Files and directories created by pub +.dart_tool/ +.packages +build/ +pubspec.lock \ No newline at end of file diff --git a/packages/restaurant_models/README.md b/packages/restaurant_models/README.md new file mode 100644 index 0000000..b87da66 --- /dev/null +++ b/packages/restaurant_models/README.md @@ -0,0 +1,62 @@ +# Restaurant Models + +[![style: very good analysis][very_good_analysis_badge]][very_good_analysis_link] +[![Powered by Mason](https://img.shields.io/endpoint?url=https%3A%2F%2Ftinyurl.com%2Fmason-badge)](https://github.com/felangel/mason) +[![License: MIT][license_badge]][license_link] + +Restaurant models + +## Installation ๐Ÿ’ป + +**โ— In order to start using Restaurant Models you must have the [Dart SDK][dart_install_link] installed on your machine.** + +Install via `dart pub add`: + +```sh +dart pub add restaurant_models +``` + +--- + +## Continuous Integration ๐Ÿค– + +Restaurant Models comes with a built-in [GitHub Actions workflow][github_actions_link] powered by [Very Good Workflows][very_good_workflows_link] but you can also add your preferred CI/CD solution. + +Out of the box, on each pull request and push, the CI `formats`, `lints`, and `tests` the code. This ensures the code remains consistent and behaves correctly as you add functionality or make changes. The project uses [Very Good Analysis][very_good_analysis_link] for a strict set of analysis options used by our team. Code coverage is enforced using the [Very Good Workflows][very_good_coverage_link]. + +--- + +## Running Tests ๐Ÿงช + +To run all unit tests: + +```sh +dart pub global activate coverage 1.2.0 +dart test --coverage=coverage +dart pub global run coverage:format_coverage --lcov --in=coverage --out=coverage/lcov.info +``` + +To view the generated coverage report you can use [lcov](https://github.com/linux-test-project/lcov). + +```sh +# Generate Coverage Report +genhtml coverage/lcov.info -o coverage/ + +# Open Coverage Report +open coverage/index.html +``` + +[dart_install_link]: https://dart.dev/get-dart +[github_actions_link]: https://docs.github.com/en/actions/learn-github-actions +[license_badge]: https://img.shields.io/badge/license-MIT-blue.svg +[license_link]: https://opensource.org/licenses/MIT +[logo_black]: https://raw.githubusercontent.com/VGVentures/very_good_brand/main/styles/README/vgv_logo_black.png#gh-light-mode-only +[logo_white]: https://raw.githubusercontent.com/VGVentures/very_good_brand/main/styles/README/vgv_logo_white.png#gh-dark-mode-only +[mason_link]: https://github.com/felangel/mason +[very_good_analysis_badge]: https://img.shields.io/badge/style-very_good_analysis-B22C89.svg +[very_good_analysis_link]: https://pub.dev/packages/very_good_analysis +[very_good_coverage_link]: https://github.com/marketplace/actions/very-good-coverage +[very_good_ventures_link]: https://verygood.ventures +[very_good_ventures_link_light]: https://verygood.ventures#gh-light-mode-only +[very_good_ventures_link_dark]: https://verygood.ventures#gh-dark-mode-only +[very_good_workflows_link]: https://github.com/VeryGoodOpenSource/very_good_workflows diff --git a/packages/restaurant_models/analysis_options.yaml b/packages/restaurant_models/analysis_options.yaml new file mode 100644 index 0000000..d59f1e2 --- /dev/null +++ b/packages/restaurant_models/analysis_options.yaml @@ -0,0 +1,4 @@ +include: package:very_good_analysis/analysis_options.6.0.0.yaml +analyzer: + exclude: + - '**/*.g.dart' \ No newline at end of file diff --git a/packages/restaurant_models/coverage_badge.svg b/packages/restaurant_models/coverage_badge.svg new file mode 100644 index 0000000..499e98c --- /dev/null +++ b/packages/restaurant_models/coverage_badge.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + coverage + coverage + 100% + 100% + + diff --git a/packages/restaurant_models/lib/restaurant_models.dart b/packages/restaurant_models/lib/restaurant_models.dart new file mode 100644 index 0000000..f9147ce --- /dev/null +++ b/packages/restaurant_models/lib/restaurant_models.dart @@ -0,0 +1,4 @@ +/// Restaurant models +library; + +export 'src/restaurant_models.dart'; diff --git a/packages/restaurant_models/lib/src/restaurant.dart b/packages/restaurant_models/lib/src/restaurant.dart new file mode 100644 index 0000000..b4198a3 --- /dev/null +++ b/packages/restaurant_models/lib/src/restaurant.dart @@ -0,0 +1,245 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'restaurant.g.dart'; + +/// {@template category} +/// A class representing a restaurant category, including the alias and title. +/// Used to categorize restaurants in a user-friendly way. +/// {@endtemplate} +@JsonSerializable() +class Category { + /// {@macro category} + Category({ + this.alias, + this.title, + }); + + /// Factory method to create a [Category] object from a JSON map. + factory Category.fromJson(Map json) => + _$CategoryFromJson(json); + + /// The alias of the category. + final String? alias; + + /// The human-readable title of the category. + final String? title; + + /// Converts the [Category] object into a JSON map. + Map toJson() => _$CategoryToJson(this); +} + +/// {@template hours} +/// A class representing the opening hours of a restaurant, indicating if it's +/// currently open. +/// {@endtemplate} +@JsonSerializable() +class Hours { + /// {@macro hours} + const Hours({ + this.isOpenNow, + }); + + /// Factory method to create a [Hours] object from a JSON map. + factory Hours.fromJson(Map json) => _$HoursFromJson(json); + + /// A boolean indicating if the restaurant is currently open. + @JsonKey(name: 'is_open_now') + final bool? isOpenNow; + + /// Converts the [Hours] object into a JSON map. + Map toJson() => _$HoursToJson(this); +} + +/// {@template user} +/// A class representing a user who can leave reviews, including the user's ID, +/// image, and name. +/// {@endtemplate} +@JsonSerializable() +class User { + /// {@macro user} + const User({ + this.id, + this.imageUrl, + this.name, + }); + + /// Factory method to create a [User] object from a JSON map. + factory User.fromJson(Map json) => _$UserFromJson(json); + + /// The ID of the user. + final String? id; + + /// The URL of the user's image. + @JsonKey(name: 'image_url') + final String? imageUrl; + + /// The name of the user. + final String? name; + + /// Converts the [User] object into a JSON map. + Map toJson() => _$UserToJson(this); +} + +/// {@template review} +/// A class representing a review of a restaurant, containing the review text, +/// rating, and user information. +/// {@endtemplate} +@JsonSerializable() +class Review { + /// {@macro review} + const Review({ + this.id, + this.rating, + this.user, + this.text, + }); + + /// Factory method to create a [Review] object from a JSON map. + factory Review.fromJson(Map json) => _$ReviewFromJson(json); + + /// The ID of the review. + final String? id; + + /// The rating given by the user. + final int? rating; + + /// The review text provided by the user. + final String? text; + + /// The user who left the review. + final User? user; + + /// Converts the [Review] object into a JSON map. + Map toJson() => _$ReviewToJson(this); +} + +/// {@template location} +/// A class representing the location of a restaurant, with a formatted address. +/// {@endtemplate} +@JsonSerializable() +class Location { + /// {@macro location} + Location({ + this.formattedAddress, + }); + + /// Factory method to create a [Location] object from a JSON map. + factory Location.fromJson(Map json) => + _$LocationFromJson(json); + + /// The formatted address of the restaurant. + @JsonKey(name: 'formatted_address') + final String? formattedAddress; + + /// Converts the [Location] object into a JSON map. + Map toJson() => _$LocationToJson(this); +} + +/// {@template restaurant} +/// A class representing a restaurant, including its name, rating, categories, +/// and other relevant details. +/// {@endtemplate} +@JsonSerializable() +class Restaurant { + /// {@macro restaurant} + const Restaurant({ + this.id, + this.name, + this.price, + this.rating, + this.photos, + this.categories, + this.hours, + this.reviews, + this.location, + }); + + /// Factory method to create a [Restaurant] object from a JSON map. + factory Restaurant.fromJson(Map json) => + _$RestaurantFromJson(json); + + /// The ID of the restaurant. + final String? id; + + /// The name of the restaurant. + final String? name; + + /// The price range of the restaurant, usually indicated by symbols like $. + final String? price; + + /// The rating of the restaurant. + final double? rating; + + /// A list of URLs of the restaurant's photos. + final List? photos; + + /// A list of categories that the restaurant belongs to. + final List? categories; + + /// A list of opening hours for the restaurant. + final List? hours; + + /// A list of reviews for the restaurant. + final List? reviews; + + /// The location of the restaurant. + final Location? location; + + /// Converts the [Restaurant] object into a JSON map. + Map toJson() => _$RestaurantToJson(this); + + /// Retrieves the first category to display to the user. + /// Returns an empty string if no categories are available. + String get displayCategory { + if (categories != null && categories!.isNotEmpty) { + return categories!.first.title ?? ''; + } + return ''; + } + + /// Retrieves the first image to display as the hero image. + /// Returns an empty string if no photos are available. + String get heroImage { + if (photos != null && photos!.isNotEmpty) { + return photos!.first; + } + return ''; + } + + /// Determines if the restaurant is currently open based on the first set of + /// hours. + /// Returns `false` if no hours are available. + bool get isOpen { + if (hours != null && hours!.isNotEmpty) { + return hours!.first.isOpenNow ?? false; + } + return false; + } +} + +/// {@template restaurant_query_result} +/// A class representing the result of a restaurant search query, including the +/// total number of results and a list of restaurants. +/// {@endtemplate} +@JsonSerializable() +class RestaurantQueryResult { + /// {@macro restaurant_query_result} + const RestaurantQueryResult({ + this.total, + this.restaurants, + }); + + /// Factory method to create a [RestaurantQueryResult] object from a JSON map. + factory RestaurantQueryResult.fromJson(Map json) => + _$RestaurantQueryResultFromJson(json); + + /// The total number of restaurants found in the query. + final int? total; + + /// The list of restaurants returned by the query. + @JsonKey(name: 'business') + final List? restaurants; + + /// Converts the [RestaurantQueryResult] object into a JSON map. + Map toJson() => _$RestaurantQueryResultToJson(this); +} diff --git a/lib/models/restaurant.g.dart b/packages/restaurant_models/lib/src/restaurant.g.dart similarity index 95% rename from lib/models/restaurant.g.dart rename to packages/restaurant_models/lib/src/restaurant.g.dart index 3ed33f9..dea6677 100644 --- a/lib/models/restaurant.g.dart +++ b/packages/restaurant_models/lib/src/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/packages/restaurant_models/lib/src/restaurant_models.dart b/packages/restaurant_models/lib/src/restaurant_models.dart new file mode 100644 index 0000000..d5a0fcf --- /dev/null +++ b/packages/restaurant_models/lib/src/restaurant_models.dart @@ -0,0 +1 @@ +export 'restaurant.dart'; diff --git a/packages/restaurant_models/pubspec.yaml b/packages/restaurant_models/pubspec.yaml new file mode 100644 index 0000000..deba76d --- /dev/null +++ b/packages/restaurant_models/pubspec.yaml @@ -0,0 +1,19 @@ +name: restaurant_models +description: Restaurant models +version: 0.1.0+1 +publish_to: none + +environment: + sdk: ^3.4.4 + +dependencies: + json_annotation: ^4.9.0 + +dev_dependencies: + build_runner: ^2.4.10 + json_serializable: ^6.8.0 + mocktail: ^1.0.4 + test: ^1.25.8 + very_good_analysis: ^6.0.0 + + diff --git a/packages/restaurant_models/test/src/restaurant_test.dart b/packages/restaurant_models/test/src/restaurant_test.dart new file mode 100644 index 0000000..1bcf7bd --- /dev/null +++ b/packages/restaurant_models/test/src/restaurant_test.dart @@ -0,0 +1,280 @@ +// ignore_for_file: avoid_dynamic_calls + +import 'package:restaurant_models/restaurant_models.dart'; +import 'package:test/test.dart'; + +void main() { + group('Category', () { + group('toJson', () { + test('can be serialized', () { + final category = Category(alias: 'fast_food', title: 'Fast Food'); + final json = category.toJson(); + + expect(json['alias'], category.alias); + expect(json['title'], category.title); + }); + }); + + group('fromJson', () { + test('can be deserialized', () { + final json = {'alias': 'fast_food', 'title': 'Fast Food'}; + final category = Category.fromJson(json); + + expect(category.alias, json['alias']); + expect(category.title, json['title']); + }); + }); + }); + + group('Hours', () { + group('toJson', () { + test('can be serialized', () { + const hours = Hours(isOpenNow: true); + final json = hours.toJson(); + + expect(json['is_open_now'], hours.isOpenNow); + }); + }); + + group('fromJson', () { + test('can be deserialized', () { + final json = {'is_open_now': true}; + final hours = Hours.fromJson(json); + + expect(hours.isOpenNow, json['is_open_now']); + }); + }); + }); + + group('User', () { + group('toJson', () { + test('can be serialized', () { + const user = + User(id: '123', imageUrl: 'http://image.com', name: 'John'); + final json = user.toJson(); + + expect(json['id'], user.id); + expect(json['image_url'], user.imageUrl); + expect(json['name'], user.name); + }); + }); + + group('fromJson', () { + test('can be deserialized', () { + final json = { + 'id': '123', + 'image_url': 'http://image.com', + 'name': 'John', + }; + final user = User.fromJson(json); + + expect(user.id, json['id']); + expect(user.imageUrl, json['image_url']); + expect(user.name, json['name']); + }); + }); + }); + + group('Review', () { + group('toJson', () { + test('can be serialized', () { + const user = + User(id: '123', imageUrl: 'http://image.com', name: 'John'); + const review = + Review(id: 'review123', rating: 5, text: 'Great!', user: user); + final json = review.toJson(); + + expect(json['id'], review.id); + expect(json['rating'], review.rating); + expect(json['text'], review.text); + expect(json['user'], isNotNull); + }); + }); + + group('fromJson', () { + test('can be deserialized', () { + final json = { + 'id': 'review123', + 'rating': 5, + 'text': 'Great!', + 'user': { + 'id': '123', + 'image_url': 'http://image.com', + 'name': 'John', + }, + }; + final review = Review.fromJson(json); + + expect(review.id, json['id']); + expect(review.rating, json['rating']); + expect(review.text, json['text']); + expect(review.user?.id, isNotNull); + }); + }); + }); + + group('Location', () { + group('toJson', () { + test('can be serialized', () { + final location = Location(formattedAddress: '123 Street, City'); + final json = location.toJson(); + + expect(json['formatted_address'], location.formattedAddress); + }); + }); + + group('fromJson', () { + test('can be deserialized', () { + final json = {'formatted_address': '123 Street, City'}; + final location = Location.fromJson(json); + + expect(location.formattedAddress, json['formatted_address']); + }); + }); + }); + + group('Restaurant', () { + group('toJson', () { + test('can be serialized', () { + final category = Category(alias: 'fast_food', title: 'Fast Food'); + const hours = Hours(isOpenNow: true); + const user = + User(id: '123', imageUrl: 'http://image.com', name: 'John'); + const review = + Review(id: 'review123', rating: 5, text: 'Great!', user: user); + final location = Location(formattedAddress: '123 Street, City'); + final restaurant = Restaurant( + id: 'rest123', + name: 'Restaurant 1', + price: r'$$', + rating: 4.5, + photos: ['http://image.com/photo1'], + categories: [category], + hours: [hours], + reviews: [review], + location: location, + ); + final json = restaurant.toJson(); + + expect(json['id'], restaurant.id); + expect(json['name'], restaurant.name); + expect(json['price'], restaurant.price); + expect(json['rating'], restaurant.rating); + expect(json['photos'], isNotNull); + expect( + json['categories'], + isNotEmpty, + ); + expect( + json['hours'], + isNotEmpty, + ); + expect(json['reviews'], isNotEmpty); + expect( + json['location'], + isNotNull, + ); + }); + }); + + group('fromJson', () { + test('can be deserialized', () { + final json = { + 'id': 'rest123', + 'name': 'Restaurant 1', + 'price': r'$$', + 'rating': 4.5, + 'photos': ['http://image.com/photo1'], + 'categories': [ + {'alias': 'fast_food', 'title': 'Fast Food'}, + ], + 'hours': [ + {'is_open_now': true}, + ], + 'reviews': [ + { + 'id': 'review123', + 'rating': 5, + 'text': 'Great!', + 'user': { + 'id': '123', + 'image_url': 'http://image.com', + 'name': 'John', + }, + } + ], + 'location': {'formatted_address': '123 Street, City'}, + }; + final restaurant = Restaurant.fromJson(json); + + expect(restaurant.id, json['id']); + expect(restaurant.name, json['name']); + expect(restaurant.price, json['price']); + expect(restaurant.rating, json['rating']); + expect(restaurant.photos?.first, isNotNull); + expect( + restaurant.categories?.first.title, + isNotNull, + ); + expect( + restaurant.hours?.first.isOpenNow, + isNotNull, + ); + expect(restaurant.reviews?.first.id, isNotNull); + expect( + restaurant.location?.formattedAddress, + isNotNull, + ); + }); + }); + + test('displayCategory returns correct category', () { + final category = Category(alias: 'fast_food', title: 'Fast Food'); + final restaurant = Restaurant(categories: [category]); + expect(restaurant.displayCategory, 'Fast Food'); + }); + + test('heroImage returns correct image', () { + const restaurant = Restaurant(photos: ['http://image.com/photo1']); + expect(restaurant.heroImage, 'http://image.com/photo1'); + }); + + test('isOpen returns correct status', () { + const hours = Hours(isOpenNow: true); + const restaurant = Restaurant(hours: [hours]); + expect(restaurant.isOpen, true); + }); + }); + + group('RestaurantQueryResult', () { + group('toJson', () { + test('can be serialized', () { + const restaurant = Restaurant(id: 'rest123', name: 'Restaurant 1'); + const queryResult = + RestaurantQueryResult(total: 1, restaurants: [restaurant]); + final json = queryResult.toJson(); + + expect(json['total'], queryResult.total); + expect(json['business'], isNotNull); + }); + }); + + group('fromJson', () { + test('can be deserialized', () { + final json = { + 'total': 1, + 'business': [ + {'id': 'rest123', 'name': 'Restaurant 1'}, + ], + }; + final queryResult = RestaurantQueryResult.fromJson(json); + + expect(queryResult.total, json['total']); + expect( + queryResult.restaurants?.first.id, + isNotNull, + ); + }); + }); + }); +} diff --git a/packages/restaurant_repository/.github/ISSUE_TEMPLATE/bug_report.md b/packages/restaurant_repository/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..50a4c7b --- /dev/null +++ b/packages/restaurant_repository/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,29 @@ +--- +name: Bug Report +about: Create a report to help us improve +title: "fix: " +labels: bug +--- + +**Description** + +A clear and concise description of what the bug is. + +**Steps To Reproduce** + +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected Behavior** + +A clear and concise description of what you expected to happen. + +**Screenshots** + +If applicable, add screenshots to help explain your problem. + +**Additional Context** + +Add any other context about the problem here. diff --git a/packages/restaurant_repository/.github/ISSUE_TEMPLATE/build.md b/packages/restaurant_repository/.github/ISSUE_TEMPLATE/build.md new file mode 100644 index 0000000..0cf8e62 --- /dev/null +++ b/packages/restaurant_repository/.github/ISSUE_TEMPLATE/build.md @@ -0,0 +1,14 @@ +--- +name: Build System +about: Changes that affect the build system or external dependencies +title: "build: " +labels: build +--- + +**Description** + +Describe what changes need to be done to the build system and why. + +**Requirements** + +- [ ] The build system is passing diff --git a/packages/restaurant_repository/.github/ISSUE_TEMPLATE/chore.md b/packages/restaurant_repository/.github/ISSUE_TEMPLATE/chore.md new file mode 100644 index 0000000..498ebfd --- /dev/null +++ b/packages/restaurant_repository/.github/ISSUE_TEMPLATE/chore.md @@ -0,0 +1,14 @@ +--- +name: Chore +about: Other changes that don't modify src or test files +title: "chore: " +labels: chore +--- + +**Description** + +Clearly describe what change is needed and why. If this changes code then please use another issue type. + +**Requirements** + +- [ ] No functional changes to the code diff --git a/packages/restaurant_repository/.github/ISSUE_TEMPLATE/ci.md b/packages/restaurant_repository/.github/ISSUE_TEMPLATE/ci.md new file mode 100644 index 0000000..fa2dd9e --- /dev/null +++ b/packages/restaurant_repository/.github/ISSUE_TEMPLATE/ci.md @@ -0,0 +1,14 @@ +--- +name: Continuous Integration +about: Changes to the CI configuration files and scripts +title: "ci: " +labels: ci +--- + +**Description** + +Describe what changes need to be done to the ci/cd system and why. + +**Requirements** + +- [ ] The ci system is passing diff --git a/packages/restaurant_repository/.github/ISSUE_TEMPLATE/config.yml b/packages/restaurant_repository/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..ec4bb38 --- /dev/null +++ b/packages/restaurant_repository/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false \ No newline at end of file diff --git a/packages/restaurant_repository/.github/ISSUE_TEMPLATE/documentation.md b/packages/restaurant_repository/.github/ISSUE_TEMPLATE/documentation.md new file mode 100644 index 0000000..f494a4d --- /dev/null +++ b/packages/restaurant_repository/.github/ISSUE_TEMPLATE/documentation.md @@ -0,0 +1,14 @@ +--- +name: Documentation +about: Improve the documentation so all collaborators have a common understanding +title: "docs: " +labels: documentation +--- + +**Description** + +Clearly describe what documentation you are looking to add or improve. + +**Requirements** + +- [ ] Requirements go here diff --git a/packages/restaurant_repository/.github/ISSUE_TEMPLATE/feature_request.md b/packages/restaurant_repository/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..ddd2fcc --- /dev/null +++ b/packages/restaurant_repository/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,18 @@ +--- +name: Feature Request +about: A new feature to be added to the project +title: "feat: " +labels: feature +--- + +**Description** + +Clearly describe what you are looking to add. The more context the better. + +**Requirements** + +- [ ] Checklist of requirements to be fulfilled + +**Additional Context** + +Add any other context or screenshots about the feature request go here. diff --git a/packages/restaurant_repository/.github/ISSUE_TEMPLATE/performance.md b/packages/restaurant_repository/.github/ISSUE_TEMPLATE/performance.md new file mode 100644 index 0000000..699b8d4 --- /dev/null +++ b/packages/restaurant_repository/.github/ISSUE_TEMPLATE/performance.md @@ -0,0 +1,14 @@ +--- +name: Performance Update +about: A code change that improves performance +title: "perf: " +labels: performance +--- + +**Description** + +Clearly describe what code needs to be changed and what the performance impact is going to be. Bonus point's if you can tie this directly to user experience. + +**Requirements** + +- [ ] There is no drop in test coverage. diff --git a/packages/restaurant_repository/.github/ISSUE_TEMPLATE/refactor.md b/packages/restaurant_repository/.github/ISSUE_TEMPLATE/refactor.md new file mode 100644 index 0000000..1626c57 --- /dev/null +++ b/packages/restaurant_repository/.github/ISSUE_TEMPLATE/refactor.md @@ -0,0 +1,14 @@ +--- +name: Refactor +about: A code change that neither fixes a bug nor adds a feature +title: "refactor: " +labels: refactor +--- + +**Description** + +Clearly describe what needs to be refactored and why. Please provide links to related issues (bugs or upcoming features) in order to help prioritize. + +**Requirements** + +- [ ] There is no drop in test coverage. diff --git a/packages/restaurant_repository/.github/ISSUE_TEMPLATE/revert.md b/packages/restaurant_repository/.github/ISSUE_TEMPLATE/revert.md new file mode 100644 index 0000000..9d121dc --- /dev/null +++ b/packages/restaurant_repository/.github/ISSUE_TEMPLATE/revert.md @@ -0,0 +1,16 @@ +--- +name: Revert Commit +about: Reverts a previous commit +title: "revert: " +labels: revert +--- + +**Description** + +Provide a link to a PR/Commit that you are looking to revert and why. + +**Requirements** + +- [ ] Change has been reverted +- [ ] No change in test coverage has happened +- [ ] A new ticket is created for any follow on work that needs to happen diff --git a/packages/restaurant_repository/.github/ISSUE_TEMPLATE/style.md b/packages/restaurant_repository/.github/ISSUE_TEMPLATE/style.md new file mode 100644 index 0000000..02244a7 --- /dev/null +++ b/packages/restaurant_repository/.github/ISSUE_TEMPLATE/style.md @@ -0,0 +1,14 @@ +--- +name: Style Changes +about: Changes that do not affect the meaning of the code (white space, formatting, missing semi-colons, etc) +title: "style: " +labels: style +--- + +**Description** + +Clearly describe what you are looking to change and why. + +**Requirements** + +- [ ] There is no drop in test coverage. diff --git a/packages/restaurant_repository/.github/ISSUE_TEMPLATE/test.md b/packages/restaurant_repository/.github/ISSUE_TEMPLATE/test.md new file mode 100644 index 0000000..431a7ea --- /dev/null +++ b/packages/restaurant_repository/.github/ISSUE_TEMPLATE/test.md @@ -0,0 +1,14 @@ +--- +name: Test +about: Adding missing tests or correcting existing tests +title: "test: " +labels: test +--- + +**Description** + +List out the tests that need to be added or changed. Please also include any information as to why this was not covered in the past. + +**Requirements** + +- [ ] There is no drop in test coverage. diff --git a/packages/restaurant_repository/.github/PULL_REQUEST_TEMPLATE.md b/packages/restaurant_repository/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..1169936 --- /dev/null +++ b/packages/restaurant_repository/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,27 @@ + + +## Status + +**READY/IN DEVELOPMENT/HOLD** + +## Description + + + +## Type of Change + + + +- [ ] โœจ New feature (non-breaking change which adds functionality) +- [ ] ๐Ÿ› ๏ธ Bug fix (non-breaking change which fixes an issue) +- [ ] โŒ Breaking change (fix or feature that would cause existing functionality to change) +- [ ] ๐Ÿงน Code refactor +- [ ] โœ… Build configuration change +- [ ] ๐Ÿ“ Documentation +- [ ] ๐Ÿ—‘๏ธ Chore diff --git a/packages/restaurant_repository/.github/cspell.json b/packages/restaurant_repository/.github/cspell.json new file mode 100644 index 0000000..6af305c --- /dev/null +++ b/packages/restaurant_repository/.github/cspell.json @@ -0,0 +1,21 @@ +{ + "version": "0.2", + "$schema": "https://raw.githubusercontent.com/streetsidesoftware/cspell/main/cspell.schema.json", + "dictionaries": ["vgv_allowed", "vgv_forbidden"], + "dictionaryDefinitions": [ + { + "name": "vgv_allowed", + "path": "https://raw.githubusercontent.com/verygoodopensource/very_good_dictionaries/main/allowed.txt", + "description": "Allowed VGV Spellings" + }, + { + "name": "vgv_forbidden", + "path": "https://raw.githubusercontent.com/verygoodopensource/very_good_dictionaries/main/forbidden.txt", + "description": "Forbidden VGV Spellings" + } + ], + "useGitignore": true, + "words": [ + "restaurant_repository" + ] +} diff --git a/packages/restaurant_repository/.github/dependabot.yaml b/packages/restaurant_repository/.github/dependabot.yaml new file mode 100644 index 0000000..63b035c --- /dev/null +++ b/packages/restaurant_repository/.github/dependabot.yaml @@ -0,0 +1,11 @@ +version: 2 +enable-beta-ecosystems: true +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" + - package-ecosystem: "pub" + directory: "/" + schedule: + interval: "daily" diff --git a/packages/restaurant_repository/.github/workflows/main.yaml b/packages/restaurant_repository/.github/workflows/main.yaml new file mode 100644 index 0000000..c7146c3 --- /dev/null +++ b/packages/restaurant_repository/.github/workflows/main.yaml @@ -0,0 +1,27 @@ +name: ci + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + semantic_pull_request: + uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/semantic_pull_request.yml@v1 + + spell-check: + uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/spell_check.yml@v1 + with: + includes: "**/*.md" + modified_files_only: false + + build: + uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/dart_package.yml@v1 + diff --git a/packages/restaurant_repository/.gitignore b/packages/restaurant_repository/.gitignore new file mode 100644 index 0000000..526da15 --- /dev/null +++ b/packages/restaurant_repository/.gitignore @@ -0,0 +1,7 @@ +# See https://www.dartlang.org/guides/libraries/private-files + +# Files and directories created by pub +.dart_tool/ +.packages +build/ +pubspec.lock \ No newline at end of file diff --git a/packages/restaurant_repository/README.md b/packages/restaurant_repository/README.md new file mode 100644 index 0000000..17f6482 --- /dev/null +++ b/packages/restaurant_repository/README.md @@ -0,0 +1,62 @@ +# Restaurant Repository + +[![style: very good analysis][very_good_analysis_badge]][very_good_analysis_link] +[![Powered by Mason](https://img.shields.io/endpoint?url=https%3A%2F%2Ftinyurl.com%2Fmason-badge)](https://github.com/felangel/mason) +[![License: MIT][license_badge]][license_link] + +Repository for restaurants + +## Installation ๐Ÿ’ป + +**โ— In order to start using Restaurant Repository you must have the [Dart SDK][dart_install_link] installed on your machine.** + +Install via `dart pub add`: + +```sh +dart pub add restaurant_repository +``` + +--- + +## Continuous Integration ๐Ÿค– + +Restaurant Repository comes with a built-in [GitHub Actions workflow][github_actions_link] powered by [Very Good Workflows][very_good_workflows_link] but you can also add your preferred CI/CD solution. + +Out of the box, on each pull request and push, the CI `formats`, `lints`, and `tests` the code. This ensures the code remains consistent and behaves correctly as you add functionality or make changes. The project uses [Very Good Analysis][very_good_analysis_link] for a strict set of analysis options used by our team. Code coverage is enforced using the [Very Good Workflows][very_good_coverage_link]. + +--- + +## Running Tests ๐Ÿงช + +To run all unit tests: + +```sh +dart pub global activate coverage 1.2.0 +dart test --coverage=coverage +dart pub global run coverage:format_coverage --lcov --in=coverage --out=coverage/lcov.info +``` + +To view the generated coverage report you can use [lcov](https://github.com/linux-test-project/lcov). + +```sh +# Generate Coverage Report +genhtml coverage/lcov.info -o coverage/ + +# Open Coverage Report +open coverage/index.html +``` + +[dart_install_link]: https://dart.dev/get-dart +[github_actions_link]: https://docs.github.com/en/actions/learn-github-actions +[license_badge]: https://img.shields.io/badge/license-MIT-blue.svg +[license_link]: https://opensource.org/licenses/MIT +[logo_black]: https://raw.githubusercontent.com/VGVentures/very_good_brand/main/styles/README/vgv_logo_black.png#gh-light-mode-only +[logo_white]: https://raw.githubusercontent.com/VGVentures/very_good_brand/main/styles/README/vgv_logo_white.png#gh-dark-mode-only +[mason_link]: https://github.com/felangel/mason +[very_good_analysis_badge]: https://img.shields.io/badge/style-very_good_analysis-B22C89.svg +[very_good_analysis_link]: https://pub.dev/packages/very_good_analysis +[very_good_coverage_link]: https://github.com/marketplace/actions/very-good-coverage +[very_good_ventures_link]: https://verygood.ventures +[very_good_ventures_link_light]: https://verygood.ventures#gh-light-mode-only +[very_good_ventures_link_dark]: https://verygood.ventures#gh-dark-mode-only +[very_good_workflows_link]: https://github.com/VeryGoodOpenSource/very_good_workflows diff --git a/packages/restaurant_repository/analysis_options.yaml b/packages/restaurant_repository/analysis_options.yaml new file mode 100644 index 0000000..bb72091 --- /dev/null +++ b/packages/restaurant_repository/analysis_options.yaml @@ -0,0 +1 @@ +include: package:very_good_analysis/analysis_options.6.0.0.yaml diff --git a/packages/restaurant_repository/coverage_badge.svg b/packages/restaurant_repository/coverage_badge.svg new file mode 100644 index 0000000..499e98c --- /dev/null +++ b/packages/restaurant_repository/coverage_badge.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + coverage + coverage + 100% + 100% + + diff --git a/packages/restaurant_repository/lib/restaurant_repository.dart b/packages/restaurant_repository/lib/restaurant_repository.dart new file mode 100644 index 0000000..914d642 --- /dev/null +++ b/packages/restaurant_repository/lib/restaurant_repository.dart @@ -0,0 +1,4 @@ +/// Repository for restaurants +library; + +export 'src/restaurant_repository.dart'; diff --git a/packages/restaurant_repository/lib/src/restaurant_repository.dart b/packages/restaurant_repository/lib/src/restaurant_repository.dart new file mode 100644 index 0000000..e6ab2fc --- /dev/null +++ b/packages/restaurant_repository/lib/src/restaurant_repository.dart @@ -0,0 +1,172 @@ +import 'dart:convert'; + +import 'package:restaurant_gql_client/restaurant_gql_client.dart'; +import 'package:restaurant_models/restaurant_models.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// {@template fetch_restaurants_exception} +/// Exception thrown when fetching restaurants fails. +/// {@endtemplate} +class FetchRestaurantsException implements Exception { + /// {@macro fetch_restaurants_exception} + FetchRestaurantsException([this.message]); + + /// The error message for the exception. + final String? message; + + @override + String toString() => 'FetchRestaurantsException: $message'; +} + +/// {@template save_favorite_restaurant_exception} +/// Exception thrown when saving a restaurant to favorites fails. +/// {@endtemplate} +class SaveFavoriteRestaurantException implements Exception { + /// {@macro save_favorite_restaurant_exception} + SaveFavoriteRestaurantException([this.message]); + + /// The error message for the exception. + final String? message; + + @override + String toString() => 'SaveFavoriteRestaurantException: $message'; +} + +/// {@template delete_favorite_restaurant_exception} +/// Exception thrown when deleting a restaurant from favorites fails. +/// {@endtemplate} +class DeleteFavoriteRestaurantException implements Exception { + /// {@macro delete_favorite_restaurant_exception} + DeleteFavoriteRestaurantException([this.message]); + + /// The error message for the exception. + final String? message; + + @override + String toString() => 'DeleteFavoriteRestaurantException: $message'; +} + +/// {@template restaurant_repository} +/// A repository for managing restaurant data and user preferences. +/// +/// This repository handles both the fetching of restaurant data from the +/// GraphQL +/// API and the management of favorite restaurants locally via +/// `SharedPreferences`. +/// +/// It provides methods to: +/// - Fetch restaurant data. +/// - Save, retrieve, and delete favorite restaurants. +/// - Check if a specific restaurant is marked as a favorite. +/// {@endtemplate} +class RestaurantRepository { + /// {@macro restaurant_repository} + const RestaurantRepository( + RestaurantGqlClient restaurantGqlClient, + SharedPreferences sharedPreferences, + ) : _restaurantGqlClient = restaurantGqlClient, + _sharedPreferences = sharedPreferences; + + /// Key used for storing favorite restaurants in shared preferences. + static const String favoriteRestaurants = 'favoriteRestaurants'; + + final RestaurantGqlClient _restaurantGqlClient; + final SharedPreferences _sharedPreferences; + + /// Checks if a restaurant is marked as a favorite. + /// + /// Takes the restaurant's [id] and checks against the stored favorites. + /// + /// Returns `true` if the restaurant is a favorite, otherwise `false`. + /// + /// Throws a [FetchRestaurantsException] if fetching favorite restaurants + /// fails. + Future isFavorite(String id) async { + try { + final favorites = await fetchFavoriteRestaurants(); + return favorites.any((restaurant) => restaurant.id == id); + } catch (error) { + throw FetchRestaurantsException( + 'Failed to check if restaurant is favorite: $error', + ); + } + } + + /// Saves a restaurant to the list of favorite restaurants. + /// + /// Takes a [restaurant] and adds it to the stored favorites. + /// + /// Throws a [SaveFavoriteRestaurantException] if saving fails. + Future saveFavoriteRestaurant(Restaurant restaurant) async { + try { + final favorites = await fetchFavoriteRestaurants(); + final newFavorites = List.from(favorites)..add(restaurant); + final encoded = + newFavorites.map((value) => jsonEncode(value.toJson())).toList(); + await _sharedPreferences.setStringList(favoriteRestaurants, encoded); + } catch (error) { + throw SaveFavoriteRestaurantException( + 'Failed to save favorite restaurant: $error', + ); + } + } + + /// Deletes a restaurant from the list of favorite restaurants. + /// + /// Takes a [restaurant] and removes it from the stored favorites. + /// + /// Throws a [DeleteFavoriteRestaurantException] if deletion fails. + Future deleteFavoriteRestaurant(Restaurant restaurant) async { + try { + final favorites = await fetchFavoriteRestaurants(); + final newFavorites = List.from(favorites) + ..removeWhere((element) => element.id == restaurant.id); + final encoded = + newFavorites.map((value) => jsonEncode(value.toJson())).toList(); + await _sharedPreferences.setStringList(favoriteRestaurants, encoded); + } catch (error) { + throw DeleteFavoriteRestaurantException( + 'Failed to delete favorite restaurant: $error', + ); + } + } + + /// Fetches the list of favorite restaurants from local storage. + /// + /// Returns a list of [Restaurant] objects that are marked as favorites. + /// + /// Throws a [FetchRestaurantsException] if fetching favorites fails. + Future> fetchFavoriteRestaurants() async { + try { + final jsonList = + _sharedPreferences.getStringList(favoriteRestaurants) ?? []; + + return jsonList + .map( + (e) => Restaurant.fromJson(jsonDecode(e) as Map), + ) + .toList(); + } catch (error) { + throw FetchRestaurantsException( + 'Failed to fetch favorite restaurants: $error', + ); + } + } + + /// Fetches a list of restaurants from the GraphQL API. + /// + /// Takes an optional [offset] to paginate results. + /// + /// Returns a [RestaurantQueryResult] containing the fetched restaurants. + /// + /// Throws a [FetchRestaurantsException] if the API request fails. + Future fetchRestaurants({int offset = 0}) async { + try { + final response = + await _restaurantGqlClient.getRestaurants(offset: offset); + return response; + } catch (error) { + throw FetchRestaurantsException('Failed to fetch restaurants: $error'); + } + } +} diff --git a/packages/restaurant_repository/pubspec.yaml b/packages/restaurant_repository/pubspec.yaml new file mode 100644 index 0000000..73494e4 --- /dev/null +++ b/packages/restaurant_repository/pubspec.yaml @@ -0,0 +1,19 @@ +name: restaurant_repository +description: Repository for restaurants +version: 0.1.0+1 +publish_to: none + +environment: + sdk: ^3.4.4 + +dependencies: + restaurant_gql_client: + path: ../restaurant_gql_client + restaurant_models: + path: ../restaurant_models + shared_preferences: 2.3.2 + +dev_dependencies: + mocktail: ^1.0.4 + test: ^1.20.0 + very_good_analysis: ^6.0.0 diff --git a/packages/restaurant_repository/test/src/restaurant_repository_test.dart b/packages/restaurant_repository/test/src/restaurant_repository_test.dart new file mode 100644 index 0000000..d7191e9 --- /dev/null +++ b/packages/restaurant_repository/test/src/restaurant_repository_test.dart @@ -0,0 +1,176 @@ +import 'dart:convert'; + +import 'package:mocktail/mocktail.dart'; +import 'package:restaurant_gql_client/restaurant_gql_client.dart'; +import 'package:restaurant_models/restaurant_models.dart'; +import 'package:restaurant_repository/restaurant_repository.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:test/test.dart'; + +class MockRestaurantGqlClient extends Mock implements RestaurantGqlClient {} + +class MockSharedPreferences extends Mock implements SharedPreferences {} + +void main() { + late RestaurantRepository repository; + late MockRestaurantGqlClient mockGqlClient; + late MockSharedPreferences mockSharedPreferences; + + setUp(() { + mockGqlClient = MockRestaurantGqlClient(); + mockSharedPreferences = MockSharedPreferences(); + repository = RestaurantRepository(mockGqlClient, mockSharedPreferences); + }); + + group('RestaurantRepository', () { + const restaurant = Restaurant( + id: '1', + name: 'Test Restaurant', + price: r'$$', + rating: 4.5, + photos: ['http://image.com'], + ); + + group('isFavorite', () { + test('returns true when restaurant is a favorite', () async { + when(() => mockSharedPreferences.getStringList(any())).thenReturn( + [jsonEncode(restaurant.toJson())], + ); + + final result = await repository.isFavorite(restaurant.id!); + + expect(result, isTrue); + verify(() => mockSharedPreferences.getStringList(any())).called(1); + }); + + test('returns false when restaurant is not a favorite', () async { + when(() => mockSharedPreferences.getStringList(any())).thenReturn( + [], + ); + + final result = await repository.isFavorite(restaurant.id!); + + expect(result, isFalse); + verify(() => mockSharedPreferences.getStringList(any())).called(1); + }); + + test('throws FetchRestaurantsException on error', () async { + when(() => mockSharedPreferences.getStringList(any())) + .thenThrow(Exception('SharedPreferences error')); + + expect( + () async => repository.isFavorite(restaurant.id!), + throwsA(isA()), + ); + }); + }); + + group('saveFavoriteRestaurant', () { + test('saves a favorite restaurant successfully', () async { + when(() => mockSharedPreferences.getStringList(any())).thenReturn([]); + when(() => mockSharedPreferences.setStringList(any(), any())) + .thenAnswer((_) async => true); + + await repository.saveFavoriteRestaurant(restaurant); + + verify(() => mockSharedPreferences.getStringList(any())).called(1); + verify(() => mockSharedPreferences.setStringList(any(), any())) + .called(1); + }); + + test('throws SaveFavoriteRestaurantException on error', () async { + when(() => mockSharedPreferences.getStringList(any())) + .thenThrow(Exception('SharedPreferences error')); + + expect( + () async => repository.saveFavoriteRestaurant(restaurant), + throwsA(isA()), + ); + }); + }); + + group('deleteFavoriteRestaurant', () { + test('deletes a favorite restaurant successfully', () async { + when(() => mockSharedPreferences.getStringList(any())).thenReturn( + [jsonEncode(restaurant.toJson())], + ); + when(() => mockSharedPreferences.setStringList(any(), any())) + .thenAnswer((_) async => true); + + await repository.deleteFavoriteRestaurant(restaurant); + + verify(() => mockSharedPreferences.getStringList(any())).called(1); + verify(() => mockSharedPreferences.setStringList(any(), any())) + .called(1); + }); + + test('throws DeleteFavoriteRestaurantException on error', () async { + when(() => mockSharedPreferences.getStringList(any())) + .thenThrow(Exception('SharedPreferences error')); + + expect( + () async => repository.deleteFavoriteRestaurant(restaurant), + throwsA(isA()), + ); + }); + }); + + group('fetchFavoriteRestaurants', () { + test('fetches favorite restaurants successfully', () async { + when(() => mockSharedPreferences.getStringList(any())).thenReturn( + [jsonEncode(restaurant.toJson())], + ); + + final result = await repository.fetchFavoriteRestaurants(); + + expect(jsonEncode(result), jsonEncode([restaurant])); + verify(() => mockSharedPreferences.getStringList(any())).called(1); + }); + + test('returns empty list when no favorites found', () async { + when(() => mockSharedPreferences.getStringList(any())).thenReturn([]); + + final result = await repository.fetchFavoriteRestaurants(); + + expect(result, isEmpty); + verify(() => mockSharedPreferences.getStringList(any())).called(1); + }); + + test('throws FetchRestaurantsException on error', () async { + when(() => mockSharedPreferences.getStringList(any())) + .thenThrow(Exception('SharedPreferences error')); + + expect( + () async => repository.fetchFavoriteRestaurants(), + throwsA(isA()), + ); + }); + }); + + group('fetchRestaurants', () { + test('fetches restaurants successfully from GQL client', () async { + const mockQueryResult = RestaurantQueryResult( + total: 1, + restaurants: [restaurant], + ); + when(() => mockGqlClient.getRestaurants()) + .thenAnswer((_) async => mockQueryResult); + + final result = await repository.fetchRestaurants(); + + expect(result, mockQueryResult); + verify(() => mockGqlClient.getRestaurants()).called(1); + }); + + test('throws FetchRestaurantsException on GQL error', () async { + when(() => mockGqlClient.getRestaurants()) + .thenThrow(Exception('GQL error')); + + expect( + () async => repository.fetchRestaurants(), + throwsA(isA()), + ); + }); + }); + }); +} diff --git a/packages/restaurant_ui/.github/ISSUE_TEMPLATE/bug_report.md b/packages/restaurant_ui/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..50a4c7b --- /dev/null +++ b/packages/restaurant_ui/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,29 @@ +--- +name: Bug Report +about: Create a report to help us improve +title: "fix: " +labels: bug +--- + +**Description** + +A clear and concise description of what the bug is. + +**Steps To Reproduce** + +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected Behavior** + +A clear and concise description of what you expected to happen. + +**Screenshots** + +If applicable, add screenshots to help explain your problem. + +**Additional Context** + +Add any other context about the problem here. diff --git a/packages/restaurant_ui/.github/ISSUE_TEMPLATE/build.md b/packages/restaurant_ui/.github/ISSUE_TEMPLATE/build.md new file mode 100644 index 0000000..0cf8e62 --- /dev/null +++ b/packages/restaurant_ui/.github/ISSUE_TEMPLATE/build.md @@ -0,0 +1,14 @@ +--- +name: Build System +about: Changes that affect the build system or external dependencies +title: "build: " +labels: build +--- + +**Description** + +Describe what changes need to be done to the build system and why. + +**Requirements** + +- [ ] The build system is passing diff --git a/packages/restaurant_ui/.github/ISSUE_TEMPLATE/chore.md b/packages/restaurant_ui/.github/ISSUE_TEMPLATE/chore.md new file mode 100644 index 0000000..498ebfd --- /dev/null +++ b/packages/restaurant_ui/.github/ISSUE_TEMPLATE/chore.md @@ -0,0 +1,14 @@ +--- +name: Chore +about: Other changes that don't modify src or test files +title: "chore: " +labels: chore +--- + +**Description** + +Clearly describe what change is needed and why. If this changes code then please use another issue type. + +**Requirements** + +- [ ] No functional changes to the code diff --git a/packages/restaurant_ui/.github/ISSUE_TEMPLATE/ci.md b/packages/restaurant_ui/.github/ISSUE_TEMPLATE/ci.md new file mode 100644 index 0000000..fa2dd9e --- /dev/null +++ b/packages/restaurant_ui/.github/ISSUE_TEMPLATE/ci.md @@ -0,0 +1,14 @@ +--- +name: Continuous Integration +about: Changes to the CI configuration files and scripts +title: "ci: " +labels: ci +--- + +**Description** + +Describe what changes need to be done to the ci/cd system and why. + +**Requirements** + +- [ ] The ci system is passing diff --git a/packages/restaurant_ui/.github/ISSUE_TEMPLATE/config.yml b/packages/restaurant_ui/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..ec4bb38 --- /dev/null +++ b/packages/restaurant_ui/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false \ No newline at end of file diff --git a/packages/restaurant_ui/.github/ISSUE_TEMPLATE/documentation.md b/packages/restaurant_ui/.github/ISSUE_TEMPLATE/documentation.md new file mode 100644 index 0000000..f494a4d --- /dev/null +++ b/packages/restaurant_ui/.github/ISSUE_TEMPLATE/documentation.md @@ -0,0 +1,14 @@ +--- +name: Documentation +about: Improve the documentation so all collaborators have a common understanding +title: "docs: " +labels: documentation +--- + +**Description** + +Clearly describe what documentation you are looking to add or improve. + +**Requirements** + +- [ ] Requirements go here diff --git a/packages/restaurant_ui/.github/ISSUE_TEMPLATE/feature_request.md b/packages/restaurant_ui/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..ddd2fcc --- /dev/null +++ b/packages/restaurant_ui/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,18 @@ +--- +name: Feature Request +about: A new feature to be added to the project +title: "feat: " +labels: feature +--- + +**Description** + +Clearly describe what you are looking to add. The more context the better. + +**Requirements** + +- [ ] Checklist of requirements to be fulfilled + +**Additional Context** + +Add any other context or screenshots about the feature request go here. diff --git a/packages/restaurant_ui/.github/ISSUE_TEMPLATE/performance.md b/packages/restaurant_ui/.github/ISSUE_TEMPLATE/performance.md new file mode 100644 index 0000000..699b8d4 --- /dev/null +++ b/packages/restaurant_ui/.github/ISSUE_TEMPLATE/performance.md @@ -0,0 +1,14 @@ +--- +name: Performance Update +about: A code change that improves performance +title: "perf: " +labels: performance +--- + +**Description** + +Clearly describe what code needs to be changed and what the performance impact is going to be. Bonus point's if you can tie this directly to user experience. + +**Requirements** + +- [ ] There is no drop in test coverage. diff --git a/packages/restaurant_ui/.github/ISSUE_TEMPLATE/refactor.md b/packages/restaurant_ui/.github/ISSUE_TEMPLATE/refactor.md new file mode 100644 index 0000000..1626c57 --- /dev/null +++ b/packages/restaurant_ui/.github/ISSUE_TEMPLATE/refactor.md @@ -0,0 +1,14 @@ +--- +name: Refactor +about: A code change that neither fixes a bug nor adds a feature +title: "refactor: " +labels: refactor +--- + +**Description** + +Clearly describe what needs to be refactored and why. Please provide links to related issues (bugs or upcoming features) in order to help prioritize. + +**Requirements** + +- [ ] There is no drop in test coverage. diff --git a/packages/restaurant_ui/.github/ISSUE_TEMPLATE/revert.md b/packages/restaurant_ui/.github/ISSUE_TEMPLATE/revert.md new file mode 100644 index 0000000..9d121dc --- /dev/null +++ b/packages/restaurant_ui/.github/ISSUE_TEMPLATE/revert.md @@ -0,0 +1,16 @@ +--- +name: Revert Commit +about: Reverts a previous commit +title: "revert: " +labels: revert +--- + +**Description** + +Provide a link to a PR/Commit that you are looking to revert and why. + +**Requirements** + +- [ ] Change has been reverted +- [ ] No change in test coverage has happened +- [ ] A new ticket is created for any follow on work that needs to happen diff --git a/packages/restaurant_ui/.github/ISSUE_TEMPLATE/style.md b/packages/restaurant_ui/.github/ISSUE_TEMPLATE/style.md new file mode 100644 index 0000000..02244a7 --- /dev/null +++ b/packages/restaurant_ui/.github/ISSUE_TEMPLATE/style.md @@ -0,0 +1,14 @@ +--- +name: Style Changes +about: Changes that do not affect the meaning of the code (white space, formatting, missing semi-colons, etc) +title: "style: " +labels: style +--- + +**Description** + +Clearly describe what you are looking to change and why. + +**Requirements** + +- [ ] There is no drop in test coverage. diff --git a/packages/restaurant_ui/.github/ISSUE_TEMPLATE/test.md b/packages/restaurant_ui/.github/ISSUE_TEMPLATE/test.md new file mode 100644 index 0000000..431a7ea --- /dev/null +++ b/packages/restaurant_ui/.github/ISSUE_TEMPLATE/test.md @@ -0,0 +1,14 @@ +--- +name: Test +about: Adding missing tests or correcting existing tests +title: "test: " +labels: test +--- + +**Description** + +List out the tests that need to be added or changed. Please also include any information as to why this was not covered in the past. + +**Requirements** + +- [ ] There is no drop in test coverage. diff --git a/packages/restaurant_ui/.github/PULL_REQUEST_TEMPLATE.md b/packages/restaurant_ui/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..1169936 --- /dev/null +++ b/packages/restaurant_ui/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,27 @@ + + +## Status + +**READY/IN DEVELOPMENT/HOLD** + +## Description + + + +## Type of Change + + + +- [ ] โœจ New feature (non-breaking change which adds functionality) +- [ ] ๐Ÿ› ๏ธ Bug fix (non-breaking change which fixes an issue) +- [ ] โŒ Breaking change (fix or feature that would cause existing functionality to change) +- [ ] ๐Ÿงน Code refactor +- [ ] โœ… Build configuration change +- [ ] ๐Ÿ“ Documentation +- [ ] ๐Ÿ—‘๏ธ Chore diff --git a/packages/restaurant_ui/.github/cspell.json b/packages/restaurant_ui/.github/cspell.json new file mode 100644 index 0000000..79c19e3 --- /dev/null +++ b/packages/restaurant_ui/.github/cspell.json @@ -0,0 +1,21 @@ +{ + "version": "0.2", + "$schema": "https://raw.githubusercontent.com/streetsidesoftware/cspell/main/cspell.schema.json", + "dictionaries": ["vgv_allowed", "vgv_forbidden"], + "dictionaryDefinitions": [ + { + "name": "vgv_allowed", + "path": "https://raw.githubusercontent.com/verygoodopensource/very_good_dictionaries/main/allowed.txt", + "description": "Allowed VGV Spellings" + }, + { + "name": "vgv_forbidden", + "path": "https://raw.githubusercontent.com/verygoodopensource/very_good_dictionaries/main/forbidden.txt", + "description": "Forbidden VGV Spellings" + } + ], + "useGitignore": true, + "words": [ + "restaurant_ui" + ] +} diff --git a/packages/restaurant_ui/.github/dependabot.yaml b/packages/restaurant_ui/.github/dependabot.yaml new file mode 100644 index 0000000..63b035c --- /dev/null +++ b/packages/restaurant_ui/.github/dependabot.yaml @@ -0,0 +1,11 @@ +version: 2 +enable-beta-ecosystems: true +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" + - package-ecosystem: "pub" + directory: "/" + schedule: + interval: "daily" diff --git a/packages/restaurant_ui/.github/workflows/main.yaml b/packages/restaurant_ui/.github/workflows/main.yaml new file mode 100644 index 0000000..cf84703 --- /dev/null +++ b/packages/restaurant_ui/.github/workflows/main.yaml @@ -0,0 +1,25 @@ +name: ci + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + pull_request: + branches: + - main + +jobs: + semantic_pull_request: + uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/semantic_pull_request.yml@v1 + + spell-check: + uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/spell_check.yml@v1 + with: + includes: "**/*.md" + modified_files_only: false + + build: + uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/flutter_package.yml@v1 + with: + flutter_channel: stable diff --git a/packages/restaurant_ui/.gitignore b/packages/restaurant_ui/.gitignore new file mode 100644 index 0000000..06ef8e6 --- /dev/null +++ b/packages/restaurant_ui/.gitignore @@ -0,0 +1,44 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# VSCode related +.vscode/* + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +/build/ +pubspec.lock + +# Web related +lib/generated_plugin_registrant.dart + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Test related +coverage \ No newline at end of file diff --git a/packages/restaurant_ui/README.md b/packages/restaurant_ui/README.md new file mode 100644 index 0000000..66df963 --- /dev/null +++ b/packages/restaurant_ui/README.md @@ -0,0 +1,67 @@ +# Restaurant Ui + +[![style: very good analysis][very_good_analysis_badge]][very_good_analysis_link] +[![Powered by Mason](https://img.shields.io/endpoint?url=https%3A%2F%2Ftinyurl.com%2Fmason-badge)](https://github.com/felangel/mason) +[![License: MIT][license_badge]][license_link] + +restaurant ui components + +## Installation ๐Ÿ’ป + +**โ— In order to start using Restaurant Ui you must have the [Flutter SDK][flutter_install_link] installed on your machine.** + +Install via `flutter pub add`: + +```sh +dart pub add restaurant_ui +``` + +--- + +## Continuous Integration ๐Ÿค– + +Restaurant Ui comes with a built-in [GitHub Actions workflow][github_actions_link] powered by [Very Good Workflows][very_good_workflows_link] but you can also add your preferred CI/CD solution. + +Out of the box, on each pull request and push, the CI `formats`, `lints`, and `tests` the code. This ensures the code remains consistent and behaves correctly as you add functionality or make changes. The project uses [Very Good Analysis][very_good_analysis_link] for a strict set of analysis options used by our team. Code coverage is enforced using the [Very Good Workflows][very_good_coverage_link]. + +--- + +## Running Tests ๐Ÿงช + +For first time users, install the [very_good_cli][very_good_cli_link]: + +```sh +dart pub global activate very_good_cli +``` + +To run all unit tests: + +```sh +very_good test --coverage +``` + +To view the generated coverage report you can use [lcov](https://github.com/linux-test-project/lcov). + +```sh +# Generate Coverage Report +genhtml coverage/lcov.info -o coverage/ + +# Open Coverage Report +open coverage/index.html +``` + +[flutter_install_link]: https://docs.flutter.dev/get-started/install +[github_actions_link]: https://docs.github.com/en/actions/learn-github-actions +[license_badge]: https://img.shields.io/badge/license-MIT-blue.svg +[license_link]: https://opensource.org/licenses/MIT +[logo_black]: https://raw.githubusercontent.com/VGVentures/very_good_brand/main/styles/README/vgv_logo_black.png#gh-light-mode-only +[logo_white]: https://raw.githubusercontent.com/VGVentures/very_good_brand/main/styles/README/vgv_logo_white.png#gh-dark-mode-only +[mason_link]: https://github.com/felangel/mason +[very_good_analysis_badge]: https://img.shields.io/badge/style-very_good_analysis-B22C89.svg +[very_good_analysis_link]: https://pub.dev/packages/very_good_analysis +[very_good_cli_link]: https://pub.dev/packages/very_good_cli +[very_good_coverage_link]: https://github.com/marketplace/actions/very-good-coverage +[very_good_ventures_link]: https://verygood.ventures +[very_good_ventures_link_light]: https://verygood.ventures#gh-light-mode-only +[very_good_ventures_link_dark]: https://verygood.ventures#gh-dark-mode-only +[very_good_workflows_link]: https://github.com/VeryGoodOpenSource/very_good_workflows diff --git a/packages/restaurant_ui/analysis_options.yaml b/packages/restaurant_ui/analysis_options.yaml new file mode 100644 index 0000000..bb72091 --- /dev/null +++ b/packages/restaurant_ui/analysis_options.yaml @@ -0,0 +1 @@ +include: package:very_good_analysis/analysis_options.6.0.0.yaml diff --git a/packages/restaurant_ui/coverage_badge.svg b/packages/restaurant_ui/coverage_badge.svg new file mode 100644 index 0000000..499e98c --- /dev/null +++ b/packages/restaurant_ui/coverage_badge.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + coverage + coverage + 100% + 100% + + diff --git a/packages/restaurant_ui/lib/restaurant_ui.dart b/packages/restaurant_ui/lib/restaurant_ui.dart new file mode 100644 index 0000000..6d4214d --- /dev/null +++ b/packages/restaurant_ui/lib/restaurant_ui.dart @@ -0,0 +1,8 @@ +/// restaurant ui components +library; + +export 'src/rating_view.dart'; +export 'src/restaurant_card.dart'; +export 'src/restaurant_review.dart'; +export 'src/restaurant_status.dart'; +export 'src/typography.dart'; diff --git a/packages/restaurant_ui/lib/src/rating_view.dart b/packages/restaurant_ui/lib/src/rating_view.dart new file mode 100644 index 0000000..00a47e1 --- /dev/null +++ b/packages/restaurant_ui/lib/src/rating_view.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; + +/// {@template rating_view} +/// A widget that displays a star-based rating system for a restaurant. +/// +/// The rating is displayed using a row of star icons, where the number of stars +/// is based on the truncated rating value. +/// {@endtemplate} +class RatingView extends StatelessWidget { + /// {@macro rating_view} + const RatingView({required this.rating, super.key}); + + /// The rating value, which will be truncated to determine how many stars + /// are displayed. + final double rating; + + @override + Widget build(BuildContext context) { + // Truncates the rating value to an integer for the star display. + final stars = rating.truncate(); + + return Row( + mainAxisSize: MainAxisSize.min, + children: List.filled( + stars, + const Icon( + Icons.star, + color: Colors.yellow, + size: 16, + ), + ), + ); + } +} diff --git a/packages/restaurant_ui/lib/src/restaurant_card.dart b/packages/restaurant_ui/lib/src/restaurant_card.dart new file mode 100644 index 0000000..97b6606 --- /dev/null +++ b/packages/restaurant_ui/lib/src/restaurant_card.dart @@ -0,0 +1,165 @@ +import 'package:flutter/material.dart'; +import 'package:restaurant_ui/restaurant_ui.dart'; + +/// {@template restaurant_card} +/// A reusable card widget that displays restaurant details including +/// a photo, title, price, rating, category, and its open status. +/// +/// It includes a gesture detector to handle tap events for user interaction. +/// {@endtemplate} +class RestaurantCard extends StatelessWidget { + /// {@macro restaurant_card} + const RestaurantCard({ + required this.title, + required this.isOpen, + required this.price, + required this.rating, + required this.category, + required this.photoUrl, + required this.tag, + this.onTap, + super.key, + }); + + /// The title of the restaurant. + final String title; + + /// Unique tag used for [Hero] widget transition. + final String tag; + + /// Indicates whether the restaurant is open. + final bool isOpen; + + /// URL of the restaurant's photo. + final String photoUrl; + + /// The price level of the restaurant. + final String price; + + /// The restaurant's rating, typically out of 5. + final double rating; + + /// The category of the restaurant (e.g., "Italian", "Mexican"). + final String category; + + /// Callback function triggered when the card is tapped. + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Card( + color: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + child: Padding( + padding: const EdgeInsets.all(8), + child: Row( + children: [ + Hero( + tag: tag, + child: RestaurantNetworkImage(photoUrl: photoUrl), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: AppTextStyles.openRegularHeadline, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + '$price $category', + style: AppTextStyles.openRegularText, + ), + const SizedBox(height: 4), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + RatingView(rating: rating), + RestaurantStatus(isOpen: isOpen), + ], + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} + +/// {@template restaurant_network_image} +/// A widget that displays an image from a given URL with rounded corners. +/// +/// If the image is loading or fails to load, appropriate widgets (like a +/// loading spinner or error icon) are shown. +/// {@endtemplate} +class RestaurantNetworkImage extends StatelessWidget { + /// {@macro restaurant_network_image} + const RestaurantNetworkImage({ + required this.photoUrl, + super.key, + this.radius = 8, + this.height = 80, + this.width = 80, + this.fit = BoxFit.cover, + }); + + /// The URL of the image to display. + final String photoUrl; + + /// The border radius to apply to the image. + final double radius; + + /// The height of the image. + final double height; + + /// The width of the image. + final double width; + + /// How the image should be inscribed into the box. + final BoxFit fit; + + @override + Widget build(BuildContext context) { + return ClipRRect( + borderRadius: BorderRadius.circular(radius), + child: Image( + height: height, + width: width, + image: NetworkImage(photoUrl), + fit: fit, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) { + return child; + } + return Center( + child: CircularProgressIndicator( + value: loadingProgress.expectedTotalBytes != null + ? loadingProgress.cumulativeBytesLoaded / + (loadingProgress.expectedTotalBytes ?? 1) + : null, + ), + ); + }, + errorBuilder: (context, error, stackTrace) { + return const Center( + child: Icon( + Icons.error, + color: Colors.red, + ), + ); + }, + ), + ); + } +} diff --git a/packages/restaurant_ui/lib/src/restaurant_review.dart b/packages/restaurant_ui/lib/src/restaurant_review.dart new file mode 100644 index 0000000..17804a3 --- /dev/null +++ b/packages/restaurant_ui/lib/src/restaurant_review.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'package:restaurant_ui/restaurant_ui.dart'; + +/// {@template restaurant_review} +/// A widget that displays a review for a restaurant, including the user's name, +/// profile picture, rating, and the review text. +/// +/// It also includes a divider to separate reviews. +/// {@endtemplate} +class RestaurantReview extends StatelessWidget { + /// {@macro restaurant_review} + const RestaurantReview({ + required this.rating, + required this.review, + required this.userName, + required this.userPhoto, + super.key, + }); + + /// The rating given by the user for the restaurant, typically out of 5. + final int rating; + + /// The actual review text provided by the user. + final String review; + + /// The name of the user who submitted the review. + final String userName; + + /// The URL of the user's profile picture. + final String userPhoto; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + RatingView(rating: rating.toDouble()), + const SizedBox(height: 8), + Text(review, style: AppTextStyles.openRegularText), + const SizedBox(height: 8), + Row( + children: [ + RestaurantNetworkImage( + photoUrl: userPhoto, + radius: 40, + width: 40, + height: 40, + ), + const SizedBox(width: 8), + Text(userName), + ], + ), + const Divider(), + ], + ); + } +} diff --git a/packages/restaurant_ui/lib/src/restaurant_status.dart b/packages/restaurant_ui/lib/src/restaurant_status.dart new file mode 100644 index 0000000..7523f54 --- /dev/null +++ b/packages/restaurant_ui/lib/src/restaurant_status.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; +import 'package:restaurant_ui/restaurant_ui.dart'; + +/// {@template restaurant_status} +/// A widget that displays the current status of a restaurant (open or closed). +/// It shows a text ('Open now' or 'Closed') and a colored icon (green for open, +/// red for closed). +/// {@endtemplate} +class RestaurantStatus extends StatelessWidget { + /// {@macro restaurant_status} + const RestaurantStatus({required this.isOpen, super.key}); + + /// Whether the restaurant is currently open or not. + final bool isOpen; + + @override + Widget build(BuildContext context) { + final title = isOpen ? 'Open now' : 'Closed'; + + final iconColor = isOpen ? Colors.green : Colors.red; + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(title, style: AppTextStyles.openRegularItalic), + const SizedBox(width: 4), + Icon(Icons.circle, size: 8, color: iconColor), + ], + ); + } +} diff --git a/packages/restaurant_ui/lib/src/typography.dart b/packages/restaurant_ui/lib/src/typography.dart new file mode 100644 index 0000000..b341cd1 --- /dev/null +++ b/packages/restaurant_ui/lib/src/typography.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; + +/// A utility class that defines common text styles used throughout the app. +/// +/// The class provides static constants for different fonts like **Lora** and +/// **Open Sans** with various styles such as regular, bold, italic, etc. +/// These styles can be easily reused across the app to maintain consistency. +class AppTextStyles { + ////----- Lora -----// + + /// A bold headline style using the **Lora** font. + /// + /// - Font weight: 700 (Bold) + /// - Font size: 18 + static const loraRegularHeadline = TextStyle( + fontFamily: 'Lora', + fontWeight: FontWeight.w700, + fontSize: 18, + ); + + /// A medium title style using the **Lora** font. + /// + /// - Font weight: 500 (Medium) + /// - Font size: 16 + static const loraRegularTitle = TextStyle( + fontFamily: 'Lora', + fontWeight: FontWeight.w500, + fontSize: 16, + ); + + //----- Open Sans -----// + + /// A regular headline style using the **Open Sans** font. + /// + /// - Font weight: 400 (Regular) + /// - Font size: 16 + /// - Color: Black + static const openRegularHeadline = TextStyle( + fontFamily: 'OpenSans', + fontWeight: FontWeight.w400, + fontSize: 16, + color: Colors.black, + ); + + /// A semi-bold title style using the **Open Sans** font. + /// + /// - Font weight: 600 (Semi-Bold) + /// - Font size: 14 + /// - Color: Black + static const openRegularTitleSemiBold = TextStyle( + fontFamily: 'OpenSans', + fontWeight: FontWeight.w600, + fontSize: 14, + color: Colors.black, + ); + + /// A regular title style using the **Open Sans** font. + /// + /// - Font weight: 400 (Regular) + /// - Font size: 14 + /// - Color: Black + static const openRegularTitle = TextStyle( + fontFamily: 'OpenSans', + fontWeight: FontWeight.w400, + fontSize: 14, + color: Colors.black, + ); + + /// A regular text style using the **Open Sans** font for general text. + /// + /// - Font weight: 400 (Regular) + /// - Font size: 12 + /// - Color: Black + static const openRegularText = TextStyle( + fontFamily: 'OpenSans', + fontWeight: FontWeight.w400, + fontSize: 12, + color: Colors.black, + ); + + /// A regular italic text style using the **Open Sans** font. + /// + /// - Font weight: 400 (Regular) + /// - Font style: Italic + /// - Font size: 12 + /// - Color: Black + static const openRegularItalic = TextStyle( + fontFamily: 'OpenSans', + fontWeight: FontWeight.w400, + fontStyle: FontStyle.italic, + fontSize: 12, + color: Colors.black, + ); +} diff --git a/packages/restaurant_ui/pubspec.yaml b/packages/restaurant_ui/pubspec.yaml new file mode 100644 index 0000000..f83230f --- /dev/null +++ b/packages/restaurant_ui/pubspec.yaml @@ -0,0 +1,18 @@ +name: restaurant_ui +description: restaurant ui components +version: 0.1.0+1 +publish_to: none + +environment: + sdk: ^3.4.4 + flutter: ^3.22.3 + +dependencies: + flutter: + sdk: flutter + +dev_dependencies: + flutter_test: + sdk: flutter + mocktail: ^1.0.4 + very_good_analysis: ^6.0.0 diff --git a/packages/restaurant_ui/test/src/rating_view_test.dart b/packages/restaurant_ui/test/src/rating_view_test.dart new file mode 100644 index 0000000..cae05fe --- /dev/null +++ b/packages/restaurant_ui/test/src/rating_view_test.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:restaurant_ui/restaurant_ui.dart'; + +void main() { + group('RatingView', () { + testWidgets('displays the correct number of stars based on rating', + (WidgetTester tester) async { + const rating = 4.7; + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: RatingView(rating: rating), + ), + ), + ); + + expect(find.byIcon(Icons.star), findsNWidgets(4)); + }); + + testWidgets('displays no stars for a zero rating', + (WidgetTester tester) async { + const rating = 0.0; + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: RatingView(rating: rating), + ), + ), + ); + + expect(find.byIcon(Icons.star), findsNothing); + }); + + testWidgets('displays stars based on the truncated rating value', + (WidgetTester tester) async { + const rating = 3.9; + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: RatingView(rating: rating), + ), + ), + ); + + expect(find.byIcon(Icons.star), findsNWidgets(3)); + }); + }); +} diff --git a/packages/restaurant_ui/test/src/restaurant_card_test.dart b/packages/restaurant_ui/test/src/restaurant_card_test.dart new file mode 100644 index 0000000..6f976d1 --- /dev/null +++ b/packages/restaurant_ui/test/src/restaurant_card_test.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:restaurant_ui/restaurant_ui.dart'; + +void main() { + group('RestaurantCard', () { + testWidgets('displays restaurant details correctly', + (WidgetTester tester) async { + const title = 'Test Restaurant'; + const isOpen = true; + const price = r'\$\$'; + const rating = 4.5; + const category = 'Italian'; + const photoUrl = 'https://example.com/photo.jpg'; + const tag = 'restaurant-tag'; + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: RestaurantCard( + title: title, + isOpen: isOpen, + price: price, + rating: rating, + category: category, + photoUrl: photoUrl, + tag: tag, + ), + ), + ), + ); + + expect(find.text(title), findsOneWidget); + expect(find.text('$price $category'), findsOneWidget); + expect(find.byType(RatingView), findsOneWidget); + expect(find.byType(RestaurantStatus), findsOneWidget); + expect(find.byType(Hero), findsOneWidget); + }); + + testWidgets('calls onTap when card is tapped', (WidgetTester tester) async { + var tapped = false; + const title = 'Test Restaurant'; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: RestaurantCard( + title: title, + isOpen: true, + price: r'$$', + rating: 4.5, + category: 'Italian', + photoUrl: 'https://example.com/photo.jpg', + tag: 'restaurant-tag', + onTap: () { + tapped = true; + }, + ), + ), + ), + ); + + await tester.tap(find.byType(RestaurantCard)); + expect(tapped, isTrue); + }); + }); + + group('RestaurantNetworkImage', () { + testWidgets('displays image from network correctly', + (WidgetTester tester) async { + const photoUrl = 'https://example.com/photo.jpg'; + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: RestaurantNetworkImage( + photoUrl: photoUrl, + ), + ), + ), + ); + + expect(find.byType(Image), findsOneWidget); + }); + }); +} diff --git a/packages/restaurant_ui/test/src/restaurant_review_test.dart b/packages/restaurant_ui/test/src/restaurant_review_test.dart new file mode 100644 index 0000000..a9e874f --- /dev/null +++ b/packages/restaurant_ui/test/src/restaurant_review_test.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:restaurant_ui/restaurant_ui.dart'; + +void main() { + group('RestaurantReview', () { + const rating = 4; + const reviewText = 'This is an amazing restaurant with great service.'; + const userName = 'John Doe'; + const userPhotoUrl = 'https://example.com/photo.jpg'; + + testWidgets("displays user's name, review text, and rating", + (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: RestaurantReview( + rating: rating, + review: reviewText, + userName: userName, + userPhoto: userPhotoUrl, + ), + ), + ), + ); + + expect(find.text(reviewText), findsOneWidget); + + expect(find.text(userName), findsOneWidget); + + final ratingWidget = tester.widget(find.byType(RatingView)); + expect(ratingWidget.rating, rating.toDouble()); + }); + + testWidgets("displays user's profile picture with correct size and radius", + (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: RestaurantReview( + rating: rating, + review: reviewText, + userName: userName, + userPhoto: userPhotoUrl, + ), + ), + ), + ); + + final networkImage = tester.widget( + find.byType(RestaurantNetworkImage), + ); + expect(networkImage.photoUrl, userPhotoUrl); + expect(networkImage.radius, 40); + expect(networkImage.width, 40); + expect(networkImage.height, 40); + }); + + testWidgets('renders Divider and proper spacing between elements', + (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: RestaurantReview( + rating: rating, + review: reviewText, + userName: userName, + userPhoto: userPhotoUrl, + ), + ), + ), + ); + }); + + testWidgets('properly handles layout of all elements in RestaurantReview', + (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: RestaurantReview( + rating: rating, + review: reviewText, + userName: userName, + userPhoto: userPhotoUrl, + ), + ), + ), + ); + + final column = tester.widget(find.byType(Column)); + expect(column.crossAxisAlignment, CrossAxisAlignment.start); + }); + }); +} diff --git a/packages/restaurant_ui/test/src/restaurant_status_test.dart b/packages/restaurant_ui/test/src/restaurant_status_test.dart new file mode 100644 index 0000000..7582aa1 --- /dev/null +++ b/packages/restaurant_ui/test/src/restaurant_status_test.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:restaurant_ui/restaurant_ui.dart'; + +void main() { + group('RestaurantStatus', () { + testWidgets('displays "Open now" with green icon when isOpen is true', + (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: RestaurantStatus(isOpen: true), + ), + ), + ); + + expect(find.text('Open now'), findsOneWidget); + + final icon = tester.widget(find.byIcon(Icons.circle)); + expect(icon.color, Colors.green); + }); + + testWidgets('displays "Closed" with red icon when isOpen is false', + (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: RestaurantStatus(isOpen: false), + ), + ), + ); + + expect(find.text('Closed'), findsOneWidget); + + final icon = tester.widget(find.byIcon(Icons.circle)); + expect(icon.color, Colors.red); + }); + + testWidgets('ensures proper layout with spacing between text and icon', + (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: RestaurantStatus(isOpen: true), + ), + ), + ); + + final row = tester.widget(find.byType(Row)); + final children = row.children; + expect(children.length, 3); + + final sizedBox = children[1] as SizedBox; + expect(sizedBox.width, 4); + }); + }); +} diff --git a/pubspec.lock b/pubspec.lock index f95a63e..72211f7 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: @@ -33,78 +33,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.11.0" - boolean_selector: - dependency: transitive - description: - name: boolean_selector - sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" - url: "https://pub.dev" - source: hosted - version: "2.1.1" - build: - dependency: transitive - description: - name: build - sha256: "3fbda25365741f8251b39f3917fb3c8e286a96fd068a5a242e11c2012d495777" - url: "https://pub.dev" - source: hosted - version: "2.3.1" - build_config: - dependency: transitive - description: - name: build_config - sha256: bf80fcfb46a29945b423bd9aad884590fb1dc69b330a4d4700cac476af1708d1 - url: "https://pub.dev" - source: hosted - version: "1.1.1" - build_daemon: - dependency: transitive - description: - name: build_daemon - sha256: "5f02d73eb2ba16483e693f80bee4f088563a820e47d1027d4cdfe62b5bb43e65" - url: "https://pub.dev" - source: hosted - version: "4.0.0" - build_resolvers: - dependency: transitive + bloc: + dependency: "direct main" description: - name: build_resolvers - sha256: "6c4dd11d05d056e76320b828a1db0fc01ccd376922526f8e9d6c796a5adbac20" + name: bloc + sha256: "106842ad6569f0b60297619e9e0b1885c2fb9bf84812935490e6c5275777804e" url: "https://pub.dev" source: hosted - version: "2.2.1" - build_runner: + version: "8.1.4" + bloc_test: dependency: "direct dev" description: - name: build_runner - sha256: "644dc98a0f179b872f612d3eb627924b578897c629788e858157fa5e704ca0c7" + name: bloc_test + sha256: "165a6ec950d9252ebe36dc5335f2e6eb13055f33d56db0eeb7642768849b43d2" url: "https://pub.dev" source: hosted - version: "2.4.11" - build_runner_core: - dependency: transitive - description: - name: build_runner_core - sha256: f4d6244cc071ba842c296cb1c4ee1b31596b9f924300647ac7a1445493471a3f - url: "https://pub.dev" - source: hosted - version: "7.2.3" - built_collection: - dependency: transitive - description: - name: built_collection - sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" - url: "https://pub.dev" - source: hosted - version: "5.1.1" - built_value: + version: "9.1.7" + boolean_selector: dependency: transitive description: - name: built_value - sha256: b6c9911b2d670376918d5b8779bc27e0e612a94ec3ff0343689e991d8d0a3b8a + name: boolean_selector + sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" url: "https://pub.dev" source: hosted - version: "8.1.4" + version: "2.1.1" characters: dependency: transitive description: @@ -113,22 +65,6 @@ 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 - url: "https://pub.dev" - source: hosted - version: "2.0.1" clock: dependency: transitive description: @@ -137,14 +73,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" - code_builder: - dependency: transitive - description: - name: code_builder - sha256: f692079e25e7869c14132d39f223f8eec9830eb76131925143b2129c4bb01b37 - url: "https://pub.dev" - source: hosted - version: "4.10.0" collection: dependency: transitive description: @@ -157,26 +85,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" + coverage: + dependency: transitive + description: + name: coverage + sha256: c1fb2dce3c0085f39dc72668e85f8e0210ec7de05345821ff58530567df345a5 + url: "https://pub.dev" + source: hosted + version: "1.9.2" crypto: dependency: transitive description: name: crypto - sha256: cf75650c66c0316274e21d7c43d3dea246273af5955bd94e8184837cd577575c + sha256: ec30d999af904f33454ba22ed9a86162b35e52b44ac4807d1d93c288041d7d27 url: "https://pub.dev" source: hosted - version: "3.0.1" - dart_style: + version: "3.0.5" + diff_match_patch: dependency: transitive description: - name: dart_style - sha256: "1efa911ca7086affd35f463ca2fc1799584fb6aa89883cf0af8e3664d6a02d55" + name: diff_match_patch + sha256: "2efc9e6e8f449d0abe15be240e2c2a3bcd977c8d126cfd70598aee60af35c0a4" url: "https://pub.dev" source: hosted - version: "2.3.2" + version: "0.4.1" + equatable: + dependency: "direct main" + description: + name: equatable + sha256: c2b87cb7756efdf69892005af546c56c0b5037f54d2a88269b4f347a505e3ca2 + url: "https://pub.dev" + source: hosted + version: "2.0.5" fake_async: dependency: transitive description: @@ -185,27 +129,35 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.1" - file: + ffi: dependency: transitive description: - name: file - sha256: b69516f2c26a5bcac4eee2e32512e1a5205ab312b3536c1c1227b2b942b5f9ad + name: ffi + sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" url: "https://pub.dev" source: hosted - version: "6.1.2" - fixnum: + version: "2.1.3" + file: dependency: transitive description: - name: fixnum - sha256: "6a2ef17156f4dc49684f9d99aaf4a93aba8ac49f5eac861755f5730ddf6e2e4e" + name: file + sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c" url: "https://pub.dev" source: hosted - version: "1.0.0" + version: "7.0.0" flutter: dependency: "direct main" description: flutter source: sdk version: "0.0.0" + flutter_bloc: + dependency: "direct main" + description: + name: flutter_bloc + sha256: b594505eac31a0518bdcb4b5b79573b8d9117b193cc80cc12e17d639b10aa27a + url: "https://pub.dev" + source: hosted + version: "8.1.6" flutter_lints: dependency: "direct dev" description: @@ -219,30 +171,27 @@ 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: 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" - graphs: - dependency: transitive - description: - name: graphs - sha256: aedc5a15e78fc65a6e23bcd927f24c64dd995062bcd1ca6eda65a3cff92a4d19 - url: "https://pub.dev" - source: hosted - version: "2.3.1" + version: "2.1.2" http: dependency: "direct main" description: @@ -255,50 +204,42 @@ 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" + dependency: transitive description: name: json_annotation sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" url: "https://pub.dev" source: hosted version: "4.9.0" - json_serializable: - dependency: "direct dev" - description: - name: json_serializable - sha256: ea1432d167339ea9b5bb153f0571d0039607a873d6e04e0117af043f14a1fd4b - url: "https://pub.dev" - source: hosted - version: "6.8.0" leak_tracker: dependency: transitive description: @@ -335,10 +276,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: @@ -367,18 +308,42 @@ packages: dependency: transitive description: name: mime - sha256: fd5f81041e6a9fc9b9d7fa2cb8a01123f9f5d5d49136e06cb9dc7d33689529f4 + sha256: "801fd0b26f14a4a58ccb09d5892c3fbdeff209594300a542492cf13fba9d247a" + url: "https://pub.dev" + source: hosted + version: "1.0.6" + mocktail: + dependency: "direct dev" + description: + name: mocktail + sha256: "890df3f9688106f25755f26b1c60589a92b3ab91a22b8b224947ad041bf172d8" + url: "https://pub.dev" + source: hosted + version: "1.0.4" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" url: "https://pub.dev" source: hosted - version: "1.0.1" + version: "1.0.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" 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: @@ -387,67 +352,207 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.0" + 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" + 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: name: pool - sha256: "05955e3de2683e1746222efd14b775df7131139e07695dc8e24650f6b4204504" + sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" url: "https://pub.dev" source: hosted - version: "1.5.0" + version: "1.5.1" + provider: + dependency: transitive + description: + name: provider + sha256: c8a055ee5ce3fd98d6fc872478b03823ffdb448699c6ebdbbc71d59b596fd48c + url: "https://pub.dev" + source: hosted + version: "6.1.2" pub_semver: dependency: transitive description: name: pub_semver - sha256: b5a5fcc6425ea43704852ba4453ba94b08c2226c63418a260240c3a054579014 + sha256: "40d3ab1bbd474c4c2328c91e3a7df8c6dd629b79ece4c4bd04bee496a224fb0c" url: "https://pub.dev" source: hosted - version: "2.1.0" - pubspec_parse: + version: "2.1.4" + restaurant_gql_client: + dependency: "direct main" + description: + path: "packages/restaurant_gql_client" + relative: true + source: path + version: "0.1.0+1" + restaurant_models: + dependency: "direct main" + description: + path: "packages/restaurant_models" + relative: true + source: path + version: "0.1.0+1" + restaurant_repository: + dependency: "direct main" + description: + path: "packages/restaurant_repository" + relative: true + source: path + version: "0.1.0+1" + restaurant_ui: + dependency: "direct main" + description: + path: "packages/restaurant_ui" + relative: true + source: path + version: "0.1.0+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: pubspec_parse - sha256: "3686efe4a4613a4449b1a4ae08670aadbd3376f2e78d93e3f8f0919db02a7256" + name: shared_preferences_android + sha256: "480ba4345773f56acda9abf5f50bd966f581dac5d514e5fc4a18c62976bbba7e" url: "https://pub.dev" source: hosted - version: "1.2.0" + 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: name: shelf - sha256: c240984c924796e055e831a0a36db23be8cb04f170b26df572931ab36418421d + sha256: ad29c505aee705f41a4d8963641f91ac4cee3c8fad5947e033390a7bd8180fa4 url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.4.1" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.dev" + source: hosted + version: "1.1.3" shelf_web_socket: dependency: transitive description: name: shelf_web_socket - sha256: fd84910bf7d58db109082edf7326b75322b8f186162028482f53dc892f00332d + sha256: "9ca081be41c60190ebcb4766b2486a7d50261db7bd0f5d9615f2d653637a84c1" url: "https://pub.dev" source: hosted - version: "1.0.1" + version: "1.0.4" sky_engine: dependency: transitive description: flutter source: sdk version: "0.0.99" - source_gen: + source_map_stack_trace: dependency: transitive description: - name: source_gen - sha256: "14658ba5f669685cd3d63701d01b31ea748310f7ab854e471962670abcf57832" + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b url: "https://pub.dev" source: hosted - version: "1.5.0" - source_helper: + version: "2.1.2" + source_maps: dependency: transitive description: - name: source_helper - sha256: "6adebc0006c37dd63fe05bca0a929b99f06402fc95aa35bf36d67f5c06de01fd" + name: source_maps + sha256: "708b3f6b97248e5781f493b765c3337db11c5d2c81c3094f10904bfa8004c703" url: "https://pub.dev" source: hosted - version: "1.3.4" + version: "0.10.12" source_span: dependency: transitive description: @@ -472,14 +577,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" - stream_transform: - dependency: transitive - description: - name: stream_transform - sha256: ed464977cb26a1f41537e177e190c67223dbd9f4f683489b6ab2e5d211ec564e - url: "https://pub.dev" - source: hosted - version: "2.0.0" string_scanner: dependency: transitive description: @@ -496,6 +593,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.1" + test: + dependency: transitive + description: + name: test + sha256: "7ee446762c2c50b3bd4ea96fe13ffac69919352bd3b4b17bac3f3465edc58073" + url: "https://pub.dev" + source: hosted + version: "1.25.2" test_api: dependency: transitive description: @@ -504,22 +609,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.0" - timing: + test_core: dependency: transitive description: - name: timing - sha256: c386d07d7f5efc613479a7c4d9d64b03710b03cfaa7e8ad5f2bfb295a1f0dfad + name: test_core + sha256: "2bc4b4ecddd75309300d8096f781c0e3280ca1ef85beda558d33fcbedc2eead4" url: "https://pub.dev" source: hosted - version: "1.0.0" + version: "0.6.0" 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_math: dependency: transitive description: @@ -540,10 +645,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: @@ -556,18 +661,34 @@ packages: dependency: transitive description: name: web_socket_channel - sha256: "0c2ada1b1aeb2ad031ca81872add6be049b8cb479262c6ad3c4b0f9c24eaab2f" + sha256: d88238e5eac9a42bb43ca4e721edba3c08c6354d4a53063afaa568516217621b url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.4.0" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: faea9dee56b520b55a566385b84f2e8de55e7496104adada9962e0bd11bcff1d + url: "https://pub.dev" + source: hosted + version: "1.0.4" yaml: 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" + dart: ">=3.4.4 <4.0.0" + flutter: ">=3.22.3" diff --git a/pubspec.yaml b/pubspec.yaml index bc8a205..1fee59b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -11,17 +11,30 @@ environment: flutter: ">=3.19.6" dependencies: + bloc: ^8.1.4 flutter: sdk: flutter + flutter_bloc: ^8.1.6 http: ^1.2.2 - json_annotation: ^4.9.0 + restaurant_gql_client: + path: packages/restaurant_gql_client + restaurant_models: + path: packages/restaurant_models + restaurant_ui: + path: packages/restaurant_ui + restaurant_repository: + path: packages/restaurant_repository + equatable: ^2.0.5 + shared_preferences: ^2.3.2 + + dev_dependencies: + bloc_test: ^9.1.7 flutter_test: sdk: flutter flutter_lints: ^4.0.0 - build_runner: ^2.4.10 - json_serializable: ^6.8.0 + mocktail: ^1.0.4 flutter: generate: true @@ -45,4 +58,4 @@ flutter: style: italic - asset: assets/fonts/OpenSans/OpenSans-SemiBold.ttf weight: 600 - + diff --git a/test/favorite_restaurants_list/bloc/favorite_restaurants_bloc_test.dart b/test/favorite_restaurants_list/bloc/favorite_restaurants_bloc_test.dart new file mode 100644 index 0000000..06e7403 --- /dev/null +++ b/test/favorite_restaurants_list/bloc/favorite_restaurants_bloc_test.dart @@ -0,0 +1,61 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:restaurant_repository/restaurant_repository.dart'; +import 'package:restaurant_models/restaurant_models.dart'; +import 'package:restaurant_tour/favorite_restaurants_list/bloc/favorite_restaurants_bloc.dart'; + +class MockRestaurantRepository extends Mock implements RestaurantRepository {} + +void main() { + late FavoriteRestaurantsBloc favoriteRestaurantsBloc; + late MockRestaurantRepository mockRestaurantRepository; + + setUp(() { + mockRestaurantRepository = MockRestaurantRepository(); + favoriteRestaurantsBloc = FavoriteRestaurantsBloc(mockRestaurantRepository); + }); + + group('FavoriteRestaurantsBloc', () { + test('initial state is FavoriteRestaurantsLoading', () { + expect(favoriteRestaurantsBloc.state, const FavoriteRestaurantsLoading()); + }); + + blocTest( + 'emits [FavoriteRestaurantsLoading, FavoriteRestaurantsData] when ' + 'FetchFavoriteRestaurants is added and fetch is successful', + build: () { + when(() => mockRestaurantRepository.fetchFavoriteRestaurants()) + .thenAnswer((_) async => []); + return favoriteRestaurantsBloc; + }, + act: (bloc) => bloc.add(const FetchFavoriteRestaurants()), + expect: () => [ + const FavoriteRestaurantsLoading(), + const FavoriteRestaurantsData(restaurants: []), + ], + verify: (_) { + verify(() => mockRestaurantRepository.fetchFavoriteRestaurants()) + .called(1); + }, + ); + + blocTest( + 'emits [FavoriteRestaurantsLoading, FavoriteRestaurantsError] when FetchFavoriteRestaurants is added and fetch fails', + build: () { + when(() => mockRestaurantRepository.fetchFavoriteRestaurants()) + .thenThrow(Exception('error')); + return favoriteRestaurantsBloc; + }, + act: (bloc) => bloc.add(const FetchFavoriteRestaurants()), + expect: () => [ + const FavoriteRestaurantsLoading(), + const FavoriteRestaurantsError(message: 'Exception: error'), + ], + verify: (_) { + verify(() => mockRestaurantRepository.fetchFavoriteRestaurants()) + .called(1); + }, + ); + }); +} diff --git a/test/favorite_restaurants_list/bloc/favorite_restaurants_event_test.dart b/test/favorite_restaurants_list/bloc/favorite_restaurants_event_test.dart new file mode 100644 index 0000000..65fa315 --- /dev/null +++ b/test/favorite_restaurants_list/bloc/favorite_restaurants_event_test.dart @@ -0,0 +1,13 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:restaurant_tour/favorite_restaurants_list/bloc/favorite_restaurants_bloc.dart'; + +void main() { + group('FavoriteRestaurantsEvent', () { + test('supports value comparisons for FetchFavoriteRestaurants', () { + expect( + const FetchFavoriteRestaurants(), + equals(const FetchFavoriteRestaurants()), + ); + }); + }); +} diff --git a/test/favorite_restaurants_list/bloc/favorite_restaurants_state_test.dart b/test/favorite_restaurants_list/bloc/favorite_restaurants_state_test.dart new file mode 100644 index 0000000..193f7ab --- /dev/null +++ b/test/favorite_restaurants_list/bloc/favorite_restaurants_state_test.dart @@ -0,0 +1,29 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:restaurant_models/restaurant_models.dart'; +import 'package:restaurant_tour/favorite_restaurants_list/bloc/favorite_restaurants_bloc.dart'; + +void main() { + group('FavoriteRestaurantsState', () { + test('supports value comparisons for FavoriteRestaurantsLoading', () { + expect( + const FavoriteRestaurantsLoading(), + equals(const FavoriteRestaurantsLoading()), + ); + }); + + test('supports value comparisons for FavoriteRestaurantsError', () { + expect( + const FavoriteRestaurantsError(message: 'error'), + equals(const FavoriteRestaurantsError(message: 'error')), + ); + }); + + test('supports value comparisons for FavoriteRestaurantsData', () { + final restaurants = [const Restaurant(id: '1', name: 'Restaurant 1')]; + expect( + FavoriteRestaurantsData(restaurants: restaurants), + equals(FavoriteRestaurantsData(restaurants: restaurants)), + ); + }); + }); +} diff --git a/test/favorite_restaurants_list/view/favorite_restaurants_list_view_test.dart b/test/favorite_restaurants_list/view/favorite_restaurants_list_view_test.dart new file mode 100644 index 0000000..acf3fa8 --- /dev/null +++ b/test/favorite_restaurants_list/view/favorite_restaurants_list_view_test.dart @@ -0,0 +1,96 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:restaurant_models/restaurant_models.dart'; +import 'package:restaurant_tour/favorite_restaurants_list/bloc/favorite_restaurants_bloc.dart'; +import 'package:restaurant_tour/favorite_restaurants_list/view/favorite_restaurants_list_view.dart'; +import 'package:restaurant_ui/restaurant_ui.dart'; + +class MockFavoriteRestaurantsBloc + extends MockBloc + implements FavoriteRestaurantsBloc {} + +class MockRestaurant extends Mock implements Restaurant {} + +void main() { + late FavoriteRestaurantsBloc favoriteRestaurantsBloc; + + setUp(() { + favoriteRestaurantsBloc = MockFavoriteRestaurantsBloc(); + }); + + Widget createWidgetUnderTest() { + return MaterialApp( + home: BlocProvider.value( + value: favoriteRestaurantsBloc, + child: const FavoriteRestaurantsListView(), + ), + ); + } + + group('FavoriteRestaurantsListView', () { + testWidgets( + 'displays CircularProgressIndicator when state is FavoriteRestaurantsLoading', + (tester) async { + when(() => favoriteRestaurantsBloc.state) + .thenReturn(const FavoriteRestaurantsLoading()); + + await tester.pumpWidget(createWidgetUnderTest()); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }); + + testWidgets('displays error text when state is FavoriteRestaurantsError', + (tester) async { + when(() => favoriteRestaurantsBloc.state).thenReturn( + const FavoriteRestaurantsError(message: 'An error occurred'), + ); + + await tester.pumpWidget(createWidgetUnderTest()); + + expect(find.text('error'), findsOneWidget); + }); + + testWidgets( + 'displays a list of restaurants when state is FavoriteRestaurantsData', + (tester) async { + final mockRestaurants = [ + MockRestaurant(), + MockRestaurant(), + ]; + + when(() => favoriteRestaurantsBloc.state) + .thenReturn(FavoriteRestaurantsData(restaurants: mockRestaurants)); + + when(() => mockRestaurants[0].id).thenReturn('1'); + when(() => mockRestaurants[0].name).thenReturn('Restaurant 1'); + when(() => mockRestaurants[0].heroImage) + .thenReturn('http://example.com/restaurant1.jpg'); + when(() => mockRestaurants[0].isOpen).thenReturn(true); + when(() => mockRestaurants[0].price).thenReturn(r'$$'); + when(() => mockRestaurants[0].rating).thenReturn(4.5); + when(() => mockRestaurants[0].displayCategory).thenReturn('Category 1'); + + when(() => mockRestaurants[1].id).thenReturn('2'); + when(() => mockRestaurants[1].name).thenReturn('Restaurant 2'); + when(() => mockRestaurants[1].heroImage) + .thenReturn('http://example.com/restaurant2.jpg'); + when(() => mockRestaurants[1].isOpen).thenReturn(false); + when(() => mockRestaurants[1].price).thenReturn(r'$$$'); + when(() => mockRestaurants[1].rating).thenReturn(3.8); + when(() => mockRestaurants[1].displayCategory).thenReturn('Category 2'); + + await tester.pumpWidget(createWidgetUnderTest()); + + expect(find.byType(ListView), findsOneWidget); + expect(find.byType(RestaurantCard), findsNWidgets(2)); + + expect(find.text('Restaurant 1'), findsOneWidget); + expect(find.text('Restaurant 2'), findsOneWidget); + expect(find.text(r'$$ Category 1'), findsOneWidget); + expect(find.text(r'$$$ Category 2'), findsOneWidget); + }); + }); +} diff --git a/test/restaurant_detail/bloc/restaurant_detail_bloc_test.dart b/test/restaurant_detail/bloc/restaurant_detail_bloc_test.dart new file mode 100644 index 0000000..71f33ed --- /dev/null +++ b/test/restaurant_detail/bloc/restaurant_detail_bloc_test.dart @@ -0,0 +1,106 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:restaurant_models/restaurant_models.dart'; +import 'package:restaurant_repository/restaurant_repository.dart'; +import 'package:restaurant_tour/restaurant_detail/bloc/restaurant_detail_bloc.dart'; + +class MockRestaurantRepository extends Mock implements RestaurantRepository {} + +class MockRestaurant extends Mock implements Restaurant {} + +void main() { + late RestaurantDetailBloc bloc; + late MockRestaurantRepository mockRepository; + late Restaurant restaurant; + + setUp(() { + mockRepository = MockRestaurantRepository(); + restaurant = MockRestaurant(); + bloc = RestaurantDetailBloc(mockRepository); + + when(() => restaurant.id).thenReturn('restaurant-1'); + }); + + group('RestaurantDetailBloc', () { + blocTest( + 'emits [RestaurantDetailLoading, RestaurantDetailLoaded] when ' + 'FetchRestaurantIsFavorite is added and the restaurant is a favorite', + build: () { + when(() => mockRepository.isFavorite('restaurant-1')) + .thenAnswer((_) async => true); + return bloc; + }, + act: (bloc) => + bloc.add(FetchRestaurantIsFavorite(restaurant: restaurant)), + expect: () => [ + const RestaurantDetailLoading(), + const RestaurantDetailLoaded(isFavorite: true), + ], + verify: (_) { + verify(() => mockRepository.isFavorite('restaurant-1')).called(1); + }, + ); + + blocTest( + 'emits [RestaurantDetailLoading, RestaurantDetailLoaded] when ' + 'FetchRestaurantIsFavorite is added and the restaurant is not a favorite', + build: () { + when(() => mockRepository.isFavorite('restaurant-1')) + .thenAnswer((_) async => false); + return bloc; + }, + act: (bloc) => + bloc.add(FetchRestaurantIsFavorite(restaurant: restaurant)), + expect: () => [ + const RestaurantDetailLoading(), + const RestaurantDetailLoaded(isFavorite: false), + ], + verify: (_) { + verify(() => mockRepository.isFavorite('restaurant-1')).called(1); + }, + ); + + blocTest( + 'emits [RestaurantDetailLoaded] when ToggleRestaurantFavorite is ' + 'added and the restaurant is a favorite (removes it)', + build: () { + when(() => mockRepository.isFavorite('restaurant-1')) + .thenAnswer((_) async => true); + when(() => mockRepository.deleteFavoriteRestaurant(restaurant)) + .thenAnswer((_) async {}); + return bloc; + }, + act: (bloc) => bloc.add(ToggleRestaurantFavorite(restaurant: restaurant)), + expect: () => [ + const RestaurantDetailLoaded(isFavorite: false), + ], + verify: (_) { + verify(() => mockRepository.isFavorite('restaurant-1')).called(1); + verify(() => mockRepository.deleteFavoriteRestaurant(restaurant)) + .called(1); + }, + ); + + blocTest( + 'emits [RestaurantDetailLoaded] when ToggleRestaurantFavorite is ' + 'added and the restaurant is not a favorite (adds it)', + build: () { + when(() => mockRepository.isFavorite('restaurant-1')) + .thenAnswer((_) async => false); + when(() => mockRepository.saveFavoriteRestaurant(restaurant)) + .thenAnswer((_) async {}); + return bloc; + }, + act: (bloc) => bloc.add(ToggleRestaurantFavorite(restaurant: restaurant)), + expect: () => [ + const RestaurantDetailLoaded(isFavorite: true), + ], + verify: (_) { + verify(() => mockRepository.isFavorite('restaurant-1')).called(1); + verify(() => mockRepository.saveFavoriteRestaurant(restaurant)) + .called(1); + }, + ); + }); +} diff --git a/test/restaurant_detail/bloc/restaurant_detail_event_test.dart b/test/restaurant_detail/bloc/restaurant_detail_event_test.dart new file mode 100644 index 0000000..6811f89 --- /dev/null +++ b/test/restaurant_detail/bloc/restaurant_detail_event_test.dart @@ -0,0 +1,33 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:restaurant_models/restaurant_models.dart'; +import 'package:restaurant_tour/restaurant_detail/bloc/restaurant_detail_bloc.dart'; + +void main() { + group('RestaurantDetailEvent', () { + final restaurant = Restaurant( + id: '1', + name: 'Test Restaurant', + price: '\$\$', + rating: 4.5, + photos: ['photo_url'], + categories: [], + hours: [], + reviews: [], + location: Location(formattedAddress: '123 Test St'), + ); + + test('FetchRestaurantIsFavorite supports value comparisons', () { + expect( + FetchRestaurantIsFavorite(restaurant: restaurant), + FetchRestaurantIsFavorite(restaurant: restaurant), + ); + }); + + test('ToggleRestaurantFavorite supports value comparisons', () { + expect( + ToggleRestaurantFavorite(restaurant: restaurant), + ToggleRestaurantFavorite(restaurant: restaurant), + ); + }); + }); +} diff --git a/test/restaurant_detail/bloc/restaurant_detail_state_test.dart b/test/restaurant_detail/bloc/restaurant_detail_state_test.dart new file mode 100644 index 0000000..81d7c2c --- /dev/null +++ b/test/restaurant_detail/bloc/restaurant_detail_state_test.dart @@ -0,0 +1,24 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:restaurant_tour/restaurant_detail/bloc/restaurant_detail_bloc.dart'; + +void main() { + group('RestaurantDetailState', () { + test('RestaurantDetailLoading supports value comparisons', () { + expect( + const RestaurantDetailLoading(), + const RestaurantDetailLoading(), + ); + }); + + test('RestaurantDetailLoaded supports value comparisons', () { + expect( + const RestaurantDetailLoaded(isFavorite: true), + const RestaurantDetailLoaded(isFavorite: true), + ); + expect( + const RestaurantDetailLoaded(isFavorite: false), + isNot(const RestaurantDetailLoaded(isFavorite: true)), + ); + }); + }); +} diff --git a/test/restaurant_detail/view/restaurant_detail_test.dart b/test/restaurant_detail/view/restaurant_detail_test.dart new file mode 100644 index 0000000..363d689 --- /dev/null +++ b/test/restaurant_detail/view/restaurant_detail_test.dart @@ -0,0 +1,181 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:restaurant_models/restaurant_models.dart'; +import 'package:restaurant_repository/restaurant_repository.dart'; +import 'package:restaurant_tour/restaurant_detail/restaurant_detail.dart'; + +class MockRestaurantDetailBloc extends Mock implements RestaurantDetailBloc {} + +class MockRestaurantRepository extends Mock implements RestaurantRepository {} + +void main() { + late MockRestaurantDetailBloc mockRestaurantDetailBloc; + late MockRestaurantRepository mockRestaurantRepository; + late Restaurant testRestaurant; + + Widget createWidgetUnderTest() { + return RepositoryProvider.value( + value: mockRestaurantRepository, + child: MultiBlocProvider( + providers: [ + BlocProvider.value( + value: mockRestaurantDetailBloc, + ), + ], + child: MaterialApp( + home: Scaffold( + body: RestaurantDetailView( + restaurant: testRestaurant, + ), + ), + ), + ), + ); + } + + setUp(() { + mockRestaurantDetailBloc = MockRestaurantDetailBloc(); + mockRestaurantRepository = MockRestaurantRepository(); + + testRestaurant = Restaurant( + id: '1', + name: 'Test Restaurant', + price: '\$\$', + rating: 4.5, + categories: [Category(title: 'Italian', alias: 'italian')], + photos: ['https://example.com/photo.jpg'], + reviews: [ + const Review( + id: 'r1', + rating: 5, + text: 'Excellent!', + user: User( + id: 'u1', + name: 'John Doe', + imageUrl: 'https://example.com/user.jpg', + ), + ), + ], + location: Location(formattedAddress: '123 Main St, City, Country'), + hours: [const Hours(isOpenNow: true)], + ); + + when(() => mockRestaurantDetailBloc.state) + .thenReturn(const RestaurantDetailLoading()); + + when(() => mockRestaurantDetailBloc.stream).thenAnswer( + (_) => Stream.fromIterable([const RestaurantDetailLoading()]), + ); + }); + + group('RestaurantDetailView', () { + testWidgets('renders CircularProgressIndicator while loading', + (WidgetTester tester) async { + when(() => mockRestaurantDetailBloc.state) + .thenReturn(const RestaurantDetailLoading()); + + await tester.pumpWidget( + createWidgetUnderTest(), + ); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }); + + testWidgets('renders RestaurantDetailLoaded state with correct data', + (WidgetTester tester) async { + when(() => mockRestaurantDetailBloc.state) + .thenReturn(const RestaurantDetailLoaded(isFavorite: false)); + + when(() => mockRestaurantDetailBloc.stream).thenAnswer( + (_) => Stream.fromIterable( + [const RestaurantDetailLoaded(isFavorite: false)], + ), + ); + + await tester.pumpWidget( + createWidgetUnderTest(), + ); + + await tester.pump(); + + expect(find.text('Test Restaurant'), findsOneWidget); + expect(find.text('\$\$ Italian'), findsOneWidget); + expect(find.byIcon(Icons.favorite_border), findsOneWidget); + }); + + testWidgets('renders favorite icon as filled when restaurant is favorite', + (WidgetTester tester) async { + when(() => mockRestaurantDetailBloc.state) + .thenReturn(const RestaurantDetailLoaded(isFavorite: true)); + + when(() => mockRestaurantDetailBloc.stream).thenAnswer( + (_) => Stream.fromIterable( + [const RestaurantDetailLoaded(isFavorite: true)], + ), + ); + + await tester.pumpWidget( + createWidgetUnderTest(), + ); + + await tester.pump(); + + expect(find.byIcon(Icons.favorite), findsOneWidget); + }); + + testWidgets('triggers ToggleRestaurantFavorite on tap of favorite icon', + (WidgetTester tester) async { + when(() => mockRestaurantDetailBloc.state) + .thenReturn(const RestaurantDetailLoaded(isFavorite: false)); + + when(() => mockRestaurantDetailBloc.stream).thenAnswer( + (_) => Stream.fromIterable( + [const RestaurantDetailLoaded(isFavorite: false)], + ), + ); + + await tester.pumpWidget( + createWidgetUnderTest(), + ); + + await tester.pump(); + + await tester.tap(find.byIcon(Icons.favorite_border)); + verify( + () => mockRestaurantDetailBloc + .add(ToggleRestaurantFavorite(restaurant: testRestaurant)), + ).called(1); + }); + + testWidgets( + 'renders all sections correctly in RestaurantDetailLoaded state', + (WidgetTester tester) async { + when(() => mockRestaurantDetailBloc.state) + .thenReturn(const RestaurantDetailLoaded(isFavorite: false)); + + when(() => mockRestaurantDetailBloc.stream).thenAnswer( + (_) => Stream.fromIterable( + [const RestaurantDetailLoaded(isFavorite: false)], + ), + ); + + await tester.pumpWidget( + createWidgetUnderTest(), + ); + + await tester.pump(); + + expect(find.byType(Hero), findsOneWidget); + expect(find.text('\$\$ Italian'), findsOneWidget); + + expect(find.text('123 Main St, City, Country'), findsOneWidget); + + expect(find.text('4.5'), findsOneWidget); + + expect(find.text('John Doe'), findsOneWidget); + expect(find.text('Excellent!'), findsOneWidget); + }); + }); +} diff --git a/test/restaurant_list/bloc/restaurant_list_bloc_test.dart b/test/restaurant_list/bloc/restaurant_list_bloc_test.dart new file mode 100644 index 0000000..3216a7b --- /dev/null +++ b/test/restaurant_list/bloc/restaurant_list_bloc_test.dart @@ -0,0 +1,76 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:restaurant_models/restaurant_models.dart'; +import 'package:restaurant_repository/restaurant_repository.dart'; +import 'package:restaurant_tour/restaurant_list/bloc/restaurant_list_bloc.dart'; + +class MockRestaurantRepository extends Mock implements RestaurantRepository {} + +void main() { + group('RestaurantListBloc', () { + late MockRestaurantRepository restaurantRepository; + + setUp(() { + restaurantRepository = MockRestaurantRepository(); + }); + + blocTest( + 'emits [RestaurantListLoading, RestaurantListData] when FetchRestaurantList is added', + build: () { + when(() => restaurantRepository.fetchRestaurants()).thenAnswer( + (_) async => const RestaurantQueryResult( + restaurants: [ + Restaurant(id: '1', name: 'Restaurant 1'), + Restaurant(id: '2', name: 'Restaurant 2'), + ], + ), + ); + return RestaurantListBloc(restaurantRepository); + }, + act: (bloc) => bloc.add(const FetchRestaurantList()), + expect: () => [ + const RestaurantListLoading(), + const RestaurantListData( + restaurants: [ + Restaurant(id: '1', name: 'Restaurant 1'), + Restaurant(id: '2', name: 'Restaurant 2'), + ], + ), + ], + ); + + blocTest( + 'emits [RestaurantListLoading, RestaurantListError] when fetchRestaurants throws an error', + build: () { + when(() => restaurantRepository.fetchRestaurants()) + .thenThrow(Exception('Error fetching restaurants')); + return RestaurantListBloc(restaurantRepository); + }, + act: (bloc) => bloc.add(const FetchRestaurantList()), + expect: () => [ + const RestaurantListLoading(), + const RestaurantListError( + message: 'Exception: Error fetching restaurants', + ), + ], + ); + + blocTest( + 'emits [RestaurantDetail] when GoToRestaurantDetail is added', + build: () { + return RestaurantListBloc(restaurantRepository); + }, + act: (bloc) => bloc.add( + const GoToRestaurantDetail( + restaurant: Restaurant(id: '1', name: 'Restaurant 1'), + ), + ), + expect: () => [ + const RestaurantDetail( + restaurant: Restaurant(id: '1', name: 'Restaurant 1'), + ), + ], + ); + }); +} diff --git a/test/restaurant_list/bloc/restaurant_list_event_test.dart b/test/restaurant_list/bloc/restaurant_list_event_test.dart new file mode 100644 index 0000000..d7291e5 --- /dev/null +++ b/test/restaurant_list/bloc/restaurant_list_event_test.dart @@ -0,0 +1,22 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:restaurant_models/restaurant_models.dart'; +import 'package:restaurant_tour/restaurant_list/restaurant_list.dart'; + +void main() { + group('RestaurantListEvent', () { + test('supports value comparisons for FetchRestaurantList', () { + expect( + const FetchRestaurantList(), + equals(const FetchRestaurantList()), + ); + }); + + test('supports value comparisons for GoToRestaurantDetail', () { + const restaurant = Restaurant(id: '1', name: 'Restaurant 1'); + expect( + const GoToRestaurantDetail(restaurant: restaurant), + equals(const GoToRestaurantDetail(restaurant: restaurant)), + ); + }); + }); +} diff --git a/test/restaurant_list/bloc/restaurant_list_state_test.dart b/test/restaurant_list/bloc/restaurant_list_state_test.dart new file mode 100644 index 0000000..f0c1d4b --- /dev/null +++ b/test/restaurant_list/bloc/restaurant_list_state_test.dart @@ -0,0 +1,37 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:restaurant_models/restaurant_models.dart'; +import 'package:restaurant_tour/restaurant_list/restaurant_list.dart'; + +void main() { + group('RestaurantListState', () { + test('supports value comparisons for RestaurantListLoading', () { + expect( + const RestaurantListLoading(), + equals(const RestaurantListLoading()), + ); + }); + + test('supports value comparisons for RestaurantListError', () { + expect( + const RestaurantListError(message: 'error'), + equals(const RestaurantListError(message: 'error')), + ); + }); + + test('supports value comparisons for RestaurantListData', () { + final restaurants = [const Restaurant(id: '1', name: 'Restaurant 1')]; + expect( + RestaurantListData(restaurants: restaurants), + equals(RestaurantListData(restaurants: restaurants)), + ); + }); + + test('supports value comparisons for RestaurantDetail', () { + const restaurant = Restaurant(id: '1', name: 'Restaurant 1'); + expect( + const RestaurantDetail(restaurant: restaurant), + equals(const RestaurantDetail(restaurant: restaurant)), + ); + }); + }); +} diff --git a/test/restaurant_list/view/restaurant_list_test.dart b/test/restaurant_list/view/restaurant_list_test.dart new file mode 100644 index 0000000..cc84da2 --- /dev/null +++ b/test/restaurant_list/view/restaurant_list_test.dart @@ -0,0 +1,91 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:restaurant_models/restaurant_models.dart'; +import 'package:restaurant_tour/favorite_restaurants_list/favorite_restaurant_list.dart'; +import 'package:restaurant_tour/restaurant_list/restaurant_list.dart'; +import 'package:restaurant_ui/restaurant_ui.dart'; + +class MockRestaurantListBloc + extends MockBloc + implements RestaurantListBloc {} + +class MockFavoriteRestaurantsBloc + extends MockBloc + implements FavoriteRestaurantsBloc {} + +class MockRestaurant extends Mock implements Restaurant {} + +void main() { + late RestaurantListBloc restaurantListBloc; + late FavoriteRestaurantsBloc favoriteRestaurantsBloc; + late Restaurant mockRestaurant; + + setUp(() { + restaurantListBloc = MockRestaurantListBloc(); + favoriteRestaurantsBloc = MockFavoriteRestaurantsBloc(); + mockRestaurant = MockRestaurant(); + }); + + Widget createWidgetUnderTest() { + return MultiBlocProvider( + providers: [ + BlocProvider.value(value: restaurantListBloc), + BlocProvider.value( + value: favoriteRestaurantsBloc, + ), + ], + child: const MaterialApp( + home: Scaffold(body: RestaurantListView()), + ), + ); + } + + group('RestaurantListView', () { + testWidgets( + 'displays CircularProgressIndicator when state is RestaurantListLoading', + (tester) async { + when(() => restaurantListBloc.state) + .thenReturn(const RestaurantListLoading()); + + await tester.pumpWidget(createWidgetUnderTest()); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }); + + testWidgets('displays error message when state is RestaurantListError', + (tester) async { + when(() => restaurantListBloc.state) + .thenReturn(const RestaurantListError(message: 'error')); + + await tester.pumpWidget(createWidgetUnderTest()); + + expect(find.text('error'), findsOneWidget); + }); + + testWidgets('displays RestaurantCards when state is RestaurantListData', + (tester) async { + final mockRestaurants = [ + mockRestaurant, + mockRestaurant, + ]; + + when(() => restaurantListBloc.state) + .thenReturn(RestaurantListData(restaurants: mockRestaurants)); + when(() => mockRestaurant.id).thenReturn('1'); + when(() => mockRestaurant.name).thenReturn('Restaurant 1'); + when(() => mockRestaurant.heroImage) + .thenReturn('http://example.com/image.png'); + when(() => mockRestaurant.isOpen).thenReturn(true); + when(() => mockRestaurant.price).thenReturn('\$\$'); + when(() => mockRestaurant.rating).thenReturn(4.5); + when(() => mockRestaurant.displayCategory).thenReturn('Category'); + + await tester.pumpWidget(createWidgetUnderTest()); + + expect(find.byType(RestaurantCard), findsNWidgets(2)); + }); + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart deleted file mode 100644 index b729d48..0000000 --- a/test/widget_test.dart +++ /dev/null @@ -1,19 +0,0 @@ -// 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_test/flutter_test.dart'; -import 'package:restaurant_tour/main.dart'; - -void main() { - testWidgets('Page loads', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const RestaurantTour()); - - // Verify that tests will run - expect(find.text('Fetch Restaurants'), findsOneWidget); - }); -}