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/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 new file mode 100644 index 0000000..cef7322 --- /dev/null +++ b/lib/core/domain/error/data_error.dart @@ -0,0 +1,34 @@ +import 'package:restaurant_tour/core/domain/error/error.dart'; + +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 { + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is UnknownError; + } + + @override + int get hashCode => runtimeType.hashCode; +} diff --git a/lib/core/domain/error/error.dart b/lib/core/domain/error/error.dart new file mode 100644 index 0000000..6e59396 --- /dev/null +++ b/lib/core/domain/error/error.dart @@ -0,0 +1 @@ +abstract class BaseError {} 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/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.dart b/lib/data/dtos/restaurant_dto.dart new file mode 100644 index 0000000..cc4c65a --- /dev/null +++ b/lib/data/dtos/restaurant_dto.dart @@ -0,0 +1,22 @@ +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..fe51973 --- /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?.map((e) => e.toJson()).toList(), + }; diff --git a/lib/data/models/category.dart b/lib/data/models/category.dart new file mode 100644 index 0000000..0281f5d --- /dev/null +++ b/lib/data/models/category.dart @@ -0,0 +1,32 @@ +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, + ); + + factory Category.fromDomain(category_domain_model.Category domain) => + Category( + alias: domain.alias, + title: domain.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..d6b27e6 --- /dev/null +++ b/lib/data/models/hours.dart @@ -0,0 +1,26 @@ +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, + ); + + factory Hours.fromDomain(hours_domain_model.Hours domain) => Hours( + isOpenNow: domain.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..8a4e876 --- /dev/null +++ b/lib/data/models/location.dart @@ -0,0 +1,29 @@ +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, + ); + + factory Location.fromDomain(location_domain_model.Location domain) => + Location( + formattedAddress: domain.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..bcd0f6f --- /dev/null +++ b/lib/data/models/restaurant.dart @@ -0,0 +1,66 @@ +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'; + +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(), + ); + + 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 new file mode 100644 index 0000000..fe99415 --- /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?.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 new file mode 100644 index 0000000..bf9c7ab --- /dev/null +++ b/lib/data/models/review.dart @@ -0,0 +1,39 @@ +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(), + ); + + 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 new file mode 100644 index 0000000..4d90613 --- /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?.toJson(), + }; diff --git a/lib/data/models/user.dart b/lib/data/models/user.dart new file mode 100644 index 0000000..c01aa3e --- /dev/null +++ b/lib/data/models/user.dart @@ -0,0 +1,34 @@ +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, + ); + + factory User.fromDomain(review_domain_model.User domain) => User( + id: domain.id, + imageUrl: domain.imageUrl, + name: domain.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/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_cached_response.dart b/lib/data/repositories/mock/mocked_cached_response.dart new file mode 100644 index 0000000..5df69c0 --- /dev/null +++ b/lib/data/repositories/mock/mocked_cached_response.dart @@ -0,0 +1,1147 @@ +// I'm using this to not reach the rate limit of the API +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/mock/mocked_restaurants_repository.dart b/lib/data/repositories/mock/mocked_restaurants_repository.dart new file mode 100644 index 0000000..dfa9801 --- /dev/null +++ b/lib/data/repositories/mock/mocked_restaurants_repository.dart @@ -0,0 +1,67 @@ +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'; + +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, BaseError>> getRestaurants({ + int offset = 0, + }) async { + await Future.delayed(const Duration(seconds: 2)); // Simulate network delay + + try { + final response = cachedResponse; + + final data = RestaurantDto.fromJson(response['data']['search']); + + if (data.restaurants != null) { + return Success(data.restaurants!.map((e) => e.toDomain()).toList()); + } + + return Error(UnknownError()); + } catch (e) { + return Error(UnknownError()); + } + } + + @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/data/repositories/restaurants_repository.dart b/lib/data/repositories/restaurants_repository.dart new file mode 100644 index 0000000..c0600ea --- /dev/null +++ b/lib/data/repositories/restaurants_repository.dart @@ -0,0 +1,73 @@ +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/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 { + RestaurantsRepository({ + required BaseRestaurantRemoteDataSource remoteDataSource, + required BaseRestaurantLocalDataSource localDataSource, + }) : _remoteDataSource = remoteDataSource, + _localDataSource = localDataSource; + + final BaseRestaurantRemoteDataSource _remoteDataSource; + final BaseRestaurantLocalDataSource _localDataSource; + + @override + Stream> getFavorites() { + return _localDataSource + .getFavorites() + .map((event) => event.map((e) => e.toDomain()).toList()); + } + + @override + Future, BaseError>> getRestaurants({ + int offset = 0, + }) async { + final data = await _remoteDataSource.getRestaurants(offset: offset); + + 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 (localData == null) { + return Error(ReadLocalDataError()); + } + + 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 Success(data.getOrThrow().map((e) => e.toDomain()).toList()); + } catch (e) { + return Error(UnknownError()); + } + } + + @override + void toggleFavorite(Restaurant restaurant) { + _localDataSource.toggleFavorite( + restaurant_data_model.Restaurant.fromDomain(restaurant), + ); + } + + @override + dispose() {} +} diff --git a/lib/di/di.dart b/lib/di/di.dart new file mode 100644 index 0000000..724ba3a --- /dev/null +++ b/lib/di/di.dart @@ -0,0 +1,76 @@ +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'; +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'; + +final getIt = GetIt.instance; + +Future setupDI() async { + const apiKey = String.fromEnvironment('API_KEY'); + const mockApi = bool.fromEnvironment('MOCK_API', defaultValue: false); + final shouldMockApi = apiKey.isEmpty || mockApi; + + getIt.registerLazySingleton( + () => Dio( + BaseOptions( + baseUrl: 'https://api.yelp.com', + headers: { + 'Authorization': 'Bearer $apiKey', + 'Content-Type': 'application/graphql', + }, + ), + ), + ); + + 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( + remoteDataSource: getIt.get(), + localDataSource: 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..1205cc6 --- /dev/null +++ b/lib/domain/models/restaurant.dart @@ -0,0 +1,62 @@ +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 extends Equatable { + 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; + } + + // 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/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..1725db3 --- /dev/null +++ b/lib/domain/models/user.dart @@ -0,0 +1,12 @@ +class User { + final String? id; + final String imageUrl; + final String? name; + + User({ + this.id, + String? imageUrl, + this.name, + }) : 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 new file mode 100644 index 0000000..4ff2007 --- /dev/null +++ b/lib/domain/repositories/restaurants_repository.dart @@ -0,0 +1,16 @@ +import 'package:multiple_result/multiple_result.dart'; + +import 'package:restaurant_tour/core/domain/error/error.dart'; +import 'package:restaurant_tour/domain/models/restaurant.dart'; + +abstract class BaseRestaurantsRepository { + const BaseRestaurantsRepository(); + + Future, BaseError>> 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..e20bf3b --- /dev/null +++ b/lib/domain/use_cases/get_restaurants_use_case.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/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, BaseError>> 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..e16ca7b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,52 +1,52 @@ 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'; +import 'package:restaurant_tour/presentation/screens/restaurant_details/restaurant_details_screen.dart'; -void main() { +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + await 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: Builder( + builder: (context) { + return HomeScreen( + getAllRestaurantsUseCase: getIt.get(), + getFavoriteRestaurantsUseCase: + 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( + restaurant: restaurant, + isFavorite: isFavorite, + onToggleFavorite: () { + final toggleFavoriteUseCase = + getIt.get(); + toggleFavoriteUseCase(restaurant); + }, + ), + ), + ); + }, + ); + }, ), ); } 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..28d8122 --- /dev/null +++ b/lib/presentation/components/rating_stars.dart @@ -0,0 +1,21 @@ +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..70e7cc6 --- /dev/null +++ b/lib/presentation/components/restaurant_card.dart @@ -0,0 +1,92 @@ +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'; + +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: CachedNetworkImage( + imageUrl: 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..d76bf99 --- /dev/null +++ b/lib/presentation/components/review_card.dart @@ -0,0 +1,49 @@ +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 + 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: CachedNetworkImageProvider(review.user!.imageUrl), + fit: BoxFit.cover, + ), + ), + ), + const SizedBox(width: 8), + Text( + review.user!.name!, + style: AppTextStyles.openRegularText, + ), + ], + ), + ], + ); + } +} 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..dd9da58 --- /dev/null +++ b/lib/presentation/screens/home/_tabs/all_restaurants_tab/all_restaurants_tab.dart @@ -0,0 +1,147 @@ +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!); + } + + if (state.restaurants.isEmpty) { + return const Center( + child: Text("No restaurants found"), + ); + } + + 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..226f26d --- /dev/null +++ b/lib/presentation/screens/home/_tabs/all_restaurants_tab/bloc/all_restaurants_tab_bloc.dart @@ -0,0 +1,37 @@ +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) => emit(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/_tabs/favorite_restaurants_tab/favorite_restaurants_tab.dart b/lib/presentation/screens/home/_tabs/favorite_restaurants_tab/favorite_restaurants_tab.dart new file mode 100644 index 0000000..1c004a5 --- /dev/null +++ b/lib/presentation/screens/home/_tabs/favorite_restaurants_tab/favorite_restaurants_tab.dart @@ -0,0 +1,35 @@ +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/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 new file mode 100644 index 0000000..b14047d --- /dev/null +++ b/lib/presentation/screens/home/home_screen.dart @@ -0,0 +1,101 @@ +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/_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 StatelessWidget { + 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 + Widget build(BuildContext context) { + 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", + ), + ], + ), + ), + 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/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..1370c5d --- /dev/null +++ b/lib/presentation/screens/restaurant_details/restaurant_details_screen.dart @@ -0,0 +1,179 @@ +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'; +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; + 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: CachedNetworkImage( + imageUrl: 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 ?? "", + 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, + ), + 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) { + final review = widget.restaurant.reviews![index]; + return ReviewCard( + review: review, + ); + }, + ), + //-- 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..b20a52e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,26 +5,26 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: ae92f5d747aee634b87f89d9946000c2de774be1d6ac3e58268224348cd0101a + sha256: "0b2f2bd91ba804e53a61d757b986f89f1f9eaed5b11e4b2f5a2468d86d6c9fc7" url: "https://pub.dev" source: hosted - version: "61.0.0" + version: "67.0.0" analyzer: dependency: transitive description: name: analyzer - sha256: ea3d8652bda62982addfd92fdc2d0214e5f82e43325104990d4f4c4a2a313562 + sha256: "37577842a27e4338429a1cbc32679d508836510b056f1eedf0c8d20e39c1383d" url: "https://pub.dev" source: hosted - version: "5.13.0" + version: "6.4.1" args: dependency: transitive description: name: args - sha256: "0bd9a99b6eb96f07af141f0eb53eace8983e8e5aa5de59777aca31684680ef22" + sha256: "7cf60b9f0cc88203c5a190b4cd62a99feea42759a7fa695010eb5de1c0b2252a" url: "https://pub.dev" source: hosted - version: "2.3.0" + version: "2.5.0" async: dependency: transitive description: @@ -33,6 +33,22 @@ 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" + 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: @@ -45,10 +61,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 +77,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 +101,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,34 +117,50 @@ packages: dependency: transitive description: name: built_value - sha256: b6c9911b2d670376918d5b8779bc27e0e612a94ec3ff0343689e991d8d0a3b8a + sha256: c7913a9737ee4007efedaffc968c049fd0f3d0e49109e778edc10de9426005cb url: "https://pub.dev" source: hosted - version: "8.1.4" - characters: + 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: characters - sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" + name: cached_network_image_platform_interface + sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829" url: "https://pub.dev" source: hosted - version: "1.3.0" - charcode: + version: "4.1.1" + cached_network_image_web: dependency: transitive description: - name: charcode - sha256: fb98c0f6d12c920a02ee2d998da788bca066ca5f148492b7085ee23372b12306 + name: cached_network_image_web + sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062" url: "https://pub.dev" source: hosted version: "1.3.1" + characters: + dependency: transitive + description: + name: characters + sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" + url: "https://pub.dev" + source: hosted + version: "1.3.0" 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 +189,42 @@ packages: dependency: transitive description: name: convert - sha256: f08428ad63615f96a27e34221c65e1a451439b5f26030f78d790f461c686d65d + sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.1.1" + coverage: + dependency: transitive + description: + name: coverage + sha256: c1fb2dce3c0085f39dc72668e85f8e0210ec7de05345821ff58530567df345a5 + url: "https://pub.dev" + source: hosted + version: "1.9.2" crypto: dependency: transitive description: name: crypto - sha256: cf75650c66c0316274e21d7c43d3dea246273af5955bd94e8184837cd577575c + sha256: ec30d999af904f33454ba22ed9a86162b35e52b44ac4807d1d93c288041d7d27 url: "https://pub.dev" source: hosted - version: "3.0.1" + 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" + 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: @@ -189,10 +237,18 @@ 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" + equatable: + dependency: "direct main" + description: + name: equatable + sha256: c2b87cb7756efdf69892005af546c56c0b5037f54d2a88269b4f347a505e3ca2 + url: "https://pub.dev" + source: hosted + version: "2.0.5" fake_async: dependency: transitive description: @@ -201,27 +257,51 @@ 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: 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 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_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: @@ -251,14 +331,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 +363,54 @@ 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" + 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: 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 +459,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,20 +503,76 @@ 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" + mocktail: + dependency: transitive + description: + name: mocktail + sha256: "890df3f9688106f25755f26b1c60589a92b3ab91a22b8b224947ad041bf172d8" + url: "https://pub.dev" + source: hosted + version: "1.0.4" + multiple_result: + dependency: "direct main" + description: + name: multiple_result + sha256: a7a8aa7a068648521ebd41e8c7296a990cd7ec5e15e7efa210c26fefd6e4f193 + 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: + name: network_image_mock + sha256: "855cdd01d42440e0cffee0d6c2370909fc31b3bcba308a59829f24f64be42db7" + 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" + 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: name: package_config - sha256: a4d5ede5ca9c3d88a2fef1147a078570c861714c806485c596b109819135bc12 + sha256: "1c5b77ccc91e4823a5af61ee74e6b972db1ef98c2ff5a18d3161c982a55448bd" url: "https://pub.dev" source: hosted - version: "2.0.2" + version: "2.1.0" path: - dependency: transitive + dependency: "direct main" description: name: path sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" @@ -419,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: @@ -427,46 +643,102 @@ 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: name: pool - sha256: "05955e3de2683e1746222efd14b775df7131139e07695dc8e24650f6b4204504" + sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" url: "https://pub.dev" source: hosted - version: "1.5.0" + version: "1.5.1" + provider: + dependency: transitive + description: + name: provider + sha256: c8a055ee5ce3fd98d6fc872478b03823ffdb448699c6ebdbbc71d59b596fd48c + url: "https://pub.dev" + source: hosted + version: "6.1.2" pub_semver: dependency: transitive description: name: pub_semver - sha256: b5a5fcc6425ea43704852ba4453ba94b08c2226c63418a260240c3a054579014 + sha256: "40d3ab1bbd474c4c2328c91e3a7df8c6dd629b79ece4c4bd04bee496a224fb0c" url: "https://pub.dev" source: hosted - version: "2.1.0" + 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" + 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: name: shelf - sha256: c240984c924796e055e831a0a36db23be8cb04f170b26df572931ab36418421d + sha256: ad29c505aee705f41a4d8963641f91ac4cee3c8fad5947e033390a7bd8180fa4 url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.4.1" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.dev" + source: hosted + version: "1.1.3" shelf_web_socket: dependency: transitive description: name: shelf_web_socket - sha256: fd84910bf7d58db109082edf7326b75322b8f186162028482f53dc892f00332d + sha256: "9ca081be41c60190ebcb4766b2486a7d50261db7bd0f5d9615f2d653637a84c1" url: "https://pub.dev" source: hosted - version: "1.0.1" + version: "1.0.4" sky_engine: dependency: transitive description: flutter @@ -488,6 +760,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: @@ -496,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: @@ -516,10 +828,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: @@ -528,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: @@ -536,6 +856,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: @@ -544,22 +872,46 @@ 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: name: timing - sha256: c386d07d7f5efc613479a7c4d9d64b03710b03cfaa7e8ad5f2bfb295a1f0dfad + sha256: "70a3b636575d4163c477e6de42f247a23b315ae20e86442bebe32d3cabf61c32" url: "https://pub.dev" source: hosted - version: "1.0.0" + 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: name: typed_data - sha256: "53bdf7e979cfbf3e28987552fd72f637e63f3c8724c9e56d9246942dc2fa36ee" + sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c url: "https://pub.dev" source: hosted - version: "1.3.0" + 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: @@ -604,10 +956,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: @@ -620,10 +972,26 @@ packages: dependency: transitive description: name: web_socket_channel - sha256: "0c2ada1b1aeb2ad031ca81872add6be049b8cb479262c6ad3c4b0f9c24eaab2f" + sha256: d88238e5eac9a42bb43ca4e721edba3c08c6354d4a53063afaa568516217621b url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.4.0" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: faea9dee56b520b55a566385b84f2e8de55e7496104adada9962e0bd11bcff1d + url: "https://pub.dev" + source: hosted + version: "1.0.4" xml: dependency: transitive description: @@ -636,10 +1004,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" + flutter: ">=3.22.0" diff --git a/pubspec.yaml b/pubspec.yaml index 4018593..5854eef 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,6 +16,15 @@ dependencies: dio: ^5.6.0 json_annotation: ^4.9.0 flutter_svg: ^2.0.10 + get_it: ^7.7.0 + multiple_result: ^5.1.0 + 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: @@ -23,6 +32,10 @@ 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 + test: ^1.25.2 + bloc_test: ^9.1.7 flutter: generate: true @@ -47,3 +60,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.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..48b532a --- /dev/null +++ b/test/data/dtos/restaurant_dto_test.dart @@ -0,0 +1,23 @@ +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'; + +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..d418b55 --- /dev/null +++ b/test/data/repositories/restaurants_repository_test.dart @@ -0,0 +1,72 @@ +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(() async { + httpClient = Dio(BaseOptions(baseUrl: route)); + dioAdapter = DioAdapter( + dio: httpClient, + matcher: const UrlRequestMatcher(), + ); + databaseProvider = FakeDatabaseProvider(); + await databaseProvider.init(); + + restaurantsRepository = RestaurantsRepository( + remoteDataSource: RestaurantRemoteDataSource(httpClient: httpClient), + localDataSource: RestaurantLocalDataSource( + databaseProvider: databaseProvider, + ), + ); + }); + + tearDownAll(() { + databaseProvider.close(); + 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.tryGetSuccess()?.length, 20); + }); + + test('should return favorite restaurants', () async { + const restaurant = Restaurant(id: 'test'); + restaurantsRepository.toggleFavorite(restaurant); + + await pumpEventQueue(); + + 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..8515cbb --- /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..9c5c399 --- /dev/null +++ b/test/domain/use_cases/get_restaurants_use_case_test.dart @@ -0,0 +1,24 @@ +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.tryGetSuccess()?.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..647f748 --- /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_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(); + } +} 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..1ee8236 --- /dev/null +++ b/test/fakes/repositories/fake_restaurants_repository.dart @@ -0,0 +1,45 @@ +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 { + final List favoriteRestaurants = [ + fakeRestaurant.toDomain(), + ]; + + List get restaurants => [ + fakeRestaurant.toDomain(), + fakeRestaurant2.toDomain(), + ]; + + @override + Stream> getFavorites() { + return Stream.value(favoriteRestaurants); + } + + @override + Future, BaseError>> getRestaurants({int offset = 0}) async { + return Success(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..6dfad1e --- /dev/null +++ b/test/presentation/components/rating_stars_test.dart @@ -0,0 +1,37 @@ +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..59f60ed --- /dev/null +++ b/test/presentation/components/restaurant_card_test.dart @@ -0,0 +1,45 @@ +import 'package:flutter_test/flutter_test.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'; + +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..bbf9f72 --- /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/_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..ac2f4ba --- /dev/null +++ b/test/presentation/screens/home/_tabs/all_restaurants_tab/bloc/all_restaurants_tab_bloc_test.dart @@ -0,0 +1,86 @@ +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: () => 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(), + ), + act: (bloc) => bloc.add(const LoadAllRestaurants()), + expect: () async { + return [ + AllRestaurantsTabState( + error: UnknownError(), + contentIsLoading: false, + ), + ]; + }, + ); + }, + ); +} 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..d0eca51 --- /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/_tabs/all_restaurants_tab/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/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), + ]; + }, + ); + }, + ); +} 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..7d39a23 --- /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/_tabs/favorite_restaurants_tab/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..ce255c1 --- /dev/null +++ b/test/presentation/screens/home/home_screen_test.dart @@ -0,0 +1,136 @@ +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, + onTapRestaurant: (restaurant, isFavorite) {}, + ), + ), + ), + ); + + 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, + onTapRestaurant: (restaurant, isFavorite) {}, + ), + ), + ), + ); + + await mockNetworkImagesFor(() => tester.pumpAndSettle()); + + 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)); + }); + + 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); + }); +} 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..23b7e90 --- /dev/null +++ b/test/presentation/screens/restaurant_details/restaurant_details_screen_test.dart @@ -0,0 +1,33 @@ +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); - }); -}