From 47eaed455e9664748bc41d460cf9ee8c9f043b46 Mon Sep 17 00:00:00 2001 From: Felipe Reis Date: Sat, 7 Sep 2024 01:31:58 -0300 Subject: [PATCH 01/23] First commit --- .gitignore | 4 +- analysis_options.yaml | 3 +- lib/core/theme/colors.dart | 6 + lib/{ => core/theme}/typography.dart | 0 lib/data/dtos/restaurant_dto.dart | 21 + lib/data/dtos/restaurant_dto.g.dart | 21 + lib/data/models/category.dart | 26 + lib/data/models/category.g.dart | 17 + lib/data/models/hours.dart | 22 + lib/data/models/hours.g.dart | 15 + lib/data/models/location.dart | 24 + lib/data/models/location.g.dart | 15 + lib/data/models/restaurant.dart | 52 + lib/data/models/restaurant.g.dart | 41 + lib/data/models/review.dart | 32 + lib/data/models/review.g.dart | 23 + lib/data/models/user.dart | 28 + lib/data/models/user.g.dart | 19 + lib/data/repositories/cached_response.dart | 1145 +++++++++++++++++ .../repositories/restaurants_repository.dart | 118 ++ lib/di/di.dart | 45 + lib/domain/models/category.dart | 9 + lib/domain/models/hours.dart | 7 + lib/domain/models/location.dart | 7 + lib/domain/models/restaurant.dart | 53 + lib/domain/models/review.dart | 15 + lib/domain/models/user.dart | 11 + .../repositories/restaurants_repository.dart | 13 + .../get_favorites_restaurants_use_case.dart | 14 + .../use_cases/get_restaurants_use_case.dart | 14 + lib/domain/use_cases/toggle_favorite.dart | 14 + lib/main.dart | 52 +- lib/models/restaurant.dart | 157 --- lib/models/restaurant.g.dart | 109 -- lib/presentation/components/rating_stars.dart | 20 + .../components/restaurant_card.dart | 91 ++ lib/presentation/components/review_card.dart | 50 + .../screens/home/all_restaurants_tab.dart | 76 ++ .../home/favorite_restaurants_tab.dart | 36 + .../screens/home/home_screen.dart | 130 ++ .../restaurant_details_screen.dart | 193 +++ lib/repositories/yelp_repository.dart | 111 -- pubspec.lock | 192 +-- pubspec.yaml | 3 + test.sh | 4 + test/data/dtos/restaurant_dto_test.dart | 22 + .../restaurants_repository_test.dart | 58 + ...t_favorites_restaurants_use_case_test.dart | 23 + .../get_restaurants_use_case_test.dart | 23 + .../use_cases/toggle_favorite_test.dart | 50 + test/fakes/data/fake_restaurant.dart | 117 ++ .../fake_restaurants_repository.dart | 43 + test/make_testable_widget.dart | 9 + .../components/rating_stars_test.dart | 36 + .../components/restaurant_card_test.dart | 46 + .../components/review_card_test.dart | 24 + .../home/all_restaurants_tab_test.dart | 66 + .../home/favorite_restaurants_tab_test.dart | 53 + .../screens/home/home_screen_test.dart | 79 ++ .../restaurant_details_screen_test.dart | 32 + test/widget_test.dart | 19 - 61 files changed, 3246 insertions(+), 512 deletions(-) create mode 100644 lib/core/theme/colors.dart rename lib/{ => core/theme}/typography.dart (100%) create mode 100644 lib/data/dtos/restaurant_dto.dart create mode 100644 lib/data/dtos/restaurant_dto.g.dart create mode 100644 lib/data/models/category.dart create mode 100644 lib/data/models/category.g.dart create mode 100644 lib/data/models/hours.dart create mode 100644 lib/data/models/hours.g.dart create mode 100644 lib/data/models/location.dart create mode 100644 lib/data/models/location.g.dart create mode 100644 lib/data/models/restaurant.dart create mode 100644 lib/data/models/restaurant.g.dart create mode 100644 lib/data/models/review.dart create mode 100644 lib/data/models/review.g.dart create mode 100644 lib/data/models/user.dart create mode 100644 lib/data/models/user.g.dart create mode 100644 lib/data/repositories/cached_response.dart create mode 100644 lib/data/repositories/restaurants_repository.dart create mode 100644 lib/di/di.dart create mode 100644 lib/domain/models/category.dart create mode 100644 lib/domain/models/hours.dart create mode 100644 lib/domain/models/location.dart create mode 100644 lib/domain/models/restaurant.dart create mode 100644 lib/domain/models/review.dart create mode 100644 lib/domain/models/user.dart create mode 100644 lib/domain/repositories/restaurants_repository.dart create mode 100644 lib/domain/use_cases/get_favorites_restaurants_use_case.dart create mode 100644 lib/domain/use_cases/get_restaurants_use_case.dart create mode 100644 lib/domain/use_cases/toggle_favorite.dart delete mode 100644 lib/models/restaurant.dart delete mode 100644 lib/models/restaurant.g.dart create mode 100644 lib/presentation/components/rating_stars.dart create mode 100644 lib/presentation/components/restaurant_card.dart create mode 100644 lib/presentation/components/review_card.dart create mode 100644 lib/presentation/screens/home/all_restaurants_tab.dart create mode 100644 lib/presentation/screens/home/favorite_restaurants_tab.dart create mode 100644 lib/presentation/screens/home/home_screen.dart create mode 100644 lib/presentation/screens/restaurant_details/restaurant_details_screen.dart delete mode 100644 lib/repositories/yelp_repository.dart create mode 100755 test.sh create mode 100644 test/data/dtos/restaurant_dto_test.dart create mode 100644 test/data/repositories/restaurants_repository_test.dart create mode 100644 test/domain/use_cases/get_favorites_restaurants_use_case_test.dart create mode 100644 test/domain/use_cases/get_restaurants_use_case_test.dart create mode 100644 test/domain/use_cases/toggle_favorite_test.dart create mode 100644 test/fakes/data/fake_restaurant.dart create mode 100644 test/fakes/repositories/fake_restaurants_repository.dart create mode 100644 test/make_testable_widget.dart create mode 100644 test/presentation/components/rating_stars_test.dart create mode 100644 test/presentation/components/restaurant_card_test.dart create mode 100644 test/presentation/components/review_card_test.dart create mode 100644 test/presentation/screens/home/all_restaurants_tab_test.dart create mode 100644 test/presentation/screens/home/favorite_restaurants_tab_test.dart create mode 100644 test/presentation/screens/home/home_screen_test.dart create mode 100644 test/presentation/screens/restaurant_details/restaurant_details_screen_test.dart delete mode 100644 test/widget_test.dart diff --git a/.gitignore b/.gitignore index 1be2d87..aebb765 100644 --- a/.gitignore +++ b/.gitignore @@ -46,4 +46,6 @@ app.*.map.json /android/app/release # fvm -.fvm/flutter_sdk \ No newline at end of file +.fvm/flutter_sdk + +coverage/ \ No newline at end of file diff --git a/analysis_options.yaml b/analysis_options.yaml index adc7c5b..cca95b4 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -10,4 +10,5 @@ linter: ## The following rules are excluded only to keep compatibility with our previous lint set (pedantic). ## There's room to discuss them individually and raise PRs adjusting our codebase. avoid_print: false - require_trailing_commas: true \ No newline at end of file + require_trailing_commas: true + always_use_package_imports: true \ No newline at end of file diff --git a/lib/core/theme/colors.dart b/lib/core/theme/colors.dart new file mode 100644 index 0000000..918682a --- /dev/null +++ b/lib/core/theme/colors.dart @@ -0,0 +1,6 @@ +import 'dart:ui'; + +class AppColors { + static const star = Color(0xFFFFB800); + static const dividerColor = Color(0xFFEEEEEE); +} diff --git a/lib/typography.dart b/lib/core/theme/typography.dart similarity index 100% rename from lib/typography.dart rename to lib/core/theme/typography.dart diff --git a/lib/data/dtos/restaurant_dto.dart b/lib/data/dtos/restaurant_dto.dart new file mode 100644 index 0000000..3859553 --- /dev/null +++ b/lib/data/dtos/restaurant_dto.dart @@ -0,0 +1,21 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:restaurant_tour/data/models/restaurant.dart'; + +part 'restaurant_dto.g.dart'; + +@JsonSerializable() +class RestaurantDto { + final int? total; + @JsonKey(name: 'business') + final List? restaurants; + + const RestaurantDto({ + this.total, + this.restaurants, + }); + + factory RestaurantDto.fromJson(Map json) => + _$RestaurantDtoFromJson(json); + + Map toJson() => _$RestaurantDtoToJson(this); +} diff --git a/lib/data/dtos/restaurant_dto.g.dart b/lib/data/dtos/restaurant_dto.g.dart new file mode 100644 index 0000000..e9eeaa4 --- /dev/null +++ b/lib/data/dtos/restaurant_dto.g.dart @@ -0,0 +1,21 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'restaurant_dto.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +RestaurantDto _$RestaurantDtoFromJson(Map json) => + RestaurantDto( + total: (json['total'] as num?)?.toInt(), + restaurants: (json['business'] as List?) + ?.map((e) => Restaurant.fromJson(e as Map)) + .toList(), + ); + +Map _$RestaurantDtoToJson(RestaurantDto instance) => + { + 'total': instance.total, + 'business': instance.restaurants, + }; diff --git a/lib/data/models/category.dart b/lib/data/models/category.dart new file mode 100644 index 0000000..0a89023 --- /dev/null +++ b/lib/data/models/category.dart @@ -0,0 +1,26 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:restaurant_tour/domain/models/category.dart' + as category_domain_model; + +part 'category.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); + + category_domain_model.Category toDomain() => category_domain_model.Category( + alias: alias, + title: title, + ); +} diff --git a/lib/data/models/category.g.dart b/lib/data/models/category.g.dart new file mode 100644 index 0000000..6965921 --- /dev/null +++ b/lib/data/models/category.g.dart @@ -0,0 +1,17 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'category.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Category _$CategoryFromJson(Map json) => Category( + alias: json['alias'] as String?, + title: json['title'] as String?, + ); + +Map _$CategoryToJson(Category instance) => { + 'alias': instance.alias, + 'title': instance.title, + }; diff --git a/lib/data/models/hours.dart b/lib/data/models/hours.dart new file mode 100644 index 0000000..23b8eda --- /dev/null +++ b/lib/data/models/hours.dart @@ -0,0 +1,22 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:restaurant_tour/domain/models/hours.dart' as hours_domain_model; + +part 'hours.g.dart'; + +@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); + + hours_domain_model.Hours toDomain() => hours_domain_model.Hours( + isOpenNow: isOpenNow, + ); +} diff --git a/lib/data/models/hours.g.dart b/lib/data/models/hours.g.dart new file mode 100644 index 0000000..334d39b --- /dev/null +++ b/lib/data/models/hours.g.dart @@ -0,0 +1,15 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'hours.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Hours _$HoursFromJson(Map json) => Hours( + isOpenNow: json['is_open_now'] as bool?, + ); + +Map _$HoursToJson(Hours instance) => { + 'is_open_now': instance.isOpenNow, + }; diff --git a/lib/data/models/location.dart b/lib/data/models/location.dart new file mode 100644 index 0000000..5c7db6b --- /dev/null +++ b/lib/data/models/location.dart @@ -0,0 +1,24 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:restaurant_tour/domain/models/location.dart' + as location_domain_model; + +part 'location.g.dart'; + +@JsonSerializable() +class Location { + @JsonKey(name: 'formatted_address') + final String? formattedAddress; + + Location({ + this.formattedAddress, + }); + + factory Location.fromJson(Map json) => + _$LocationFromJson(json); + + Map toJson() => _$LocationToJson(this); + + location_domain_model.Location toDomain() => location_domain_model.Location( + formattedAddress: formattedAddress, + ); +} diff --git a/lib/data/models/location.g.dart b/lib/data/models/location.g.dart new file mode 100644 index 0000000..0504a7c --- /dev/null +++ b/lib/data/models/location.g.dart @@ -0,0 +1,15 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'location.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Location _$LocationFromJson(Map json) => Location( + formattedAddress: json['formatted_address'] as String?, + ); + +Map _$LocationToJson(Location instance) => { + 'formatted_address': instance.formattedAddress, + }; diff --git a/lib/data/models/restaurant.dart b/lib/data/models/restaurant.dart new file mode 100644 index 0000000..b76c6ee --- /dev/null +++ b/lib/data/models/restaurant.dart @@ -0,0 +1,52 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:restaurant_tour/data/models/category.dart'; +import 'package:restaurant_tour/data/models/hours.dart'; +import 'package:restaurant_tour/data/models/location.dart'; +import 'package:restaurant_tour/data/models/review.dart'; +import 'package:restaurant_tour/domain/models/restaurant.dart' + as restaurant_domain_model; + +part 'restaurant.g.dart'; + +@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); + + restaurant_domain_model.Restaurant toDomain() => + restaurant_domain_model.Restaurant( + id: id, + name: name, + price: price, + rating: rating, + photos: photos, + categories: categories?.map((e) => e.toDomain()).toList(), + hours: hours?.map((e) => e.toDomain()).toList(), + reviews: reviews?.map((e) => e.toDomain()).toList(), + location: location?.toDomain(), + ); +} diff --git a/lib/data/models/restaurant.g.dart b/lib/data/models/restaurant.g.dart new file mode 100644 index 0000000..764b5a7 --- /dev/null +++ b/lib/data/models/restaurant.g.dart @@ -0,0 +1,41 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'restaurant.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Restaurant _$RestaurantFromJson(Map json) => Restaurant( + id: json['id'] as String?, + name: json['name'] as String?, + price: json['price'] as String?, + rating: (json['rating'] as num?)?.toDouble(), + photos: + (json['photos'] as List?)?.map((e) => e as String).toList(), + categories: (json['categories'] as List?) + ?.map((e) => Category.fromJson(e as Map)) + .toList(), + hours: (json['hours'] as List?) + ?.map((e) => Hours.fromJson(e as Map)) + .toList(), + reviews: (json['reviews'] as List?) + ?.map((e) => Review.fromJson(e as Map)) + .toList(), + location: json['location'] == null + ? null + : Location.fromJson(json['location'] as Map), + ); + +Map _$RestaurantToJson(Restaurant instance) => + { + 'id': instance.id, + 'name': instance.name, + 'price': instance.price, + 'rating': instance.rating, + 'photos': instance.photos, + 'categories': instance.categories, + 'hours': instance.hours, + 'reviews': instance.reviews, + 'location': instance.location, + }; diff --git a/lib/data/models/review.dart b/lib/data/models/review.dart new file mode 100644 index 0000000..451eddd --- /dev/null +++ b/lib/data/models/review.dart @@ -0,0 +1,32 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:restaurant_tour/data/models/user.dart'; +import 'package:restaurant_tour/domain/models/review.dart' + as review_domain_model; + +part 'review.g.dart'; + +@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); + + review_domain_model.Review toDomain() => review_domain_model.Review( + id: id, + rating: rating, + text: text, + user: user?.toDomain(), + ); +} diff --git a/lib/data/models/review.g.dart b/lib/data/models/review.g.dart new file mode 100644 index 0000000..531e735 --- /dev/null +++ b/lib/data/models/review.g.dart @@ -0,0 +1,23 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'review.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Review _$ReviewFromJson(Map json) => Review( + id: json['id'] as String?, + 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, + }; diff --git a/lib/data/models/user.dart b/lib/data/models/user.dart new file mode 100644 index 0000000..9d5a511 --- /dev/null +++ b/lib/data/models/user.dart @@ -0,0 +1,28 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:restaurant_tour/domain/models/user.dart' as review_domain_model; + +part 'user.g.dart'; + +@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); + + review_domain_model.User toDomain() => review_domain_model.User( + id: id, + imageUrl: imageUrl, + name: name, + ); +} diff --git a/lib/data/models/user.g.dart b/lib/data/models/user.g.dart new file mode 100644 index 0000000..4c49488 --- /dev/null +++ b/lib/data/models/user.g.dart @@ -0,0 +1,19 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'user.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +User _$UserFromJson(Map json) => User( + id: json['id'] as String?, + imageUrl: json['image_url'] as String?, + name: json['name'] as String?, + ); + +Map _$UserToJson(User instance) => { + 'id': instance.id, + 'image_url': instance.imageUrl, + 'name': instance.name, + }; diff --git a/lib/data/repositories/cached_response.dart b/lib/data/repositories/cached_response.dart new file mode 100644 index 0000000..3510e92 --- /dev/null +++ b/lib/data/repositories/cached_response.dart @@ -0,0 +1,1145 @@ +final cachedResponse = { + "data": { + "search": { + "total": 7520, + "business": [ + { + "id": "vHz2RLtfUMVRPFmd7VBEHA", + "name": "Gordon Ramsay Hell's Kitchen", + "price": "\$\$\$", + "rating": 4.4, + "photos": [ + "https://s3-media2.fl.yelpcdn.com/bphoto/q771KjLzI5y638leJsnJnQ/o.jpg", + ], + "reviews": [ + { + "id": "F88H5ow44AmiwisbrbswPw", + "rating": 5, + "text": + "This entire experience is always so amazing. Every single dish is cooked to perfection. Every beef dish was so tender. The desserts were absolutely...", + "user": { + "id": "y742Fi1jF_JAqq5sRUlLEw", + "image_url": + "https://s3-media2.fl.yelpcdn.com/photo/rEWek1sYL0F35KZ0zRt3sw/o.jpg", + "name": "Ashley L.", + }, + }, + { + "id": "VJCoQlkk4Fjac0OPoRP8HQ", + "rating": 5, + "text": + "Me and my husband came to celebrate my birthday here and it was a 10/10 experience. Firstly, I booked the wrong area which was the Gordon Ramsay pub and...", + "user": { + "id": "0bQNLf0POLTW4VhQZqOZoQ", + "image_url": + "https://s3-media3.fl.yelpcdn.com/photo/i_0K5RUOQnoIw1c4QzHmTg/o.jpg", + "name": "Glydel L.", + }, + }, + { + "id": "EeCKH7eUVDsZv0Ii9wcPiQ", + "rating": 5, + "text": + "phenomenal! Bridgette made our experience as superb as the food coming to the table! would definitely come here again and try everything else on the menu,...", + "user": { + "id": "gL7AGuKBW4ne93_mR168pQ", + "image_url": + "https://s3-media1.fl.yelpcdn.com/photo/iU1sA7y3dEEc4iRL9LnWQQ/o.jpg", + "name": "Sydney O.", + }, + } + ], + "categories": [ + {"title": "New American", "alias": "newamerican"}, + {"title": "Seafood", "alias": "seafood"}, + ], + "hours": [ + {"is_open_now": true}, + ], + "location": { + "formatted_address": "3570 Las Vegas Blvd S\nLas Vegas, NV 89109", + }, + }, + { + "id": "faPVqws-x-5k2CQKDNtHxw", + "name": "Yardbird", + "price": "\$\$", + "rating": 4.5, + "photos": [ + "https://s3-media1.fl.yelpcdn.com/bphoto/xYJaanpF3Dl1OovhmpqAYw/o.jpg", + ], + "reviews": [ + { + "id": "CN9oD1ncHKZtsGN7U1EMnA", + "rating": 5, + "text": + "The food was delicious and the host and waitress were very nice, my husband and I really loved all the food, their cocktails are also amazing.", + "user": { + "id": "HArOfrshTW9s1HhN8oz8rg", + "image_url": + "https://s3-media3.fl.yelpcdn.com/photo/4sDrkYRIZxsXKCYdo9d1bQ/o.jpg", + "name": "Snow7 C.", + }, + }, + { + "id": "Qd-GV_v5gFHYO4VHw_6Dzw", + "rating": 5, + "text": + "Their Chicken and waffles are the best! I thought it was too big for one person, you had better to share it with some people", + "user": { + "id": "ww0-zb-Nv5ccWd1Vbdmo-A", + "image_url": + "https://s3-media4.fl.yelpcdn.com/photo/g-9Uqpy-lNszg0EXTuqwzQ/o.jpg", + "name": "Eri O.", + }, + }, + { + "id": "cqMrOWT9kRQOt3VUqOUbHg", + "rating": 5, + "text": + "Our last meal in Vegas was amazing at Yardbird. We have been to the Yardbird in Chicago so we thought we knew what to expect; however, we were blown away by...", + "user": { + "id": "10oig4nwHnOAnAApdYvNrg", + "image_url": null, + "name": "Ellie K.", + }, + } + ], + "categories": [ + {"title": "Southern", "alias": "southern"}, + {"title": "New American", "alias": "newamerican"}, + {"title": "Cocktail Bars", "alias": "cocktailbars"}, + ], + "hours": [ + {"is_open_now": true}, + ], + "location": { + "formatted_address": "3355 Las Vegas Blvd S\nLas Vegas, NV 89109", + }, + }, + { + "id": "QXV3L_QFGj8r6nWX2kS2hA", + "name": "Nacho Daddy", + "price": "\$\$", + "rating": 4.4, + "photos": [ + "https://s3-media4.fl.yelpcdn.com/bphoto/pu9doqMplB5x5SEs8ikW6w/o.jpg", + ], + "reviews": [ + { + "id": "ZUmf3YPOAfJFmNxZ0G2sAA", + "rating": 5, + "text": + "First - the service is incredible here. But the food is out of this world! Not to mention the margs - You will not leave disappointed.", + "user": { + "id": "J0MRFwpKN06MCOj9vv78dQ", + "image_url": + "https://s3-media2.fl.yelpcdn.com/photo/YZpS54TUdmdcok38lZAI_Q/o.jpg", + "name": "Chris A.", + }, + }, + { + "id": "hBgZYMYRptmOiEur5gwMYA", + "rating": 5, + "text": + "The food here is very good. I enjoyed the atmosphere as well. My server Daisy was very attentive and personable.", + "user": { + "id": "nz3l8hjtsnbrp1xcN8zk4Q", + "image_url": null, + "name": "Joe B.", + }, + }, + { + "id": "ksJ6G7Jwq9x6J-st2Z-ynw", + "rating": 5, + "text": + "Service was so fast and friendly! The nachos are truly good and kept hot by flame! Highly recommend!", + "user": { + "id": "ZyJIBp75lHEa4Ve-J-I1Bg", + "image_url": null, + "name": "Sadie G.", + }, + } + ], + "categories": [ + {"title": "New American", "alias": "newamerican"}, + {"title": "Mexican", "alias": "mexican"}, + {"title": "Breakfast & Brunch", "alias": "breakfast_brunch"}, + ], + "hours": [ + {"is_open_now": true}, + ], + "location": { + "formatted_address": + "3663 Las Vegas Blvd\nSte 595\nLas Vegas, NV 89109", + }, + }, + { + "id": "syhA1ugJpyNLaB0MiP19VA", + "name": "888 Japanese BBQ", + "price": "\$\$\$", + "rating": 4.8, + "photos": [ + "https://s3-media1.fl.yelpcdn.com/bphoto/V_zmwCUG1o_vR29xfkb-ng/o.jpg", + ], + "reviews": [ + { + "id": "S7ftRkufT8eOlmW1jpgH0A", + "rating": 5, + "text": + "The GOAT of Kbbq in Vegas!\nCoz yelp wanted me to type more than 85 characters so dont mind this...gnsgngenv gebg dhngdngbscgejegjfjegnfsneybgssybgsbye", + "user": { + "id": "MYfJmm9I5u1jsMg9JearYg", + "image_url": null, + "name": "Leonard L.", + }, + }, + { + "id": "wFIuXMZFCrGhx6iQIW1fxg", + "rating": 5, + "text": + "Fantastic meet selection! Great quality of food! Definitely come back soon! The cobe beef is melting in your mouth", + "user": { + "id": "4Wx67UxwYv3YshUQTPAgfA", + "image_url": null, + "name": "Gongliang Y.", + }, + }, + { + "id": "mb9gfnkSopq00f4LBZVPig", + "rating": 5, + "text": + "Food service and Ambiance are so high quality.povw and always come back every other week .", + "user": { + "id": "AKEHRiPmlrwKHxiiJlLGEQ", + "image_url": + "https://s3-media4.fl.yelpcdn.com/photo/GdoKcKDBW0fWQ4To-X_clA/o.jpg", + "name": "Mellon D.", + }, + } + ], + "categories": [ + {"title": "Barbeque", "alias": "bbq"}, + {"title": "Japanese", "alias": "japanese"}, + ], + "hours": [ + {"is_open_now": true}, + ], + "location": { + "formatted_address": "3550 S Decatur Blvd\nLas Vegas, NV 89103", + }, + }, + { + "id": "2iTsRqUsPGRH1li1WVRvKQ", + "name": "Carson Kitchen", + "price": "\$\$", + "rating": 4.5, + "photos": [ + "https://s3-media2.fl.yelpcdn.com/bphoto/LhaPvLHIrsHu8ZMLgV04OQ/o.jpg", + ], + "reviews": [ + { + "id": "PzKQYLK6skSfAUP73P8YXQ", + "rating": 5, + "text": + "Our son gave his mother a birthday gift of a meal at Carson Kitchen. He's the kind of guy that does thorough reviews on everything he's interested in...", + "user": { + "id": "Cvlm-uNVOY2i5zPWQdLupA", + "image_url": + "https://s3-media3.fl.yelpcdn.com/photo/ZT4s2popID75p_yJbo1xjg/o.jpg", + "name": "Bill H.", + }, + }, + { + "id": "pq6VEb97OpbB-KwvsJVyfw", + "rating": 4, + "text": + "Came here during my most recent Vegas trip and was intrigued by the menu options! There's a parking lot close by (pay by the booth) but since I came on a...", + "user": { + "id": "TMeT1a_1MJLOYobdY6Bs-A", + "image_url": + "https://s3-media2.fl.yelpcdn.com/photo/CxCo55gIOATctXc5wLa5CQ/o.jpg", + "name": "Amy E.", + }, + }, + { + "id": "5LF6EKorAR01mWStVYmYBw", + "rating": 4, + "text": + "The service and the atmosphere were amazing! Our server was very knowledgeable about the menu and helped guide our selections. We tired five different...", + "user": { + "id": "a71YY9h3GRv7F-4_OGGiRQ", + "image_url": + "https://s3-media1.fl.yelpcdn.com/photo/3EDvhfkljrLyodxSrn8Fqg/o.jpg", + "name": "May G.", + }, + } + ], + "categories": [ + {"title": "New American", "alias": "newamerican"}, + {"title": "Desserts", "alias": "desserts"}, + {"title": "Cocktail Bars", "alias": "cocktailbars"}, + ], + "hours": [ + {"is_open_now": true}, + ], + "location": { + "formatted_address": "124 S 6th St\nSte 100\nLas Vegas, NV 89101", + }, + }, + { + "id": "JPfi__QJAaRzmfh5aOyFEw", + "name": "Shang Artisan Noodle - Flamingo Road", + "price": "\$\$", + "rating": 4.6, + "photos": [ + "https://s3-media3.fl.yelpcdn.com/bphoto/TqV2TDWH-7Wje5B9Oh1EZw/o.jpg", + ], + "reviews": [ + { + "id": "GcGUAH0FPeyfw7rw7eu2Sg", + "rating": 5, + "text": + "Best beef noodle soup I've ever had. Portion sizes huge. Family of 5 could have shared 3 bowls with some appetizers. Spicy wonton and beef dumplings were...", + "user": { + "id": "4H2AFePQc7B4LGWhGkAb2g", + "image_url": null, + "name": "AA K.", + }, + }, + { + "id": "T4pf_Ea3AjFUCCc5T0uc8A", + "rating": 5, + "text": + "Damn! Quite possibly my new favorite restaurant in Vegas and will be in my rotation of my trips in town.\n\nEverything was delicious but their speciality is...", + "user": { + "id": "CQUDh80m48xnzUkx-X5NAw", + "image_url": + "https://s3-media4.fl.yelpcdn.com/photo/R0G1VPVoe_YjmITQOOJX1A/o.jpg", + "name": "David N.", + }, + }, + { + "id": "fIxGDenpGq6z517SyCh7Rw", + "rating": 4, + "text": + "Overall 4.5. Yummy food, great atmosphere!\n\nGot there around 7:15pm and got seated right away.\n\nBeef pancake (4/5)\nSpicy wonton (4/5)\nShang fried rice...", + "user": { + "id": "jg23eiZehaDhp-aBuYZlhg", + "image_url": + "https://s3-media4.fl.yelpcdn.com/photo/GX--5VghTJVN2JtBwu7YAA/o.jpg", + "name": "Allison J.", + }, + } + ], + "categories": [ + {"title": "Noodles", "alias": "noodles"}, + {"title": "Chinese", "alias": "chinese"}, + {"title": "Soup", "alias": "soup"}, + ], + "hours": [ + {"is_open_now": true}, + ], + "location": { + "formatted_address": + "4983 W Flamingo Rd\nSte B\nLas Vegas, NV 89103", + }, + }, + { + "id": "rdE9gg0WB7Z8kRytIMSapg", + "name": "Lazy Dog Restaurant & Bar", + "price": "\$\$", + "rating": 4.5, + "photos": [ + "https://s3-media2.fl.yelpcdn.com/bphoto/_Wz-fNXawmbBinSf9Ev15g/o.jpg", + ], + "reviews": [ + { + "id": "la_qZrx85d4b3WkeWBdbJA", + "rating": 5, + "text": + "Returned to celebrate our 20th Wedding Anniversary and was best ever! Anthony F. is exceptional! His energy amazing and recommendations on the ale's is...", + "user": { + "id": "VHG6QeWwufacGY0M1ohJ3A", + "image_url": null, + "name": "Cheryl K.", + }, + }, + { + "id": "BCpLW2R6MIF23ePczZ9hew", + "rating": 3, + "text": + "Fish & chips don't bother ordering. Bland. Burger was dry for medium rare. Pink but dry, frozen patty? Root beer & vanilla cream excellent. Dog friendly a...", + "user": { + "id": "gsOZjtJX8i3FezAMPt4kFw", + "image_url": null, + "name": "Christopher C.", + }, + }, + { + "id": "n5R8ulxap3NlVvFI9Jpt7g", + "rating": 5, + "text": + "Amazing food. Super yummy drinks. Great deals. All around great place to bring yourself, your family, and your doggies!! Always get excellent service....", + "user": { + "id": "mpHWQc0QfftpIJ8BK9pQlQ", + "image_url": null, + "name": "Michelle N.", + }, + } + ], + "categories": [ + {"title": "New American", "alias": "newamerican"}, + {"title": "Comfort Food", "alias": "comfortfood"}, + {"title": "Burgers", "alias": "burgers"}, + ], + "hours": [ + {"is_open_now": true}, + ], + "location": { + "formatted_address": "6509 S Las Vegas Blvd\nLas Vegas, NV 89119", + }, + }, + { + "id": "nUpz0YiBsOK7ff9k3vUJ3A", + "name": "Buddy V's Ristorante", + "price": "\$\$", + "rating": 4.2, + "photos": [ + "https://s3-media2.fl.yelpcdn.com/bphoto/cQxDwddn5H6c8ZGBQnjwnQ/o.jpg", + ], + "reviews": [ + { + "id": "JGb9E8nERjsNFM2F7SqCNA", + "rating": 5, + "text": + "Great food and great service.\nNice location.. they have outdoor and indoor seating.\nMeatballs are highly recommended!", + "user": { + "id": "loDGoLca5JC6dARvBQCUmg", + "image_url": + "https://s3-media4.fl.yelpcdn.com/photo/It7kRVx2aq3EPC9amExlPA/o.jpg", + "name": "Daniel V.", + }, + }, + { + "id": "vKNoy0gx2hyXABmM2sGX2A", + "rating": 3, + "text": + "Not impressed at all. Service was slow even though they weren't crowded. I know this is Vegas but they weren't too busy at all. The ambiance was your...", + "user": { + "id": "dNUpq4OiK2J2185__17__A", + "image_url": + "https://s3-media2.fl.yelpcdn.com/photo/qevpEGx3xWkEtDDwrzI37w/o.jpg", + "name": "Jaquita L.", + }, + }, + { + "id": "37kIixegf3pTb3jb6i1Y5g", + "rating": 3, + "text": + "Overall, the restaurant was average. The calamari was the redeeming aspect since it was one of the best I had, so make sure to get that (Hoboken style, as...", + "user": { + "id": "IAOAGReoxWaxhZm5-EpmOg", + "image_url": + "https://s3-media4.fl.yelpcdn.com/photo/YI-5O4mLRjh3-o0keMuzbA/o.jpg", + "name": "Juliet M.", + }, + } + ], + "categories": [ + {"title": "Italian", "alias": "italian"}, + {"title": "American", "alias": "tradamerican"}, + {"title": "Wine Bars", "alias": "wine_bars"}, + ], + "hours": [ + {"is_open_now": true}, + ], + "location": { + "formatted_address": "3327 S Las Vegas Blvd\nLas Vegas, NV 89109", + }, + }, + { + "id": "SAIrNOB4PtDA4gziNCucwg", + "name": "Herbs & Rye", + "price": "\$\$\$", + "rating": 4.4, + "photos": [ + "https://s3-media3.fl.yelpcdn.com/bphoto/95wd9m1E7A5Fuou1eUc3Bw/o.jpg", + ], + "reviews": [ + { + "id": "eYWs3etppqtg5qvRORwVpQ", + "rating": 5, + "text": + "Went for dinner tonight and our bartender, Sean, was absolutely incredible. The service was perfect, and the ribeyes were extraordinary! We will absolutely...", + "user": { + "id": "lJjf-QPnNFZSDBIstB9_9w", + "image_url": + "https://s3-media2.fl.yelpcdn.com/photo/H0qtUihKn4eXcUbp757VCw/o.jpg", + "name": "Connor W.", + }, + }, + { + "id": "_DJM84FO9CREfFD0yuVXLw", + "rating": 5, + "text": + "Always consistent with great vibe, food, service, and hospitality! Hands down one of the best in the city!", + "user": { + "id": "jek0voQcahZGkM8V3Lh0FA", + "image_url": + "https://s3-media3.fl.yelpcdn.com/photo/7td8s4dxonwE2kWMNks7aQ/o.jpg", + "name": "Ryan James C.", + }, + }, + { + "id": "7T3Ycz88VP7B9EmnPCewTQ", + "rating": 5, + "text": + "We had the best experience at Herbs and Rye. We were celebrating my Dads birthday and we treated like royalty. The service was impeccable and unobtrusive....", + "user": { + "id": "dOOEi2Qig6jsU-lDhdtcDw", + "image_url": null, + "name": "Cynthia A.", + }, + } + ], + "categories": [ + {"title": "Steakhouses", "alias": "steak"}, + {"title": "Cocktail Bars", "alias": "cocktailbars"}, + ], + "hours": [ + {"is_open_now": true}, + ], + "location": { + "formatted_address": "3713 W Sahara Ave\nLas Vegas, NV 89102", + }, + }, + { + "id": "gOOfBSBZlffCkQ7dr7cpdw", + "name": "CHICA", + "price": "\$\$", + "rating": 4.3, + "photos": [ + "https://s3-media2.fl.yelpcdn.com/bphoto/FxmtjuzPDiL7vx5KyceWuQ/o.jpg", + ], + "reviews": [ + { + "id": "xXQzEfd0czYwW_PW_QW1RQ", + "rating": 5, + "text": + "Came here with a group of 8 for brunch and we all had a wonderful experience. Our waitress, Karena, was amazing! She was super attentive and such a good...", + "user": { + "id": "A8wuelxCSNiuS6IFY6WKbw", + "image_url": null, + "name": "Joanna M.", + }, + }, + { + "id": "k0mR3x34X9bXMZfyTsO8nQ", + "rating": 5, + "text": + "The food was amazing. I had the Latin breakfast. Our table shared the donuts...delicious. We had drinks and they were made with fresh ingredients. They...", + "user": { + "id": "47SO7vTL6Louu9Gbkq8UeA", + "image_url": null, + "name": "Brandi T.", + }, + }, + { + "id": "jG_bhu9-7aQfHjdM9kn0MA", + "rating": 5, + "text": + "I came to CHICA with a group of 4 for dinner on a Saturday night and it was absolutely amazing. We went during Labor Day weekend so we made sure to make...", + "user": { + "id": "xDwRFFuIP0Kk1gXVwtJx7g", + "image_url": + "https://s3-media4.fl.yelpcdn.com/photo/pUoAQbE_-tQOJ9uOLGIDFA/o.jpg", + "name": "Christie L.", + }, + } + ], + "categories": [ + {"title": "Latin American", "alias": "latin"}, + {"title": "Breakfast & Brunch", "alias": "breakfast_brunch"}, + {"title": "Cocktail Bars", "alias": "cocktailbars"}, + ], + "hours": [ + {"is_open_now": true}, + ], + "location": { + "formatted_address": + "3355 South Las Vegas Blvd\nSte 106\nLas Vegas, NV 89109", + }, + }, + { + "id": "I6EDDi4-Eq_XlFghcDCUhw", + "name": "Joe's Seafood Prime Steak & Stone Crab", + "price": "\$\$\$", + "rating": 4.4, + "photos": [ + "https://s3-media1.fl.yelpcdn.com/bphoto/i5DVfdS-wOEPHBlVdw_Pvw/o.jpg", + ], + "reviews": [ + { + "id": "87zJUacg5ksnwF3-aJUo7g", + "rating": 5, + "text": + "100/10. Food, service and atmosphere are TOP notch. Our server Danny was the most amazing waiter we have ever experienced. He was patient, attentive and...", + "user": { + "id": "xMmxDGs9DWhB4X1lgkERkA", + "image_url": null, + "name": "Jeff N.", + }, + }, + { + "id": "WYKcaMOPhZ__qqQJlI44ng", + "rating": 4, + "text": + "Anniversary Dinner \nFood was outstanding\nPrices were spot on\nAmbience was beautiful\nBuser was top notch\nServer needs a personality! \n\nOur server Mindy was...", + "user": { + "id": "9m-AG--3nt_8P8lSmdWpKw", + "image_url": null, + "name": "Diane P.", + }, + }, + { + "id": "gR_sU8D3SvogzALreBwyQQ", + "rating": 5, + "text": + "So my friend and I were in Vegas a couple of weeks ago to celebrate his birthday, and he decided he wanted to go here for his birthday dinner. There's also...", + "user": { + "id": "GkhswbL80CZnYGwaXNHMcA", + "image_url": + "https://s3-media3.fl.yelpcdn.com/photo/xrLeqfrG7eu0gCAY-hFW-g/o.jpg", + "name": "Scott T.", + }, + } + ], + "categories": [ + {"title": "Seafood", "alias": "seafood"}, + {"title": "Steakhouses", "alias": "steak"}, + {"title": "Wine Bars", "alias": "wine_bars"}, + ], + "hours": [ + {"is_open_now": true}, + ], + "location": { + "formatted_address": "3500 Las Vegas Blvd S\nLas Vegas, NV 89109", + }, + }, + { + "id": "4JNXUYY8wbaaDmk3BPzlWw", + "name": "Mon Ami Gabi", + "price": "\$\$\$", + "rating": 4.2, + "photos": [ + "https://s3-media3.fl.yelpcdn.com/bphoto/FFhN_E1rV0txRVa6elzcZw/o.jpg", + ], + "reviews": [ + { + "id": "rAHgAhEdG0xoQspXc_6sZw", + "rating": 4, + "text": + "Great food and great atmosphere but I still feel that everything here in Vegas has gotten out of control with the pricing. Two salads and a pasta plate with...", + "user": { + "id": "EE1M_Gq7uwGQhDb_v1POQQ", + "image_url": null, + "name": "Bert K.", + }, + }, + { + "id": "baBnM1ontpOLgoeu2xv6Wg", + "rating": 5, + "text": + "the breakfast was amazing, possibly the best french toast i've ever eaten. i'd love to try more items in the future, super appetizing. ate an entire french...", + "user": { + "id": "xSvgz_-dtVa_GINcR85wzA", + "image_url": null, + "name": "Lilly H.", + }, + }, + { + "id": "ZlBhxy_izcFJzn34h8BwPg", + "rating": 5, + "text": + "I have had too many meals to count here and one this is always perfect, their gluten allergy protocol. \n\nNever felt ill after eating here. They have a...", + "user": { + "id": "m_LEVtvivKIjIubE_7Jdhw", + "image_url": + "https://s3-media4.fl.yelpcdn.com/photo/y0uCpU1HtJdr9HHq6BkI1Q/o.jpg", + "name": "Hex T.", + }, + } + ], + "categories": [ + {"title": "French", "alias": "french"}, + {"title": "Steakhouses", "alias": "steak"}, + {"title": "Breakfast & Brunch", "alias": "breakfast_brunch"}, + ], + "hours": [ + {"is_open_now": true}, + ], + "location": { + "formatted_address": "3655 Las Vegas Blvd S\nLas Vegas, NV 89109", + }, + }, + { + "id": "-1m9o3vGRA8IBPNvNqKLmA", + "name": "Bavette's Steakhouse & Bar", + "price": "\$\$\$\$", + "rating": 4.5, + "photos": [ + "https://s3-media2.fl.yelpcdn.com/bphoto/pgcnYRHtbw_x_-OG8K4xVg/o.jpg", + ], + "reviews": [ + { + "id": "SV29OIiCP3KLyC_8Du7Tyw", + "rating": 5, + "text": + "Few steaks wow me, but this one did. I've been to my share of steakhouses, and while steak is generally good anywhere that you get it, the filet mignon here...", + "user": { + "id": "k0HPyDqzf7NuzGk9p570nw", + "image_url": + "https://s3-media4.fl.yelpcdn.com/photo/9ObAXwt_jOnhmOTsf4Phsw/o.jpg", + "name": "Anh N.", + }, + }, + { + "id": "PbKZJlLCWVcnHLUV0AK45g", + "rating": 5, + "text": + "For a great dining experience look no further!\n\nBavette's has it all; delicious food, fantastic cocktails, and a service staff above them all.\n\nWe were a...", + "user": { + "id": "IJxjNg4fMDar8WTcY_s1NQ", + "image_url": + "https://s3-media1.fl.yelpcdn.com/photo/DN4xv1FYk_5yvPBhydRZGg/o.jpg", + "name": "Lisha K.", + }, + }, + { + "id": "Bk8AQJD8APVBWR6Y_Opvpw", + "rating": 5, + "text": + "First time at Bavettes and not sure what took us so long. Upon entry you feel whisked into a whole other atmosphere from the casino. The dark woods and...", + "user": { + "id": "c1sHJlr0MizIANx49BTXWQ", + "image_url": + "https://s3-media4.fl.yelpcdn.com/photo/y9JnzleHF9G9Lx6EHIu8SA/o.jpg", + "name": "Alyssa Y.", + }, + } + ], + "categories": [ + {"title": "Steakhouses", "alias": "steak"}, + {"title": "Bars", "alias": "bars"}, + {"title": "New American", "alias": "newamerican"}, + ], + "hours": [ + {"is_open_now": true}, + ], + "location": { + "formatted_address": "3770 Las Vegas Blvd S\nLas Vegas, NV 89109", + }, + }, + { + "id": "7hWNnAj4VwK6FAUBN8E8lg", + "name": "Edo Gastro Tapas And Wine", + "price": "\$\$", + "rating": 4.7, + "photos": [ + "https://s3-media3.fl.yelpcdn.com/bphoto/1TT9VdPSVZ3Fwfw8ITn5JQ/o.jpg", + ], + "reviews": [ + { + "id": "8SNBw1F5yqi8iJKwf1g1tw", + "rating": 5, + "text": + "Tasting menu is definitely the way to go here for the fullest experience (interestingly enough, few other tables seemed to be doing it...). The chef's...", + "user": { + "id": "6ZEIvCcj3xCx8TNH7-R64A", + "image_url": + "https://s3-media2.fl.yelpcdn.com/photo/xsROks2lA4ZUGOVkNyNPMA/o.jpg", + "name": "Brian P.", + }, + }, + { + "id": "CN6HmmrBduwye_1h20yFKQ", + "rating": 4, + "text": + "A quaint restaurant in such an unassuming location. \nIt's busy and hectic outside in the plaza that this restaurant is located at. The plaza is a little old...", + "user": { + "id": "WPre6Q2d6-6GFLD027fYPg", + "image_url": + "https://s3-media2.fl.yelpcdn.com/photo/is4aaKXtCOMRng_FavKK5w/o.jpg", + "name": "Ann N.", + }, + }, + { + "id": "5VI9DhR07Xci2a4D3oz7oQ", + "rating": 5, + "text": + "I was in heaven eating the jamón, with cheese plate and the pan con tomato...wooooo weeeee!!! I literally closed my eyes and transported to myself to Spain...", + "user": { + "id": "Y7LNldoENmAignc9S37t6g", + "image_url": + "https://s3-media4.fl.yelpcdn.com/photo/YuI0oh9GeJYzM4Zj3Jni9w/o.jpg", + "name": "Nicole P.", + }, + } + ], + "categories": [ + {"title": "Tapas/Small Plates", "alias": "tapasmallplates"}, + {"title": "Spanish", "alias": "spanish"}, + {"title": "Wine Bars", "alias": "wine_bars"}, + ], + "hours": [ + {"is_open_now": true}, + ], + "location": { + "formatted_address": + "3400 S Jones Blvd\nSte 11A\nLas Vegas, NV 89146", + }, + }, + { + "id": "QCCVxVRt1amqv0AaEWSKkg", + "name": "Esther's Kitchen", + "price": "\$\$", + "rating": 4.5, + "photos": [ + "https://s3-media3.fl.yelpcdn.com/bphoto/uk6-4u8H6BpxaJAKDEzFOA/o.jpg", + ], + "reviews": [ + { + "id": "exJ7J1xtJgfYX8wKnOJb7g", + "rating": 5, + "text": + "Sat at the bar, place was jumping at lunch time, spotting the whos who of Vegas, Friendly staff with amazing food and service. Cant wait to get back there...", + "user": { + "id": "fJuUotyAX1KtJ7yXmfwzXA", + "image_url": null, + "name": "Barry D.", + }, + }, + { + "id": "VjmUIlp_Y0_0ISEjqZvKAw", + "rating": 5, + "text": + "Our server Josh was AMAZING! He was so attentive and sweet I've been to their on location and the new one does not disappoint. I tried something new...", + "user": { + "id": "59qcS7L8sHAaxziIg4_i5A", + "image_url": null, + "name": "Caitlin S.", + }, + }, + { + "id": "fYGyOGLuDQcZJva0tHjdxQ", + "rating": 5, + "text": + "Esther's Kitchen\n\nWe had a wonderful lunch experience! Rocco was our waiter, and he was exceptional--so friendly, talkative, and made us feel right at home....", + "user": { + "id": "jsH3aUC_UuFYv5etKNNgLQ", + "image_url": + "https://s3-media3.fl.yelpcdn.com/photo/zG63zZ6Bx8M47sanNzUTUg/o.jpg", + "name": "S M.", + }, + } + ], + "categories": [ + {"title": "Italian", "alias": "italian"}, + {"title": "Pizza", "alias": "pizza"}, + {"title": "Cocktail Bars", "alias": "cocktailbars"}, + ], + "hours": [ + {"is_open_now": true}, + ], + "location": { + "formatted_address": "1131 S Main St\nLas Vegas, NV 89104", + }, + }, + { + "id": "mU3vlAVzTxgmZUu6F4XixA", + "name": "Momofuku", + "price": "\$\$", + "rating": 4.1, + "photos": [ + "https://s3-media1.fl.yelpcdn.com/bphoto/mB1g53Nqa62Q04u4oNuCSw/o.jpg", + ], + "reviews": [ + { + "id": "mAEPxxFflcYD6ZtzvnxzKg", + "rating": 3, + "text": + "Service subpar. Lamb was average. Pork belly for kids bad. Overall not worth the prices.", + "user": { + "id": "s4qyTcSQtHzlW8O4nm867A", + "image_url": + "https://s3-media1.fl.yelpcdn.com/photo/lbb5PhyDftjXRuTV8mdBsA/o.jpg", + "name": "Jon L.", + }, + }, + { + "id": "40BE2te-wIXkc3xevcp4Ew", + "rating": 3, + "text": + "Service is pretty good.\n\nFor food, ordered corn rib, and it was fantastic. The ramen was just so so: mushroom ramen was too salty. kid ordered the other...", + "user": { + "id": "Dk68URVdrfDzQJvghTs9nA", + "image_url": null, + "name": "Peng Z.", + }, + }, + { + "id": "2Gq0rU2lqnHKlFK1Lrn2xA", + "rating": 5, + "text": + "Food was amazing \nRamen 5/5 great flavor even the vegan one \nAppetizer 6/5 the asparagus sauce dipped everything in it. \nDessert 5/5 love the asain flavors...", + "user": { + "id": "ercYn3dqoUjZxUawQED4kA", + "image_url": + "https://s3-media3.fl.yelpcdn.com/photo/cBS38RP3-jD5yG40Xo53UQ/o.jpg", + "name": "Tina T.", + }, + } + ], + "categories": [ + {"title": "New American", "alias": "newamerican"}, + {"title": "Asian Fusion", "alias": "asianfusion"}, + ], + "hours": [ + {"is_open_now": true}, + ], + "location": { + "formatted_address": + "3708 Las Vegas Blvd S\nLevel 2\nBoulevard Tower\nLas Vegas, NV 89109", + }, + }, + { + "id": "igHYkXZMLAc9UdV5VnR_AA", + "name": "Echo & Rig", + "price": "\$\$\$", + "rating": 4.4, + "photos": [ + "https://s3-media1.fl.yelpcdn.com/bphoto/Q9swks1BO-w-hVskIHrCVg/o.jpg", + ], + "reviews": [ + { + "id": "vbEuCit3l5lLrMkxEoaPNg", + "rating": 4, + "text": + "I've been a regular at Echo & Rig for some time, and it's always been a pleasant experience--until our visit this evening. From the moment we walked in, we...", + "user": { + "id": "e9Mwwtzm7X5kiM7RcJRmsg", + "image_url": null, + "name": "Stacie E.", + }, + }, + { + "id": "cH3e_BfQnIMT8Bv4NrmQSg", + "rating": 5, + "text": + "We went on a Monday night and we were able to get a seat within 5 minutes. \n\nThe venue is 2 stories and beautifully decorated. Perfect for a date night and...", + "user": { + "id": "-PXJEs_9T0lRKpssxf3otg", + "image_url": + "https://s3-media1.fl.yelpcdn.com/photo/eBKTnyOnHYTMNvLBcgrGwQ/o.jpg", + "name": "Cynthia H.", + }, + }, + { + "id": "1-YbhlzRDykg4BwukjXGAQ", + "rating": 4, + "text": + "Excellent destination for small plates. I've enjoyed making it a point to try a new dish each time I've come here. \n\nThe pork belly burnt ends are probably...", + "user": { + "id": "JN-F23BIngBKd9MSaXoI8w", + "image_url": + "https://s3-media2.fl.yelpcdn.com/photo/CfZ3sLM1OHNwXKbK9OKQnQ/o.jpg", + "name": "Kevin B.", + }, + } + ], + "categories": [ + {"title": "Steakhouses", "alias": "steak"}, + {"title": "Butcher", "alias": "butcher"}, + {"title": "Tapas/Small Plates", "alias": "tapasmallplates"}, + ], + "hours": [ + {"is_open_now": true}, + ], + "location": { + "formatted_address": "440 S Rampart Blvd\nLas Vegas, NV 89145", + }, + }, + { + "id": "UidEFF1WpnU4duev4fjPlQ", + "name": "Therapy ", + "price": "\$\$", + "rating": 4.3, + "photos": [ + "https://s3-media3.fl.yelpcdn.com/bphoto/otaMuPtauoEb6qZzmHlAlQ/o.jpg", + ], + "reviews": [ + { + "id": "a3UISKdTa1aMxok4mmzNsQ", + "rating": 5, + "text": + "Step into Therapy and take a sit, Chris the bartender is pretty chill. Talking to him is like talking to a long time friend, the type you don't see for a...", + "user": { + "id": "SbMQm6pAPRwg04y44S5zLA", + "image_url": + "https://s3-media1.fl.yelpcdn.com/photo/3ZuAxm31p7iwQ_zV2lgWOA/o.jpg", + "name": "Vittor V.", + }, + }, + { + "id": "hfZ-9d6Xxztb9o-cEJmR7Q", + "rating": 5, + "text": + "The food and drinks great! Try the loaded crab fries ~ got seated and served quick- Dallas was the best!", + "user": { + "id": "7_uRkPfh8fvewEHDnhx6mg", + "image_url": null, + "name": "Patricia L.", + }, + }, + { + "id": "yVHXlr736j2rSOCbJZOyMg", + "rating": 5, + "text": + "This place was the all time party vibe!!! We had heard great things about the atmosphere drinks and food, so we had to try it out. Luckily it was our...", + "user": { + "id": "idFOQhuCk-yoeu1LGLAI0g", + "image_url": + "https://s3-media4.fl.yelpcdn.com/photo/FuUFgNOFmE5ZTS6JzLQ2Kg/o.jpg", + "name": "Brianna M.", + }, + } + ], + "categories": [ + {"title": "Bars", "alias": "bars"}, + {"title": "New American", "alias": "newamerican"}, + {"title": "Dance Clubs", "alias": "danceclubs"}, + ], + "hours": [ + {"is_open_now": true}, + ], + "location": { + "formatted_address": "518 Fremont St\nLas Vegas, NV 89101", + }, + }, + { + "id": "wmId49_BwzfWd3ww6GDMeA", + "name": "Cleaver - Butchered Meats, Seafood & Cocktails", + "price": "\$\$\$", + "rating": 4.5, + "photos": [ + "https://s3-media3.fl.yelpcdn.com/bphoto/htN_B2atKva2hsKorxEgrg/o.jpg", + ], + "reviews": [ + { + "id": "zQvOnn54BB8c4gcxHHG8AQ", + "rating": 5, + "text": + "The food, the cocktails... the SERVICE... all amazing!! We really enjoyed this dinning experience. Parking was easy and free. This is not far from the...", + "user": { + "id": "Zpo7e6uD1MYGk0RpeGyEhg", + "image_url": + "https://s3-media1.fl.yelpcdn.com/photo/mgiNwxyQkG0kEL8SOia31A/o.jpg", + "name": "Jennifer O.", + }, + }, + { + "id": "otnuRPgB3lQIfhD1AUViOw", + "rating": 5, + "text": + "Easily the best meal I've had in my life. Everything cooked to perfection. Cocktails were the right balance of flavor and alcohol. Our waiter was attentive...", + "user": { + "id": "49hRCMad22gCJCN40p--nQ", + "image_url": null, + "name": "Daisy M.", + }, + }, + { + "id": "vUvlNBgdtarV9AHmE1_y8w", + "rating": 5, + "text": + "We went for a bachelor party that I was hosting. We had 28 of us and everything was perfect. Best decision we made and very reasonable for price. 1000%...", + "user": { + "id": "Z-8mXl3jRGhwZqmnALrrEg", + "image_url": null, + "name": "Patrick V.", + }, + } + ], + "categories": [ + {"title": "Steakhouses", "alias": "steak"}, + {"title": "Seafood", "alias": "seafood"}, + {"title": "Cocktail Bars", "alias": "cocktailbars"}, + ], + "hours": [ + {"is_open_now": true}, + ], + "location": { + "formatted_address": "3900 Paradise Rd\nSte D1\nLas Vegas, NV 89169", + }, + }, + { + "id": "XnJeadLrlj9AZB8qSdIR2Q", + "name": "Joel Robuchon", + "price": "\$\$\$\$", + "rating": 4.5, + "photos": [ + "https://s3-media4.fl.yelpcdn.com/bphoto/8282ZD9hrsGH9a-kejFzxw/o.jpg", + ], + "reviews": [ + { + "id": "r7FpihYh8TtwfpKgrI2syw", + "rating": 5, + "text": + "Rating: 4.5/5\n\nJoel Robuchon is a paragon of luxury dining. The opulent ambiance, characterized by soft lighting, a grand chandelier, and lavish floral...", + "user": { + "id": "dvTlsNXCiLzBmGPcQPMA9A", + "image_url": + "https://s3-media3.fl.yelpcdn.com/photo/-XaQAXzr8og8SY7SyaNjLw/o.jpg", + "name": "Ayush K.", + }, + }, + { + "id": "aAUIYHJCTkXOufvSDxRoXA", + "rating": 4, + "text": + "We have tried some French restaurants but never a big fan. So far, Joel Robuchon is my favorite. \nA kind reminder if you make the reservation through MGM...", + "user": { + "id": "BFFDzZR0ixxD3azljG5ysA", + "image_url": + "https://s3-media2.fl.yelpcdn.com/photo/R2ixq_srpqu10cTZ1uMZWw/o.jpg", + "name": "Felicity C.", + }, + }, + { + "id": "XMmZhe0rGtNkHub372PyTQ", + "rating": 4, + "text": + "We had our anniversary dinner at Joel Robuchon in Las Vegas this year.  It is always a pleasure to celebrate with our beloved daughter. Joel Robuchon is the...", + "user": { + "id": "bv3sEZrvDqUguzlZeQDBUg", + "image_url": + "https://s3-media3.fl.yelpcdn.com/photo/mZGY1nkIZjadOpP4RjMdmg/o.jpg", + "name": "Kitty L.", + }, + } + ], + "categories": [ + {"title": "French", "alias": "french"}, + ], + "hours": [ + {"is_open_now": true}, + ], + "location": { + "formatted_address": "3799 Las Vegas Blvd S\nLas Vegas, NV 89109", + }, + } + ], + }, + }, +}; diff --git a/lib/data/repositories/restaurants_repository.dart b/lib/data/repositories/restaurants_repository.dart new file mode 100644 index 0000000..a397530 --- /dev/null +++ b/lib/data/repositories/restaurants_repository.dart @@ -0,0 +1,118 @@ +import 'dart:async'; + +import 'package:dio/dio.dart'; +import 'package:restaurant_tour/data/dtos/restaurant_dto.dart'; +import 'package:restaurant_tour/domain/models/restaurant.dart'; +import 'package:restaurant_tour/domain/repositories/restaurants_repository.dart'; + +import 'package:restaurant_tour/data/repositories/cached_response.dart'; + +class RestaurantsRepository extends BaseRestaurantsRepository { + // I could have created a remote data provider here + final Dio _httpClient; + + RestaurantsRepository({ + required Dio httpClient, + }) : _httpClient = httpClient; + + // This is a simple in memory provider for the favorites + final List _favorites = []; + final StreamController> + _favoriteRestaurantsStreamController = + StreamController>(); + + Stream> get _favoriteRestaurants => + _favoriteRestaurantsStreamController.stream; + + @override + Stream> getFavorites() { + return _favoriteRestaurants; + } + + @override + Future> getRestaurants({int offset = 0}) async { + //TODO: Remove this... just to not reach the rate limit + final response = cachedResponse; + + final data = RestaurantDto.fromJson(response['data']['search']); + + if (data.restaurants != null) { + return data.restaurants!.map((e) => e.toDomain()).toList(); + } + + return []; + + try { + final response = await _httpClient.post>( + '/v3/graphql', + data: _getRestaurantsQuery(offset), + ); + + final data = RestaurantDto.fromJson(response.data!['data']['search']); + + if (data.restaurants != null) { + return data.restaurants!.map((e) => e.toDomain()).toList(); + } + + return []; + } catch (e) { + return []; + } + } + + @override + void toggleFavorite(Restaurant restaurant) { + final found = + _favorites.indexWhere((element) => element.id == restaurant.id) != -1; + + if (found) { + _favorites.removeWhere((element) => element.id == restaurant.id); + } else { + _favorites.add(restaurant); + } + + _favoriteRestaurantsStreamController.add(_favorites); + } + + @override + dispose() { + _favoriteRestaurantsStreamController.close(); + } + + String _getRestaurantsQuery(int offset) { + return ''' +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/di/di.dart b/lib/di/di.dart new file mode 100644 index 0000000..0e32d30 --- /dev/null +++ b/lib/di/di.dart @@ -0,0 +1,45 @@ +import 'package:dio/dio.dart'; +import 'package:get_it/get_it.dart'; +import 'package:restaurant_tour/domain/use_cases/get_restaurants_use_case.dart'; +import 'package:restaurant_tour/domain/use_cases/toggle_favorite.dart'; + +import 'package:restaurant_tour/data/repositories/restaurants_repository.dart'; +import 'package:restaurant_tour/domain/repositories/restaurants_repository.dart'; +import 'package:restaurant_tour/domain/use_cases/get_favorites_restaurants_use_case.dart'; + +final getIt = GetIt.instance; + +// TODO: Expose it in a more secure way +const _apiKey = + 'FQVwPGF1gSkxwYDrdntEfehFGJRXb5HYBcLfesykIgAEeopf6_YrfvRmY_iGkQOnQ97oRwAqOcrGwHK0In71SGPnOJRKEBYUOo8IhiIS53HUVMdE2BiY1JeKIDLbZnYx'; + +void setupDI() { + getIt.registerLazySingleton( + () => Dio( + BaseOptions( + baseUrl: 'https://api.yelp.com', + headers: { + 'Authorization': 'Bearer $_apiKey', + 'Content-Type': 'application/graphql', + }, + ), + ), + ); + + getIt.registerLazySingleton( + () => RestaurantsRepository(httpClient: getIt.get()), + dispose: (repo) => repo.dispose(), + ); + + getIt.registerFactory( + () => GetRestaurantsUseCase(restaurantsRepository: getIt.get()), + ); + + getIt.registerFactory( + () => GetFavoriteRestaurantsUseCase(restaurantsRepository: getIt.get()), + ); + + getIt.registerFactory( + () => ToggleFavoriteUseCase(restaurantsRepository: getIt.get()), + ); +} diff --git a/lib/domain/models/category.dart b/lib/domain/models/category.dart new file mode 100644 index 0000000..ed9391e --- /dev/null +++ b/lib/domain/models/category.dart @@ -0,0 +1,9 @@ +class Category { + final String? alias; + final String? title; + + Category({ + this.alias, + this.title, + }); +} diff --git a/lib/domain/models/hours.dart b/lib/domain/models/hours.dart new file mode 100644 index 0000000..8e4bb3a --- /dev/null +++ b/lib/domain/models/hours.dart @@ -0,0 +1,7 @@ +class Hours { + final bool? isOpenNow; + + const Hours({ + this.isOpenNow, + }); +} diff --git a/lib/domain/models/location.dart b/lib/domain/models/location.dart new file mode 100644 index 0000000..be446b6 --- /dev/null +++ b/lib/domain/models/location.dart @@ -0,0 +1,7 @@ +class Location { + final String? formattedAddress; + + Location({ + this.formattedAddress, + }); +} diff --git a/lib/domain/models/restaurant.dart b/lib/domain/models/restaurant.dart new file mode 100644 index 0000000..d778b0e --- /dev/null +++ b/lib/domain/models/restaurant.dart @@ -0,0 +1,53 @@ +import 'package:restaurant_tour/domain/models/category.dart'; +import 'package:restaurant_tour/domain/models/hours.dart'; +import 'package:restaurant_tour/domain/models/location.dart'; +import 'package:restaurant_tour/domain/models/review.dart'; + +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, + }); + + /// 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; + } +} diff --git a/lib/domain/models/review.dart b/lib/domain/models/review.dart new file mode 100644 index 0000000..089bcf6 --- /dev/null +++ b/lib/domain/models/review.dart @@ -0,0 +1,15 @@ +import 'package:restaurant_tour/domain/models/user.dart'; + +class Review { + final String? id; + final int? rating; + final String? text; + final User? user; + + const Review({ + this.id, + this.rating, + this.user, + this.text, + }); +} diff --git a/lib/domain/models/user.dart b/lib/domain/models/user.dart new file mode 100644 index 0000000..5ecc26c --- /dev/null +++ b/lib/domain/models/user.dart @@ -0,0 +1,11 @@ +class User { + final String? id; + final String? imageUrl; + final String? name; + + const User({ + this.id, + this.imageUrl, + this.name, + }); +} diff --git a/lib/domain/repositories/restaurants_repository.dart b/lib/domain/repositories/restaurants_repository.dart new file mode 100644 index 0000000..ae8c145 --- /dev/null +++ b/lib/domain/repositories/restaurants_repository.dart @@ -0,0 +1,13 @@ +import 'package:restaurant_tour/domain/models/restaurant.dart'; + +abstract class BaseRestaurantsRepository { + const BaseRestaurantsRepository(); + + Future?> getRestaurants({int offset = 0}); + + Stream> getFavorites(); + + void toggleFavorite(Restaurant restaurant); + + void dispose(); +} diff --git a/lib/domain/use_cases/get_favorites_restaurants_use_case.dart b/lib/domain/use_cases/get_favorites_restaurants_use_case.dart new file mode 100644 index 0000000..797891e --- /dev/null +++ b/lib/domain/use_cases/get_favorites_restaurants_use_case.dart @@ -0,0 +1,14 @@ +import 'package:restaurant_tour/domain/models/restaurant.dart'; +import 'package:restaurant_tour/domain/repositories/restaurants_repository.dart'; + +class GetFavoriteRestaurantsUseCase { + final BaseRestaurantsRepository _restaurantsRepository; + + GetFavoriteRestaurantsUseCase({ + required BaseRestaurantsRepository restaurantsRepository, + }) : _restaurantsRepository = restaurantsRepository; + + Stream> call() { + return _restaurantsRepository.getFavorites(); + } +} diff --git a/lib/domain/use_cases/get_restaurants_use_case.dart b/lib/domain/use_cases/get_restaurants_use_case.dart new file mode 100644 index 0000000..5db531f --- /dev/null +++ b/lib/domain/use_cases/get_restaurants_use_case.dart @@ -0,0 +1,14 @@ +import 'package:restaurant_tour/domain/models/restaurant.dart'; +import 'package:restaurant_tour/domain/repositories/restaurants_repository.dart'; + +class GetRestaurantsUseCase { + final BaseRestaurantsRepository _restaurantsRepository; + + GetRestaurantsUseCase({ + required BaseRestaurantsRepository restaurantsRepository, + }) : _restaurantsRepository = restaurantsRepository; + + Future?> call() { + return _restaurantsRepository.getRestaurants(); + } +} diff --git a/lib/domain/use_cases/toggle_favorite.dart b/lib/domain/use_cases/toggle_favorite.dart new file mode 100644 index 0000000..0ef8245 --- /dev/null +++ b/lib/domain/use_cases/toggle_favorite.dart @@ -0,0 +1,14 @@ +import 'package:restaurant_tour/domain/models/restaurant.dart'; +import 'package:restaurant_tour/domain/repositories/restaurants_repository.dart'; + +class ToggleFavoriteUseCase { + final BaseRestaurantsRepository _restaurantsRepository; + + ToggleFavoriteUseCase({ + required BaseRestaurantsRepository restaurantsRepository, + }) : _restaurantsRepository = restaurantsRepository; + + void call(Restaurant restaurant) { + return _restaurantsRepository.toggleFavorite(restaurant); + } +} diff --git a/lib/main.dart b/lib/main.dart index 3a4af7d..9d41cf0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,52 +1,28 @@ import 'package:flutter/material.dart'; -import 'package:restaurant_tour/repositories/yelp_repository.dart'; +import 'package:restaurant_tour/di/di.dart'; +import 'package:restaurant_tour/domain/use_cases/get_favorites_restaurants_use_case.dart'; +import 'package:restaurant_tour/domain/use_cases/get_restaurants_use_case.dart'; +import 'package:restaurant_tour/domain/use_cases/toggle_favorite.dart'; +import 'package:restaurant_tour/presentation/screens/home/home_screen.dart'; void main() { + setupDI(); runApp(const RestaurantTour()); } class RestaurantTour extends StatelessWidget { - const RestaurantTour({Key? key}) : super(key: key); + const RestaurantTour({super.key}); @override Widget build(BuildContext context) { - return const MaterialApp( + return MaterialApp( + debugShowCheckedModeBanner: false, title: 'Restaurant Tour', - home: HomePage(), - ); - } -} - -class HomePage extends StatelessWidget { - const HomePage({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - return Scaffold( - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text('Restaurant Tour'), - ElevatedButton( - child: const Text('Fetch Restaurants'), - onPressed: () async { - final yelpRepo = YelpRepository(); - - try { - final result = await yelpRepo.getRestaurants(); - if (result != null) { - print('Fetched ${result.restaurants!.length} restaurants'); - } else { - print('No restaurants fetched'); - } - } catch (e) { - print('Failed to fetch restaurants: $e'); - } - }, - ), - ], - ), + home: HomeScreen( + getAllRestaurantsUseCase: getIt.get(), + getFavoriteRestaurantsUseCase: + getIt.get(), + toggleFavoriteUseCase: getIt.get(), ), ); } 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/models/restaurant.g.dart b/lib/models/restaurant.g.dart deleted file mode 100644 index 3ed33f9..0000000 --- a/lib/models/restaurant.g.dart +++ /dev/null @@ -1,109 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'restaurant.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -Category _$CategoryFromJson(Map json) => Category( - alias: json['alias'] as String?, - title: json['title'] as String?, - ); - -Map _$CategoryToJson(Category instance) => { - 'alias': instance.alias, - 'title': instance.title, - }; - -Hours _$HoursFromJson(Map json) => Hours( - isOpenNow: json['is_open_now'] as bool?, - ); - -Map _$HoursToJson(Hours instance) => { - 'is_open_now': instance.isOpenNow, - }; - -User _$UserFromJson(Map json) => User( - id: json['id'] as String?, - imageUrl: json['image_url'] as String?, - name: json['name'] as String?, - ); - -Map _$UserToJson(User instance) => { - 'id': instance.id, - 'image_url': instance.imageUrl, - 'name': instance.name, - }; - -Review _$ReviewFromJson(Map json) => Review( - id: json['id'] as String?, - rating: json['rating'] as int?, - user: json['user'] == null - ? null - : User.fromJson(json['user'] as Map), - ); - -Map _$ReviewToJson(Review instance) => { - 'id': instance.id, - 'rating': instance.rating, - 'user': instance.user, - }; - -Location _$LocationFromJson(Map json) => Location( - formattedAddress: json['formatted_address'] as String?, - ); - -Map _$LocationToJson(Location instance) => { - 'formatted_address': instance.formattedAddress, - }; - -Restaurant _$RestaurantFromJson(Map json) => Restaurant( - id: json['id'] as String?, - name: json['name'] as String?, - price: json['price'] as String?, - rating: (json['rating'] as num?)?.toDouble(), - photos: - (json['photos'] as List?)?.map((e) => e as String).toList(), - categories: (json['categories'] as List?) - ?.map((e) => Category.fromJson(e as Map)) - .toList(), - hours: (json['hours'] as List?) - ?.map((e) => Hours.fromJson(e as Map)) - .toList(), - reviews: (json['reviews'] as List?) - ?.map((e) => Review.fromJson(e as Map)) - .toList(), - location: json['location'] == null - ? null - : Location.fromJson(json['location'] as Map), - ); - -Map _$RestaurantToJson(Restaurant instance) => - { - 'id': instance.id, - 'name': instance.name, - 'price': instance.price, - 'rating': instance.rating, - 'photos': instance.photos, - 'categories': instance.categories, - 'hours': instance.hours, - 'reviews': instance.reviews, - 'location': instance.location, - }; - -RestaurantQueryResult _$RestaurantQueryResultFromJson( - Map json) => - RestaurantQueryResult( - total: json['total'] as int?, - restaurants: (json['business'] as List?) - ?.map((e) => Restaurant.fromJson(e as Map)) - .toList(), - ); - -Map _$RestaurantQueryResultToJson( - RestaurantQueryResult instance) => - { - 'total': instance.total, - 'business': instance.restaurants, - }; diff --git a/lib/presentation/components/rating_stars.dart b/lib/presentation/components/rating_stars.dart new file mode 100644 index 0000000..0c190b8 --- /dev/null +++ b/lib/presentation/components/rating_stars.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; +import 'package:restaurant_tour/core/theme/colors.dart'; + +class RatingStars extends StatelessWidget { + final double rating; + const RatingStars({super.key, required this.rating}); + + @override + Widget build(BuildContext context) { + final rating = this.rating.round(); + return Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + for (var i = 0; i < rating; i++) + const Icon(Icons.star, color: AppColors.star, size: 12), + ], + ); + } +} diff --git a/lib/presentation/components/restaurant_card.dart b/lib/presentation/components/restaurant_card.dart new file mode 100644 index 0000000..65b7809 --- /dev/null +++ b/lib/presentation/components/restaurant_card.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; +import 'package:restaurant_tour/domain/models/restaurant.dart'; +import 'package:restaurant_tour/presentation/components/rating_stars.dart'; + +import 'package:restaurant_tour/core/theme/typography.dart'; + +class RestaurantCard extends StatelessWidget { + final Restaurant restaurant; + final void Function() onTap; + + const RestaurantCard({ + super.key, + required this.restaurant, + required this.onTap, + }); + + String get openStatusLabel => restaurant.isOpen ? "Open Now" : "Closed"; + Color get openStatusColor => + restaurant.isOpen ? const Color(0xFF5CD313) : const Color(0xFFEA5E5E); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Card( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: SizedBox( + width: 88, + height: 88, + child: Image.network( + restaurant.heroImage, + fit: BoxFit.cover, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + restaurant.name!, + maxLines: 2, + softWrap: true, + overflow: TextOverflow.ellipsis, + style: AppTextStyles.loraRegularTitle, + ), + const Spacer(), + Text( + "${restaurant.price!} ${restaurant.displayCategory} ", + style: AppTextStyles.openRegularText, + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + RatingStars(rating: restaurant.rating!), + const Spacer(), + Text( + openStatusLabel, + style: AppTextStyles.openRegularItalic, + ), + const SizedBox(width: 8), + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: openStatusColor, + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/presentation/components/review_card.dart b/lib/presentation/components/review_card.dart new file mode 100644 index 0000000..4ba4d81 --- /dev/null +++ b/lib/presentation/components/review_card.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import 'package:restaurant_tour/domain/models/review.dart'; +import 'package:restaurant_tour/presentation/components/rating_stars.dart'; + +import 'package:restaurant_tour/core/theme/typography.dart'; + +class ReviewCard extends StatelessWidget { + final Review review; + const ReviewCard({super.key, required this.review}); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + RatingStars(rating: review.rating!.toDouble()), + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Text( + review.text!, + style: AppTextStyles.openRegularHeadline, + ), + ), + Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + shape: BoxShape.circle, + image: DecorationImage( + image: NetworkImage( + review.user!.imageUrl!, + ), + fit: BoxFit.cover, + ), + ), + ), + const SizedBox(width: 8), + Text( + review.user!.name!, + style: AppTextStyles.openRegularText, + ), + ], + ), + ], + ); + } +} diff --git a/lib/presentation/screens/home/all_restaurants_tab.dart b/lib/presentation/screens/home/all_restaurants_tab.dart new file mode 100644 index 0000000..4b8f89d --- /dev/null +++ b/lib/presentation/screens/home/all_restaurants_tab.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; +import 'package:restaurant_tour/domain/models/restaurant.dart'; +import 'package:restaurant_tour/domain/use_cases/toggle_favorite.dart'; + +import 'package:restaurant_tour/domain/use_cases/get_restaurants_use_case.dart'; +import 'package:restaurant_tour/presentation/components/restaurant_card.dart'; + +class AllRestaurantsTab extends StatefulWidget { + final GetRestaurantsUseCase getAllRestaurantsUseCase; + final ToggleFavoriteUseCase toggleFavoriteUseCase; + final List favoriteRestaurants; + final void Function(Restaurant restaurant, bool isFavorite) onTapRestaurant; + + const AllRestaurantsTab({ + super.key, + required this.getAllRestaurantsUseCase, + required this.toggleFavoriteUseCase, + required this.favoriteRestaurants, + required this.onTapRestaurant, + }); + + @override + State createState() => _AllRestaurantsTabState(); +} + +class _AllRestaurantsTabState extends State { + late Future?> restaurantsFuture; + @override + void initState() { + super.initState(); + restaurantsFuture = widget.getAllRestaurantsUseCase(); + } + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: restaurantsFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center( + child: CircularProgressIndicator( + color: Colors.black, + ), + ); + } + + if (snapshot.hasError || snapshot.data == null) { + return const Center( + child: Text("Failed to fetch restaurants"), + ); + } + + if (snapshot.data!.isEmpty) { + return const Center( + child: Text("No restaurants found"), + ); + } + + return ListView.builder( + itemCount: snapshot.data!.length, + itemBuilder: (context, index) { + final restaurant = snapshot.data![index]; + final isFavorite = widget.favoriteRestaurants + .indexWhere((element) => element.id == restaurant.id) != + -1; + + return RestaurantCard( + restaurant: restaurant, + onTap: () => widget.onTapRestaurant(restaurant, isFavorite), + ); + }, + ); + }, + ); + } +} diff --git a/lib/presentation/screens/home/favorite_restaurants_tab.dart b/lib/presentation/screens/home/favorite_restaurants_tab.dart new file mode 100644 index 0000000..d5961d3 --- /dev/null +++ b/lib/presentation/screens/home/favorite_restaurants_tab.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import 'package:restaurant_tour/domain/models/restaurant.dart'; + +import 'package:restaurant_tour/presentation/components/restaurant_card.dart'; + + +class FavoriteRestaurantsTab extends StatelessWidget { + final List restaurants; + final void Function(Restaurant restaurant, bool isFavorite) onTapRestaurant; + + const FavoriteRestaurantsTab({ + super.key, + required this.restaurants, + required this.onTapRestaurant, + }); + + @override + Widget build(BuildContext context) { + if (restaurants.isEmpty) { + return const Center( + child: Text("No favorites restaurants"), + ); + } + + return ListView.builder( + itemCount: restaurants.length, + itemBuilder: (context, index) { + final restaurant = restaurants[index]; + return RestaurantCard( + restaurant: restaurant, + onTap: () => onTapRestaurant(restaurant, true), + ); + }, + ); + } +} diff --git a/lib/presentation/screens/home/home_screen.dart b/lib/presentation/screens/home/home_screen.dart new file mode 100644 index 0000000..891ae5c --- /dev/null +++ b/lib/presentation/screens/home/home_screen.dart @@ -0,0 +1,130 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:restaurant_tour/domain/models/restaurant.dart'; +import 'package:restaurant_tour/domain/use_cases/get_favorites_restaurants_use_case.dart'; +import 'package:restaurant_tour/domain/use_cases/get_restaurants_use_case.dart'; +import 'package:restaurant_tour/domain/use_cases/toggle_favorite.dart'; +import 'package:restaurant_tour/presentation/screens/home/all_restaurants_tab.dart'; +import 'package:restaurant_tour/presentation/screens/home/favorite_restaurants_tab.dart'; +import 'package:restaurant_tour/core/theme/typography.dart'; + +import 'package:restaurant_tour/presentation/screens/restaurant_details/restaurant_details_screen.dart'; + +class HomeScreen extends StatefulWidget { + final GetRestaurantsUseCase getAllRestaurantsUseCase; + final GetFavoriteRestaurantsUseCase getFavoriteRestaurantsUseCase; + final ToggleFavoriteUseCase toggleFavoriteUseCase; + + const HomeScreen({ + super.key, + required this.getAllRestaurantsUseCase, + required this.getFavoriteRestaurantsUseCase, + required this.toggleFavoriteUseCase, + }); + + @override + State createState() => _HomeScreenState(); +} + +class _HomeScreenState extends State { + late final StreamSubscription> _favoritesSubscription; + List favoriteRestaurants = []; + + @override + void initState() { + super.initState(); + _favoritesSubscription = + widget.getFavoriteRestaurantsUseCase().listen((data) { + setState(() { + favoriteRestaurants = data; + }); + }); + } + + @override + void dispose() { + _favoritesSubscription.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return DefaultTabController( + length: 2, + initialIndex: 0, + child: Scaffold( + appBar: AppBar( + centerTitle: true, + title: const Text( + "RestauranTour", + style: AppTextStyles.loraRegularHeadline, + ), + bottom: const TabBar( + labelStyle: AppTextStyles.openRegularTitleSemiBold, + labelColor: Colors.black, + unselectedLabelColor: Color(0xFF606060), + indicatorColor: Colors.black, + indicator: BoxDecoration( + border: Border( + bottom: BorderSide( + color: Colors.black, + width: 2.0, + ), + ), + ), + tabs: [ + Tab( + text: "All Restaurants", + ), + Tab( + text: "My Favorites", + ), + ], + ), + ), + body: Padding( + padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 12), + child: TabBarView( + children: [ + AllRestaurantsTab( + getAllRestaurantsUseCase: widget.getAllRestaurantsUseCase, + toggleFavoriteUseCase: widget.toggleFavoriteUseCase, + favoriteRestaurants: favoriteRestaurants, + onTapRestaurant: (restaurant, isFavorite) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => RestaurantDetailsScreen( + restaurant: restaurant, + isFavorite: isFavorite, + onToggleFavorite: () { + widget.toggleFavoriteUseCase(restaurant); + }, + ), + ), + ); + }, + ), + FavoriteRestaurantsTab( + restaurants: favoriteRestaurants, + onTapRestaurant: (restaurant, isFavorite) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => RestaurantDetailsScreen( + restaurant: restaurant, + isFavorite: true, + onToggleFavorite: () { + widget.toggleFavoriteUseCase(restaurant); + }, + ), + ), + ); + }, + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/presentation/screens/restaurant_details/restaurant_details_screen.dart b/lib/presentation/screens/restaurant_details/restaurant_details_screen.dart new file mode 100644 index 0000000..0d77336 --- /dev/null +++ b/lib/presentation/screens/restaurant_details/restaurant_details_screen.dart @@ -0,0 +1,193 @@ +import 'package:flutter/material.dart'; +import 'package:restaurant_tour/domain/models/restaurant.dart'; +import 'package:restaurant_tour/domain/models/review.dart'; +import 'package:restaurant_tour/domain/models/user.dart'; +import 'package:restaurant_tour/presentation/components/review_card.dart'; + +import 'package:restaurant_tour/core/theme/colors.dart'; +import 'package:restaurant_tour/core/theme/typography.dart'; +import 'package:restaurant_tour/presentation/components/rating_stars.dart'; + +class RestaurantDetailsScreen extends StatefulWidget { + final Restaurant restaurant; + final VoidCallback onToggleFavorite; + final bool isFavorite; + + const RestaurantDetailsScreen({ + super.key, + required this.restaurant, + required this.onToggleFavorite, + required this.isFavorite, + }); + + @override + State createState() => + _RestaurantDetailsScreenState(); +} + +class _RestaurantDetailsScreenState extends State { + late bool isFavorite; + + @override + void initState() { + super.initState(); + isFavorite = widget.isFavorite; + } + + String get openStatusLabel => + widget.restaurant.isOpen ? "Open Now" : "Closed"; + + Color get openStatusColor => widget.restaurant.isOpen + ? const Color(0xFF5CD313) + : const Color(0xFFEA5E5E); + + IconData get favoriteIcon => + isFavorite ? Icons.favorite : Icons.favorite_border; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(widget.restaurant.name!), + actions: [ + IconButton( + icon: Icon(favoriteIcon), + onPressed: () { + setState(() { + isFavorite = !isFavorite; + widget.onToggleFavorite(); + }); + }, + ), + ], + ), + body: ListView( + children: [ + SizedBox( + width: double.infinity, + height: 361, + child: Image.network( + widget.restaurant.heroImage, + fit: BoxFit.cover, + ), + ), + Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + "${widget.restaurant.price!} ${widget.restaurant.displayCategory} ", + style: AppTextStyles.openRegularText, + ), + const Spacer(), + Text( + openStatusLabel, + style: AppTextStyles.openRegularItalic, + ), + const SizedBox(width: 8), + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: openStatusColor, + ), + ), + ], + ), + const Padding( + padding: EdgeInsets.symmetric(vertical: 24), + child: Divider( + height: 1, + color: AppColors.dividerColor, + ), + ), + Text( + widget.restaurant.location?.formattedAddress ?? + "102 Lakeside Ave Seattle, WA 98122", + style: AppTextStyles.openRegularTitleSemiBold, + ), + const Padding( + padding: EdgeInsets.symmetric(vertical: 24), + child: Divider( + height: 1, + color: AppColors.dividerColor, + ), + ), + // Rating + const Text( + "Overall Rating", + style: AppTextStyles.openRegularText, + ), + const SizedBox(height: 16), + Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text( + widget.restaurant.rating.toString(), + style: AppTextStyles.loraRegularHeadline + .copyWith(fontSize: 28), + ), + const SizedBox(width: 2), + RatingStars(rating: widget.restaurant.rating!), + ], + ), + //-- Rating + const Padding( + padding: EdgeInsets.symmetric(vertical: 24), + child: Divider( + height: 1, + color: AppColors.dividerColor, + ), + ), + // Reviews + Text( + "${widget.restaurant.reviews!.length} Reviews", + style: AppTextStyles.openRegularText, + ), + // TODO: add real data + const SizedBox(height: 16), + ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: widget.restaurant.reviews!.length, + separatorBuilder: (context, index) => const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Divider( + height: 1, + color: AppColors.dividerColor, + ), + ), + itemBuilder: (context, index) { + return const ReviewCard( + review: Review( + id: 'asdasdasd', + rating: 4, + text: + "Review text goes here. Review text goes here. Review text goes here. This is a review. This is a review. This is a review that is 4 lines long.", + user: User( + id: "asdasd", + name: "User name", + imageUrl: "https://via.placeholder.com/150", + ), + ), + ); + }, + ), + + //-- Reviews + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/repositories/yelp_repository.dart b/lib/repositories/yelp_repository.dart deleted file mode 100644 index 9eab02a..0000000 --- a/lib/repositories/yelp_repository.dart +++ /dev/null @@ -1,111 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:flutter/foundation.dart'; -import 'package:restaurant_tour/models/restaurant.dart'; - -const _apiKey = ''; - -class YelpRepository { - late Dio dio; - - YelpRepository({ - @visibleForTesting Dio? dio, - }) : dio = dio ?? - Dio( - BaseOptions( - baseUrl: 'https://api.yelp.com', - headers: { - 'Authorization': 'Bearer $_apiKey', - 'Content-Type': 'application/graphql', - }, - ), - ); - - /// Returns a response in this shape - /// { - /// "data": { - /// "search": { - /// "total": 5056, - /// "business": [ - /// { - /// "id": "faPVqws-x-5k2CQKDNtHxw", - /// "name": "Yardbird Southern Table & Bar", - /// "price": "$$", - /// "rating": 4.5, - /// "photos": [ - /// "https:///s3-media4.fl.yelpcdn.com/bphoto/_zXRdYX4r1OBfF86xKMbDw/o.jpg" - /// ], - /// "reviews": [ - /// { - /// "id": "sjZoO8wcK1NeGJFDk5i82Q", - /// "rating": 5, - /// "user": { - /// "id": "BuBCkWFNT_O2dbSnBZvpoQ", - /// "image_url": "https:///s3-media2.fl.yelpcdn.com/photo/v8tbTjYaFvkzh1d7iE-pcQ/o.jpg", - /// "name": "Gina T.", - /// "text": "I love this place! The food is amazing and the service is great." - /// } - /// }, - /// { - /// "id": "okpO9hfpxQXssbTZTKq9hA", - /// "rating": 5, - /// "user": { - /// "id": "0x9xu_b0Ct_6hG6jaxpztw", - /// "image_url": "https:///s3-media3.fl.yelpcdn.com/photo/gjz8X6tqE3e4praK4HfCiA/o.jpg", - /// "name": "Crystal L.", - /// "text": "Greate place to eat" - /// } - /// }, - /// ... - /// ] - /// } - /// } - /// - Future getRestaurants({int offset = 0}) async { - try { - final response = await dio.post>( - '/v3/graphql', - data: _getQuery(offset), - ); - return RestaurantQueryResult.fromJson(response.data!['data']['search']); - } catch (e) { - return null; - } - } - - String _getQuery(int offset) { - return ''' -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/pubspec.lock b/pubspec.lock index 27b6e40..d86a43e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,26 +5,26 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: ae92f5d747aee634b87f89d9946000c2de774be1d6ac3e58268224348cd0101a + sha256: "0b2f2bd91ba804e53a61d757b986f89f1f9eaed5b11e4b2f5a2468d86d6c9fc7" url: "https://pub.dev" source: hosted - version: "61.0.0" + version: "67.0.0" analyzer: dependency: transitive description: name: analyzer - sha256: ea3d8652bda62982addfd92fdc2d0214e5f82e43325104990d4f4c4a2a313562 + sha256: "37577842a27e4338429a1cbc32679d508836510b056f1eedf0c8d20e39c1383d" url: "https://pub.dev" source: hosted - version: "5.13.0" + version: "6.4.1" args: dependency: transitive description: name: args - sha256: "0bd9a99b6eb96f07af141f0eb53eace8983e8e5aa5de59777aca31684680ef22" + sha256: "7cf60b9f0cc88203c5a190b4cd62a99feea42759a7fa695010eb5de1c0b2252a" url: "https://pub.dev" source: hosted - version: "2.3.0" + version: "2.5.0" async: dependency: transitive description: @@ -45,10 +45,10 @@ packages: dependency: transitive description: name: build - sha256: "3fbda25365741f8251b39f3917fb3c8e286a96fd068a5a242e11c2012d495777" + sha256: "80184af8b6cb3e5c1c4ec6d8544d27711700bc3e6d2efad04238c7b5290889f0" url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.4.1" build_config: dependency: transitive description: @@ -61,18 +61,18 @@ packages: dependency: transitive description: name: build_daemon - sha256: "5f02d73eb2ba16483e693f80bee4f088563a820e47d1027d4cdfe62b5bb43e65" + sha256: "79b2aef6ac2ed00046867ed354c88778c9c0f029df8a20fe10b5436826721ef9" url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "4.0.2" build_resolvers: dependency: transitive description: name: build_resolvers - sha256: "6c4dd11d05d056e76320b828a1db0fc01ccd376922526f8e9d6c796a5adbac20" + sha256: "339086358431fa15d7eca8b6a36e5d783728cf025e559b834f4609a1fcfb7b0a" url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "2.4.2" build_runner: dependency: "direct dev" description: @@ -85,10 +85,10 @@ packages: dependency: transitive description: name: build_runner_core - sha256: f4d6244cc071ba842c296cb1c4ee1b31596b9f924300647ac7a1445493471a3f + sha256: e3c79f69a64bdfcd8a776a3c28db4eb6e3fb5356d013ae5eb2e52007706d5dbe url: "https://pub.dev" source: hosted - version: "7.2.3" + version: "7.3.1" built_collection: dependency: transitive description: @@ -101,10 +101,10 @@ packages: dependency: transitive description: name: built_value - sha256: b6c9911b2d670376918d5b8779bc27e0e612a94ec3ff0343689e991d8d0a3b8a + sha256: c7913a9737ee4007efedaffc968c049fd0f3d0e49109e778edc10de9426005cb url: "https://pub.dev" source: hosted - version: "8.1.4" + version: "8.9.2" characters: dependency: transitive description: @@ -113,22 +113,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" - charcode: - dependency: transitive - description: - name: charcode - sha256: fb98c0f6d12c920a02ee2d998da788bca066ca5f148492b7085ee23372b12306 - url: "https://pub.dev" - source: hosted - version: "1.3.1" checked_yaml: dependency: transitive description: name: checked_yaml - sha256: dd007e4fb8270916820a0d66e24f619266b60773cddd082c6439341645af2659 + sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "2.0.3" clock: dependency: transitive description: @@ -157,26 +149,26 @@ packages: dependency: transitive description: name: convert - sha256: f08428ad63615f96a27e34221c65e1a451439b5f26030f78d790f461c686d65d + sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.1.1" crypto: dependency: transitive description: name: crypto - sha256: cf75650c66c0316274e21d7c43d3dea246273af5955bd94e8184837cd577575c + sha256: ec30d999af904f33454ba22ed9a86162b35e52b44ac4807d1d93c288041d7d27 url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.5" dart_style: dependency: transitive description: name: dart_style - sha256: "1efa911ca7086affd35f463ca2fc1799584fb6aa89883cf0af8e3664d6a02d55" + sha256: "99e066ce75c89d6b29903d788a7bb9369cf754f7b24bf70bf4b6d6d6b26853b9" url: "https://pub.dev" source: hosted - version: "2.3.2" + version: "2.3.6" dio: dependency: "direct main" description: @@ -189,10 +181,10 @@ packages: dependency: transitive description: name: dio_web_adapter - sha256: "36c5b2d79eb17cdae41e974b7a8284fec631651d2a6f39a8a2ff22327e90aeac" + sha256: "33259a9276d6cea88774a0000cfae0d861003497755969c92faa223108620dc8" url: "https://pub.dev" source: hosted - version: "1.0.1" + version: "2.0.0" fake_async: dependency: transitive description: @@ -205,18 +197,18 @@ packages: dependency: transitive description: name: file - sha256: b69516f2c26a5bcac4eee2e32512e1a5205ab312b3536c1c1227b2b942b5f9ad + sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c" url: "https://pub.dev" source: hosted - version: "6.1.2" + version: "7.0.0" fixnum: dependency: transitive description: name: fixnum - sha256: "6a2ef17156f4dc49684f9d99aaf4a93aba8ac49f5eac861755f5730ddf6e2e4e" + sha256: "25517a4deb0c03aa0f32fd12db525856438902d9c16536311e76cdc57b31d7d1" url: "https://pub.dev" source: hosted - version: "1.0.0" + version: "1.1.0" flutter: dependency: "direct main" description: flutter @@ -251,14 +243,22 @@ packages: url: "https://pub.dev" source: hosted version: "3.2.0" + get_it: + dependency: "direct main" + description: + name: get_it + sha256: d85128a5dae4ea777324730dc65edd9c9f43155c109d5cc0a69cab74139fbac1 + url: "https://pub.dev" + source: hosted + version: "7.7.0" glob: dependency: transitive description: name: glob - sha256: "8321dd2c0ab0683a91a51307fa844c6db4aa8e3981219b78961672aaab434658" + sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63" url: "https://pub.dev" source: hosted - version: "2.0.2" + version: "2.1.2" graphs: dependency: transitive description: @@ -275,38 +275,46 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.2" + http_mock_adapter: + dependency: "direct dev" + description: + name: http_mock_adapter + sha256: "46399c78bd4a0af071978edd8c502d7aeeed73b5fb9860bca86b5ed647a63c1b" + url: "https://pub.dev" + source: hosted + version: "0.6.1" http_multi_server: dependency: transitive description: name: http_multi_server - sha256: bfb651625e251a88804ad6d596af01ea903544757906addcb2dcdf088b5ea185 + sha256: "97486f20f9c2f7be8f514851703d0119c3596d14ea63227af6f7a481ef2b2f8b" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.2.1" http_parser: dependency: transitive description: name: http_parser - sha256: e362d639ba3bc07d5a71faebb98cde68c05bfbcfbbb444b60b6f60bb67719185 + sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "4.0.2" io: dependency: transitive description: name: io - sha256: "0d4c73c3653ab85bf696d51a9657604c900a370549196a91f33e4c39af760852" + sha256: "2ec25704aba361659e10e3e5f5d672068d332fc8ac516421d483a11e5cbd061e" url: "https://pub.dev" source: hosted - version: "1.0.3" + version: "1.0.4" js: dependency: transitive description: name: js - sha256: d9bdfd70d828eeb352390f81b18d6a354ef2044aa28ef25682079797fa7cd174 + sha256: c1b2e9b5ea78c45e1a0788d29606ba27dc5f71f019f32ca5140f61ef071838cf url: "https://pub.dev" source: hosted - version: "0.6.3" + version: "0.7.1" json_annotation: dependency: "direct main" description: @@ -355,14 +363,22 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" + logger: + dependency: transitive + description: + name: logger + sha256: "697d067c60c20999686a0add96cf6aba723b3aa1f83ecf806a8097231529ec32" + url: "https://pub.dev" + source: hosted + version: "2.4.0" logging: dependency: transitive description: name: logging - sha256: "293ae2d49fd79d4c04944c3a26dfd313382d5f52e821ec57119230ae16031ad4" + sha256: "623a88c9594aa774443aa3eb2d41807a48486b5613e67599fb4c41c0ad47c340" url: "https://pub.dev" source: hosted - version: "1.0.2" + version: "1.2.0" matcher: dependency: transitive description: @@ -391,18 +407,34 @@ packages: dependency: transitive description: name: mime - sha256: fd5f81041e6a9fc9b9d7fa2cb8a01123f9f5d5d49136e06cb9dc7d33689529f4 + sha256: "801fd0b26f14a4a58ccb09d5892c3fbdeff209594300a542492cf13fba9d247a" url: "https://pub.dev" source: hosted - version: "1.0.1" + version: "1.0.6" + mockito: + dependency: transitive + description: + name: mockito + sha256: "6841eed20a7befac0ce07df8116c8b8233ed1f4486a7647c7fc5a02ae6163917" + url: "https://pub.dev" + source: hosted + version: "5.4.4" + network_image_mock: + dependency: "direct dev" + description: + name: network_image_mock + sha256: "855cdd01d42440e0cffee0d6c2370909fc31b3bcba308a59829f24f64be42db7" + url: "https://pub.dev" + source: hosted + version: "2.1.1" package_config: dependency: transitive description: name: package_config - sha256: a4d5ede5ca9c3d88a2fef1147a078570c861714c806485c596b109819135bc12 + sha256: "1c5b77ccc91e4823a5af61ee74e6b972db1ef98c2ff5a18d3161c982a55448bd" url: "https://pub.dev" source: hosted - version: "2.0.2" + version: "2.1.0" path: dependency: transitive description: @@ -431,42 +463,42 @@ packages: dependency: transitive description: name: pool - sha256: "05955e3de2683e1746222efd14b775df7131139e07695dc8e24650f6b4204504" + sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" url: "https://pub.dev" source: hosted - version: "1.5.0" + version: "1.5.1" pub_semver: dependency: transitive description: name: pub_semver - sha256: b5a5fcc6425ea43704852ba4453ba94b08c2226c63418a260240c3a054579014 + sha256: "40d3ab1bbd474c4c2328c91e3a7df8c6dd629b79ece4c4bd04bee496a224fb0c" url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.1.4" pubspec_parse: dependency: transitive description: name: pubspec_parse - sha256: "3686efe4a4613a4449b1a4ae08670aadbd3376f2e78d93e3f8f0919db02a7256" + sha256: c799b721d79eb6ee6fa56f00c04b472dcd44a30d258fac2174a6ec57302678f8 url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.3.0" shelf: dependency: transitive description: name: shelf - sha256: c240984c924796e055e831a0a36db23be8cb04f170b26df572931ab36418421d + sha256: ad29c505aee705f41a4d8963641f91ac4cee3c8fad5947e033390a7bd8180fa4 url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.4.1" shelf_web_socket: dependency: transitive description: name: shelf_web_socket - sha256: fd84910bf7d58db109082edf7326b75322b8f186162028482f53dc892f00332d + sha256: "073c147238594ecd0d193f3456a5fe91c4b0abbcc68bf5cd95b36c4e194ac611" url: "https://pub.dev" source: hosted - version: "1.0.1" + version: "2.0.0" sky_engine: dependency: transitive description: flutter @@ -516,10 +548,10 @@ packages: dependency: transitive description: name: stream_transform - sha256: ed464977cb26a1f41537e177e190c67223dbd9f4f683489b6ab2e5d211ec564e + sha256: "14a00e794c7c11aa145a170587321aedce29769c08d7f58b1d141da75e3b1c6f" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.1.0" string_scanner: dependency: transitive description: @@ -548,18 +580,18 @@ packages: dependency: transitive description: name: timing - sha256: c386d07d7f5efc613479a7c4d9d64b03710b03cfaa7e8ad5f2bfb295a1f0dfad + sha256: "70a3b636575d4163c477e6de42f247a23b315ae20e86442bebe32d3cabf61c32" url: "https://pub.dev" source: hosted - version: "1.0.0" + version: "1.0.1" typed_data: dependency: transitive description: name: typed_data - sha256: "53bdf7e979cfbf3e28987552fd72f637e63f3c8724c9e56d9246942dc2fa36ee" + sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.3.2" vector_graphics: dependency: transitive description: @@ -604,10 +636,10 @@ packages: dependency: transitive description: name: watcher - sha256: e42dfcc48f67618344da967b10f62de57e04bae01d9d3af4c2596f3712a88c99 + sha256: "3d2ad6751b3c16cf07c7fca317a1413b3f26530319181b37e3b9039b84fc01d8" url: "https://pub.dev" source: hosted - version: "1.0.1" + version: "1.1.0" web: dependency: transitive description: @@ -616,14 +648,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "3c12d96c0c9a4eec095246debcea7b86c0324f22df69893d538fcc6f1b8cce83" + url: "https://pub.dev" + source: hosted + version: "0.1.6" web_socket_channel: dependency: transitive description: name: web_socket_channel - sha256: "0c2ada1b1aeb2ad031ca81872add6be049b8cb479262c6ad3c4b0f9c24eaab2f" + sha256: "9f187088ed104edd8662ca07af4b124465893caf063ba29758f97af57e61da8f" url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "3.0.1" xml: dependency: transitive description: @@ -636,10 +676,10 @@ packages: dependency: transitive description: name: yaml - sha256: "3cee79b1715110341012d27756d9bae38e650588acd38d3f3c610822e1337ace" + sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" url: "https://pub.dev" source: hosted - version: "3.1.0" + version: "3.1.2" sdks: dart: ">=3.4.0 <4.0.0" flutter: ">=3.19.6" diff --git a/pubspec.yaml b/pubspec.yaml index 4018593..e84f04d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,6 +16,7 @@ dependencies: dio: ^5.6.0 json_annotation: ^4.9.0 flutter_svg: ^2.0.10 + get_it: ^7.7.0 dev_dependencies: flutter_test: @@ -23,6 +24,8 @@ dev_dependencies: flutter_lints: ^4.0.0 build_runner: ^2.4.10 json_serializable: ^6.8.0 + http_mock_adapter: ^0.6.1 + network_image_mock: ^2.1.1 flutter: generate: true diff --git a/test.sh b/test.sh new file mode 100755 index 0000000..7d5052a --- /dev/null +++ b/test.sh @@ -0,0 +1,4 @@ +fvm flutter test --coverage +remove_from_coverage -f coverage/lcov.info -r '\.g\.dart$' +genhtml coverage/lcov.info -o coverage/html +open coverage/html/index.html \ No newline at end of file diff --git a/test/data/dtos/restaurant_dto_test.dart b/test/data/dtos/restaurant_dto_test.dart new file mode 100644 index 0000000..64125a9 --- /dev/null +++ b/test/data/dtos/restaurant_dto_test.dart @@ -0,0 +1,22 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:restaurant_tour/data/dtos/restaurant_dto.dart'; +import 'package:restaurant_tour/data/repositories/cached_response.dart'; + +void main() { + testWidgets('RestaurantDto fromJson should return an instance', + (tester) async { + final restaurantDto = + RestaurantDto.fromJson(cachedResponse['data']['search']); + + expect(restaurantDto, isA()); + }); + + testWidgets('RestaurantDto toJson should return a map', (tester) async { + final restaurantDto = + RestaurantDto.fromJson(cachedResponse['data']['search']); + + final map = restaurantDto.toJson(); + + expect(map, isA()); + }); +} diff --git a/test/data/repositories/restaurants_repository_test.dart b/test/data/repositories/restaurants_repository_test.dart new file mode 100644 index 0000000..254c83d --- /dev/null +++ b/test/data/repositories/restaurants_repository_test.dart @@ -0,0 +1,58 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http_mock_adapter/http_mock_adapter.dart'; +import 'package:restaurant_tour/data/repositories/cached_response.dart'; +import 'package:restaurant_tour/data/repositories/restaurants_repository.dart'; +import 'package:restaurant_tour/domain/models/restaurant.dart'; + +void main() { + late RestaurantsRepository restaurantsRepository; + late Dio httpClient; + late DioAdapter dioAdapter; + + const route = 'https://example.com'; + + setUp(() { + httpClient = Dio(BaseOptions( + baseUrl: route, + )); + dioAdapter = DioAdapter( + dio: httpClient, + matcher: const UrlRequestMatcher(), + ); + + restaurantsRepository = RestaurantsRepository(httpClient: httpClient); + }); + + tearDownAll(() { + restaurantsRepository.dispose(); + }); + + test('should return a list of restaurants', () async { + dioAdapter.onPost( + '/v3/graphql', + (server) => server.reply( + 200, + cachedResponse, + delay: const Duration(seconds: 1), + ), + data: null, + ); + + final data = await restaurantsRepository.getRestaurants(); + + expect(data.length, 20); + }); + + test('should return favorite restaurants', () { + const restaurant = Restaurant(id: 'test'); + restaurantsRepository.toggleFavorite(restaurant); + + expectLater( + restaurantsRepository.getFavorites(), + emitsInOrder([ + [restaurant] + ]), + ); + }); +} diff --git a/test/domain/use_cases/get_favorites_restaurants_use_case_test.dart b/test/domain/use_cases/get_favorites_restaurants_use_case_test.dart new file mode 100644 index 0000000..5ce4eae --- /dev/null +++ b/test/domain/use_cases/get_favorites_restaurants_use_case_test.dart @@ -0,0 +1,23 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:restaurant_tour/domain/use_cases/get_favorites_restaurants_use_case.dart'; + +import '../../fakes/repositories/fake_restaurants_repository.dart'; + +void main() { + late GetFavoriteRestaurantsUseCase getFavoriteRestaurantsUseCase; + late FakeRestaurantsRepository fakeRestaurantsRepository; + + setUp(() { + fakeRestaurantsRepository = FakeRestaurantsRepository(); + getFavoriteRestaurantsUseCase = GetFavoriteRestaurantsUseCase( + restaurantsRepository: fakeRestaurantsRepository, + ); + }); + + test('should return a stream of favorite restaurants', () { + expectLater( + getFavoriteRestaurantsUseCase(), + emitsInOrder([fakeRestaurantsRepository.favoriteRestaurants]), + ); + }); +} diff --git a/test/domain/use_cases/get_restaurants_use_case_test.dart b/test/domain/use_cases/get_restaurants_use_case_test.dart new file mode 100644 index 0000000..26c1d66 --- /dev/null +++ b/test/domain/use_cases/get_restaurants_use_case_test.dart @@ -0,0 +1,23 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:restaurant_tour/domain/use_cases/get_restaurants_use_case.dart'; + +import '../../fakes/repositories/fake_restaurants_repository.dart'; + +void main() { + late GetRestaurantsUseCase getRestaurantsUseCase; + late FakeRestaurantsRepository fakeRestaurantsRepository; + + setUp(() { + fakeRestaurantsRepository = FakeRestaurantsRepository(); + getRestaurantsUseCase = GetRestaurantsUseCase( + restaurantsRepository: fakeRestaurantsRepository, + ); + }); + + test('should get restaurants', () async { + final data = await getRestaurantsUseCase(); + + expect(data?.length, fakeRestaurantsRepository.restaurants.length); + }); +} diff --git a/test/domain/use_cases/toggle_favorite_test.dart b/test/domain/use_cases/toggle_favorite_test.dart new file mode 100644 index 0000000..692e616 --- /dev/null +++ b/test/domain/use_cases/toggle_favorite_test.dart @@ -0,0 +1,50 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:restaurant_tour/domain/use_cases/get_favorites_restaurants_use_case.dart'; +import 'package:restaurant_tour/domain/use_cases/toggle_favorite.dart'; + +import '../../fakes/repositories/fake_restaurants_repository.dart'; + +void main() { + late ToggleFavoriteUseCase toggleFavoriteUseCase; + late GetFavoriteRestaurantsUseCase getFavoriteRestaurantsUseCase; + late FakeRestaurantsRepository fakeRestaurantsRepository; + + setUp(() { + fakeRestaurantsRepository = FakeRestaurantsRepository(); + toggleFavoriteUseCase = ToggleFavoriteUseCase( + restaurantsRepository: fakeRestaurantsRepository, + ); + getFavoriteRestaurantsUseCase = GetFavoriteRestaurantsUseCase( + restaurantsRepository: fakeRestaurantsRepository, + ); + }); + + test('should remove a restaurant from favorites', () async { + final favoriteRestaurants = + List.of(fakeRestaurantsRepository.favoriteRestaurants); + + toggleFavoriteUseCase(favoriteRestaurants.first); + + favoriteRestaurants + .removeWhere((element) => element.id == favoriteRestaurants.first.id); + + expectLater( + getFavoriteRestaurantsUseCase(), + emitsInOrder([favoriteRestaurants]), + ); + }); + + test('should add a restaurant to favorites', () async { + final newFavoriteRestaurant = fakeRestaurantsRepository.restaurants.last; + final favoriteRestaurants = + List.of(fakeRestaurantsRepository.favoriteRestaurants); + favoriteRestaurants.add(newFavoriteRestaurant); + + toggleFavoriteUseCase(newFavoriteRestaurant); + + expectLater( + getFavoriteRestaurantsUseCase(), + emitsInOrder([favoriteRestaurants]), + ); + }); +} diff --git a/test/fakes/data/fake_restaurant.dart b/test/fakes/data/fake_restaurant.dart new file mode 100644 index 0000000..57cce52 --- /dev/null +++ b/test/fakes/data/fake_restaurant.dart @@ -0,0 +1,117 @@ +import 'package:restaurant_tour/data/models/restaurant.dart'; + +final fakeRestaurant = Restaurant.fromJson({ + "id": "vHz2RLtfUMVRPFmd7VBEHA", + "name": "Gordon Ramsay Hell's Kitchen", + "price": "\$\$\$", + "rating": 4.4, + "photos": [ + "https://s3-media2.fl.yelpcdn.com/bphoto/q771KjLzI5y638leJsnJnQ/o.jpg" + ], + "reviews": [ + { + "id": "F88H5ow44AmiwisbrbswPw", + "rating": 5, + "text": + "This entire experience is always so amazing. Every single dish is cooked to perfection. Every beef dish was so tender. The desserts were absolutely...", + "user": { + "id": "y742Fi1jF_JAqq5sRUlLEw", + "image_url": + "https://s3-media2.fl.yelpcdn.com/photo/rEWek1sYL0F35KZ0zRt3sw/o.jpg", + "name": "Ashley L." + } + }, + { + "id": "VJCoQlkk4Fjac0OPoRP8HQ", + "rating": 5, + "text": + "Me and my husband came to celebrate my birthday here and it was a 10/10 experience. Firstly, I booked the wrong area which was the Gordon Ramsay pub and...", + "user": { + "id": "0bQNLf0POLTW4VhQZqOZoQ", + "image_url": + "https://s3-media3.fl.yelpcdn.com/photo/i_0K5RUOQnoIw1c4QzHmTg/o.jpg", + "name": "Glydel L." + } + }, + { + "id": "EeCKH7eUVDsZv0Ii9wcPiQ", + "rating": 5, + "text": + "phenomenal! Bridgette made our experience as superb as the food coming to the table! would definitely come here again and try everything else on the menu,...", + "user": { + "id": "gL7AGuKBW4ne93_mR168pQ", + "image_url": + "https://s3-media1.fl.yelpcdn.com/photo/iU1sA7y3dEEc4iRL9LnWQQ/o.jpg", + "name": "Sydney O." + } + } + ], + "categories": [ + {"title": "New American", "alias": "newamerican"}, + {"title": "Seafood", "alias": "seafood"} + ], + "hours": [ + {"is_open_now": true} + ], + "location": { + "formatted_address": "3570 Las Vegas Blvd S\nLas Vegas, NV 89109" + } +}); + +final fakeRestaurant2 = Restaurant.fromJson({ + "id": "faPVqws-x-5k2CQKDNtHxw", + "name": "Yardbird", + "price": "\$\$", + "rating": 4.5, + "photos": [ + "https://s3-media1.fl.yelpcdn.com/bphoto/xYJaanpF3Dl1OovhmpqAYw/o.jpg" + ], + "reviews": [ + { + "id": "CN9oD1ncHKZtsGN7U1EMnA", + "rating": 5, + "text": + "The food was delicious and the host and waitress were very nice, my husband and I really loved all the food, their cocktails are also amazing.", + "user": { + "id": "HArOfrshTW9s1HhN8oz8rg", + "image_url": + "https://s3-media3.fl.yelpcdn.com/photo/4sDrkYRIZxsXKCYdo9d1bQ/o.jpg", + "name": "Snow7 C." + } + }, + { + "id": "Qd-GV_v5gFHYO4VHw_6Dzw", + "rating": 5, + "text": + "Their Chicken and waffles are the best! I thought it was too big for one person, you had better to share it with some people", + "user": { + "id": "ww0-zb-Nv5ccWd1Vbdmo-A", + "image_url": + "https://s3-media4.fl.yelpcdn.com/photo/g-9Uqpy-lNszg0EXTuqwzQ/o.jpg", + "name": "Eri O." + } + }, + { + "id": "cqMrOWT9kRQOt3VUqOUbHg", + "rating": 5, + "text": + "Our last meal in Vegas was amazing at Yardbird. We have been to the Yardbird in Chicago so we thought we knew what to expect; however, we were blown away by...", + "user": { + "id": "10oig4nwHnOAnAApdYvNrg", + "image_url": null, + "name": "Ellie K." + } + } + ], + "categories": [ + {"title": "Southern", "alias": "southern"}, + {"title": "New American", "alias": "newamerican"}, + {"title": "Cocktail Bars", "alias": "cocktailbars"} + ], + "hours": [ + {"is_open_now": true} + ], + "location": { + "formatted_address": "3355 Las Vegas Blvd S\nLas Vegas, NV 89109" + } +}); diff --git a/test/fakes/repositories/fake_restaurants_repository.dart b/test/fakes/repositories/fake_restaurants_repository.dart new file mode 100644 index 0000000..d918f9b --- /dev/null +++ b/test/fakes/repositories/fake_restaurants_repository.dart @@ -0,0 +1,43 @@ +import 'package:restaurant_tour/domain/models/restaurant.dart'; +import 'package:restaurant_tour/domain/repositories/restaurants_repository.dart'; + +import '../data/fake_restaurant.dart'; + +class FakeRestaurantsRepository extends BaseRestaurantsRepository { + final List favoriteRestaurants = [ + fakeRestaurant.toDomain(), + ]; + + List get restaurants => [ + fakeRestaurant.toDomain(), + fakeRestaurant2.toDomain(), + ]; + + @override + Stream> getFavorites() { + return Stream.value(favoriteRestaurants); + } + + @override + Future> getRestaurants({int offset = 0}) async { + return restaurants; + } + + @override + void toggleFavorite(Restaurant restaurant) { + final found = favoriteRestaurants + .indexWhere((element) => element.id == restaurant.id) != + -1; + + if (found) { + favoriteRestaurants.removeWhere((element) => element.id == restaurant.id); + } else { + favoriteRestaurants.add(restaurant); + } + } + + @override + void dispose() { + // TODO: implement dispose + } +} diff --git a/test/make_testable_widget.dart b/test/make_testable_widget.dart new file mode 100644 index 0000000..2f40b76 --- /dev/null +++ b/test/make_testable_widget.dart @@ -0,0 +1,9 @@ +import 'package:flutter/material.dart'; + +Widget makeTestableWidget({ + required Widget child, +}) { + return MaterialApp( + home: child, + ); +} diff --git a/test/presentation/components/rating_stars_test.dart b/test/presentation/components/rating_stars_test.dart new file mode 100644 index 0000000..625d62a --- /dev/null +++ b/test/presentation/components/rating_stars_test.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:restaurant_tour/presentation/components/rating_stars.dart'; + +import '../../make_testable_widget.dart'; + +void main() { + testWidgets('RatingStars should render correctly with different values', + (tester) async { + for (var i = 1; i < 6; i++) { + await tester.pumpWidget( + makeTestableWidget( + child: RatingStars(rating: i.toDouble()), + ), + ); + + expect(find.byIcon(Icons.star), findsNWidgets(i)); + } + + await tester.pumpWidget( + makeTestableWidget( + child: const RatingStars(rating: 3.5), + ), + ); + + expect(find.byIcon(Icons.star), findsNWidgets(4)); + + await tester.pumpWidget( + makeTestableWidget( + child: const RatingStars(rating: 3.3), + ), + ); + + expect(find.byIcon(Icons.star), findsNWidgets(3)); + }); +} diff --git a/test/presentation/components/restaurant_card_test.dart b/test/presentation/components/restaurant_card_test.dart new file mode 100644 index 0000000..0190d1c --- /dev/null +++ b/test/presentation/components/restaurant_card_test.dart @@ -0,0 +1,46 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:restaurant_tour/data/models/restaurant.dart'; +import 'package:network_image_mock/network_image_mock.dart'; + +import 'package:restaurant_tour/presentation/components/restaurant_card.dart'; + +import '../../fakes/data/fake_restaurant.dart'; +import '../../make_testable_widget.dart'; + +void main() { + testWidgets('RestaurantCard should render correctly', (tester) async { + await mockNetworkImagesFor( + () => tester.pumpWidget( + makeTestableWidget( + child: RestaurantCard( + restaurant: fakeRestaurant.toDomain(), + onTap: () {}, + ), + ), + ), + ); + + expect(find.byType(RestaurantCard), findsOneWidget); + }); + + testWidgets('RestaurantCard onTap callback should be called', (tester) async { + var onTapCalled = false; + + await mockNetworkImagesFor( + () => tester.pumpWidget( + makeTestableWidget( + child: RestaurantCard( + restaurant: fakeRestaurant.toDomain(), + onTap: () { + onTapCalled = true; + }, + ), + ), + ), + ); + + await tester.tap(find.byType(RestaurantCard)); + + expect(onTapCalled, isTrue); + }); +} diff --git a/test/presentation/components/review_card_test.dart b/test/presentation/components/review_card_test.dart new file mode 100644 index 0000000..9b6d1f5 --- /dev/null +++ b/test/presentation/components/review_card_test.dart @@ -0,0 +1,24 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:network_image_mock/network_image_mock.dart'; +import 'package:restaurant_tour/presentation/components/review_card.dart'; + +import '../../fakes/data/fake_restaurant.dart'; +import '../../make_testable_widget.dart'; + +void main() { + testWidgets('ReviewCard should render correctly', (tester) async { + final review = fakeRestaurant.toDomain().reviews!.first; + + await mockNetworkImagesFor( + () => tester.pumpWidget( + makeTestableWidget( + child: ReviewCard( + review: review, + ), + ), + ), + ); + + expect(find.byType(ReviewCard), findsOneWidget); + }); +} diff --git a/test/presentation/screens/home/all_restaurants_tab_test.dart b/test/presentation/screens/home/all_restaurants_tab_test.dart new file mode 100644 index 0000000..2271ac4 --- /dev/null +++ b/test/presentation/screens/home/all_restaurants_tab_test.dart @@ -0,0 +1,66 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:network_image_mock/network_image_mock.dart'; +import 'package:restaurant_tour/domain/use_cases/get_restaurants_use_case.dart'; +import 'package:restaurant_tour/domain/use_cases/toggle_favorite.dart'; +import 'package:restaurant_tour/presentation/components/restaurant_card.dart'; +import 'package:restaurant_tour/presentation/screens/home/all_restaurants_tab.dart'; + +import '../../../fakes/repositories/fake_restaurants_repository.dart'; +import '../../../make_testable_widget.dart'; + +void main() { + late GetRestaurantsUseCase getRestaurantsUseCase; + late ToggleFavoriteUseCase toggleFavoriteUseCase; + late FakeRestaurantsRepository fakeRestaurantsRepository; + + setUp(() { + fakeRestaurantsRepository = FakeRestaurantsRepository(); + getRestaurantsUseCase = + GetRestaurantsUseCase(restaurantsRepository: fakeRestaurantsRepository); + toggleFavoriteUseCase = + ToggleFavoriteUseCase(restaurantsRepository: fakeRestaurantsRepository); + }); + + testWidgets('AllRestaurantsTab should render correctly', (tester) async { + await mockNetworkImagesFor( + () => tester.pumpWidget( + makeTestableWidget( + child: AllRestaurantsTab( + toggleFavoriteUseCase: toggleFavoriteUseCase, + getAllRestaurantsUseCase: getRestaurantsUseCase, + favoriteRestaurants: [], + onTapRestaurant: (restaurant, isFavorite) {}, + ), + ), + ), + ); + + expect(find.byType(AllRestaurantsTab), findsOneWidget); + }); + + testWidgets('AllRestaurantsTab onTapRestaurant callback should be called', + (tester) async { + var onTapRestaurantWasCalled = false; + + await mockNetworkImagesFor( + () => tester.pumpWidget( + makeTestableWidget( + child: AllRestaurantsTab( + toggleFavoriteUseCase: toggleFavoriteUseCase, + getAllRestaurantsUseCase: getRestaurantsUseCase, + favoriteRestaurants: [], + onTapRestaurant: (restaurant, isFavorite) { + onTapRestaurantWasCalled = true; + }, + ), + ), + ), + ); + + await mockNetworkImagesFor(() => tester.pumpAndSettle()); + + await tester.tap(find.byType(RestaurantCard).first); + + expect(onTapRestaurantWasCalled, isTrue); + }); +} diff --git a/test/presentation/screens/home/favorite_restaurants_tab_test.dart b/test/presentation/screens/home/favorite_restaurants_tab_test.dart new file mode 100644 index 0000000..9cc95ea --- /dev/null +++ b/test/presentation/screens/home/favorite_restaurants_tab_test.dart @@ -0,0 +1,53 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:network_image_mock/network_image_mock.dart'; +import 'package:restaurant_tour/presentation/components/restaurant_card.dart'; +import 'package:restaurant_tour/presentation/screens/home/favorite_restaurants_tab.dart'; + +import '../../../fakes/data/fake_restaurant.dart'; +import '../../../make_testable_widget.dart'; + +void main() { + testWidgets('FavoriteRestaurantsTab should render correctly', (tester) async { + await mockNetworkImagesFor( + () => tester.pumpWidget( + makeTestableWidget( + child: FavoriteRestaurantsTab( + restaurants: [ + fakeRestaurant.toDomain(), + fakeRestaurant2.toDomain() + ], + onTapRestaurant: (restaurant, isFavorite) {}, + ), + ), + ), + ); + + expect(find.byType(FavoriteRestaurantsTab), findsOneWidget); + }); + + testWidgets( + 'FavoriteRestaurantsTab onTapRestaurant callback should be called', + (tester) async { + var onTapRestaurantWasCalled = false; + + await mockNetworkImagesFor( + () => tester.pumpWidget( + makeTestableWidget( + child: FavoriteRestaurantsTab( + restaurants: [ + fakeRestaurant.toDomain(), + fakeRestaurant2.toDomain() + ], + onTapRestaurant: (restaurant, isFavorite) { + onTapRestaurantWasCalled = true; + }, + ), + ), + ), + ); + + await tester.tap(find.byType(RestaurantCard).first); + + expect(onTapRestaurantWasCalled, isTrue); + }); +} diff --git a/test/presentation/screens/home/home_screen_test.dart b/test/presentation/screens/home/home_screen_test.dart new file mode 100644 index 0000000..18167e9 --- /dev/null +++ b/test/presentation/screens/home/home_screen_test.dart @@ -0,0 +1,79 @@ +import 'dart:math'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:network_image_mock/network_image_mock.dart'; +import 'package:restaurant_tour/domain/use_cases/get_favorites_restaurants_use_case.dart'; +import 'package:restaurant_tour/domain/use_cases/get_restaurants_use_case.dart'; +import 'package:restaurant_tour/domain/use_cases/toggle_favorite.dart'; +import 'package:restaurant_tour/presentation/components/restaurant_card.dart'; +import 'package:restaurant_tour/presentation/screens/home/home_screen.dart'; + +import '../../../fakes/repositories/fake_restaurants_repository.dart'; +import '../../../make_testable_widget.dart'; + +void main() { + late GetRestaurantsUseCase getRestaurantsUseCase; + late GetFavoriteRestaurantsUseCase getFavoriteRestaurantsUseCase; + late ToggleFavoriteUseCase toggleFavoriteUseCase; + late FakeRestaurantsRepository fakeRestaurantsRepository; + + setUp(() { + fakeRestaurantsRepository = FakeRestaurantsRepository(); + getRestaurantsUseCase = + GetRestaurantsUseCase(restaurantsRepository: fakeRestaurantsRepository); + getFavoriteRestaurantsUseCase = GetFavoriteRestaurantsUseCase( + restaurantsRepository: fakeRestaurantsRepository); + toggleFavoriteUseCase = + ToggleFavoriteUseCase(restaurantsRepository: fakeRestaurantsRepository); + }); + + testWidgets('HomeScreen should render correctly', (tester) async { + await mockNetworkImagesFor( + () => tester.pumpWidget( + makeTestableWidget( + child: HomeScreen( + toggleFavoriteUseCase: toggleFavoriteUseCase, + getAllRestaurantsUseCase: getRestaurantsUseCase, + getFavoriteRestaurantsUseCase: getFavoriteRestaurantsUseCase, + ), + ), + ), + ); + + expect(find.byType(HomeScreen), findsOneWidget); + + expect(find.text("RestauranTour"), findsOneWidget); + expect(find.text("All Restaurants"), findsOneWidget); + expect(find.text("My Favorites"), findsOneWidget); + + await mockNetworkImagesFor(() => tester.pumpAndSettle()); + + expect(find.byType(RestaurantCard), findsNWidgets(2)); + }); + + testWidgets('should switch tabs', (tester) async { + await mockNetworkImagesFor( + () => tester.pumpWidget( + makeTestableWidget( + child: HomeScreen( + toggleFavoriteUseCase: toggleFavoriteUseCase, + getAllRestaurantsUseCase: getRestaurantsUseCase, + getFavoriteRestaurantsUseCase: getFavoriteRestaurantsUseCase, + ), + ), + ), + ); + + await mockNetworkImagesFor( + () => tester.pumpAndSettle(const Duration(seconds: 1)), + ); + + expect(find.byType(RestaurantCard), findsWidgets); + + await tester.tap(find.text("My Favorites")); + await tester.pumpAndSettle(); + + // there is only one favorite restaurant + expect(find.byType(RestaurantCard), findsNWidgets(1)); + }); +} diff --git a/test/presentation/screens/restaurant_details/restaurant_details_screen_test.dart b/test/presentation/screens/restaurant_details/restaurant_details_screen_test.dart new file mode 100644 index 0000000..9f06834 --- /dev/null +++ b/test/presentation/screens/restaurant_details/restaurant_details_screen_test.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:network_image_mock/network_image_mock.dart'; +import 'package:restaurant_tour/presentation/screens/restaurant_details/restaurant_details_screen.dart'; + +import '../../../fakes/data/fake_restaurant.dart'; +import '../../../make_testable_widget.dart'; + +void main() { + testWidgets('RestaurantDetailsScreen should render correctly', + (tester) async { + await mockNetworkImagesFor( + () => tester.pumpWidget( + makeTestableWidget( + child: RestaurantDetailsScreen( + restaurant: fakeRestaurant.toDomain(), + isFavorite: false, + onToggleFavorite: () {}, + ), + ), + ), + ); + + expect(find.byType(RestaurantDetailsScreen), findsOneWidget); + + expect(find.text(fakeRestaurant.name!), findsOneWidget); + // is open now + expect(find.text("Open Now"), findsOneWidget); + // is not favorite + expect(find.byIcon(Icons.favorite_border), findsOneWidget); + }); +} 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); - }); -} From 94298e8c911d429aad68da994832c740b5cf012d Mon Sep 17 00:00:00 2001 From: Felipe Reis Date: Sat, 7 Sep 2024 01:48:50 -0300 Subject: [PATCH 02/23] refactor: add onTapRestaurant callback to HomeScreen and update tests --- lib/main.dart | 30 +++++++-- .../screens/home/home_screen.dart | 33 ++-------- .../screens/home/home_screen_test.dart | 63 ++++++++++++++++++- 3 files changed, 91 insertions(+), 35 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 9d41cf0..b9be196 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,6 +4,7 @@ import 'package:restaurant_tour/domain/use_cases/get_favorites_restaurants_use_c import 'package:restaurant_tour/domain/use_cases/get_restaurants_use_case.dart'; import 'package:restaurant_tour/domain/use_cases/toggle_favorite.dart'; import 'package:restaurant_tour/presentation/screens/home/home_screen.dart'; +import 'package:restaurant_tour/presentation/screens/restaurant_details/restaurant_details_screen.dart'; void main() { setupDI(); @@ -18,11 +19,30 @@ class RestaurantTour extends StatelessWidget { return MaterialApp( debugShowCheckedModeBanner: false, title: 'Restaurant Tour', - home: HomeScreen( - getAllRestaurantsUseCase: getIt.get(), - getFavoriteRestaurantsUseCase: - getIt.get(), - toggleFavoriteUseCase: getIt.get(), + home: Builder( + builder: (context) { + return HomeScreen( + getAllRestaurantsUseCase: getIt.get(), + getFavoriteRestaurantsUseCase: + getIt.get(), + toggleFavoriteUseCase: getIt.get(), + onTapRestaurant: (restaurant, isFavorite) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => RestaurantDetailsScreen( + restaurant: restaurant, + isFavorite: isFavorite, + onToggleFavorite: () { + final toggleFavoriteUseCase = + getIt.get(); + toggleFavoriteUseCase(restaurant); + }, + ), + ), + ); + }, + ); + }, ), ); } diff --git a/lib/presentation/screens/home/home_screen.dart b/lib/presentation/screens/home/home_screen.dart index 891ae5c..805cd55 100644 --- a/lib/presentation/screens/home/home_screen.dart +++ b/lib/presentation/screens/home/home_screen.dart @@ -9,18 +9,19 @@ import 'package:restaurant_tour/presentation/screens/home/all_restaurants_tab.da import 'package:restaurant_tour/presentation/screens/home/favorite_restaurants_tab.dart'; import 'package:restaurant_tour/core/theme/typography.dart'; -import 'package:restaurant_tour/presentation/screens/restaurant_details/restaurant_details_screen.dart'; class HomeScreen extends StatefulWidget { final GetRestaurantsUseCase getAllRestaurantsUseCase; final GetFavoriteRestaurantsUseCase getFavoriteRestaurantsUseCase; final ToggleFavoriteUseCase toggleFavoriteUseCase; + final void Function(Restaurant restaurant, bool isFavorite) onTapRestaurant; const HomeScreen({ super.key, required this.getAllRestaurantsUseCase, required this.getFavoriteRestaurantsUseCase, required this.toggleFavoriteUseCase, + required this.onTapRestaurant, }); @override @@ -91,35 +92,13 @@ class _HomeScreenState extends State { getAllRestaurantsUseCase: widget.getAllRestaurantsUseCase, toggleFavoriteUseCase: widget.toggleFavoriteUseCase, favoriteRestaurants: favoriteRestaurants, - onTapRestaurant: (restaurant, isFavorite) { - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => RestaurantDetailsScreen( - restaurant: restaurant, - isFavorite: isFavorite, - onToggleFavorite: () { - widget.toggleFavoriteUseCase(restaurant); - }, - ), - ), - ); - }, + onTapRestaurant: (restaurant, isFavorite) => + widget.onTapRestaurant(restaurant, isFavorite), ), FavoriteRestaurantsTab( restaurants: favoriteRestaurants, - onTapRestaurant: (restaurant, isFavorite) { - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => RestaurantDetailsScreen( - restaurant: restaurant, - isFavorite: true, - onToggleFavorite: () { - widget.toggleFavoriteUseCase(restaurant); - }, - ), - ), - ); - }, + onTapRestaurant: (restaurant, isFavorite) => + widget.onTapRestaurant(restaurant, isFavorite), ), ], ), diff --git a/test/presentation/screens/home/home_screen_test.dart b/test/presentation/screens/home/home_screen_test.dart index 18167e9..3783855 100644 --- a/test/presentation/screens/home/home_screen_test.dart +++ b/test/presentation/screens/home/home_screen_test.dart @@ -35,6 +35,7 @@ void main() { toggleFavoriteUseCase: toggleFavoriteUseCase, getAllRestaurantsUseCase: getRestaurantsUseCase, getFavoriteRestaurantsUseCase: getFavoriteRestaurantsUseCase, + onTapRestaurant: (restaurant, isFavorite) {}, ), ), ), @@ -59,14 +60,13 @@ void main() { toggleFavoriteUseCase: toggleFavoriteUseCase, getAllRestaurantsUseCase: getRestaurantsUseCase, getFavoriteRestaurantsUseCase: getFavoriteRestaurantsUseCase, + onTapRestaurant: (restaurant, isFavorite) {}, ), ), ), ); - await mockNetworkImagesFor( - () => tester.pumpAndSettle(const Duration(seconds: 1)), - ); + await mockNetworkImagesFor(() => tester.pumpAndSettle()); expect(find.byType(RestaurantCard), findsWidgets); @@ -76,4 +76,61 @@ void main() { // there is only one favorite restaurant expect(find.byType(RestaurantCard), findsNWidgets(1)); }); + + testWidgets( + 'onTapRestaurant callback should be called in the "All Restaurants" tab', + (tester) async { + bool onTapRestaurantWasCalled = false; + + await mockNetworkImagesFor( + () => tester.pumpWidget( + makeTestableWidget( + child: HomeScreen( + toggleFavoriteUseCase: toggleFavoriteUseCase, + getAllRestaurantsUseCase: getRestaurantsUseCase, + getFavoriteRestaurantsUseCase: getFavoriteRestaurantsUseCase, + onTapRestaurant: (restaurant, isFavorite) { + onTapRestaurantWasCalled = true; + }, + ), + ), + ), + ); + + await mockNetworkImagesFor(() => tester.pumpAndSettle()); + + await tester.tap(find.byType(RestaurantCard).first); + + expect(onTapRestaurantWasCalled, isTrue); + }); + + testWidgets( + 'onTapRestaurant callback should be called in the "My Favorites" tab', + (tester) async { + bool onTapRestaurantWasCalled = false; + + await mockNetworkImagesFor( + () => tester.pumpWidget( + makeTestableWidget( + child: HomeScreen( + toggleFavoriteUseCase: toggleFavoriteUseCase, + getAllRestaurantsUseCase: getRestaurantsUseCase, + getFavoriteRestaurantsUseCase: getFavoriteRestaurantsUseCase, + onTapRestaurant: (restaurant, isFavorite) { + onTapRestaurantWasCalled = true; + }, + ), + ), + ), + ); + + await mockNetworkImagesFor(() => tester.pumpAndSettle()); + + await tester.tap(find.text("My Favorites")); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(RestaurantCard).first); + + expect(onTapRestaurantWasCalled, isTrue); + }); } From b5f3bb3d65991d40d9313165b1f77b33a1ce86f8 Mon Sep 17 00:00:00 2001 From: Felipe Reis Date: Sat, 7 Sep 2024 01:50:50 -0300 Subject: [PATCH 03/23] remove a comment --- .../screens/restaurant_details/restaurant_details_screen.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/presentation/screens/restaurant_details/restaurant_details_screen.dart b/lib/presentation/screens/restaurant_details/restaurant_details_screen.dart index 0d77336..586d86d 100644 --- a/lib/presentation/screens/restaurant_details/restaurant_details_screen.dart +++ b/lib/presentation/screens/restaurant_details/restaurant_details_screen.dart @@ -152,7 +152,6 @@ class _RestaurantDetailsScreenState extends State { "${widget.restaurant.reviews!.length} Reviews", style: AppTextStyles.openRegularText, ), - // TODO: add real data const SizedBox(height: 16), ListView.separated( shrinkWrap: true, From bf0438b33f9f8a579d1e6f89fa957a6ec094788b Mon Sep 17 00:00:00 2001 From: Felipe Reis Date: Sat, 7 Sep 2024 01:54:19 -0300 Subject: [PATCH 04/23] refactor: remove api key from code --- lib/di/di.dart | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/lib/di/di.dart b/lib/di/di.dart index 0e32d30..dd33dc1 100644 --- a/lib/di/di.dart +++ b/lib/di/di.dart @@ -9,17 +9,15 @@ import 'package:restaurant_tour/domain/use_cases/get_favorites_restaurants_use_c final getIt = GetIt.instance; -// TODO: Expose it in a more secure way -const _apiKey = - 'FQVwPGF1gSkxwYDrdntEfehFGJRXb5HYBcLfesykIgAEeopf6_YrfvRmY_iGkQOnQ97oRwAqOcrGwHK0In71SGPnOJRKEBYUOo8IhiIS53HUVMdE2BiY1JeKIDLbZnYx'; - void setupDI() { + const apiKey = String.fromEnvironment('API_KEY'); + getIt.registerLazySingleton( () => Dio( BaseOptions( baseUrl: 'https://api.yelp.com', headers: { - 'Authorization': 'Bearer $_apiKey', + 'Authorization': 'Bearer $apiKey', 'Content-Type': 'application/graphql', }, ), From 8f13496028dfe02da647bd80e2217776a2287b8a Mon Sep 17 00:00:00 2001 From: Felipe Reis Date: Sat, 7 Sep 2024 01:56:21 -0300 Subject: [PATCH 05/23] refactor: remove some code used for testing in the RestaurantsRepository --- lib/data/repositories/cached_response.dart | 4 +++- lib/data/repositories/restaurants_repository.dart | 15 +-------------- 2 files changed, 4 insertions(+), 15 deletions(-) diff --git a/lib/data/repositories/cached_response.dart b/lib/data/repositories/cached_response.dart index 3510e92..5df69c0 100644 --- a/lib/data/repositories/cached_response.dart +++ b/lib/data/repositories/cached_response.dart @@ -1,3 +1,4 @@ +// I'm using this to not reach the rate limit of the API final cachedResponse = { "data": { "search": { @@ -1080,7 +1081,8 @@ final cachedResponse = { {"is_open_now": true}, ], "location": { - "formatted_address": "3900 Paradise Rd\nSte D1\nLas Vegas, NV 89169", + "formatted_address": + "3900 Paradise Rd\nSte D1\nLas Vegas, NV 89169", }, }, { diff --git a/lib/data/repositories/restaurants_repository.dart b/lib/data/repositories/restaurants_repository.dart index a397530..e1efd28 100644 --- a/lib/data/repositories/restaurants_repository.dart +++ b/lib/data/repositories/restaurants_repository.dart @@ -5,10 +5,8 @@ import 'package:restaurant_tour/data/dtos/restaurant_dto.dart'; import 'package:restaurant_tour/domain/models/restaurant.dart'; import 'package:restaurant_tour/domain/repositories/restaurants_repository.dart'; -import 'package:restaurant_tour/data/repositories/cached_response.dart'; - class RestaurantsRepository extends BaseRestaurantsRepository { - // I could have created a remote data provider here + // I could have created a remote data provider for this final Dio _httpClient; RestaurantsRepository({ @@ -31,17 +29,6 @@ class RestaurantsRepository extends BaseRestaurantsRepository { @override Future> getRestaurants({int offset = 0}) async { - //TODO: Remove this... just to not reach the rate limit - final response = cachedResponse; - - final data = RestaurantDto.fromJson(response['data']['search']); - - if (data.restaurants != null) { - return data.restaurants!.map((e) => e.toDomain()).toList(); - } - - return []; - try { final response = await _httpClient.post>( '/v3/graphql', From d508d6a890810753977bd4f0b14644bc07e2e871 Mon Sep 17 00:00:00 2001 From: Felipe Reis Date: Sat, 7 Sep 2024 11:11:53 -0300 Subject: [PATCH 06/23] refactor: getRestaurants can return null --- lib/data/repositories/restaurants_repository.dart | 6 +++--- test/data/repositories/restaurants_repository_test.dart | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/data/repositories/restaurants_repository.dart b/lib/data/repositories/restaurants_repository.dart index e1efd28..e7b1e73 100644 --- a/lib/data/repositories/restaurants_repository.dart +++ b/lib/data/repositories/restaurants_repository.dart @@ -28,7 +28,7 @@ class RestaurantsRepository extends BaseRestaurantsRepository { } @override - Future> getRestaurants({int offset = 0}) async { + Future?> getRestaurants({int offset = 0}) async { try { final response = await _httpClient.post>( '/v3/graphql', @@ -41,9 +41,9 @@ class RestaurantsRepository extends BaseRestaurantsRepository { return data.restaurants!.map((e) => e.toDomain()).toList(); } - return []; + return null; } catch (e) { - return []; + return null; } } diff --git a/test/data/repositories/restaurants_repository_test.dart b/test/data/repositories/restaurants_repository_test.dart index 254c83d..7414aec 100644 --- a/test/data/repositories/restaurants_repository_test.dart +++ b/test/data/repositories/restaurants_repository_test.dart @@ -41,7 +41,7 @@ void main() { final data = await restaurantsRepository.getRestaurants(); - expect(data.length, 20); + expect(data?.length, 20); }); test('should return favorite restaurants', () { From 676c1338361411ed36d032d1f0b9d15da7be23f6 Mon Sep 17 00:00:00 2001 From: Felipe Reis Date: Sat, 7 Sep 2024 11:58:50 -0300 Subject: [PATCH 07/23] refactor: improve they way of using mocked data --- .../mocked_cached_response.dart} | 0 .../mock/mocked_restaurants_repository.dart | 61 +++++++++++++++++++ lib/di/di.dart | 7 ++- test/data/dtos/restaurant_dto_test.dart | 2 +- .../restaurants_repository_test.dart | 2 +- 5 files changed, 69 insertions(+), 3 deletions(-) rename lib/data/repositories/{cached_response.dart => mock/mocked_cached_response.dart} (100%) create mode 100644 lib/data/repositories/mock/mocked_restaurants_repository.dart diff --git a/lib/data/repositories/cached_response.dart b/lib/data/repositories/mock/mocked_cached_response.dart similarity index 100% rename from lib/data/repositories/cached_response.dart rename to lib/data/repositories/mock/mocked_cached_response.dart diff --git a/lib/data/repositories/mock/mocked_restaurants_repository.dart b/lib/data/repositories/mock/mocked_restaurants_repository.dart new file mode 100644 index 0000000..c2328e2 --- /dev/null +++ b/lib/data/repositories/mock/mocked_restaurants_repository.dart @@ -0,0 +1,61 @@ +import 'dart:async'; + +import 'package:restaurant_tour/data/dtos/restaurant_dto.dart'; +import 'package:restaurant_tour/domain/models/restaurant.dart'; +import 'package:restaurant_tour/domain/repositories/restaurants_repository.dart'; + +import 'package:restaurant_tour/data/repositories/mock/mocked_cached_response.dart'; + +class MockedRestaurantsRepository extends BaseRestaurantsRepository { + MockedRestaurantsRepository(); + + // This is a simple in memory provider for the favorites + final List _favorites = []; + final StreamController> + _favoriteRestaurantsStreamController = + StreamController>(); + + Stream> get _favoriteRestaurants => + _favoriteRestaurantsStreamController.stream; + + @override + Stream> getFavorites() { + return _favoriteRestaurants; + } + + @override + Future?> getRestaurants({int offset = 0}) async { + try { + final response = cachedResponse; + + final data = RestaurantDto.fromJson(response['data']['search']); + + if (data.restaurants != null) { + return data.restaurants!.map((e) => e.toDomain()).toList(); + } + + return null; + } catch (e) { + return null; + } + } + + @override + void toggleFavorite(Restaurant restaurant) { + final found = + _favorites.indexWhere((element) => element.id == restaurant.id) != -1; + + if (found) { + _favorites.removeWhere((element) => element.id == restaurant.id); + } else { + _favorites.add(restaurant); + } + + _favoriteRestaurantsStreamController.add(_favorites); + } + + @override + dispose() { + _favoriteRestaurantsStreamController.close(); + } +} diff --git a/lib/di/di.dart b/lib/di/di.dart index dd33dc1..d8194e6 100644 --- a/lib/di/di.dart +++ b/lib/di/di.dart @@ -7,10 +7,13 @@ import 'package:restaurant_tour/data/repositories/restaurants_repository.dart'; import 'package:restaurant_tour/domain/repositories/restaurants_repository.dart'; import 'package:restaurant_tour/domain/use_cases/get_favorites_restaurants_use_case.dart'; +import 'package:restaurant_tour/data/repositories/mock/mocked_restaurants_repository.dart'; + final getIt = GetIt.instance; void setupDI() { const apiKey = String.fromEnvironment('API_KEY'); + const mockApi = bool.fromEnvironment('MOCK_API', defaultValue: true); getIt.registerLazySingleton( () => Dio( @@ -25,7 +28,9 @@ void setupDI() { ); getIt.registerLazySingleton( - () => RestaurantsRepository(httpClient: getIt.get()), + () => mockApi + ? MockedRestaurantsRepository() + : RestaurantsRepository(httpClient: getIt.get()), dispose: (repo) => repo.dispose(), ); diff --git a/test/data/dtos/restaurant_dto_test.dart b/test/data/dtos/restaurant_dto_test.dart index 64125a9..0507fc8 100644 --- a/test/data/dtos/restaurant_dto_test.dart +++ b/test/data/dtos/restaurant_dto_test.dart @@ -1,6 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:restaurant_tour/data/dtos/restaurant_dto.dart'; -import 'package:restaurant_tour/data/repositories/cached_response.dart'; +import 'package:restaurant_tour/data/repositories/mock/mocked_cached_response.dart'; void main() { testWidgets('RestaurantDto fromJson should return an instance', diff --git a/test/data/repositories/restaurants_repository_test.dart b/test/data/repositories/restaurants_repository_test.dart index 7414aec..f7137f8 100644 --- a/test/data/repositories/restaurants_repository_test.dart +++ b/test/data/repositories/restaurants_repository_test.dart @@ -1,7 +1,7 @@ import 'package:dio/dio.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http_mock_adapter/http_mock_adapter.dart'; -import 'package:restaurant_tour/data/repositories/cached_response.dart'; +import 'package:restaurant_tour/data/repositories/mock/mocked_cached_response.dart'; import 'package:restaurant_tour/data/repositories/restaurants_repository.dart'; import 'package:restaurant_tour/domain/models/restaurant.dart'; From f72929063dadb6c218881e623fc841575fb19aec Mon Sep 17 00:00:00 2001 From: Felipe Reis Date: Sat, 7 Sep 2024 12:04:47 -0300 Subject: [PATCH 08/23] refactor: better approach to use the mocked api --- lib/di/di.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/di/di.dart b/lib/di/di.dart index d8194e6..9d3fd07 100644 --- a/lib/di/di.dart +++ b/lib/di/di.dart @@ -13,7 +13,8 @@ final getIt = GetIt.instance; void setupDI() { const apiKey = String.fromEnvironment('API_KEY'); - const mockApi = bool.fromEnvironment('MOCK_API', defaultValue: true); + final shouldMockApi = apiKey.isEmpty; + final mockApi = bool.fromEnvironment('MOCK_API', defaultValue: shouldMockApi); getIt.registerLazySingleton( () => Dio( From b0153b746b3424d1fec6a1d68b66a46d3d72a4b2 Mon Sep 17 00:00:00 2001 From: Felipe Reis Date: Sat, 7 Sep 2024 12:31:52 -0300 Subject: [PATCH 09/23] docs: add a comment to RestaurantsRepository about a improved way to return data --- lib/data/repositories/restaurants_repository.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/data/repositories/restaurants_repository.dart b/lib/data/repositories/restaurants_repository.dart index e7b1e73..c9b0fc5 100644 --- a/lib/data/repositories/restaurants_repository.dart +++ b/lib/data/repositories/restaurants_repository.dart @@ -40,7 +40,7 @@ class RestaurantsRepository extends BaseRestaurantsRepository { if (data.restaurants != null) { return data.restaurants!.map((e) => e.toDomain()).toList(); } - + // TODO: This could be improved by returning a custom response with success or error, something like either_dart return null; } catch (e) { return null; From 5dc49db19e3d5fd6d9fa4c1a0e92dcfd218ab77f Mon Sep 17 00:00:00 2001 From: Felipe Reis Date: Sat, 7 Sep 2024 12:38:56 -0300 Subject: [PATCH 10/23] docs: add some comments about passing the restaurant in the navigation --- lib/main.dart | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/main.dart b/lib/main.dart index b9be196..1c73a30 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -27,6 +27,9 @@ class RestaurantTour extends StatelessWidget { getIt.get(), toggleFavoriteUseCase: getIt.get(), onTapRestaurant: (restaurant, isFavorite) { + // As we have a simple route here, I'm just pushing it directly + // I'm also passing the restaurant to the next screen as we don't have an api to fetch it + // In a real world scenario, we would fetch the restaurant again Navigator.of(context).push( MaterialPageRoute( builder: (context) => RestaurantDetailsScreen( From e324b5d0da2c9f4b4c8c8456be0d3eb842c53e77 Mon Sep 17 00:00:00 2001 From: Felipe Reis Date: Mon, 9 Sep 2024 14:26:03 -0300 Subject: [PATCH 11/23] refactor: remove test code --- lib/presentation/components/review_card.dart | 2 +- .../restaurant_details_screen.dart | 21 ++++--------------- 2 files changed, 5 insertions(+), 18 deletions(-) diff --git a/lib/presentation/components/review_card.dart b/lib/presentation/components/review_card.dart index 4ba4d81..aa52247 100644 --- a/lib/presentation/components/review_card.dart +++ b/lib/presentation/components/review_card.dart @@ -31,7 +31,7 @@ class ReviewCard extends StatelessWidget { shape: BoxShape.circle, image: DecorationImage( image: NetworkImage( - review.user!.imageUrl!, + review.user!.imageUrl ?? 'https://eu.ui-avatars.com/api/?name=${review.user!.name!.split('').join('+')}&size=250', ), fit: BoxFit.cover, ), diff --git a/lib/presentation/screens/restaurant_details/restaurant_details_screen.dart b/lib/presentation/screens/restaurant_details/restaurant_details_screen.dart index 586d86d..5cd7095 100644 --- a/lib/presentation/screens/restaurant_details/restaurant_details_screen.dart +++ b/lib/presentation/screens/restaurant_details/restaurant_details_screen.dart @@ -1,7 +1,5 @@ import 'package:flutter/material.dart'; import 'package:restaurant_tour/domain/models/restaurant.dart'; -import 'package:restaurant_tour/domain/models/review.dart'; -import 'package:restaurant_tour/domain/models/user.dart'; import 'package:restaurant_tour/presentation/components/review_card.dart'; import 'package:restaurant_tour/core/theme/colors.dart'; @@ -109,8 +107,7 @@ class _RestaurantDetailsScreenState extends State { ), ), Text( - widget.restaurant.location?.formattedAddress ?? - "102 Lakeside Ave Seattle, WA 98122", + widget.restaurant.location?.formattedAddress ?? "", style: AppTextStyles.openRegularTitleSemiBold, ), const Padding( @@ -165,22 +162,12 @@ class _RestaurantDetailsScreenState extends State { ), ), itemBuilder: (context, index) { - return const ReviewCard( - review: Review( - id: 'asdasdasd', - rating: 4, - text: - "Review text goes here. Review text goes here. Review text goes here. This is a review. This is a review. This is a review that is 4 lines long.", - user: User( - id: "asdasd", - name: "User name", - imageUrl: "https://via.placeholder.com/150", - ), - ), + final review = widget.restaurant.reviews![index]; + return ReviewCard( + review: review, ); }, ), - //-- Reviews ], ), From 977350f02facc64fdd9b18080237a14bb9a10b65 Mon Sep 17 00:00:00 2001 From: Felipe Reis Date: Mon, 9 Sep 2024 14:55:57 -0300 Subject: [PATCH 12/23] refactor: improve how user imageUrl is displayd --- lib/domain/models/user.dart | 8 ++++---- lib/presentation/components/review_card.dart | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/domain/models/user.dart b/lib/domain/models/user.dart index 5ecc26c..863f54a 100644 --- a/lib/domain/models/user.dart +++ b/lib/domain/models/user.dart @@ -1,11 +1,11 @@ class User { final String? id; - final String? imageUrl; + final String imageUrl; final String? name; - const User({ + User({ this.id, - this.imageUrl, + String? imageUrl, this.name, - }); + }) : imageUrl = imageUrl ?? 'https://eu.ui-avatars.com/api/?name=${name?.split('').join('+')}&size=250' ; } diff --git a/lib/presentation/components/review_card.dart b/lib/presentation/components/review_card.dart index aa52247..b471829 100644 --- a/lib/presentation/components/review_card.dart +++ b/lib/presentation/components/review_card.dart @@ -31,7 +31,7 @@ class ReviewCard extends StatelessWidget { shape: BoxShape.circle, image: DecorationImage( image: NetworkImage( - review.user!.imageUrl ?? 'https://eu.ui-avatars.com/api/?name=${review.user!.name!.split('').join('+')}&size=250', + review.user!.imageUrl, ), fit: BoxFit.cover, ), From bdd3fbaf72a4e38163d1b9656a36e1f22e96beb6 Mon Sep 17 00:00:00 2001 From: Felipe Reis Date: Tue, 10 Sep 2024 22:14:07 -0300 Subject: [PATCH 13/23] fix: keep state for AllRestaurantsTab --- lib/presentation/screens/home/all_restaurants_tab.dart | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/lib/presentation/screens/home/all_restaurants_tab.dart b/lib/presentation/screens/home/all_restaurants_tab.dart index 4b8f89d..6db232f 100644 --- a/lib/presentation/screens/home/all_restaurants_tab.dart +++ b/lib/presentation/screens/home/all_restaurants_tab.dart @@ -23,16 +23,22 @@ class AllRestaurantsTab extends StatefulWidget { State createState() => _AllRestaurantsTabState(); } -class _AllRestaurantsTabState extends State { +class _AllRestaurantsTabState extends State with AutomaticKeepAliveClientMixin { late Future?> restaurantsFuture; + @override void initState() { super.initState(); restaurantsFuture = widget.getAllRestaurantsUseCase(); } + @override + bool get wantKeepAlive => true; + @override Widget build(BuildContext context) { + super.build(context); + return FutureBuilder( future: restaurantsFuture, builder: (context, snapshot) { @@ -73,4 +79,5 @@ class _AllRestaurantsTabState extends State { }, ); } + } From f44c3df0575289658c11f7e27210294a8ac99d85 Mon Sep 17 00:00:00 2001 From: Felipe Reis Date: Tue, 10 Sep 2024 22:26:54 -0300 Subject: [PATCH 14/23] fix: mockApi always returning false --- lib/di/di.dart | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/di/di.dart b/lib/di/di.dart index 9d3fd07..48f9bca 100644 --- a/lib/di/di.dart +++ b/lib/di/di.dart @@ -13,8 +13,7 @@ final getIt = GetIt.instance; void setupDI() { const apiKey = String.fromEnvironment('API_KEY'); - final shouldMockApi = apiKey.isEmpty; - final mockApi = bool.fromEnvironment('MOCK_API', defaultValue: shouldMockApi); + const mockApi = bool.fromEnvironment('MOCK_API', defaultValue: false); getIt.registerLazySingleton( () => Dio( From 93f0748338599e70992352b6dcd907a1b14e91bf Mon Sep 17 00:00:00 2001 From: Felipe Reis Date: Tue, 10 Sep 2024 22:32:17 -0300 Subject: [PATCH 15/23] refactor: add shouldMockApi --- lib/di/di.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/di/di.dart b/lib/di/di.dart index 48f9bca..60359d8 100644 --- a/lib/di/di.dart +++ b/lib/di/di.dart @@ -14,6 +14,7 @@ final getIt = GetIt.instance; void setupDI() { const apiKey = String.fromEnvironment('API_KEY'); const mockApi = bool.fromEnvironment('MOCK_API', defaultValue: false); + final shouldMockApi = apiKey.isEmpty || mockApi; getIt.registerLazySingleton( () => Dio( @@ -28,7 +29,7 @@ void setupDI() { ); getIt.registerLazySingleton( - () => mockApi + () => shouldMockApi ? MockedRestaurantsRepository() : RestaurantsRepository(httpClient: getIt.get()), dispose: (repo) => repo.dispose(), From a2c25a0b0b78378932dca725f137c9730ca2376a Mon Sep 17 00:00:00 2001 From: Felipe Reis Date: Wed, 11 Sep 2024 21:31:40 -0300 Subject: [PATCH 16/23] refactor: an example of a better way to handle errors from network calls --- lib/core/domain/error/data_error.dart | 15 +++++ lib/core/domain/error/error.dart | 1 + .../mock/mocked_restaurants_repository.dart | 13 ++-- .../repositories/restaurants_repository.dart | 30 +++++++-- .../repositories/restaurants_repository.dart | 6 +- .../use_cases/get_restaurants_use_case.dart | 6 +- .../screens/home/all_restaurants_tab.dart | 65 ++++++++++++------- pubspec.lock | 8 +++ pubspec.yaml | 1 + .../restaurants_repository_test.dart | 2 +- .../get_restaurants_use_case_test.dart | 2 +- .../fake_restaurants_repository.dart | 6 +- 12 files changed, 115 insertions(+), 40 deletions(-) create mode 100644 lib/core/domain/error/data_error.dart create mode 100644 lib/core/domain/error/error.dart diff --git a/lib/core/domain/error/data_error.dart b/lib/core/domain/error/data_error.dart new file mode 100644 index 0000000..c1497de --- /dev/null +++ b/lib/core/domain/error/data_error.dart @@ -0,0 +1,15 @@ +import 'package:restaurant_tour/core/domain/error/error.dart'; + +sealed class DataError extends BaseError {} + +sealed class NetworkError extends DataError {} + +final class NoInternetConnectionError extends NetworkError {} + +final class TimeoutError extends NetworkError {} + +final class ServerError extends NetworkError {} + +final class UnknownError extends NetworkError {} + +final class RateLimitError extends NetworkError {} \ No newline at end of file diff --git a/lib/core/domain/error/error.dart b/lib/core/domain/error/error.dart new file mode 100644 index 0000000..92fba59 --- /dev/null +++ b/lib/core/domain/error/error.dart @@ -0,0 +1 @@ +abstract class BaseError {} \ No newline at end of file diff --git a/lib/data/repositories/mock/mocked_restaurants_repository.dart b/lib/data/repositories/mock/mocked_restaurants_repository.dart index c2328e2..76c19b6 100644 --- a/lib/data/repositories/mock/mocked_restaurants_repository.dart +++ b/lib/data/repositories/mock/mocked_restaurants_repository.dart @@ -1,11 +1,16 @@ import 'dart:async'; +import 'package:dio/dio.dart'; +import 'package:multiple_result/multiple_result.dart'; +import 'package:restaurant_tour/core/domain/error/data_error.dart'; import 'package:restaurant_tour/data/dtos/restaurant_dto.dart'; import 'package:restaurant_tour/domain/models/restaurant.dart'; import 'package:restaurant_tour/domain/repositories/restaurants_repository.dart'; import 'package:restaurant_tour/data/repositories/mock/mocked_cached_response.dart'; +import '../../../core/domain/error/error.dart'; + class MockedRestaurantsRepository extends BaseRestaurantsRepository { MockedRestaurantsRepository(); @@ -24,19 +29,19 @@ class MockedRestaurantsRepository extends BaseRestaurantsRepository { } @override - Future?> getRestaurants({int offset = 0}) async { + Future, BaseError>> getRestaurants({int offset = 0}) async { try { final response = cachedResponse; final data = RestaurantDto.fromJson(response['data']['search']); if (data.restaurants != null) { - return data.restaurants!.map((e) => e.toDomain()).toList(); + return Success(data.restaurants!.map((e) => e.toDomain()).toList()); } - return null; + return Error(UnknownError()); } catch (e) { - return null; + return Error(UnknownError()); } } diff --git a/lib/data/repositories/restaurants_repository.dart b/lib/data/repositories/restaurants_repository.dart index c9b0fc5..d024d9c 100644 --- a/lib/data/repositories/restaurants_repository.dart +++ b/lib/data/repositories/restaurants_repository.dart @@ -1,10 +1,14 @@ import 'dart:async'; import 'package:dio/dio.dart'; +import 'package:multiple_result/multiple_result.dart'; import 'package:restaurant_tour/data/dtos/restaurant_dto.dart'; import 'package:restaurant_tour/domain/models/restaurant.dart'; import 'package:restaurant_tour/domain/repositories/restaurants_repository.dart'; +import '../../core/domain/error/data_error.dart'; +import '../../core/domain/error/error.dart'; + class RestaurantsRepository extends BaseRestaurantsRepository { // I could have created a remote data provider for this final Dio _httpClient; @@ -28,7 +32,7 @@ class RestaurantsRepository extends BaseRestaurantsRepository { } @override - Future?> getRestaurants({int offset = 0}) async { + Future, BaseError>> getRestaurants({int offset = 0}) async { try { final response = await _httpClient.post>( '/v3/graphql', @@ -38,12 +42,26 @@ class RestaurantsRepository extends BaseRestaurantsRepository { final data = RestaurantDto.fromJson(response.data!['data']['search']); if (data.restaurants != null) { - return data.restaurants!.map((e) => e.toDomain()).toList(); + return Success(data.restaurants!.map((e) => e.toDomain()).toList()); + } + + return Error(UnknownError()); + } on DioException catch (e) { + // we could map all network errors here and create something to share the logic + // here a simple example + if (e.response?.statusCode == 400) { + return Error(RateLimitError()); } - // TODO: This could be improved by returning a custom response with success or error, something like either_dart - return null; - } catch (e) { - return null; + + return switch (e.type) { + DioExceptionType.badResponse => Error(UnknownError()), + DioExceptionType.connectionTimeout => Error(TimeoutError()), + DioExceptionType.connectionError => Error(NoInternetConnectionError()), + _ => Error(UnknownError()), + }; + } + catch (e) { + return Error(UnknownError()); } } diff --git a/lib/domain/repositories/restaurants_repository.dart b/lib/domain/repositories/restaurants_repository.dart index ae8c145..9c1e20c 100644 --- a/lib/domain/repositories/restaurants_repository.dart +++ b/lib/domain/repositories/restaurants_repository.dart @@ -1,9 +1,13 @@ +import 'package:multiple_result/multiple_result.dart'; import 'package:restaurant_tour/domain/models/restaurant.dart'; +import '../../core/domain/error/data_error.dart'; +import '../../core/domain/error/error.dart'; + abstract class BaseRestaurantsRepository { const BaseRestaurantsRepository(); - Future?> getRestaurants({int offset = 0}); + Future, BaseError>> getRestaurants({int offset = 0}); Stream> getFavorites(); diff --git a/lib/domain/use_cases/get_restaurants_use_case.dart b/lib/domain/use_cases/get_restaurants_use_case.dart index 5db531f..c18130e 100644 --- a/lib/domain/use_cases/get_restaurants_use_case.dart +++ b/lib/domain/use_cases/get_restaurants_use_case.dart @@ -1,6 +1,10 @@ +import 'package:multiple_result/multiple_result.dart'; +import 'package:restaurant_tour/core/domain/error/error.dart'; import 'package:restaurant_tour/domain/models/restaurant.dart'; import 'package:restaurant_tour/domain/repositories/restaurants_repository.dart'; +import '../../core/domain/error/data_error.dart'; + class GetRestaurantsUseCase { final BaseRestaurantsRepository _restaurantsRepository; @@ -8,7 +12,7 @@ class GetRestaurantsUseCase { required BaseRestaurantsRepository restaurantsRepository, }) : _restaurantsRepository = restaurantsRepository; - Future?> call() { + Future, BaseError>> call() { return _restaurantsRepository.getRestaurants(); } } diff --git a/lib/presentation/screens/home/all_restaurants_tab.dart b/lib/presentation/screens/home/all_restaurants_tab.dart index 6db232f..5ba3f6f 100644 --- a/lib/presentation/screens/home/all_restaurants_tab.dart +++ b/lib/presentation/screens/home/all_restaurants_tab.dart @@ -1,10 +1,14 @@ import 'package:flutter/material.dart'; +import 'package:multiple_result/multiple_result.dart'; import 'package:restaurant_tour/domain/models/restaurant.dart'; import 'package:restaurant_tour/domain/use_cases/toggle_favorite.dart'; import 'package:restaurant_tour/domain/use_cases/get_restaurants_use_case.dart'; import 'package:restaurant_tour/presentation/components/restaurant_card.dart'; +import '../../../core/domain/error/data_error.dart'; +import '../../../core/domain/error/error.dart'; + class AllRestaurantsTab extends StatefulWidget { final GetRestaurantsUseCase getAllRestaurantsUseCase; final ToggleFavoriteUseCase toggleFavoriteUseCase; @@ -23,8 +27,9 @@ class AllRestaurantsTab extends StatefulWidget { State createState() => _AllRestaurantsTabState(); } -class _AllRestaurantsTabState extends State with AutomaticKeepAliveClientMixin { - late Future?> restaurantsFuture; +class _AllRestaurantsTabState extends State + with AutomaticKeepAliveClientMixin { + late Future, BaseError>> restaurantsFuture; @override void initState() { @@ -50,34 +55,46 @@ class _AllRestaurantsTabState extends State with AutomaticKee ); } - if (snapshot.hasError || snapshot.data == null) { - return const Center( - child: Text("Failed to fetch restaurants"), - ); - } + return snapshot.data!.when( + (data) { + return ListView.builder( + itemCount: data.length, + itemBuilder: (context, index) { + final restaurant = data[index]; + final isFavorite = widget.favoriteRestaurants + .indexWhere((element) => element.id == restaurant.id) != + -1; - if (snapshot.data!.isEmpty) { - return const Center( - child: Text("No restaurants found"), - ); - } - - return ListView.builder( - itemCount: snapshot.data!.length, - itemBuilder: (context, index) { - final restaurant = snapshot.data![index]; - final isFavorite = widget.favoriteRestaurants - .indexWhere((element) => element.id == restaurant.id) != - -1; + return RestaurantCard( + restaurant: restaurant, + onTap: () => widget.onTapRestaurant(restaurant, isFavorite), + ); + }, + ); + }, + (error) { + if (error case DataError()) { + // here we could map data errors + return switch (error) { + RateLimitError() => const Center( + child: Text("Rate limit exceeded"), + ), + NoInternetConnectionError() => const Center( + child: Text("No internet connection"), + ), + _ => const Center( + child: Text("Something went wrong"), + ), + }; + } - return RestaurantCard( - restaurant: restaurant, - onTap: () => widget.onTapRestaurant(restaurant, isFavorite), + // here we can map other errors + return const Center( + child: Text("Something went wrong"), ); }, ); }, ); } - } diff --git a/pubspec.lock b/pubspec.lock index d86a43e..248db06 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -419,6 +419,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.4.4" + multiple_result: + dependency: "direct main" + description: + name: multiple_result + sha256: a7a8aa7a068648521ebd41e8c7296a990cd7ec5e15e7efa210c26fefd6e4f193 + url: "https://pub.dev" + source: hosted + version: "5.1.0" network_image_mock: dependency: "direct dev" description: diff --git a/pubspec.yaml b/pubspec.yaml index e84f04d..2ca9306 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -17,6 +17,7 @@ dependencies: json_annotation: ^4.9.0 flutter_svg: ^2.0.10 get_it: ^7.7.0 + multiple_result: ^5.1.0 dev_dependencies: flutter_test: diff --git a/test/data/repositories/restaurants_repository_test.dart b/test/data/repositories/restaurants_repository_test.dart index f7137f8..e179311 100644 --- a/test/data/repositories/restaurants_repository_test.dart +++ b/test/data/repositories/restaurants_repository_test.dart @@ -41,7 +41,7 @@ void main() { final data = await restaurantsRepository.getRestaurants(); - expect(data?.length, 20); + expect(data.tryGetSuccess()?.length, 20); }); test('should return favorite restaurants', () { diff --git a/test/domain/use_cases/get_restaurants_use_case_test.dart b/test/domain/use_cases/get_restaurants_use_case_test.dart index 26c1d66..580520a 100644 --- a/test/domain/use_cases/get_restaurants_use_case_test.dart +++ b/test/domain/use_cases/get_restaurants_use_case_test.dart @@ -18,6 +18,6 @@ void main() { test('should get restaurants', () async { final data = await getRestaurantsUseCase(); - expect(data?.length, fakeRestaurantsRepository.restaurants.length); + expect(data.tryGetSuccess()?.length, fakeRestaurantsRepository.restaurants.length); }); } diff --git a/test/fakes/repositories/fake_restaurants_repository.dart b/test/fakes/repositories/fake_restaurants_repository.dart index d918f9b..90b0d20 100644 --- a/test/fakes/repositories/fake_restaurants_repository.dart +++ b/test/fakes/repositories/fake_restaurants_repository.dart @@ -1,3 +1,5 @@ +import 'package:multiple_result/multiple_result.dart'; +import 'package:restaurant_tour/core/domain/error/error.dart'; import 'package:restaurant_tour/domain/models/restaurant.dart'; import 'package:restaurant_tour/domain/repositories/restaurants_repository.dart'; @@ -19,8 +21,8 @@ class FakeRestaurantsRepository extends BaseRestaurantsRepository { } @override - Future> getRestaurants({int offset = 0}) async { - return restaurants; + Future, BaseError>> getRestaurants({int offset = 0}) async { + return Success(restaurants); } @override From 2f29f8bac958e3f1d76d1577d46adfaca74f3960 Mon Sep 17 00:00:00 2001 From: Felipe Reis Date: Thu, 12 Sep 2024 17:02:30 -0300 Subject: [PATCH 17/23] refactor: format code --- lib/core/domain/error/data_error.dart | 2 +- lib/core/domain/error/error.dart | 2 +- .../mock/mocked_restaurants_repository.dart | 6 +++--- lib/data/repositories/restaurants_repository.dart | 10 +++++----- lib/domain/models/user.dart | 5 +++-- lib/domain/repositories/restaurants_repository.dart | 3 +-- lib/domain/use_cases/get_restaurants_use_case.dart | 2 -- lib/presentation/screens/home/all_restaurants_tab.dart | 4 ++-- .../screens/home/favorite_restaurants_tab.dart | 1 - lib/presentation/screens/home/home_screen.dart | 1 - 10 files changed, 16 insertions(+), 20 deletions(-) diff --git a/lib/core/domain/error/data_error.dart b/lib/core/domain/error/data_error.dart index c1497de..9363c27 100644 --- a/lib/core/domain/error/data_error.dart +++ b/lib/core/domain/error/data_error.dart @@ -12,4 +12,4 @@ final class ServerError extends NetworkError {} final class UnknownError extends NetworkError {} -final class RateLimitError extends NetworkError {} \ No newline at end of file +final class RateLimitError extends NetworkError {} diff --git a/lib/core/domain/error/error.dart b/lib/core/domain/error/error.dart index 92fba59..6e59396 100644 --- a/lib/core/domain/error/error.dart +++ b/lib/core/domain/error/error.dart @@ -1 +1 @@ -abstract class BaseError {} \ No newline at end of file +abstract class BaseError {} diff --git a/lib/data/repositories/mock/mocked_restaurants_repository.dart b/lib/data/repositories/mock/mocked_restaurants_repository.dart index 76c19b6..a0d032c 100644 --- a/lib/data/repositories/mock/mocked_restaurants_repository.dart +++ b/lib/data/repositories/mock/mocked_restaurants_repository.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:dio/dio.dart'; import 'package:multiple_result/multiple_result.dart'; import 'package:restaurant_tour/core/domain/error/data_error.dart'; import 'package:restaurant_tour/data/dtos/restaurant_dto.dart'; @@ -9,7 +8,7 @@ import 'package:restaurant_tour/domain/repositories/restaurants_repository.dart' import 'package:restaurant_tour/data/repositories/mock/mocked_cached_response.dart'; -import '../../../core/domain/error/error.dart'; +import 'package:restaurant_tour/core/domain/error/error.dart'; class MockedRestaurantsRepository extends BaseRestaurantsRepository { MockedRestaurantsRepository(); @@ -29,7 +28,8 @@ class MockedRestaurantsRepository extends BaseRestaurantsRepository { } @override - Future, BaseError>> getRestaurants({int offset = 0}) async { + Future, BaseError>> getRestaurants( + {int offset = 0}) async { try { final response = cachedResponse; diff --git a/lib/data/repositories/restaurants_repository.dart b/lib/data/repositories/restaurants_repository.dart index d024d9c..77a1661 100644 --- a/lib/data/repositories/restaurants_repository.dart +++ b/lib/data/repositories/restaurants_repository.dart @@ -6,8 +6,8 @@ import 'package:restaurant_tour/data/dtos/restaurant_dto.dart'; import 'package:restaurant_tour/domain/models/restaurant.dart'; import 'package:restaurant_tour/domain/repositories/restaurants_repository.dart'; -import '../../core/domain/error/data_error.dart'; -import '../../core/domain/error/error.dart'; +import 'package:restaurant_tour/core/domain/error/data_error.dart'; +import 'package:restaurant_tour/core/domain/error/error.dart'; class RestaurantsRepository extends BaseRestaurantsRepository { // I could have created a remote data provider for this @@ -32,7 +32,8 @@ class RestaurantsRepository extends BaseRestaurantsRepository { } @override - Future, BaseError>> getRestaurants({int offset = 0}) async { + Future, BaseError>> getRestaurants( + {int offset = 0}) async { try { final response = await _httpClient.post>( '/v3/graphql', @@ -59,8 +60,7 @@ class RestaurantsRepository extends BaseRestaurantsRepository { DioExceptionType.connectionError => Error(NoInternetConnectionError()), _ => Error(UnknownError()), }; - } - catch (e) { + } catch (e) { return Error(UnknownError()); } } diff --git a/lib/domain/models/user.dart b/lib/domain/models/user.dart index 863f54a..1725db3 100644 --- a/lib/domain/models/user.dart +++ b/lib/domain/models/user.dart @@ -3,9 +3,10 @@ class User { final String imageUrl; final String? name; - User({ + User({ this.id, String? imageUrl, this.name, - }) : imageUrl = imageUrl ?? 'https://eu.ui-avatars.com/api/?name=${name?.split('').join('+')}&size=250' ; + }) : imageUrl = imageUrl ?? + 'https://eu.ui-avatars.com/api/?name=${name?.split('').join('+')}&size=250'; } diff --git a/lib/domain/repositories/restaurants_repository.dart b/lib/domain/repositories/restaurants_repository.dart index 9c1e20c..c520777 100644 --- a/lib/domain/repositories/restaurants_repository.dart +++ b/lib/domain/repositories/restaurants_repository.dart @@ -1,8 +1,7 @@ import 'package:multiple_result/multiple_result.dart'; import 'package:restaurant_tour/domain/models/restaurant.dart'; -import '../../core/domain/error/data_error.dart'; -import '../../core/domain/error/error.dart'; +import 'package:restaurant_tour/core/domain/error/error.dart'; abstract class BaseRestaurantsRepository { const BaseRestaurantsRepository(); diff --git a/lib/domain/use_cases/get_restaurants_use_case.dart b/lib/domain/use_cases/get_restaurants_use_case.dart index c18130e..d7925c4 100644 --- a/lib/domain/use_cases/get_restaurants_use_case.dart +++ b/lib/domain/use_cases/get_restaurants_use_case.dart @@ -3,8 +3,6 @@ import 'package:restaurant_tour/core/domain/error/error.dart'; import 'package:restaurant_tour/domain/models/restaurant.dart'; import 'package:restaurant_tour/domain/repositories/restaurants_repository.dart'; -import '../../core/domain/error/data_error.dart'; - class GetRestaurantsUseCase { final BaseRestaurantsRepository _restaurantsRepository; diff --git a/lib/presentation/screens/home/all_restaurants_tab.dart b/lib/presentation/screens/home/all_restaurants_tab.dart index 5ba3f6f..396bbc0 100644 --- a/lib/presentation/screens/home/all_restaurants_tab.dart +++ b/lib/presentation/screens/home/all_restaurants_tab.dart @@ -6,8 +6,8 @@ import 'package:restaurant_tour/domain/use_cases/toggle_favorite.dart'; import 'package:restaurant_tour/domain/use_cases/get_restaurants_use_case.dart'; import 'package:restaurant_tour/presentation/components/restaurant_card.dart'; -import '../../../core/domain/error/data_error.dart'; -import '../../../core/domain/error/error.dart'; +import 'package:restaurant_tour/core/domain/error/data_error.dart'; +import 'package:restaurant_tour/core/domain/error/error.dart'; class AllRestaurantsTab extends StatefulWidget { final GetRestaurantsUseCase getAllRestaurantsUseCase; diff --git a/lib/presentation/screens/home/favorite_restaurants_tab.dart b/lib/presentation/screens/home/favorite_restaurants_tab.dart index d5961d3..08843f4 100644 --- a/lib/presentation/screens/home/favorite_restaurants_tab.dart +++ b/lib/presentation/screens/home/favorite_restaurants_tab.dart @@ -3,7 +3,6 @@ import 'package:restaurant_tour/domain/models/restaurant.dart'; import 'package:restaurant_tour/presentation/components/restaurant_card.dart'; - class FavoriteRestaurantsTab extends StatelessWidget { final List restaurants; final void Function(Restaurant restaurant, bool isFavorite) onTapRestaurant; diff --git a/lib/presentation/screens/home/home_screen.dart b/lib/presentation/screens/home/home_screen.dart index 805cd55..a6c5275 100644 --- a/lib/presentation/screens/home/home_screen.dart +++ b/lib/presentation/screens/home/home_screen.dart @@ -9,7 +9,6 @@ import 'package:restaurant_tour/presentation/screens/home/all_restaurants_tab.da import 'package:restaurant_tour/presentation/screens/home/favorite_restaurants_tab.dart'; import 'package:restaurant_tour/core/theme/typography.dart'; - class HomeScreen extends StatefulWidget { final GetRestaurantsUseCase getAllRestaurantsUseCase; final GetFavoriteRestaurantsUseCase getFavoriteRestaurantsUseCase; From 6cb2949273677e9ac829378c14ed041e5fdd8a7e Mon Sep 17 00:00:00 2001 From: Felipe Reis Date: Thu, 12 Sep 2024 17:06:23 -0300 Subject: [PATCH 18/23] refactor: sort imports --- lib/data/dtos/restaurant_dto.dart | 1 + lib/data/models/category.dart | 1 + lib/data/models/hours.dart | 1 + lib/data/models/location.dart | 1 + lib/data/models/restaurant.dart | 2 ++ lib/data/models/review.dart | 2 ++ lib/data/models/user.dart | 1 + .../mock/mocked_restaurants_repository.dart | 7 +++---- .../repositories/restaurants_repository.dart | 6 +++--- lib/di/di.dart | 7 +++---- .../repositories/restaurants_repository.dart | 2 +- .../use_cases/get_restaurants_use_case.dart | 1 + lib/main.dart | 1 + lib/presentation/components/rating_stars.dart | 1 + lib/presentation/components/restaurant_card.dart | 4 ++-- lib/presentation/components/review_card.dart | 4 ++-- .../screens/home/all_restaurants_tab.dart | 10 +++++----- .../screens/home/favorite_restaurants_tab.dart | 2 +- lib/presentation/screens/home/home_screen.dart | 3 ++- .../restaurant_details_screen.dart | 4 ++-- pubspec.lock | 16 ++++++++++++++++ pubspec.yaml | 3 +++ test/data/dtos/restaurant_dto_test.dart | 1 + .../restaurants_repository_test.dart | 1 + .../get_favorites_restaurants_use_case_test.dart | 2 +- .../use_cases/get_restaurants_use_case_test.dart | 3 ++- test/domain/use_cases/toggle_favorite_test.dart | 2 +- .../fake_restaurants_repository.dart | 2 +- .../components/rating_stars_test.dart | 3 ++- .../components/restaurant_card_test.dart | 3 +-- .../components/review_card_test.dart | 2 +- .../screens/home/all_restaurants_tab_test.dart | 2 +- .../home/favorite_restaurants_tab_test.dart | 2 +- .../screens/home/home_screen_test.dart | 2 +- .../restaurant_details_screen_test.dart | 3 ++- 35 files changed, 71 insertions(+), 37 deletions(-) diff --git a/lib/data/dtos/restaurant_dto.dart b/lib/data/dtos/restaurant_dto.dart index 3859553..cc4c65a 100644 --- a/lib/data/dtos/restaurant_dto.dart +++ b/lib/data/dtos/restaurant_dto.dart @@ -1,4 +1,5 @@ import 'package:json_annotation/json_annotation.dart'; + import 'package:restaurant_tour/data/models/restaurant.dart'; part 'restaurant_dto.g.dart'; diff --git a/lib/data/models/category.dart b/lib/data/models/category.dart index 0a89023..873f86f 100644 --- a/lib/data/models/category.dart +++ b/lib/data/models/category.dart @@ -1,4 +1,5 @@ import 'package:json_annotation/json_annotation.dart'; + import 'package:restaurant_tour/domain/models/category.dart' as category_domain_model; diff --git a/lib/data/models/hours.dart b/lib/data/models/hours.dart index 23b8eda..81313b2 100644 --- a/lib/data/models/hours.dart +++ b/lib/data/models/hours.dart @@ -1,4 +1,5 @@ import 'package:json_annotation/json_annotation.dart'; + import 'package:restaurant_tour/domain/models/hours.dart' as hours_domain_model; part 'hours.g.dart'; diff --git a/lib/data/models/location.dart b/lib/data/models/location.dart index 5c7db6b..270408d 100644 --- a/lib/data/models/location.dart +++ b/lib/data/models/location.dart @@ -1,4 +1,5 @@ import 'package:json_annotation/json_annotation.dart'; + import 'package:restaurant_tour/domain/models/location.dart' as location_domain_model; diff --git a/lib/data/models/restaurant.dart b/lib/data/models/restaurant.dart index b76c6ee..78b2cdf 100644 --- a/lib/data/models/restaurant.dart +++ b/lib/data/models/restaurant.dart @@ -1,8 +1,10 @@ import 'package:json_annotation/json_annotation.dart'; + import 'package:restaurant_tour/data/models/category.dart'; import 'package:restaurant_tour/data/models/hours.dart'; import 'package:restaurant_tour/data/models/location.dart'; import 'package:restaurant_tour/data/models/review.dart'; + import 'package:restaurant_tour/domain/models/restaurant.dart' as restaurant_domain_model; diff --git a/lib/data/models/review.dart b/lib/data/models/review.dart index 451eddd..7b92f9d 100644 --- a/lib/data/models/review.dart +++ b/lib/data/models/review.dart @@ -1,5 +1,7 @@ import 'package:json_annotation/json_annotation.dart'; + import 'package:restaurant_tour/data/models/user.dart'; + import 'package:restaurant_tour/domain/models/review.dart' as review_domain_model; diff --git a/lib/data/models/user.dart b/lib/data/models/user.dart index 9d5a511..1f576e3 100644 --- a/lib/data/models/user.dart +++ b/lib/data/models/user.dart @@ -1,4 +1,5 @@ import 'package:json_annotation/json_annotation.dart'; + import 'package:restaurant_tour/domain/models/user.dart' as review_domain_model; part 'user.g.dart'; diff --git a/lib/data/repositories/mock/mocked_restaurants_repository.dart b/lib/data/repositories/mock/mocked_restaurants_repository.dart index a0d032c..7c8489f 100644 --- a/lib/data/repositories/mock/mocked_restaurants_repository.dart +++ b/lib/data/repositories/mock/mocked_restaurants_repository.dart @@ -1,15 +1,14 @@ import 'dart:async'; import 'package:multiple_result/multiple_result.dart'; + import 'package:restaurant_tour/core/domain/error/data_error.dart'; +import 'package:restaurant_tour/core/domain/error/error.dart'; import 'package:restaurant_tour/data/dtos/restaurant_dto.dart'; +import 'package:restaurant_tour/data/repositories/mock/mocked_cached_response.dart'; import 'package:restaurant_tour/domain/models/restaurant.dart'; import 'package:restaurant_tour/domain/repositories/restaurants_repository.dart'; -import 'package:restaurant_tour/data/repositories/mock/mocked_cached_response.dart'; - -import 'package:restaurant_tour/core/domain/error/error.dart'; - class MockedRestaurantsRepository extends BaseRestaurantsRepository { MockedRestaurantsRepository(); diff --git a/lib/data/repositories/restaurants_repository.dart b/lib/data/repositories/restaurants_repository.dart index 77a1661..0c0b239 100644 --- a/lib/data/repositories/restaurants_repository.dart +++ b/lib/data/repositories/restaurants_repository.dart @@ -2,12 +2,12 @@ import 'dart:async'; import 'package:dio/dio.dart'; import 'package:multiple_result/multiple_result.dart'; -import 'package:restaurant_tour/data/dtos/restaurant_dto.dart'; -import 'package:restaurant_tour/domain/models/restaurant.dart'; -import 'package:restaurant_tour/domain/repositories/restaurants_repository.dart'; import 'package:restaurant_tour/core/domain/error/data_error.dart'; import 'package:restaurant_tour/core/domain/error/error.dart'; +import 'package:restaurant_tour/data/dtos/restaurant_dto.dart'; +import 'package:restaurant_tour/domain/models/restaurant.dart'; +import 'package:restaurant_tour/domain/repositories/restaurants_repository.dart'; class RestaurantsRepository extends BaseRestaurantsRepository { // I could have created a remote data provider for this diff --git a/lib/di/di.dart b/lib/di/di.dart index 60359d8..20008ae 100644 --- a/lib/di/di.dart +++ b/lib/di/di.dart @@ -1,13 +1,12 @@ import 'package:dio/dio.dart'; import 'package:get_it/get_it.dart'; -import 'package:restaurant_tour/domain/use_cases/get_restaurants_use_case.dart'; -import 'package:restaurant_tour/domain/use_cases/toggle_favorite.dart'; +import 'package:restaurant_tour/data/repositories/mock/mocked_restaurants_repository.dart'; import 'package:restaurant_tour/data/repositories/restaurants_repository.dart'; import 'package:restaurant_tour/domain/repositories/restaurants_repository.dart'; import 'package:restaurant_tour/domain/use_cases/get_favorites_restaurants_use_case.dart'; - -import 'package:restaurant_tour/data/repositories/mock/mocked_restaurants_repository.dart'; +import 'package:restaurant_tour/domain/use_cases/get_restaurants_use_case.dart'; +import 'package:restaurant_tour/domain/use_cases/toggle_favorite.dart'; final getIt = GetIt.instance; diff --git a/lib/domain/repositories/restaurants_repository.dart b/lib/domain/repositories/restaurants_repository.dart index c520777..4ff2007 100644 --- a/lib/domain/repositories/restaurants_repository.dart +++ b/lib/domain/repositories/restaurants_repository.dart @@ -1,7 +1,7 @@ import 'package:multiple_result/multiple_result.dart'; -import 'package:restaurant_tour/domain/models/restaurant.dart'; import 'package:restaurant_tour/core/domain/error/error.dart'; +import 'package:restaurant_tour/domain/models/restaurant.dart'; abstract class BaseRestaurantsRepository { const BaseRestaurantsRepository(); diff --git a/lib/domain/use_cases/get_restaurants_use_case.dart b/lib/domain/use_cases/get_restaurants_use_case.dart index d7925c4..e20bf3b 100644 --- a/lib/domain/use_cases/get_restaurants_use_case.dart +++ b/lib/domain/use_cases/get_restaurants_use_case.dart @@ -1,4 +1,5 @@ import 'package:multiple_result/multiple_result.dart'; + import 'package:restaurant_tour/core/domain/error/error.dart'; import 'package:restaurant_tour/domain/models/restaurant.dart'; import 'package:restaurant_tour/domain/repositories/restaurants_repository.dart'; diff --git a/lib/main.dart b/lib/main.dart index 1c73a30..d40ebc3 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; + import 'package:restaurant_tour/di/di.dart'; import 'package:restaurant_tour/domain/use_cases/get_favorites_restaurants_use_case.dart'; import 'package:restaurant_tour/domain/use_cases/get_restaurants_use_case.dart'; diff --git a/lib/presentation/components/rating_stars.dart b/lib/presentation/components/rating_stars.dart index 0c190b8..28d8122 100644 --- a/lib/presentation/components/rating_stars.dart +++ b/lib/presentation/components/rating_stars.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; + import 'package:restaurant_tour/core/theme/colors.dart'; class RatingStars extends StatelessWidget { diff --git a/lib/presentation/components/restaurant_card.dart b/lib/presentation/components/restaurant_card.dart index 65b7809..930af53 100644 --- a/lib/presentation/components/restaurant_card.dart +++ b/lib/presentation/components/restaurant_card.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; -import 'package:restaurant_tour/domain/models/restaurant.dart'; -import 'package:restaurant_tour/presentation/components/rating_stars.dart'; import 'package:restaurant_tour/core/theme/typography.dart'; +import 'package:restaurant_tour/domain/models/restaurant.dart'; +import 'package:restaurant_tour/presentation/components/rating_stars.dart'; class RestaurantCard extends StatelessWidget { final Restaurant restaurant; diff --git a/lib/presentation/components/review_card.dart b/lib/presentation/components/review_card.dart index b471829..ec0a0b6 100644 --- a/lib/presentation/components/review_card.dart +++ b/lib/presentation/components/review_card.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; -import 'package:restaurant_tour/domain/models/review.dart'; -import 'package:restaurant_tour/presentation/components/rating_stars.dart'; import 'package:restaurant_tour/core/theme/typography.dart'; +import 'package:restaurant_tour/domain/models/review.dart'; +import 'package:restaurant_tour/presentation/components/rating_stars.dart'; class ReviewCard extends StatelessWidget { final Review review; diff --git a/lib/presentation/screens/home/all_restaurants_tab.dart b/lib/presentation/screens/home/all_restaurants_tab.dart index 396bbc0..6bf81af 100644 --- a/lib/presentation/screens/home/all_restaurants_tab.dart +++ b/lib/presentation/screens/home/all_restaurants_tab.dart @@ -1,13 +1,13 @@ import 'package:flutter/material.dart'; -import 'package:multiple_result/multiple_result.dart'; -import 'package:restaurant_tour/domain/models/restaurant.dart'; -import 'package:restaurant_tour/domain/use_cases/toggle_favorite.dart'; -import 'package:restaurant_tour/domain/use_cases/get_restaurants_use_case.dart'; -import 'package:restaurant_tour/presentation/components/restaurant_card.dart'; +import 'package:multiple_result/multiple_result.dart'; import 'package:restaurant_tour/core/domain/error/data_error.dart'; import 'package:restaurant_tour/core/domain/error/error.dart'; +import 'package:restaurant_tour/domain/models/restaurant.dart'; +import 'package:restaurant_tour/domain/use_cases/get_restaurants_use_case.dart'; +import 'package:restaurant_tour/domain/use_cases/toggle_favorite.dart'; +import 'package:restaurant_tour/presentation/components/restaurant_card.dart'; class AllRestaurantsTab extends StatefulWidget { final GetRestaurantsUseCase getAllRestaurantsUseCase; diff --git a/lib/presentation/screens/home/favorite_restaurants_tab.dart b/lib/presentation/screens/home/favorite_restaurants_tab.dart index 08843f4..1c004a5 100644 --- a/lib/presentation/screens/home/favorite_restaurants_tab.dart +++ b/lib/presentation/screens/home/favorite_restaurants_tab.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -import 'package:restaurant_tour/domain/models/restaurant.dart'; +import 'package:restaurant_tour/domain/models/restaurant.dart'; import 'package:restaurant_tour/presentation/components/restaurant_card.dart'; class FavoriteRestaurantsTab extends StatelessWidget { diff --git a/lib/presentation/screens/home/home_screen.dart b/lib/presentation/screens/home/home_screen.dart index a6c5275..e7e2757 100644 --- a/lib/presentation/screens/home/home_screen.dart +++ b/lib/presentation/screens/home/home_screen.dart @@ -1,13 +1,14 @@ import 'dart:async'; import 'package:flutter/material.dart'; + +import 'package:restaurant_tour/core/theme/typography.dart'; import 'package:restaurant_tour/domain/models/restaurant.dart'; import 'package:restaurant_tour/domain/use_cases/get_favorites_restaurants_use_case.dart'; import 'package:restaurant_tour/domain/use_cases/get_restaurants_use_case.dart'; import 'package:restaurant_tour/domain/use_cases/toggle_favorite.dart'; import 'package:restaurant_tour/presentation/screens/home/all_restaurants_tab.dart'; import 'package:restaurant_tour/presentation/screens/home/favorite_restaurants_tab.dart'; -import 'package:restaurant_tour/core/theme/typography.dart'; class HomeScreen extends StatefulWidget { final GetRestaurantsUseCase getAllRestaurantsUseCase; diff --git a/lib/presentation/screens/restaurant_details/restaurant_details_screen.dart b/lib/presentation/screens/restaurant_details/restaurant_details_screen.dart index 5cd7095..7c97ea1 100644 --- a/lib/presentation/screens/restaurant_details/restaurant_details_screen.dart +++ b/lib/presentation/screens/restaurant_details/restaurant_details_screen.dart @@ -1,10 +1,10 @@ import 'package:flutter/material.dart'; -import 'package:restaurant_tour/domain/models/restaurant.dart'; -import 'package:restaurant_tour/presentation/components/review_card.dart'; import 'package:restaurant_tour/core/theme/colors.dart'; import 'package:restaurant_tour/core/theme/typography.dart'; +import 'package:restaurant_tour/domain/models/restaurant.dart'; import 'package:restaurant_tour/presentation/components/rating_stars.dart'; +import 'package:restaurant_tour/presentation/components/review_card.dart'; class RestaurantDetailsScreen extends StatefulWidget { final Restaurant restaurant; diff --git a/pubspec.lock b/pubspec.lock index 248db06..515bdc2 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -299,6 +299,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.2" + import_sorter: + dependency: "direct main" + description: + name: import_sorter + sha256: eb15738ccead84e62c31e0208ea4e3104415efcd4972b86906ca64a1187d0836 + url: "https://pub.dev" + source: hosted + version: "4.6.0" io: dependency: transitive description: @@ -592,6 +600,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.1" + tint: + dependency: transitive + description: + name: tint + sha256: "9652d9a589f4536d5e392cf790263d120474f15da3cf1bee7f1fdb31b4de5f46" + url: "https://pub.dev" + source: hosted + version: "2.0.1" typed_data: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 2ca9306..c5354ae 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -18,6 +18,7 @@ dependencies: flutter_svg: ^2.0.10 get_it: ^7.7.0 multiple_result: ^5.1.0 + import_sorter: ^4.6.0 dev_dependencies: flutter_test: @@ -51,3 +52,5 @@ flutter: - asset: assets/fonts/OpenSans/OpenSans-SemiBold.ttf weight: 600 +import_sorter: + comments: false \ No newline at end of file diff --git a/test/data/dtos/restaurant_dto_test.dart b/test/data/dtos/restaurant_dto_test.dart index 0507fc8..48b532a 100644 --- a/test/data/dtos/restaurant_dto_test.dart +++ b/test/data/dtos/restaurant_dto_test.dart @@ -1,4 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; + import 'package:restaurant_tour/data/dtos/restaurant_dto.dart'; import 'package:restaurant_tour/data/repositories/mock/mocked_cached_response.dart'; diff --git a/test/data/repositories/restaurants_repository_test.dart b/test/data/repositories/restaurants_repository_test.dart index e179311..bf3761b 100644 --- a/test/data/repositories/restaurants_repository_test.dart +++ b/test/data/repositories/restaurants_repository_test.dart @@ -1,6 +1,7 @@ import 'package:dio/dio.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http_mock_adapter/http_mock_adapter.dart'; + import 'package:restaurant_tour/data/repositories/mock/mocked_cached_response.dart'; import 'package:restaurant_tour/data/repositories/restaurants_repository.dart'; import 'package:restaurant_tour/domain/models/restaurant.dart'; diff --git a/test/domain/use_cases/get_favorites_restaurants_use_case_test.dart b/test/domain/use_cases/get_favorites_restaurants_use_case_test.dart index 5ce4eae..8515cbb 100644 --- a/test/domain/use_cases/get_favorites_restaurants_use_case_test.dart +++ b/test/domain/use_cases/get_favorites_restaurants_use_case_test.dart @@ -1,6 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:restaurant_tour/domain/use_cases/get_favorites_restaurants_use_case.dart'; +import 'package:restaurant_tour/domain/use_cases/get_favorites_restaurants_use_case.dart'; import '../../fakes/repositories/fake_restaurants_repository.dart'; void main() { diff --git a/test/domain/use_cases/get_restaurants_use_case_test.dart b/test/domain/use_cases/get_restaurants_use_case_test.dart index 580520a..9c5c399 100644 --- a/test/domain/use_cases/get_restaurants_use_case_test.dart +++ b/test/domain/use_cases/get_restaurants_use_case_test.dart @@ -1,7 +1,8 @@ import 'package:flutter/foundation.dart'; + import 'package:flutter_test/flutter_test.dart'; -import 'package:restaurant_tour/domain/use_cases/get_restaurants_use_case.dart'; +import 'package:restaurant_tour/domain/use_cases/get_restaurants_use_case.dart'; import '../../fakes/repositories/fake_restaurants_repository.dart'; void main() { diff --git a/test/domain/use_cases/toggle_favorite_test.dart b/test/domain/use_cases/toggle_favorite_test.dart index 692e616..647f748 100644 --- a/test/domain/use_cases/toggle_favorite_test.dart +++ b/test/domain/use_cases/toggle_favorite_test.dart @@ -1,7 +1,7 @@ import 'package:flutter_test/flutter_test.dart'; + import 'package:restaurant_tour/domain/use_cases/get_favorites_restaurants_use_case.dart'; import 'package:restaurant_tour/domain/use_cases/toggle_favorite.dart'; - import '../../fakes/repositories/fake_restaurants_repository.dart'; void main() { diff --git a/test/fakes/repositories/fake_restaurants_repository.dart b/test/fakes/repositories/fake_restaurants_repository.dart index 90b0d20..1ee8236 100644 --- a/test/fakes/repositories/fake_restaurants_repository.dart +++ b/test/fakes/repositories/fake_restaurants_repository.dart @@ -1,8 +1,8 @@ import 'package:multiple_result/multiple_result.dart'; + import 'package:restaurant_tour/core/domain/error/error.dart'; import 'package:restaurant_tour/domain/models/restaurant.dart'; import 'package:restaurant_tour/domain/repositories/restaurants_repository.dart'; - import '../data/fake_restaurant.dart'; class FakeRestaurantsRepository extends BaseRestaurantsRepository { diff --git a/test/presentation/components/rating_stars_test.dart b/test/presentation/components/rating_stars_test.dart index 625d62a..6dfad1e 100644 --- a/test/presentation/components/rating_stars_test.dart +++ b/test/presentation/components/rating_stars_test.dart @@ -1,7 +1,8 @@ import 'package:flutter/material.dart'; + import 'package:flutter_test/flutter_test.dart'; -import 'package:restaurant_tour/presentation/components/rating_stars.dart'; +import 'package:restaurant_tour/presentation/components/rating_stars.dart'; import '../../make_testable_widget.dart'; void main() { diff --git a/test/presentation/components/restaurant_card_test.dart b/test/presentation/components/restaurant_card_test.dart index 0190d1c..59f60ed 100644 --- a/test/presentation/components/restaurant_card_test.dart +++ b/test/presentation/components/restaurant_card_test.dart @@ -1,9 +1,8 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:restaurant_tour/data/models/restaurant.dart'; import 'package:network_image_mock/network_image_mock.dart'; +import 'package:restaurant_tour/data/models/restaurant.dart'; import 'package:restaurant_tour/presentation/components/restaurant_card.dart'; - import '../../fakes/data/fake_restaurant.dart'; import '../../make_testable_widget.dart'; diff --git a/test/presentation/components/review_card_test.dart b/test/presentation/components/review_card_test.dart index 9b6d1f5..bbf9f72 100644 --- a/test/presentation/components/review_card_test.dart +++ b/test/presentation/components/review_card_test.dart @@ -1,7 +1,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:network_image_mock/network_image_mock.dart'; -import 'package:restaurant_tour/presentation/components/review_card.dart'; +import 'package:restaurant_tour/presentation/components/review_card.dart'; import '../../fakes/data/fake_restaurant.dart'; import '../../make_testable_widget.dart'; diff --git a/test/presentation/screens/home/all_restaurants_tab_test.dart b/test/presentation/screens/home/all_restaurants_tab_test.dart index 2271ac4..decfda0 100644 --- a/test/presentation/screens/home/all_restaurants_tab_test.dart +++ b/test/presentation/screens/home/all_restaurants_tab_test.dart @@ -1,10 +1,10 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:network_image_mock/network_image_mock.dart'; + import 'package:restaurant_tour/domain/use_cases/get_restaurants_use_case.dart'; import 'package:restaurant_tour/domain/use_cases/toggle_favorite.dart'; import 'package:restaurant_tour/presentation/components/restaurant_card.dart'; import 'package:restaurant_tour/presentation/screens/home/all_restaurants_tab.dart'; - import '../../../fakes/repositories/fake_restaurants_repository.dart'; import '../../../make_testable_widget.dart'; diff --git a/test/presentation/screens/home/favorite_restaurants_tab_test.dart b/test/presentation/screens/home/favorite_restaurants_tab_test.dart index 9cc95ea..58150e4 100644 --- a/test/presentation/screens/home/favorite_restaurants_tab_test.dart +++ b/test/presentation/screens/home/favorite_restaurants_tab_test.dart @@ -1,8 +1,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:network_image_mock/network_image_mock.dart'; + import 'package:restaurant_tour/presentation/components/restaurant_card.dart'; import 'package:restaurant_tour/presentation/screens/home/favorite_restaurants_tab.dart'; - import '../../../fakes/data/fake_restaurant.dart'; import '../../../make_testable_widget.dart'; diff --git a/test/presentation/screens/home/home_screen_test.dart b/test/presentation/screens/home/home_screen_test.dart index 3783855..ce255c1 100644 --- a/test/presentation/screens/home/home_screen_test.dart +++ b/test/presentation/screens/home/home_screen_test.dart @@ -2,12 +2,12 @@ import 'dart:math'; import 'package:flutter_test/flutter_test.dart'; import 'package:network_image_mock/network_image_mock.dart'; + import 'package:restaurant_tour/domain/use_cases/get_favorites_restaurants_use_case.dart'; import 'package:restaurant_tour/domain/use_cases/get_restaurants_use_case.dart'; import 'package:restaurant_tour/domain/use_cases/toggle_favorite.dart'; import 'package:restaurant_tour/presentation/components/restaurant_card.dart'; import 'package:restaurant_tour/presentation/screens/home/home_screen.dart'; - import '../../../fakes/repositories/fake_restaurants_repository.dart'; import '../../../make_testable_widget.dart'; diff --git a/test/presentation/screens/restaurant_details/restaurant_details_screen_test.dart b/test/presentation/screens/restaurant_details/restaurant_details_screen_test.dart index 9f06834..23b7e90 100644 --- a/test/presentation/screens/restaurant_details/restaurant_details_screen_test.dart +++ b/test/presentation/screens/restaurant_details/restaurant_details_screen_test.dart @@ -1,8 +1,9 @@ import 'package:flutter/material.dart'; + import 'package:flutter_test/flutter_test.dart'; import 'package:network_image_mock/network_image_mock.dart'; -import 'package:restaurant_tour/presentation/screens/restaurant_details/restaurant_details_screen.dart'; +import 'package:restaurant_tour/presentation/screens/restaurant_details/restaurant_details_screen.dart'; import '../../../fakes/data/fake_restaurant.dart'; import '../../../make_testable_widget.dart'; From 6ba12c98c06d6adcac57f79f00b06669bc0048dc Mon Sep 17 00:00:00 2001 From: Felipe Reis Date: Thu, 12 Sep 2024 20:31:17 -0300 Subject: [PATCH 19/23] refactor: add bloc to AllRestaurantsTab and HomeScreen --- .../mock/mocked_restaurants_repository.dart | 7 +- .../repositories/restaurants_repository.dart | 5 +- .../all_restaurants_tab.dart | 143 +++++++++++++++++ .../bloc/all_restaurants_tab_bloc.dart | 38 +++++ .../bloc/all_restaurants_tab_event.dart | 9 ++ .../bloc/all_restaurants_tab_state.dart | 32 ++++ .../favorite_restaurants_tab.dart | 0 .../screens/home/all_restaurants_tab.dart | 100 ------------ .../screens/home/bloc/home_bloc.dart | 31 ++++ .../screens/home/bloc/home_event.dart | 9 ++ .../screens/home/bloc/home_state.dart | 21 +++ .../screens/home/home_screen.dart | 150 +++++++++--------- pubspec.lock | 40 +++++ pubspec.yaml | 2 + .../home/all_restaurants_tab_test.dart | 2 +- .../home/favorite_restaurants_tab_test.dart | 2 +- 16 files changed, 406 insertions(+), 185 deletions(-) create mode 100644 lib/presentation/screens/home/_tabs/all_restaurants_tab/all_restaurants_tab.dart create mode 100644 lib/presentation/screens/home/_tabs/all_restaurants_tab/bloc/all_restaurants_tab_bloc.dart create mode 100644 lib/presentation/screens/home/_tabs/all_restaurants_tab/bloc/all_restaurants_tab_event.dart create mode 100644 lib/presentation/screens/home/_tabs/all_restaurants_tab/bloc/all_restaurants_tab_state.dart rename lib/presentation/screens/home/{ => _tabs/favorite_restaurants_tab}/favorite_restaurants_tab.dart (100%) delete mode 100644 lib/presentation/screens/home/all_restaurants_tab.dart create mode 100644 lib/presentation/screens/home/bloc/home_bloc.dart create mode 100644 lib/presentation/screens/home/bloc/home_event.dart create mode 100644 lib/presentation/screens/home/bloc/home_state.dart diff --git a/lib/data/repositories/mock/mocked_restaurants_repository.dart b/lib/data/repositories/mock/mocked_restaurants_repository.dart index 7c8489f..b1a7467 100644 --- a/lib/data/repositories/mock/mocked_restaurants_repository.dart +++ b/lib/data/repositories/mock/mocked_restaurants_repository.dart @@ -27,8 +27,11 @@ class MockedRestaurantsRepository extends BaseRestaurantsRepository { } @override - Future, BaseError>> getRestaurants( - {int offset = 0}) async { + Future, BaseError>> getRestaurants({ + int offset = 0, + }) async { + await Future.delayed(const Duration(seconds: 2)); // Simulate network delay + try { final response = cachedResponse; diff --git a/lib/data/repositories/restaurants_repository.dart b/lib/data/repositories/restaurants_repository.dart index 0c0b239..d5c96f5 100644 --- a/lib/data/repositories/restaurants_repository.dart +++ b/lib/data/repositories/restaurants_repository.dart @@ -32,8 +32,9 @@ class RestaurantsRepository extends BaseRestaurantsRepository { } @override - Future, BaseError>> getRestaurants( - {int offset = 0}) async { + Future, BaseError>> getRestaurants({ + int offset = 0, + }) async { try { final response = await _httpClient.post>( '/v3/graphql', diff --git a/lib/presentation/screens/home/_tabs/all_restaurants_tab/all_restaurants_tab.dart b/lib/presentation/screens/home/_tabs/all_restaurants_tab/all_restaurants_tab.dart new file mode 100644 index 0000000..1aaa4ef --- /dev/null +++ b/lib/presentation/screens/home/_tabs/all_restaurants_tab/all_restaurants_tab.dart @@ -0,0 +1,143 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:restaurant_tour/core/domain/error/data_error.dart'; +import 'package:restaurant_tour/core/domain/error/error.dart'; +import 'package:restaurant_tour/domain/models/restaurant.dart'; +import 'package:restaurant_tour/domain/use_cases/get_restaurants_use_case.dart'; +import 'package:restaurant_tour/domain/use_cases/toggle_favorite.dart'; +import 'package:restaurant_tour/presentation/components/restaurant_card.dart'; +import 'package:restaurant_tour/presentation/screens/home/_tabs/all_restaurants_tab/bloc/all_restaurants_tab_bloc.dart'; + +typedef OnTapRestaurant = void Function(Restaurant restaurant, bool isFavorite); + +// I'm just using StatefulWidget here to implement +// AutomaticKeepAliveClientMixin and keep the state of the tab when changing tabs +class AllRestaurantsTab extends StatefulWidget { + final GetRestaurantsUseCase getAllRestaurantsUseCase; + final ToggleFavoriteUseCase toggleFavoriteUseCase; + final List favoriteRestaurants; + final OnTapRestaurant onTapRestaurant; + + const AllRestaurantsTab({ + super.key, + required this.getAllRestaurantsUseCase, + required this.toggleFavoriteUseCase, + required this.favoriteRestaurants, + required this.onTapRestaurant, + }); + + @override + State createState() => _AllRestaurantsTabState(); +} + +class _AllRestaurantsTabState extends State + with AutomaticKeepAliveClientMixin { + @override + bool get wantKeepAlive => true; + + @override + Widget build(BuildContext context) { + super.build(context); + + return BlocProvider( + create: (context) => AllRestaurantsTabBloc( + getRestaurantsUseCase: widget.getAllRestaurantsUseCase, + )..add(const LoadAllRestaurants()), + child: Builder( + builder: (context) { + return BlocBuilder( + builder: (context, state) { + if (state.contentIsLoading) { + return const _LoadingContent(); + } + + if (state.error != null) { + return _ErrorContent(error: state.error!); + } + + return _RestaurantsList( + restaurants: state.restaurants, + favoriteRestaurants: widget.favoriteRestaurants, + onTapRestaurant: widget.onTapRestaurant, + ); + }, + ); + }, + ), + ); + } +} + +class _LoadingContent extends StatelessWidget { + const _LoadingContent(); + + @override + Widget build(BuildContext context) { + return const Center( + child: CircularProgressIndicator( + color: Colors.black, + ), + ); + } +} + +class _ErrorContent extends StatelessWidget { + const _ErrorContent({required this.error}); + + final BaseError error; + + @override + Widget build(BuildContext context) { + if (error case DataError()) { + // here we could map data errors + return switch (error) { + RateLimitError() => const Center( + child: Text("Rate limit exceeded"), + ), + NoInternetConnectionError() => const Center( + child: Text("No internet connection"), + ), + _ => const Center( + child: Text("Something went wrong"), + ), + }; + } + + // here we can map other errors + return const Center( + child: Text("Something went wrong"), + ); + } +} + +class _RestaurantsList extends StatelessWidget { + const _RestaurantsList({ + required this.restaurants, + required this.favoriteRestaurants, + required this.onTapRestaurant, + }); + + final List restaurants; + final List favoriteRestaurants; + final OnTapRestaurant onTapRestaurant; + + @override + Widget build(BuildContext context) { + return ListView.builder( + itemCount: restaurants.length, + itemBuilder: (context, index) { + final restaurant = restaurants[index]; + final isFavorite = favoriteRestaurants + .indexWhere((element) => element.id == restaurant.id) != + -1; + + return RestaurantCard( + restaurant: restaurant, + onTap: () => onTapRestaurant(restaurant, isFavorite), + ); + }, + ); + } +} diff --git a/lib/presentation/screens/home/_tabs/all_restaurants_tab/bloc/all_restaurants_tab_bloc.dart b/lib/presentation/screens/home/_tabs/all_restaurants_tab/bloc/all_restaurants_tab_bloc.dart new file mode 100644 index 0000000..d2d45d8 --- /dev/null +++ b/lib/presentation/screens/home/_tabs/all_restaurants_tab/bloc/all_restaurants_tab_bloc.dart @@ -0,0 +1,38 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:restaurant_tour/core/domain/error/error.dart'; +import 'package:restaurant_tour/domain/models/restaurant.dart'; +import 'package:restaurant_tour/domain/use_cases/get_restaurants_use_case.dart'; + +part 'all_restaurants_tab_event.dart'; +part 'all_restaurants_tab_state.dart'; + +class AllRestaurantsTabBloc + extends Bloc { + AllRestaurantsTabBloc({ + required GetRestaurantsUseCase getRestaurantsUseCase, + }) : _getRestaurantsUseCase = getRestaurantsUseCase, + super(const AllRestaurantsTabState()) { + on(onLoadAllRestaurants); + } + + final GetRestaurantsUseCase _getRestaurantsUseCase; + + Future onLoadAllRestaurants( + LoadAllRestaurants event, + Emitter emit, + ) async { + final data = await _getRestaurantsUseCase(); + + data.when( + (restaurants) => emit( + state.copyWith( + restaurants: restaurants, + contentIsLoading: false, + ), + ), + (error) => state.copyWith(error: error, contentIsLoading: false), + ); + } +} diff --git a/lib/presentation/screens/home/_tabs/all_restaurants_tab/bloc/all_restaurants_tab_event.dart b/lib/presentation/screens/home/_tabs/all_restaurants_tab/bloc/all_restaurants_tab_event.dart new file mode 100644 index 0000000..4da0764 --- /dev/null +++ b/lib/presentation/screens/home/_tabs/all_restaurants_tab/bloc/all_restaurants_tab_event.dart @@ -0,0 +1,9 @@ +part of 'all_restaurants_tab_bloc.dart'; + +sealed class AllRestaurantsTabEvent { + const AllRestaurantsTabEvent(); +} + +final class LoadAllRestaurants extends AllRestaurantsTabEvent { + const LoadAllRestaurants(); +} diff --git a/lib/presentation/screens/home/_tabs/all_restaurants_tab/bloc/all_restaurants_tab_state.dart b/lib/presentation/screens/home/_tabs/all_restaurants_tab/bloc/all_restaurants_tab_state.dart new file mode 100644 index 0000000..4f579d9 --- /dev/null +++ b/lib/presentation/screens/home/_tabs/all_restaurants_tab/bloc/all_restaurants_tab_state.dart @@ -0,0 +1,32 @@ +part of 'all_restaurants_tab_bloc.dart'; + +class AllRestaurantsTabState extends Equatable { + const AllRestaurantsTabState({ + this.restaurants = const [], + this.contentIsLoading = true, + this.error, + }); + + final List restaurants; + final bool contentIsLoading; + final BaseError? error; + + AllRestaurantsTabState copyWith({ + List? restaurants, + bool? contentIsLoading, + BaseError? error, + }) { + return AllRestaurantsTabState( + restaurants: restaurants ?? this.restaurants, + contentIsLoading: contentIsLoading ?? this.contentIsLoading, + error: error ?? this.error, + ); + } + + @override + List get props => [ + restaurants, + contentIsLoading, + error, + ]; +} diff --git a/lib/presentation/screens/home/favorite_restaurants_tab.dart b/lib/presentation/screens/home/_tabs/favorite_restaurants_tab/favorite_restaurants_tab.dart similarity index 100% rename from lib/presentation/screens/home/favorite_restaurants_tab.dart rename to lib/presentation/screens/home/_tabs/favorite_restaurants_tab/favorite_restaurants_tab.dart diff --git a/lib/presentation/screens/home/all_restaurants_tab.dart b/lib/presentation/screens/home/all_restaurants_tab.dart deleted file mode 100644 index 6bf81af..0000000 --- a/lib/presentation/screens/home/all_restaurants_tab.dart +++ /dev/null @@ -1,100 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:multiple_result/multiple_result.dart'; - -import 'package:restaurant_tour/core/domain/error/data_error.dart'; -import 'package:restaurant_tour/core/domain/error/error.dart'; -import 'package:restaurant_tour/domain/models/restaurant.dart'; -import 'package:restaurant_tour/domain/use_cases/get_restaurants_use_case.dart'; -import 'package:restaurant_tour/domain/use_cases/toggle_favorite.dart'; -import 'package:restaurant_tour/presentation/components/restaurant_card.dart'; - -class AllRestaurantsTab extends StatefulWidget { - final GetRestaurantsUseCase getAllRestaurantsUseCase; - final ToggleFavoriteUseCase toggleFavoriteUseCase; - final List favoriteRestaurants; - final void Function(Restaurant restaurant, bool isFavorite) onTapRestaurant; - - const AllRestaurantsTab({ - super.key, - required this.getAllRestaurantsUseCase, - required this.toggleFavoriteUseCase, - required this.favoriteRestaurants, - required this.onTapRestaurant, - }); - - @override - State createState() => _AllRestaurantsTabState(); -} - -class _AllRestaurantsTabState extends State - with AutomaticKeepAliveClientMixin { - late Future, BaseError>> restaurantsFuture; - - @override - void initState() { - super.initState(); - restaurantsFuture = widget.getAllRestaurantsUseCase(); - } - - @override - bool get wantKeepAlive => true; - - @override - Widget build(BuildContext context) { - super.build(context); - - return FutureBuilder( - future: restaurantsFuture, - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting) { - return const Center( - child: CircularProgressIndicator( - color: Colors.black, - ), - ); - } - - return snapshot.data!.when( - (data) { - return ListView.builder( - itemCount: data.length, - itemBuilder: (context, index) { - final restaurant = data[index]; - final isFavorite = widget.favoriteRestaurants - .indexWhere((element) => element.id == restaurant.id) != - -1; - - return RestaurantCard( - restaurant: restaurant, - onTap: () => widget.onTapRestaurant(restaurant, isFavorite), - ); - }, - ); - }, - (error) { - if (error case DataError()) { - // here we could map data errors - return switch (error) { - RateLimitError() => const Center( - child: Text("Rate limit exceeded"), - ), - NoInternetConnectionError() => const Center( - child: Text("No internet connection"), - ), - _ => const Center( - child: Text("Something went wrong"), - ), - }; - } - - // here we can map other errors - return const Center( - child: Text("Something went wrong"), - ); - }, - ); - }, - ); - } -} diff --git a/lib/presentation/screens/home/bloc/home_bloc.dart b/lib/presentation/screens/home/bloc/home_bloc.dart new file mode 100644 index 0000000..e2a780c --- /dev/null +++ b/lib/presentation/screens/home/bloc/home_bloc.dart @@ -0,0 +1,31 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:restaurant_tour/domain/models/restaurant.dart'; +import 'package:restaurant_tour/domain/use_cases/get_favorites_restaurants_use_case.dart'; + +part 'home_event.dart'; +part 'home_state.dart'; + +class HomeBloc extends Bloc { + HomeBloc({ + required GetFavoriteRestaurantsUseCase getFavoriteRestaurantsUseCase, + }) : _getFavoriteRestaurantsUseCase = getFavoriteRestaurantsUseCase, + super(const HomeState()) { + on(_onLoadFavoriteRestaurants); + } + + final GetFavoriteRestaurantsUseCase _getFavoriteRestaurantsUseCase; + + void _onLoadFavoriteRestaurants( + LoadFavoriteRestaurants event, + Emitter emit, + ) async { + await emit.onEach( + _getFavoriteRestaurantsUseCase(), + onData: (data) { + emit(state.copyWith(favoriteRestaurants: data)); + }, + ); + } +} diff --git a/lib/presentation/screens/home/bloc/home_event.dart b/lib/presentation/screens/home/bloc/home_event.dart new file mode 100644 index 0000000..91dccdf --- /dev/null +++ b/lib/presentation/screens/home/bloc/home_event.dart @@ -0,0 +1,9 @@ +part of 'home_bloc.dart'; + +sealed class HomeEvent { + const HomeEvent(); +} + +final class LoadFavoriteRestaurants extends HomeEvent { + const LoadFavoriteRestaurants(); +} diff --git a/lib/presentation/screens/home/bloc/home_state.dart b/lib/presentation/screens/home/bloc/home_state.dart new file mode 100644 index 0000000..93b4236 --- /dev/null +++ b/lib/presentation/screens/home/bloc/home_state.dart @@ -0,0 +1,21 @@ +part of 'home_bloc.dart'; + +// we could use something like Freezed here handle the copyWith and equality +class HomeState extends Equatable { + const HomeState({ + this.favoriteRestaurants = const [], + }); + + final List favoriteRestaurants; + + HomeState copyWith({ + List? favoriteRestaurants, + }) { + return HomeState( + favoriteRestaurants: favoriteRestaurants ?? this.favoriteRestaurants, + ); + } + + @override + List get props => [favoriteRestaurants]; +} diff --git a/lib/presentation/screens/home/home_screen.dart b/lib/presentation/screens/home/home_screen.dart index e7e2757..b14047d 100644 --- a/lib/presentation/screens/home/home_screen.dart +++ b/lib/presentation/screens/home/home_screen.dart @@ -1,16 +1,17 @@ -import 'dart:async'; - import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + import 'package:restaurant_tour/core/theme/typography.dart'; import 'package:restaurant_tour/domain/models/restaurant.dart'; import 'package:restaurant_tour/domain/use_cases/get_favorites_restaurants_use_case.dart'; import 'package:restaurant_tour/domain/use_cases/get_restaurants_use_case.dart'; import 'package:restaurant_tour/domain/use_cases/toggle_favorite.dart'; -import 'package:restaurant_tour/presentation/screens/home/all_restaurants_tab.dart'; -import 'package:restaurant_tour/presentation/screens/home/favorite_restaurants_tab.dart'; +import 'package:restaurant_tour/presentation/screens/home/_tabs/all_restaurants_tab/all_restaurants_tab.dart'; +import 'package:restaurant_tour/presentation/screens/home/_tabs/favorite_restaurants_tab/favorite_restaurants_tab.dart'; +import 'package:restaurant_tour/presentation/screens/home/bloc/home_bloc.dart'; -class HomeScreen extends StatefulWidget { +class HomeScreen extends StatelessWidget { final GetRestaurantsUseCase getAllRestaurantsUseCase; final GetFavoriteRestaurantsUseCase getFavoriteRestaurantsUseCase; final ToggleFavoriteUseCase toggleFavoriteUseCase; @@ -24,85 +25,76 @@ class HomeScreen extends StatefulWidget { required this.onTapRestaurant, }); - @override - State createState() => _HomeScreenState(); -} - -class _HomeScreenState extends State { - late final StreamSubscription> _favoritesSubscription; - List favoriteRestaurants = []; - - @override - void initState() { - super.initState(); - _favoritesSubscription = - widget.getFavoriteRestaurantsUseCase().listen((data) { - setState(() { - favoriteRestaurants = data; - }); - }); - } - - @override - void dispose() { - _favoritesSubscription.cancel(); - super.dispose(); - } - @override Widget build(BuildContext context) { - return DefaultTabController( - length: 2, - initialIndex: 0, - child: Scaffold( - appBar: AppBar( - centerTitle: true, - title: const Text( - "RestauranTour", - style: AppTextStyles.loraRegularHeadline, - ), - bottom: const TabBar( - labelStyle: AppTextStyles.openRegularTitleSemiBold, - labelColor: Colors.black, - unselectedLabelColor: Color(0xFF606060), - indicatorColor: Colors.black, - indicator: BoxDecoration( - border: Border( - bottom: BorderSide( - color: Colors.black, - width: 2.0, + return BlocProvider( + create: (context) => HomeBloc( + getFavoriteRestaurantsUseCase: getFavoriteRestaurantsUseCase, + )..add(const LoadFavoriteRestaurants()), + child: Builder( + builder: (context) { + return DefaultTabController( + length: 2, + initialIndex: 0, + child: Scaffold( + appBar: AppBar( + centerTitle: true, + title: const Text( + "RestauranTour", + style: AppTextStyles.loraRegularHeadline, + ), + bottom: const TabBar( + labelStyle: AppTextStyles.openRegularTitleSemiBold, + labelColor: Colors.black, + unselectedLabelColor: Color(0xFF606060), + indicatorColor: Colors.black, + indicator: BoxDecoration( + border: Border( + bottom: BorderSide( + color: Colors.black, + width: 2.0, + ), + ), + ), + tabs: [ + Tab( + text: "All Restaurants", + ), + Tab( + text: "My Favorites", + ), + ], ), ), - ), - tabs: [ - Tab( - text: "All Restaurants", - ), - Tab( - text: "My Favorites", - ), - ], - ), - ), - body: Padding( - padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 12), - child: TabBarView( - children: [ - AllRestaurantsTab( - getAllRestaurantsUseCase: widget.getAllRestaurantsUseCase, - toggleFavoriteUseCase: widget.toggleFavoriteUseCase, - favoriteRestaurants: favoriteRestaurants, - onTapRestaurant: (restaurant, isFavorite) => - widget.onTapRestaurant(restaurant, isFavorite), - ), - FavoriteRestaurantsTab( - restaurants: favoriteRestaurants, - onTapRestaurant: (restaurant, isFavorite) => - widget.onTapRestaurant(restaurant, isFavorite), + body: Padding( + padding: const EdgeInsets.symmetric( + vertical: 16, + horizontal: 12, + ), + child: BlocBuilder( + builder: (context, state) { + return TabBarView( + children: [ + AllRestaurantsTab( + getAllRestaurantsUseCase: getAllRestaurantsUseCase, + toggleFavoriteUseCase: toggleFavoriteUseCase, + favoriteRestaurants: state.favoriteRestaurants, + onTapRestaurant: (restaurant, isFavorite) => + onTapRestaurant(restaurant, isFavorite), + ), + FavoriteRestaurantsTab( + restaurants: state.favoriteRestaurants, + onTapRestaurant: (restaurant, isFavorite) => + onTapRestaurant(restaurant, isFavorite), + ), + ], + ); + }, + ), ), - ], - ), - ), + ), + ); + }, ), ); } diff --git a/pubspec.lock b/pubspec.lock index 515bdc2..0a9c7f4 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -33,6 +33,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.11.0" + bloc: + dependency: transitive + description: + name: bloc + sha256: "106842ad6569f0b60297619e9e0b1885c2fb9bf84812935490e6c5275777804e" + url: "https://pub.dev" + source: hosted + version: "8.1.4" boolean_selector: dependency: transitive description: @@ -185,6 +193,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + equatable: + dependency: "direct main" + description: + name: equatable + sha256: c2b87cb7756efdf69892005af546c56c0b5037f54d2a88269b4f347a505e3ca2 + url: "https://pub.dev" + source: hosted + version: "2.0.5" fake_async: dependency: transitive description: @@ -214,6 +230,14 @@ packages: 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: @@ -435,6 +459,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.1.0" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" network_image_mock: dependency: "direct dev" description: @@ -483,6 +515,14 @@ packages: url: "https://pub.dev" source: hosted 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: diff --git a/pubspec.yaml b/pubspec.yaml index c5354ae..95486a2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -19,6 +19,8 @@ dependencies: get_it: ^7.7.0 multiple_result: ^5.1.0 import_sorter: ^4.6.0 + flutter_bloc: ^8.1.6 + equatable: ^2.0.5 dev_dependencies: flutter_test: diff --git a/test/presentation/screens/home/all_restaurants_tab_test.dart b/test/presentation/screens/home/all_restaurants_tab_test.dart index decfda0..d0eca51 100644 --- a/test/presentation/screens/home/all_restaurants_tab_test.dart +++ b/test/presentation/screens/home/all_restaurants_tab_test.dart @@ -4,7 +4,7 @@ import 'package:network_image_mock/network_image_mock.dart'; import 'package:restaurant_tour/domain/use_cases/get_restaurants_use_case.dart'; import 'package:restaurant_tour/domain/use_cases/toggle_favorite.dart'; import 'package:restaurant_tour/presentation/components/restaurant_card.dart'; -import 'package:restaurant_tour/presentation/screens/home/all_restaurants_tab.dart'; +import 'package:restaurant_tour/presentation/screens/home/_tabs/all_restaurants_tab/all_restaurants_tab.dart'; import '../../../fakes/repositories/fake_restaurants_repository.dart'; import '../../../make_testable_widget.dart'; diff --git a/test/presentation/screens/home/favorite_restaurants_tab_test.dart b/test/presentation/screens/home/favorite_restaurants_tab_test.dart index 58150e4..7d39a23 100644 --- a/test/presentation/screens/home/favorite_restaurants_tab_test.dart +++ b/test/presentation/screens/home/favorite_restaurants_tab_test.dart @@ -2,7 +2,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:network_image_mock/network_image_mock.dart'; import 'package:restaurant_tour/presentation/components/restaurant_card.dart'; -import 'package:restaurant_tour/presentation/screens/home/favorite_restaurants_tab.dart'; +import 'package:restaurant_tour/presentation/screens/home/_tabs/favorite_restaurants_tab/favorite_restaurants_tab.dart'; import '../../../fakes/data/fake_restaurant.dart'; import '../../../make_testable_widget.dart'; From 3f25bd4586a71ce1976c1961566e5c61a4c4e3aa Mon Sep 17 00:00:00 2001 From: Felipe Reis Date: Thu, 12 Sep 2024 20:39:42 -0300 Subject: [PATCH 20/23] fix: forgot to emit errors in the all_restaurants_tab_bloc.dart --- .../all_restaurants_tab/bloc/all_restaurants_tab_bloc.dart | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/presentation/screens/home/_tabs/all_restaurants_tab/bloc/all_restaurants_tab_bloc.dart b/lib/presentation/screens/home/_tabs/all_restaurants_tab/bloc/all_restaurants_tab_bloc.dart index d2d45d8..226f26d 100644 --- a/lib/presentation/screens/home/_tabs/all_restaurants_tab/bloc/all_restaurants_tab_bloc.dart +++ b/lib/presentation/screens/home/_tabs/all_restaurants_tab/bloc/all_restaurants_tab_bloc.dart @@ -1,6 +1,5 @@ import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; - import 'package:restaurant_tour/core/domain/error/error.dart'; import 'package:restaurant_tour/domain/models/restaurant.dart'; import 'package:restaurant_tour/domain/use_cases/get_restaurants_use_case.dart'; @@ -32,7 +31,7 @@ class AllRestaurantsTabBloc contentIsLoading: false, ), ), - (error) => state.copyWith(error: error, contentIsLoading: false), + (error) => emit(state.copyWith(error: error, contentIsLoading: false)), ); } } From 6bfa507b290e0bd5ddfd74e2a060f9b24ebca7c5 Mon Sep 17 00:00:00 2001 From: Felipe Reis Date: Fri, 13 Sep 2024 15:08:39 -0300 Subject: [PATCH 21/23] test: adding missing tests for Blocs --- lib/core/domain/error/data_error.dart | 13 ++- lib/domain/models/restaurant.dart | 11 +- pubspec.lock | 108 ++++++++++++++++-- pubspec.yaml | 2 + .../bloc/all_restaurants_tab_bloc_test.dart | 70 ++++++++++++ .../screens/home/bloc/home_bloc_test.dart | 47 ++++++++ 6 files changed, 239 insertions(+), 12 deletions(-) create mode 100644 test/presentation/screens/home/_tabs/all_restaurants_tab/bloc/all_restaurants_tab_bloc_test.dart create mode 100644 test/presentation/screens/home/bloc/home_bloc_test.dart diff --git a/lib/core/domain/error/data_error.dart b/lib/core/domain/error/data_error.dart index 9363c27..e3c2012 100644 --- a/lib/core/domain/error/data_error.dart +++ b/lib/core/domain/error/data_error.dart @@ -10,6 +10,17 @@ final class TimeoutError extends NetworkError {} final class ServerError extends NetworkError {} -final class UnknownError extends NetworkError {} +// adding override only in this class to help with tests +// just an example as I need to compare the result of an error in a test +final class UnknownError extends NetworkError { + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is UnknownError; + } + + @override + int get hashCode => runtimeType.hashCode; +} final class RateLimitError extends NetworkError {} diff --git a/lib/domain/models/restaurant.dart b/lib/domain/models/restaurant.dart index d778b0e..1205cc6 100644 --- a/lib/domain/models/restaurant.dart +++ b/lib/domain/models/restaurant.dart @@ -1,9 +1,10 @@ +import 'package:equatable/equatable.dart'; import 'package:restaurant_tour/domain/models/category.dart'; import 'package:restaurant_tour/domain/models/hours.dart'; import 'package:restaurant_tour/domain/models/location.dart'; import 'package:restaurant_tour/domain/models/review.dart'; -class Restaurant { +class Restaurant extends Equatable { final String? id; final String? name; final String? price; @@ -50,4 +51,12 @@ class Restaurant { } return false; } + + // adding Equatable to compare objects and make AllRestaurantsTabBloc tests pass + // Using just the id and name for simplicity + @override + List get props => [ + id, + name, + ]; } diff --git a/pubspec.lock b/pubspec.lock index 0a9c7f4..47532df 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -41,6 +41,14 @@ packages: url: "https://pub.dev" source: hosted version: "8.1.4" + bloc_test: + dependency: "direct dev" + description: + name: bloc_test + sha256: "165a6ec950d9252ebe36dc5335f2e6eb13055f33d56db0eeb7642768849b43d2" + url: "https://pub.dev" + source: hosted + version: "9.1.7" boolean_selector: dependency: transitive description: @@ -161,6 +169,14 @@ packages: url: "https://pub.dev" source: hosted 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: @@ -177,6 +193,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.6" + diff_match_patch: + dependency: transitive + description: + name: diff_match_patch + sha256: "2efc9e6e8f449d0abe15be240e2c2a3bcd977c8d126cfd70598aee60af35c0a4" + url: "https://pub.dev" + source: hosted + version: "0.4.1" dio: dependency: "direct main" description: @@ -451,6 +475,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.4.4" + mocktail: + dependency: transitive + description: + name: mocktail + sha256: "890df3f9688106f25755f26b1c60589a92b3ab91a22b8b224947ad041bf172d8" + url: "https://pub.dev" + source: hosted + version: "1.0.4" multiple_result: dependency: "direct main" description: @@ -475,6 +507,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.1" + 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: @@ -547,14 +587,30 @@ packages: url: "https://pub.dev" source: hosted 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: "073c147238594ecd0d193f3456a5fe91c4b0abbcc68bf5cd95b36c4e194ac611" + sha256: "9ca081be41c60190ebcb4766b2486a7d50261db7bd0f5d9615f2d653637a84c1" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "1.0.4" sky_engine: dependency: transitive description: flutter @@ -576,6 +632,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.4" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "708b3f6b97248e5781f493b765c3337db11c5d2c81c3094f10904bfa8004c703" + url: "https://pub.dev" + source: hosted + version: "0.10.12" source_span: dependency: transitive description: @@ -624,6 +696,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.1" + test: + dependency: "direct dev" + description: + name: test + sha256: "7ee446762c2c50b3bd4ea96fe13ffac69919352bd3b4b17bac3f3465edc58073" + url: "https://pub.dev" + source: hosted + version: "1.25.2" test_api: dependency: transitive description: @@ -632,6 +712,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.0" + test_core: + dependency: transitive + description: + name: test_core + sha256: "2bc4b4ecddd75309300d8096f781c0e3280ca1ef85beda558d33fcbedc2eead4" + url: "https://pub.dev" + source: hosted + version: "0.6.0" timing: dependency: transitive description: @@ -712,22 +800,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" - web_socket: + web_socket_channel: dependency: transitive description: - name: web_socket - sha256: "3c12d96c0c9a4eec095246debcea7b86c0324f22df69893d538fcc6f1b8cce83" + name: web_socket_channel + sha256: d88238e5eac9a42bb43ca4e721edba3c08c6354d4a53063afaa568516217621b url: "https://pub.dev" source: hosted - version: "0.1.6" - web_socket_channel: + version: "2.4.0" + webkit_inspection_protocol: dependency: transitive description: - name: web_socket_channel - sha256: "9f187088ed104edd8662ca07af4b124465893caf063ba29758f97af57e61da8f" + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "1.2.1" xml: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 95486a2..9bc7006 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -30,6 +30,8 @@ dev_dependencies: json_serializable: ^6.8.0 http_mock_adapter: ^0.6.1 network_image_mock: ^2.1.1 + test: ^1.25.2 + bloc_test: ^9.1.7 flutter: generate: true diff --git a/test/presentation/screens/home/_tabs/all_restaurants_tab/bloc/all_restaurants_tab_bloc_test.dart b/test/presentation/screens/home/_tabs/all_restaurants_tab/bloc/all_restaurants_tab_bloc_test.dart new file mode 100644 index 0000000..92ce7f1 --- /dev/null +++ b/test/presentation/screens/home/_tabs/all_restaurants_tab/bloc/all_restaurants_tab_bloc_test.dart @@ -0,0 +1,70 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:multiple_result/multiple_result.dart'; +import 'package:restaurant_tour/core/domain/error/data_error.dart'; +import 'package:restaurant_tour/core/domain/error/error.dart'; +import 'package:restaurant_tour/domain/models/restaurant.dart'; +import 'package:restaurant_tour/domain/repositories/restaurants_repository.dart'; +import 'package:restaurant_tour/domain/use_cases/get_restaurants_use_case.dart'; +import 'package:restaurant_tour/presentation/screens/home/_tabs/all_restaurants_tab/bloc/all_restaurants_tab_bloc.dart'; + +import '../../../../../../fakes/repositories/fake_restaurants_repository.dart'; + +// I'm implementing a fake use case to test the error case +// This is just an example, we could create it in a separate file and share it with other tests +class FakeGetRestaurantsUseCaseWithError implements GetRestaurantsUseCase { + const FakeGetRestaurantsUseCaseWithError(); + + @override + Future, BaseError>> call() { + return Future.value( + Result, BaseError>.error(UnknownError()), + ); + } +} + +void main() { + group( + AllRestaurantsTabBloc, + () { + late AllRestaurantsTabBloc bloc; + late GetRestaurantsUseCase getRestaurantsUseCase; + late BaseRestaurantsRepository restaurantsRepository; + + setUp( + () { + restaurantsRepository = FakeRestaurantsRepository(); + getRestaurantsUseCase = GetRestaurantsUseCase( + restaurantsRepository: restaurantsRepository, + ); + bloc = AllRestaurantsTabBloc( + getRestaurantsUseCase: getRestaurantsUseCase, + ); + }, + ); + + test( + 'test the initial state', + () { + expect(bloc.state, const AllRestaurantsTabState()); + }, + ); + + blocTest( + 'emits the restaurants list when LoadAllRestaurants is added', + build: () => AllRestaurantsTabBloc( + getRestaurantsUseCase: const FakeGetRestaurantsUseCaseWithError(), + ), + act: (bloc) => bloc.add(const LoadAllRestaurants()), + expect: () async { + return [ + AllRestaurantsTabState( + error: UnknownError(), + contentIsLoading: false, + ), + ]; + }, + ); + }, + ); +} diff --git a/test/presentation/screens/home/bloc/home_bloc_test.dart b/test/presentation/screens/home/bloc/home_bloc_test.dart new file mode 100644 index 0000000..7ee15e5 --- /dev/null +++ b/test/presentation/screens/home/bloc/home_bloc_test.dart @@ -0,0 +1,47 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:restaurant_tour/domain/repositories/restaurants_repository.dart'; +import 'package:restaurant_tour/domain/use_cases/get_favorites_restaurants_use_case.dart'; +import 'package:restaurant_tour/presentation/screens/home/bloc/home_bloc.dart'; + +import '../../../../fakes/repositories/fake_restaurants_repository.dart'; + +void main() { + group( + HomeBloc, + () { + late HomeBloc bloc; + late GetFavoriteRestaurantsUseCase getFavoriteRestaurantsUseCase; + late BaseRestaurantsRepository restaurantsRepository; + + setUp( + () { + restaurantsRepository = FakeRestaurantsRepository(); + getFavoriteRestaurantsUseCase = GetFavoriteRestaurantsUseCase( + restaurantsRepository: restaurantsRepository); + bloc = HomeBloc( + getFavoriteRestaurantsUseCase: getFavoriteRestaurantsUseCase); + }, + ); + + test( + 'test the initial state', + () { + expect(bloc.state, const HomeState()); + }, + ); + + blocTest( + 'emits the favorites restaurants when LoadFavoriteRestaurants is added', + build: () => bloc, + act: (bloc) => bloc.add(const LoadFavoriteRestaurants()), + expect: () async { + final expected = await restaurantsRepository.getFavorites().single; + return [ + HomeState(favoriteRestaurants: expected), + ]; + }, + ); + }, + ); +} From 2e95bfc702d8695a63db00c665b7c0208446bec0 Mon Sep 17 00:00:00 2001 From: Felipe Reis Date: Fri, 13 Sep 2024 15:14:13 -0300 Subject: [PATCH 22/23] test: fixing tests... I wrote a test above the other --- .../bloc/all_restaurants_tab_bloc_test.dart | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/test/presentation/screens/home/_tabs/all_restaurants_tab/bloc/all_restaurants_tab_bloc_test.dart b/test/presentation/screens/home/_tabs/all_restaurants_tab/bloc/all_restaurants_tab_bloc_test.dart index 92ce7f1..ac2f4ba 100644 --- a/test/presentation/screens/home/_tabs/all_restaurants_tab/bloc/all_restaurants_tab_bloc_test.dart +++ b/test/presentation/screens/home/_tabs/all_restaurants_tab/bloc/all_restaurants_tab_bloc_test.dart @@ -52,6 +52,22 @@ void main() { blocTest( 'emits the restaurants list when LoadAllRestaurants is added', + build: () => bloc, + act: (bloc) => bloc.add(const LoadAllRestaurants()), + expect: () async { + final data = await restaurantsRepository.getRestaurants(); + final expected = data.getOrThrow(); + return [ + AllRestaurantsTabState( + restaurants: expected, + contentIsLoading: false, + ), + ]; + }, + ); + + blocTest( + 'emits an error when LoadAllRestaurants is added', build: () => AllRestaurantsTabBloc( getRestaurantsUseCase: const FakeGetRestaurantsUseCaseWithError(), ), From 54e37fe6b44a7eb6df9aed5218954b89dfc61216 Mon Sep 17 00:00:00 2001 From: Felipe Reis Date: Sun, 15 Sep 2024 18:35:17 -0300 Subject: [PATCH 23/23] feat: save data offline (restaurants list and favorites); add data sources --- build.yaml | 6 + lib/core/domain/error/data_error.dart | 12 +- .../provider/base_database_provider.dart | 9 + .../local/provider/database_provider.dart | 25 +++ .../local/restaurant_local_data_source.dart | 81 ++++++++ .../remote/restaurant_remote_data_source.dart | 87 +++++++++ lib/data/dtos/restaurant_dto.g.dart | 2 +- lib/data/models/category.dart | 7 +- lib/data/models/hours.dart | 5 +- lib/data/models/location.dart | 6 +- lib/data/models/restaurant.dart | 20 +- lib/data/models/restaurant.g.dart | 8 +- lib/data/models/review.dart | 9 +- lib/data/models/review.g.dart | 2 +- lib/data/models/user.dart | 7 +- .../restaurant_local_data_source.dart | 17 ++ .../restaurant_remote_data_source.dart | 10 + .../mock/mocked_restaurants_repository.dart | 1 - .../repositories/restaurants_repository.dart | 123 ++++-------- lib/di/di.dart | 34 +++- lib/main.dart | 6 +- .../components/restaurant_card.dart | 7 +- lib/presentation/components/review_card.dart | 7 +- .../all_restaurants_tab.dart | 8 +- .../restaurant_details_screen.dart | 6 +- pubspec.lock | 180 +++++++++++++++++- pubspec.yaml | 4 + .../restaurants_repository_test.dart | 29 ++- test/fakes/data/fake_database_provider.dart | 20 ++ 29 files changed, 604 insertions(+), 134 deletions(-) create mode 100644 build.yaml create mode 100644 lib/data/datasources/local/provider/base_database_provider.dart create mode 100644 lib/data/datasources/local/provider/database_provider.dart create mode 100644 lib/data/datasources/local/restaurant_local_data_source.dart create mode 100644 lib/data/datasources/remote/restaurant_remote_data_source.dart create mode 100644 lib/data/repositories/datasource/restaurant_local_data_source.dart create mode 100644 lib/data/repositories/datasource/restaurant_remote_data_source.dart create mode 100644 test/fakes/data/fake_database_provider.dart diff --git a/build.yaml b/build.yaml new file mode 100644 index 0000000..aaa6e0d --- /dev/null +++ b/build.yaml @@ -0,0 +1,6 @@ +targets: + $default: + builders: + json_serializable: + options: + explicit_to_json: true \ No newline at end of file diff --git a/lib/core/domain/error/data_error.dart b/lib/core/domain/error/data_error.dart index e3c2012..cef7322 100644 --- a/lib/core/domain/error/data_error.dart +++ b/lib/core/domain/error/data_error.dart @@ -4,12 +4,22 @@ sealed class DataError extends BaseError {} sealed class NetworkError extends DataError {} +sealed class LocalError extends DataError {} + +// network errors final class NoInternetConnectionError extends NetworkError {} final class TimeoutError extends NetworkError {} final class ServerError extends NetworkError {} +final class RateLimitError extends NetworkError {} + +// local errors +final class ReadLocalDataError extends LocalError {} + +final class SaveDataError extends LocalError {} + // adding override only in this class to help with tests // just an example as I need to compare the result of an error in a test final class UnknownError extends NetworkError { @@ -22,5 +32,3 @@ final class UnknownError extends NetworkError { @override int get hashCode => runtimeType.hashCode; } - -final class RateLimitError extends NetworkError {} diff --git a/lib/data/datasources/local/provider/base_database_provider.dart b/lib/data/datasources/local/provider/base_database_provider.dart new file mode 100644 index 0000000..3eb8ebb --- /dev/null +++ b/lib/data/datasources/local/provider/base_database_provider.dart @@ -0,0 +1,9 @@ +import 'package:sembast/sembast.dart'; + +abstract class BaseDatabaseProvider { + Database get database; + + Future init(); + + Future close(); +} diff --git a/lib/data/datasources/local/provider/database_provider.dart b/lib/data/datasources/local/provider/database_provider.dart new file mode 100644 index 0000000..32f57ad --- /dev/null +++ b/lib/data/datasources/local/provider/database_provider.dart @@ -0,0 +1,25 @@ +import 'package:path/path.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:restaurant_tour/data/datasources/local/provider/base_database_provider.dart'; +import 'package:sembast/sembast.dart'; +import 'package:sembast/sembast_io.dart'; + +class DatabaseProvider extends BaseDatabaseProvider { + late final Database _database; + + @override + Database get database => _database; + + @override + Future init() async { + final dir = await getApplicationDocumentsDirectory(); + await dir.create(recursive: true); + final dbPath = join(dir.path, 'database.db'); + _database = await databaseFactoryIo.openDatabase(dbPath); + } + + @override + Future close() async { + await _database.close(); + } +} diff --git a/lib/data/datasources/local/restaurant_local_data_source.dart b/lib/data/datasources/local/restaurant_local_data_source.dart new file mode 100644 index 0000000..d75631f --- /dev/null +++ b/lib/data/datasources/local/restaurant_local_data_source.dart @@ -0,0 +1,81 @@ +import 'package:multiple_result/multiple_result.dart'; +import 'package:restaurant_tour/core/domain/error/data_error.dart'; +import 'package:restaurant_tour/core/domain/error/error.dart'; +import 'package:restaurant_tour/data/datasources/local/provider/base_database_provider.dart'; +import 'package:restaurant_tour/data/models/restaurant.dart'; +import 'package:restaurant_tour/data/repositories/datasource/restaurant_local_data_source.dart'; +import 'package:sembast/sembast.dart'; + +class RestaurantLocalDataSource extends BaseRestaurantLocalDataSource { + RestaurantLocalDataSource({ + required BaseDatabaseProvider databaseProvider, + }) : _databaseProvider = databaseProvider; + + final BaseDatabaseProvider _databaseProvider; + + Database get _db => _databaseProvider.database; + + StoreRef> get _store => + stringMapStoreFactory.store('restaurants'); + + StoreRef> get _favoritesStore => + stringMapStoreFactory.store('favorites_restaurants'); + + @override + Future, BaseError>> getRestaurants() async { + try { + final data = await _store.find(_db); + final restaurants = + data.map((e) => Restaurant.fromJson(e.value)).toList(); + return Success(restaurants); + } catch (e) { + return Error(ReadLocalDataError()); + } + } + + @override + Future> insertRestaurants( + List restaurants, + ) async { + try { + await _store.addAll(_db, restaurants.map((e) => e.toJson()).toList()); + + await _db.transaction((txn) async { + for (final restaurant in restaurants) { + await _store.record(restaurant.id!).put(txn, restaurant.toJson()); + } + }); + + return Success.unit(); + } catch (e) { + return Error(SaveDataError()); + } + } + + @override + Stream> getFavorites() { + return _favoritesStore.query().onSnapshotsSync(_db).map((snapshot) { + return snapshot.map((e) => Restaurant.fromJson(e.value)).toList(); + }); + } + + @override + Future> toggleFavorite(Restaurant restaurant) async { + try { + final recordId = restaurant.id!; + final exists = await _favoritesStore.record(recordId).get(_db); + + if (exists != null) { + await _favoritesStore.record(recordId).delete(_db); + return Success.unit(); + } + + _favoritesStore.record(recordId).put(_db, restaurant.toJson()); + + return Success.unit(); + } catch (e) { + // we could map a more specific error here, but for now, we will just return a generic error + return Error(SaveDataError()); + } + } +} diff --git a/lib/data/datasources/remote/restaurant_remote_data_source.dart b/lib/data/datasources/remote/restaurant_remote_data_source.dart new file mode 100644 index 0000000..30ace75 --- /dev/null +++ b/lib/data/datasources/remote/restaurant_remote_data_source.dart @@ -0,0 +1,87 @@ +import 'package:dio/dio.dart'; +import 'package:multiple_result/multiple_result.dart'; +import 'package:restaurant_tour/core/domain/error/data_error.dart'; +import 'package:restaurant_tour/core/domain/error/error.dart'; +import 'package:restaurant_tour/data/dtos/restaurant_dto.dart'; +import 'package:restaurant_tour/data/models/restaurant.dart'; +import 'package:restaurant_tour/data/repositories/datasource/restaurant_remote_data_source.dart'; + +class RestaurantRemoteDataSource extends BaseRestaurantRemoteDataSource { + RestaurantRemoteDataSource({ + required Dio httpClient, + }) : _httpClient = httpClient; + + final Dio _httpClient; + + @override + Future, BaseError>> getRestaurants({ + int offset = 0, + }) async { + try { + final response = await _httpClient.post>( + '/v3/graphql', + data: _getRestaurantsQuery(offset), + ); + + final data = RestaurantDto.fromJson(response.data!['data']['search']); + + if (data.restaurants != null) { + return Success(data.restaurants!); + } + + return Error(UnknownError()); + } on DioException catch (e) { + // we could map all network errors here and create something to share the logic + // here a simple example + if (e.response?.statusCode == 400) { + return Error(RateLimitError()); + } + + return switch (e.type) { + DioExceptionType.badResponse => Error(UnknownError()), + DioExceptionType.connectionTimeout => Error(TimeoutError()), + DioExceptionType.connectionError => Error(NoInternetConnectionError()), + _ => Error(UnknownError()), + }; + } catch (e) { + return Error(UnknownError()); + } + } +} + +String _getRestaurantsQuery(int offset) { + return ''' +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/data/dtos/restaurant_dto.g.dart b/lib/data/dtos/restaurant_dto.g.dart index e9eeaa4..fe51973 100644 --- a/lib/data/dtos/restaurant_dto.g.dart +++ b/lib/data/dtos/restaurant_dto.g.dart @@ -17,5 +17,5 @@ RestaurantDto _$RestaurantDtoFromJson(Map json) => Map _$RestaurantDtoToJson(RestaurantDto instance) => { 'total': instance.total, - 'business': instance.restaurants, + 'business': instance.restaurants?.map((e) => e.toJson()).toList(), }; diff --git a/lib/data/models/category.dart b/lib/data/models/category.dart index 873f86f..0281f5d 100644 --- a/lib/data/models/category.dart +++ b/lib/data/models/category.dart @@ -1,5 +1,4 @@ import 'package:json_annotation/json_annotation.dart'; - import 'package:restaurant_tour/domain/models/category.dart' as category_domain_model; @@ -24,4 +23,10 @@ class Category { alias: alias, title: title, ); + + factory Category.fromDomain(category_domain_model.Category domain) => + Category( + alias: domain.alias, + title: domain.title, + ); } diff --git a/lib/data/models/hours.dart b/lib/data/models/hours.dart index 81313b2..d6b27e6 100644 --- a/lib/data/models/hours.dart +++ b/lib/data/models/hours.dart @@ -1,5 +1,4 @@ import 'package:json_annotation/json_annotation.dart'; - import 'package:restaurant_tour/domain/models/hours.dart' as hours_domain_model; part 'hours.g.dart'; @@ -20,4 +19,8 @@ class Hours { hours_domain_model.Hours toDomain() => hours_domain_model.Hours( isOpenNow: isOpenNow, ); + + factory Hours.fromDomain(hours_domain_model.Hours domain) => Hours( + isOpenNow: domain.isOpenNow, + ); } diff --git a/lib/data/models/location.dart b/lib/data/models/location.dart index 270408d..8a4e876 100644 --- a/lib/data/models/location.dart +++ b/lib/data/models/location.dart @@ -1,5 +1,4 @@ import 'package:json_annotation/json_annotation.dart'; - import 'package:restaurant_tour/domain/models/location.dart' as location_domain_model; @@ -22,4 +21,9 @@ class Location { location_domain_model.Location toDomain() => location_domain_model.Location( formattedAddress: formattedAddress, ); + + factory Location.fromDomain(location_domain_model.Location domain) => + Location( + formattedAddress: domain.formattedAddress, + ); } diff --git a/lib/data/models/restaurant.dart b/lib/data/models/restaurant.dart index 78b2cdf..bcd0f6f 100644 --- a/lib/data/models/restaurant.dart +++ b/lib/data/models/restaurant.dart @@ -1,16 +1,12 @@ -import 'package:json_annotation/json_annotation.dart'; - import 'package:restaurant_tour/data/models/category.dart'; import 'package:restaurant_tour/data/models/hours.dart'; import 'package:restaurant_tour/data/models/location.dart'; import 'package:restaurant_tour/data/models/review.dart'; - import 'package:restaurant_tour/domain/models/restaurant.dart' as restaurant_domain_model; part 'restaurant.g.dart'; -@JsonSerializable() class Restaurant { final String? id; final String? name; @@ -51,4 +47,20 @@ class Restaurant { reviews: reviews?.map((e) => e.toDomain()).toList(), location: location?.toDomain(), ); + + factory Restaurant.fromDomain(restaurant_domain_model.Restaurant domain) => + Restaurant( + id: domain.id, + name: domain.name, + price: domain.price, + rating: domain.rating, + photos: domain.photos, + categories: + domain.categories?.map((e) => Category.fromDomain(e)).toList(), + hours: domain.hours?.map((e) => Hours.fromDomain(e)).toList(), + reviews: domain.reviews?.map((e) => Review.fromDomain(e)).toList(), + location: domain.location != null + ? Location.fromDomain(domain.location!) + : null, + ); } diff --git a/lib/data/models/restaurant.g.dart b/lib/data/models/restaurant.g.dart index 764b5a7..fe99415 100644 --- a/lib/data/models/restaurant.g.dart +++ b/lib/data/models/restaurant.g.dart @@ -34,8 +34,8 @@ Map _$RestaurantToJson(Restaurant instance) => 'price': instance.price, 'rating': instance.rating, 'photos': instance.photos, - 'categories': instance.categories, - 'hours': instance.hours, - 'reviews': instance.reviews, - 'location': instance.location, + 'categories': instance.categories?.map((e) => e.toJson()).toList(), + 'hours': instance.hours?.map((e) => e.toJson()).toList(), + 'reviews': instance.reviews?.map((e) => e.toJson()).toList(), + 'location': instance.location?.toJson(), }; diff --git a/lib/data/models/review.dart b/lib/data/models/review.dart index 7b92f9d..bf9c7ab 100644 --- a/lib/data/models/review.dart +++ b/lib/data/models/review.dart @@ -1,7 +1,5 @@ import 'package:json_annotation/json_annotation.dart'; - import 'package:restaurant_tour/data/models/user.dart'; - import 'package:restaurant_tour/domain/models/review.dart' as review_domain_model; @@ -31,4 +29,11 @@ class Review { text: text, user: user?.toDomain(), ); + + factory Review.fromDomain(review_domain_model.Review domain) => Review( + id: domain.id, + rating: domain.rating, + text: domain.text, + user: domain.user != null ? User.fromDomain(domain.user!) : null, + ); } diff --git a/lib/data/models/review.g.dart b/lib/data/models/review.g.dart index 531e735..4d90613 100644 --- a/lib/data/models/review.g.dart +++ b/lib/data/models/review.g.dart @@ -19,5 +19,5 @@ Map _$ReviewToJson(Review instance) => { 'id': instance.id, 'rating': instance.rating, 'text': instance.text, - 'user': instance.user, + 'user': instance.user?.toJson(), }; diff --git a/lib/data/models/user.dart b/lib/data/models/user.dart index 1f576e3..c01aa3e 100644 --- a/lib/data/models/user.dart +++ b/lib/data/models/user.dart @@ -1,5 +1,4 @@ import 'package:json_annotation/json_annotation.dart'; - import 'package:restaurant_tour/domain/models/user.dart' as review_domain_model; part 'user.g.dart'; @@ -26,4 +25,10 @@ class User { imageUrl: imageUrl, name: name, ); + + factory User.fromDomain(review_domain_model.User domain) => User( + id: domain.id, + imageUrl: domain.imageUrl, + name: domain.name, + ); } diff --git a/lib/data/repositories/datasource/restaurant_local_data_source.dart b/lib/data/repositories/datasource/restaurant_local_data_source.dart new file mode 100644 index 0000000..9b99ee5 --- /dev/null +++ b/lib/data/repositories/datasource/restaurant_local_data_source.dart @@ -0,0 +1,17 @@ +import 'package:multiple_result/multiple_result.dart'; +import 'package:restaurant_tour/core/domain/error/error.dart'; +import 'package:restaurant_tour/data/models/restaurant.dart'; + +abstract class BaseRestaurantLocalDataSource { + Future, BaseError>> getRestaurants(); + + Future> insertRestaurants( + List restaurants, + ); + + Future> toggleFavorite( + Restaurant restaurant, + ); + + Stream> getFavorites(); +} diff --git a/lib/data/repositories/datasource/restaurant_remote_data_source.dart b/lib/data/repositories/datasource/restaurant_remote_data_source.dart new file mode 100644 index 0000000..7825b80 --- /dev/null +++ b/lib/data/repositories/datasource/restaurant_remote_data_source.dart @@ -0,0 +1,10 @@ +import 'package:multiple_result/multiple_result.dart'; +import 'package:restaurant_tour/data/models/restaurant.dart'; + +import 'package:restaurant_tour/core/domain/error/error.dart'; + +abstract class BaseRestaurantRemoteDataSource { + Future, BaseError>> getRestaurants({ + int offset = 0, + }); +} diff --git a/lib/data/repositories/mock/mocked_restaurants_repository.dart b/lib/data/repositories/mock/mocked_restaurants_repository.dart index b1a7467..dfa9801 100644 --- a/lib/data/repositories/mock/mocked_restaurants_repository.dart +++ b/lib/data/repositories/mock/mocked_restaurants_repository.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'package:multiple_result/multiple_result.dart'; - import 'package:restaurant_tour/core/domain/error/data_error.dart'; import 'package:restaurant_tour/core/domain/error/error.dart'; import 'package:restaurant_tour/data/dtos/restaurant_dto.dart'; diff --git a/lib/data/repositories/restaurants_repository.dart b/lib/data/repositories/restaurants_repository.dart index d5c96f5..c0600ea 100644 --- a/lib/data/repositories/restaurants_repository.dart +++ b/lib/data/repositories/restaurants_repository.dart @@ -1,66 +1,61 @@ import 'dart:async'; -import 'package:dio/dio.dart'; import 'package:multiple_result/multiple_result.dart'; - import 'package:restaurant_tour/core/domain/error/data_error.dart'; import 'package:restaurant_tour/core/domain/error/error.dart'; -import 'package:restaurant_tour/data/dtos/restaurant_dto.dart'; +import 'package:restaurant_tour/data/models/restaurant.dart' + as restaurant_data_model; +import 'package:restaurant_tour/data/repositories/datasource/restaurant_local_data_source.dart'; +import 'package:restaurant_tour/data/repositories/datasource/restaurant_remote_data_source.dart'; import 'package:restaurant_tour/domain/models/restaurant.dart'; import 'package:restaurant_tour/domain/repositories/restaurants_repository.dart'; class RestaurantsRepository extends BaseRestaurantsRepository { - // I could have created a remote data provider for this - final Dio _httpClient; - RestaurantsRepository({ - required Dio httpClient, - }) : _httpClient = httpClient; - - // This is a simple in memory provider for the favorites - final List _favorites = []; - final StreamController> - _favoriteRestaurantsStreamController = - StreamController>(); + required BaseRestaurantRemoteDataSource remoteDataSource, + required BaseRestaurantLocalDataSource localDataSource, + }) : _remoteDataSource = remoteDataSource, + _localDataSource = localDataSource; - Stream> get _favoriteRestaurants => - _favoriteRestaurantsStreamController.stream; + final BaseRestaurantRemoteDataSource _remoteDataSource; + final BaseRestaurantLocalDataSource _localDataSource; @override Stream> getFavorites() { - return _favoriteRestaurants; + return _localDataSource + .getFavorites() + .map((event) => event.map((e) => e.toDomain()).toList()); } @override Future, BaseError>> getRestaurants({ int offset = 0, }) async { - try { - final response = await _httpClient.post>( - '/v3/graphql', - data: _getRestaurantsQuery(offset), - ); + final data = await _remoteDataSource.getRestaurants(offset: offset); - final data = RestaurantDto.fromJson(response.data!['data']['search']); + if (data.isError()) { + // failed to fetch data from the remote source, let's try to get it from the local source + final localData = + (await _localDataSource.getRestaurants()).tryGetSuccess(); - if (data.restaurants != null) { - return Success(data.restaurants!.map((e) => e.toDomain()).toList()); + if (localData == null) { + return Error(ReadLocalDataError()); } - return Error(UnknownError()); - } on DioException catch (e) { - // we could map all network errors here and create something to share the logic - // here a simple example - if (e.response?.statusCode == 400) { - return Error(RateLimitError()); + return Success(localData.map((e) => e.toDomain()).toList()); + } + + // we have the data from the remote source, let's work on it + try { + // cache it + final cacheOperationResult = + await _localDataSource.insertRestaurants(data.getOrThrow()); + + if (cacheOperationResult.isError()) { + return Error(SaveDataError()); } - return switch (e.type) { - DioExceptionType.badResponse => Error(UnknownError()), - DioExceptionType.connectionTimeout => Error(TimeoutError()), - DioExceptionType.connectionError => Error(NoInternetConnectionError()), - _ => Error(UnknownError()), - }; + return Success(data.getOrThrow().map((e) => e.toDomain()).toList()); } catch (e) { return Error(UnknownError()); } @@ -68,57 +63,11 @@ class RestaurantsRepository extends BaseRestaurantsRepository { @override void toggleFavorite(Restaurant restaurant) { - final found = - _favorites.indexWhere((element) => element.id == restaurant.id) != -1; - - if (found) { - _favorites.removeWhere((element) => element.id == restaurant.id); - } else { - _favorites.add(restaurant); - } - - _favoriteRestaurantsStreamController.add(_favorites); + _localDataSource.toggleFavorite( + restaurant_data_model.Restaurant.fromDomain(restaurant), + ); } @override - dispose() { - _favoriteRestaurantsStreamController.close(); - } - - String _getRestaurantsQuery(int offset) { - return ''' -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 - } - } - } -} -'''; - } + dispose() {} } diff --git a/lib/di/di.dart b/lib/di/di.dart index 20008ae..724ba3a 100644 --- a/lib/di/di.dart +++ b/lib/di/di.dart @@ -1,6 +1,11 @@ import 'package:dio/dio.dart'; import 'package:get_it/get_it.dart'; - +import 'package:restaurant_tour/data/datasources/local/provider/base_database_provider.dart'; +import 'package:restaurant_tour/data/datasources/local/provider/database_provider.dart'; +import 'package:restaurant_tour/data/datasources/local/restaurant_local_data_source.dart'; +import 'package:restaurant_tour/data/datasources/remote/restaurant_remote_data_source.dart'; +import 'package:restaurant_tour/data/repositories/datasource/restaurant_local_data_source.dart'; +import 'package:restaurant_tour/data/repositories/datasource/restaurant_remote_data_source.dart'; import 'package:restaurant_tour/data/repositories/mock/mocked_restaurants_repository.dart'; import 'package:restaurant_tour/data/repositories/restaurants_repository.dart'; import 'package:restaurant_tour/domain/repositories/restaurants_repository.dart'; @@ -10,7 +15,7 @@ import 'package:restaurant_tour/domain/use_cases/toggle_favorite.dart'; final getIt = GetIt.instance; -void setupDI() { +Future setupDI() async { const apiKey = String.fromEnvironment('API_KEY'); const mockApi = bool.fromEnvironment('MOCK_API', defaultValue: false); final shouldMockApi = apiKey.isEmpty || mockApi; @@ -27,10 +32,33 @@ void setupDI() { ), ); + getIt.registerSingletonAsync( + () async { + final provider = DatabaseProvider(); + await provider.init(); + return provider; + }, + dispose: (instance) => instance.close(), + ); + + getIt.registerSingleton( + RestaurantRemoteDataSource(httpClient: getIt.get()), + ); + + getIt.registerSingletonWithDependencies( + () => RestaurantLocalDataSource(databaseProvider: getIt.get()), + dependsOn: [BaseDatabaseProvider], + ); + + await getIt.allReady(); + getIt.registerLazySingleton( () => shouldMockApi ? MockedRestaurantsRepository() - : RestaurantsRepository(httpClient: getIt.get()), + : RestaurantsRepository( + remoteDataSource: getIt.get(), + localDataSource: getIt.get(), + ), dispose: (repo) => repo.dispose(), ); diff --git a/lib/main.dart b/lib/main.dart index d40ebc3..e16ca7b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; - import 'package:restaurant_tour/di/di.dart'; import 'package:restaurant_tour/domain/use_cases/get_favorites_restaurants_use_case.dart'; import 'package:restaurant_tour/domain/use_cases/get_restaurants_use_case.dart'; @@ -7,8 +6,9 @@ import 'package:restaurant_tour/domain/use_cases/toggle_favorite.dart'; import 'package:restaurant_tour/presentation/screens/home/home_screen.dart'; import 'package:restaurant_tour/presentation/screens/restaurant_details/restaurant_details_screen.dart'; -void main() { - setupDI(); +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + await setupDI(); runApp(const RestaurantTour()); } diff --git a/lib/presentation/components/restaurant_card.dart b/lib/presentation/components/restaurant_card.dart index 930af53..70e7cc6 100644 --- a/lib/presentation/components/restaurant_card.dart +++ b/lib/presentation/components/restaurant_card.dart @@ -1,5 +1,5 @@ +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; - import 'package:restaurant_tour/core/theme/typography.dart'; import 'package:restaurant_tour/domain/models/restaurant.dart'; import 'package:restaurant_tour/presentation/components/rating_stars.dart'; @@ -15,6 +15,7 @@ class RestaurantCard extends StatelessWidget { }); String get openStatusLabel => restaurant.isOpen ? "Open Now" : "Closed"; + Color get openStatusColor => restaurant.isOpen ? const Color(0xFF5CD313) : const Color(0xFFEA5E5E); @@ -34,8 +35,8 @@ class RestaurantCard extends StatelessWidget { child: SizedBox( width: 88, height: 88, - child: Image.network( - restaurant.heroImage, + child: CachedNetworkImage( + imageUrl: restaurant.heroImage, fit: BoxFit.cover, ), ), diff --git a/lib/presentation/components/review_card.dart b/lib/presentation/components/review_card.dart index ec0a0b6..d76bf99 100644 --- a/lib/presentation/components/review_card.dart +++ b/lib/presentation/components/review_card.dart @@ -1,11 +1,12 @@ +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; - import 'package:restaurant_tour/core/theme/typography.dart'; import 'package:restaurant_tour/domain/models/review.dart'; import 'package:restaurant_tour/presentation/components/rating_stars.dart'; class ReviewCard extends StatelessWidget { final Review review; + const ReviewCard({super.key, required this.review}); @override @@ -30,9 +31,7 @@ class ReviewCard extends StatelessWidget { decoration: BoxDecoration( shape: BoxShape.circle, image: DecorationImage( - image: NetworkImage( - review.user!.imageUrl, - ), + image: CachedNetworkImageProvider(review.user!.imageUrl), fit: BoxFit.cover, ), ), diff --git a/lib/presentation/screens/home/_tabs/all_restaurants_tab/all_restaurants_tab.dart b/lib/presentation/screens/home/_tabs/all_restaurants_tab/all_restaurants_tab.dart index 1aaa4ef..dd9da58 100644 --- a/lib/presentation/screens/home/_tabs/all_restaurants_tab/all_restaurants_tab.dart +++ b/lib/presentation/screens/home/_tabs/all_restaurants_tab/all_restaurants_tab.dart @@ -1,7 +1,5 @@ import 'package:flutter/material.dart'; - import 'package:flutter_bloc/flutter_bloc.dart'; - import 'package:restaurant_tour/core/domain/error/data_error.dart'; import 'package:restaurant_tour/core/domain/error/error.dart'; import 'package:restaurant_tour/domain/models/restaurant.dart'; @@ -57,6 +55,12 @@ class _AllRestaurantsTabState extends State return _ErrorContent(error: state.error!); } + if (state.restaurants.isEmpty) { + return const Center( + child: Text("No restaurants found"), + ); + } + return _RestaurantsList( restaurants: state.restaurants, favoriteRestaurants: widget.favoriteRestaurants, diff --git a/lib/presentation/screens/restaurant_details/restaurant_details_screen.dart b/lib/presentation/screens/restaurant_details/restaurant_details_screen.dart index 7c97ea1..1370c5d 100644 --- a/lib/presentation/screens/restaurant_details/restaurant_details_screen.dart +++ b/lib/presentation/screens/restaurant_details/restaurant_details_screen.dart @@ -1,5 +1,5 @@ +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; - import 'package:restaurant_tour/core/theme/colors.dart'; import 'package:restaurant_tour/core/theme/typography.dart'; import 'package:restaurant_tour/domain/models/restaurant.dart'; @@ -64,8 +64,8 @@ class _RestaurantDetailsScreenState extends State { SizedBox( width: double.infinity, height: 361, - child: Image.network( - widget.restaurant.heroImage, + child: CachedNetworkImage( + imageUrl: widget.restaurant.heroImage, fit: BoxFit.cover, ), ), diff --git a/pubspec.lock b/pubspec.lock index 47532df..b20a52e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -121,6 +121,30 @@ packages: url: "https://pub.dev" source: hosted version: "8.9.2" + cached_network_image: + dependency: "direct main" + description: + name: cached_network_image + sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + cached_network_image_platform_interface: + dependency: transitive + description: + name: cached_network_image_platform_interface + sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829" + url: "https://pub.dev" + source: hosted + version: "4.1.1" + cached_network_image_web: + dependency: transitive + description: + name: cached_network_image_web + sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062" + url: "https://pub.dev" + source: hosted + version: "1.3.1" characters: dependency: transitive description: @@ -233,6 +257,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.1" + ffi: + dependency: transitive + description: + name: ffi + sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" + url: "https://pub.dev" + source: hosted + version: "2.1.3" file: dependency: transitive description: @@ -262,6 +294,14 @@ packages: url: "https://pub.dev" source: hosted version: "8.1.6" + flutter_cache_manager: + dependency: transitive + description: + name: flutter_cache_manager + sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" + url: "https://pub.dev" + source: hosted + version: "3.4.1" flutter_lints: dependency: "direct dev" description: @@ -515,6 +555,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.2" + octo_image: + dependency: transitive + description: + name: octo_image + sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" package_config: dependency: transitive description: @@ -524,7 +572,7 @@ packages: source: hosted version: "2.1.0" path: - dependency: transitive + dependency: "direct main" description: name: path sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" @@ -539,6 +587,54 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.1" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: fec0d61223fba3154d87759e3cc27fe2c8dc498f6386c6d6fc80d1afdd1bf378 + url: "https://pub.dev" + source: hosted + version: "2.1.4" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "6f01f8e37ec30b07bc424b4deabac37cacb1bc7e2e515ad74486039918a37eb7" + url: "https://pub.dev" + source: hosted + version: "2.2.10" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: f234384a3fdd67f989b4d54a5d73ca2a6c422fa55ae694381ae0f4375cd1ea16 + url: "https://pub.dev" + source: hosted + version: "2.4.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" petitparser: dependency: transitive description: @@ -547,6 +643,22 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.2" + platform: + dependency: transitive + description: + name: platform + sha256: "9b71283fc13df574056616011fb138fd3b793ea47cc509c189a6c3fa5f8a1a65" + url: "https://pub.dev" + source: hosted + version: "3.1.5" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" pool: dependency: transitive description: @@ -579,6 +691,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" + sembast: + dependency: "direct main" + description: + name: sembast + sha256: a49ce14fb0d81bee9f8941061a38f4b790d19c0ab01abe35a529c1fcef0512a1 + url: "https://pub.dev" + source: hosted + version: "3.7.2" shelf: dependency: transitive description: @@ -656,6 +784,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.0" + sprintf: + dependency: transitive + description: + name: sprintf + sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" + url: "https://pub.dev" + source: hosted + version: "7.0.0" + sqflite: + dependency: transitive + description: + name: sqflite + sha256: a43e5a27235518c03ca238e7b4732cf35eabe863a369ceba6cbefa537a66f16d + url: "https://pub.dev" + source: hosted + version: "2.3.3+1" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "3da423ce7baf868be70e2c0976c28a1bb2f73644268b7ffa7d2e08eab71f16a4" + url: "https://pub.dev" + source: hosted + version: "2.5.4" stack_trace: dependency: transitive description: @@ -688,6 +840,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.0" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "539ef412b170d65ecdafd780f924e5be3f60032a1128df156adad6c5b373d558" + url: "https://pub.dev" + source: hosted + version: "3.1.0+1" term_glyph: dependency: transitive description: @@ -744,6 +904,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.2" + uuid: + dependency: transitive + description: + name: uuid + sha256: f33d6bb662f0e4f79dcd7ada2e6170f3b3a2530c28fc41f49a411ddedd576a77 + url: "https://pub.dev" + source: hosted + version: "4.5.0" vector_graphics: dependency: transitive description: @@ -816,6 +984,14 @@ packages: 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" xml: dependency: transitive description: @@ -834,4 +1010,4 @@ packages: version: "3.1.2" sdks: dart: ">=3.4.0 <4.0.0" - flutter: ">=3.19.6" + flutter: ">=3.22.0" diff --git a/pubspec.yaml b/pubspec.yaml index 9bc7006..5854eef 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -21,6 +21,10 @@ dependencies: import_sorter: ^4.6.0 flutter_bloc: ^8.1.6 equatable: ^2.0.5 + sembast: ^3.7.2 + path_provider: ^2.1.4 + path: ^1.9.0 + cached_network_image: ^3.4.1 dev_dependencies: flutter_test: diff --git a/test/data/repositories/restaurants_repository_test.dart b/test/data/repositories/restaurants_repository_test.dart index bf3761b..d418b55 100644 --- a/test/data/repositories/restaurants_repository_test.dart +++ b/test/data/repositories/restaurants_repository_test.dart @@ -1,31 +1,42 @@ import 'package:dio/dio.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http_mock_adapter/http_mock_adapter.dart'; - +import 'package:restaurant_tour/data/datasources/local/provider/base_database_provider.dart'; +import 'package:restaurant_tour/data/datasources/local/restaurant_local_data_source.dart'; +import 'package:restaurant_tour/data/datasources/remote/restaurant_remote_data_source.dart'; import 'package:restaurant_tour/data/repositories/mock/mocked_cached_response.dart'; import 'package:restaurant_tour/data/repositories/restaurants_repository.dart'; import 'package:restaurant_tour/domain/models/restaurant.dart'; +import '../../fakes/data/fake_database_provider.dart'; + void main() { late RestaurantsRepository restaurantsRepository; late Dio httpClient; late DioAdapter dioAdapter; + late BaseDatabaseProvider databaseProvider; const route = 'https://example.com'; - setUp(() { - httpClient = Dio(BaseOptions( - baseUrl: route, - )); + setUp(() async { + httpClient = Dio(BaseOptions(baseUrl: route)); dioAdapter = DioAdapter( dio: httpClient, matcher: const UrlRequestMatcher(), ); + databaseProvider = FakeDatabaseProvider(); + await databaseProvider.init(); - restaurantsRepository = RestaurantsRepository(httpClient: httpClient); + restaurantsRepository = RestaurantsRepository( + remoteDataSource: RestaurantRemoteDataSource(httpClient: httpClient), + localDataSource: RestaurantLocalDataSource( + databaseProvider: databaseProvider, + ), + ); }); tearDownAll(() { + databaseProvider.close(); restaurantsRepository.dispose(); }); @@ -45,14 +56,16 @@ void main() { expect(data.tryGetSuccess()?.length, 20); }); - test('should return favorite restaurants', () { + test('should return favorite restaurants', () async { const restaurant = Restaurant(id: 'test'); restaurantsRepository.toggleFavorite(restaurant); + await pumpEventQueue(); + expectLater( restaurantsRepository.getFavorites(), emitsInOrder([ - [restaurant] + [restaurant], ]), ); }); diff --git a/test/fakes/data/fake_database_provider.dart b/test/fakes/data/fake_database_provider.dart new file mode 100644 index 0000000..a4d60f8 --- /dev/null +++ b/test/fakes/data/fake_database_provider.dart @@ -0,0 +1,20 @@ +import 'package:restaurant_tour/data/datasources/local/provider/base_database_provider.dart'; +import 'package:sembast/sembast_memory.dart'; +import 'package:sembast/src/api/v2/database.dart'; + +class FakeDatabaseProvider extends BaseDatabaseProvider { + late final Database _database; + + @override + Database get database => _database; + + @override + Future init() async { + _database = await newDatabaseFactoryMemory().openDatabase('test.db'); + } + + @override + Future close() async { + await _database.close(); + } +}