diff --git a/.fvmrc b/.fvmrc new file mode 100644 index 0000000..e03e940 --- /dev/null +++ b/.fvmrc @@ -0,0 +1,4 @@ +{ + "flutter": "3.22.3", + "flavors": {} +} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 1be2d87..feab188 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,7 @@ .pub-cache/ .pub/ /build/ +.env # Web related lib/generated_plugin_registrant.dart diff --git a/README.md b/README.md index 412d444..395e9e9 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,54 @@ # Restaurant Tour +## Overview + +As part of the solution to the Superformula challenge, I implemented **Clean Architecture** by separating the project into layers: domain, infrastructure, and UI. I also used the **Provider** package for state management and **Shared Preferences** to store favorite items locally. Additionally, I incorporated various testing approaches, including unit, widget, golden, and integration tests. The UI design follows the principles of atomic design. +![App Demo](lib/screenshots/restaurant_tour_demo.gif) + + + +## Technologies and Packages Used + +- **Flutter**: Core framework for building the mobile application. +- **Provider**: Used for state management, allowing reactive UI updates and separation of business logic. +- **Shared Preferences**: To persist local data such as favorite restaurants across app sessions. +- **Mocktail**: To mock API responses during development and testing. +- **Dio**: HTTP client for making API requests. +- **Integration, widget, Unit Testing (golden)**: Mocks and test utilities for thoroughly testing features and API interactions. + +## Project Structure + +The app is organized into three main layers: +package structure + + + +### 1. **Domain Layer** + - **Entities**: Defines the core business objects such as `RestaurantEntity`. + - **Use Cases**: Contains the business logic. Example: Fetching restaurants or marking a restaurant as a favorite. + - **Repositories**: Interfaces that act as contracts for the infrastructure layer. + +### 2. **Infrastructure Layer** + - **Data Sources**: API integrations and local data handling using Shared Preferences. + - **Mappers**: Translates data between different layers (e.g., API model to domain entities). + - **Repositories Implementations**: Concrete implementations of domain repositories using data sources. + +### 3. **UI Layer** + - **Widgets**: Flutter UI components such as `RestaurantListPage`, `FavoritesRestaurantsPage`, and `RestaurantDetailsPage`. + - **State Management**: Handled by `RestaurantProvider` and `FavoritesProvider`, allowing efficient data handling and UI updates. + - **Utilities**: Helpers for managing colors, styles, and constants throughout the app. + +## Yelp API Configuration + +To ensure the app works correctly, you need to configure the Yelp API key. Follow these steps: + +1. Create a `.env` file in the root of the project. +2. Add your Yelp API key to the `.env` file with the following format: + +```bash +YELP_API_KEY=your_api_key_here +``` +## Welcome to Superformula's Coding challenge, we are excited to see what you can build! This take home test aims to evaluate your skills in building a Flutter application. We are looking for a well-structured and well-tested application that demonstrates your knowledge of Flutter and the Dart language. diff --git a/assets/images/star.svg b/assets/images/star.svg new file mode 100644 index 0000000..9795a71 --- /dev/null +++ b/assets/images/star.svg @@ -0,0 +1,3 @@ + + + diff --git a/integration_test/app_test.dart b/integration_test/app_test.dart new file mode 100644 index 0000000..f1f7aa2 --- /dev/null +++ b/integration_test/app_test.dart @@ -0,0 +1,131 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:provider/provider.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:restaurant_tour/config/providers/favorites_provider.dart'; +import 'package:restaurant_tour/config/providers/restaurant_providers.dart'; +import 'package:restaurant_tour/domain/models/restaurant/gateway/restaurant_entity.dart'; +import 'package:restaurant_tour/domain/models/restaurant/gateway/restaurant_gateway.dart'; +import 'package:restaurant_tour/domain/usecase/restaurant/local_storage_use_case.dart'; +import 'package:restaurant_tour/infrastructure/driven_adapters/api/local_storage_api.dart'; +import 'package:restaurant_tour/ui/pages/home/widgets/card_item.dart'; +import 'package:restaurant_tour/ui/pages/restaurants/restaurant_list_page.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class MockRestaurantGateway extends Mock implements RestaurantGateway {} + +class MockLocalStorageUseCase extends Mock implements LocalStorageUseCase {} + +class MockRestaurantProvider extends RestaurantProvider { + late List _restaurants = []; + bool _isLoading = true; + MockRestaurantProvider(RestaurantGateway restaurantGateway) : super(restaurantGateway: restaurantGateway); + + @override + List? get restaurants => _restaurants; + + @override + bool get isLoading => _isLoading; + + @override + Future getRestaurants({int offset = 0}) async { + _restaurants = [ + RestaurantEntity( + id: '1', + name: 'Test Restaurant', + price: '\$\$', + rating: 4.5, + categories: [Category(title: 'American')], + photos: ['https://example.com/photo.jpg'], + hours: [Hours(isOpenNow: true)], + reviews: [], + location: Location(formattedAddress: '123 Test St.'), + ), + ]; + _isLoading = false; + notifyListeners(); + } +} + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + setUpAll(() { + registerFallbackValue( + RestaurantEntity( + id: '0', + name: 'Test Restaurant', + price: '', + rating: 0.0, + categories: [], + photos: [], + hours: [], + reviews: [], + location: Location(formattedAddress: ''), + ), + ); + }); + testWidgets( + 'GIVEN restaurants WHEN the app calls getRestaurants THEN the app shows the list of restaurants and user can tap to open detail restaurant', + (WidgetTester tester) async { + final mockRestaurantGateway = MockRestaurantGateway(); + final mockLocalStorageUseCase = MockLocalStorageUseCase(); + when(() => mockLocalStorageUseCase.getFavoriteRestaurants()).thenAnswer((_) async => []); + + when(() => mockLocalStorageUseCase.addFavoriteRestaurant(any())).thenAnswer((_) async => Future.value()); + + when(() => mockLocalStorageUseCase.deleteFavoriteRestaurant(any())).thenAnswer((_) async => Future.value()); + final prefs = await SharedPreferences.getInstance(); + + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider( + create: (_) => MockRestaurantProvider(mockRestaurantGateway), + ), + ChangeNotifierProvider( + create: (_) => FavoritesProvider(localStorageGateway: LocalStorageApi(prefs: prefs)), + ), + ], + child: const MaterialApp( + home: Scaffold( + body: RestaurantListPage(), + ), + ), + ), + ); + + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider( + create: (_) => MockRestaurantProvider(mockRestaurantGateway), + ), + ChangeNotifierProvider( + create: (_) => FavoritesProvider(localStorageGateway: LocalStorageApi(prefs: prefs)), + ), + ], + child: const MaterialApp( + home: Scaffold( + body: RestaurantListPage(), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.byType(CardItem), findsOneWidget); + + final firstRestaurant = find.byType(CardItem).first; + await tester.tap(firstRestaurant); + await tester.pumpAndSettle(); + + expect(find.text('Test Restaurant'), findsOneWidget); + await tester.pumpAndSettle(); + + final backIcon = find.byIcon(Icons.arrow_back); + await tester.tap(backIcon); + await tester.pumpAndSettle(); + + expect(find.byType(CardItem), findsOneWidget); + }); +} diff --git a/lib/config/constants/constants.dart b/lib/config/constants/constants.dart new file mode 100644 index 0000000..9b207a7 --- /dev/null +++ b/lib/config/constants/constants.dart @@ -0,0 +1,21 @@ +// coverage:ignore-file +class AppConstants { + AppConstants._(); + + static const String allRestaurants = 'All restaurants'; + static const String appName = 'RestauranTour'; + static const String env = '.env'; + static const String loading = 'Loading...'; + static const String myFavorites = 'My Favorites'; + static const String error = 'error'; + static const String noRestaurantsAvailable = 'No restaurants available'; + static const String noData = 'No Data'; + static const String openNow = 'Open Now'; + static const String closed = 'Closed'; + static const String rating = 'Rating:'; + static const String addres = 'Address'; + static const String overrallRating = 'Overall Rating'; + static const String reviews = 'Reviews'; + static const String noFavoriteRestaurants = 'No favorite restaurants'; + static const String keyFavoriteRestaurants = 'favoriteRestaurants'; +} diff --git a/lib/config/environment.dart b/lib/config/environment.dart new file mode 100644 index 0000000..2de6ac9 --- /dev/null +++ b/lib/config/environment.dart @@ -0,0 +1,23 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; + +final class Environment { + static const String _baseUrl = 'https://api.yelp.com/'; + static final String _apiKey = dotenv.env['API_KEY_YELP'] ?? ''; + + static Dio baseDioClient({String? url, String? key}) { + final baseUrl = url ?? _baseUrl; + final apiKey = key ?? _apiKey; + + return Dio( + BaseOptions( + baseUrl: baseUrl, + headers: { + 'Authorization': 'Bearer $apiKey', + 'Content-type': 'application/graphql', + 'Accept': 'application/json', + }, + ), + ); + } +} diff --git a/lib/config/providers/favorites_provider.dart b/lib/config/providers/favorites_provider.dart new file mode 100644 index 0000000..0eb75d1 --- /dev/null +++ b/lib/config/providers/favorites_provider.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'package:restaurant_tour/domain/models/restaurant/gateway/local_storage_gateway.dart'; +import 'package:restaurant_tour/domain/models/restaurant/gateway/restaurant_entity.dart'; +import 'package:restaurant_tour/domain/usecase/restaurant/local_storage_use_case.dart'; + +class FavoritesProvider extends ChangeNotifier { + final LocalStorageGatewayInterface localStorageGateway; + final LocalStorageUseCase localStorageUseCase; + + List _favoriteRestaurants = []; + + FavoritesProvider({required this.localStorageGateway}) + : localStorageUseCase = LocalStorageUseCase(localStorageGatewayInterface: localStorageGateway) { + _loadFavorites(); + } + + List get favoriteRestaurants => _favoriteRestaurants; + + Future _loadFavorites() async { + _favoriteRestaurants = (await localStorageUseCase.getFavoriteRestaurants()) ?? []; + notifyListeners(); + } + + Future toggleFavorite(RestaurantEntity restaurant) async { + if (isFavorite(restaurant.id)) { + await localStorageUseCase.deleteFavoriteRestaurant(restaurant.id); + _favoriteRestaurants.removeWhere((r) => r.id == restaurant.id); + } else { + await localStorageUseCase.addFavoriteRestaurant(restaurant); + _favoriteRestaurants.add(restaurant); + } + notifyListeners(); + } + + bool isFavorite(String restaurantId) { + return _favoriteRestaurants.any((r) => r.id == restaurantId); + } +} diff --git a/lib/config/providers/restaurant_providers.dart b/lib/config/providers/restaurant_providers.dart new file mode 100644 index 0000000..80fc029 --- /dev/null +++ b/lib/config/providers/restaurant_providers.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:restaurant_tour/domain/models/restaurant/gateway/restaurant_entity.dart'; +import 'package:restaurant_tour/domain/models/restaurant/gateway/restaurant_gateway.dart'; +import 'package:restaurant_tour/domain/usecase/restaurant/restaurant_use_case.dart'; + +class RestaurantProvider extends ChangeNotifier { + final RestaurantGateway restaurantGateway; + final RestaurantUseCase restaurantUseCase; + + RestaurantProvider({required this.restaurantGateway}) + : restaurantUseCase = RestaurantUseCase(restaurantGateway: restaurantGateway); + + List? _restaurants = []; + List? get restaurants => _restaurants; + + bool _isLoading = false; + bool get isLoading => _isLoading; + + String? _errorMessage; + String? get errorMessage => _errorMessage; + + int _offset = 0; + bool _hasMore = true; + bool get hasMore => _hasMore; + + Future getRestaurants({int offset = 0}) async { + if (_isLoading || !_hasMore) return; + _isLoading = true; + _errorMessage = null; + notifyListeners(); + + try { + final result = await restaurantUseCase.fetchRestaurants(offset: offset); + if (result == null || result.isEmpty) { + _hasMore = false; + } else { + if (offset == 0) { + _restaurants = result; + } else { + _restaurants?.addAll(result); + } + _offset += result.length; + } + } catch (e) { + _errorMessage = 'Error retrieving restaurants: $e'; + if (_offset == 0) _restaurants = null; + } finally { + _isLoading = false; + notifyListeners(); + } + } + + Future loadMoreRestaurants() async { + await getRestaurants(offset: _offset); + } +} diff --git a/lib/config/routes/app_routes.dart b/lib/config/routes/app_routes.dart new file mode 100644 index 0000000..0d600bd --- /dev/null +++ b/lib/config/routes/app_routes.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import 'package:restaurant_tour/ui/pages/home/home_page.dart'; +import 'package:restaurant_tour/ui/pages/detail_restaurant/restaurant_details_page.dart'; +import 'package:restaurant_tour/ui/pages/restaurants/restaurant_list_page.dart'; + +class AppRoutes { + AppRoutes._(); // Private constructor to prevent instantiation. + static const String home = '/'; + static const String restaurantList = '/restaurantList'; + static const String restaurantDetails = '/restaurantDetails'; +} + +class RouterManager { + static Route generateRoute(RouteSettings settings) { + switch (settings.name) { + case AppRoutes.home: + return MaterialPageRoute(builder: (_) => const HomePage()); + case AppRoutes.restaurantList: + return MaterialPageRoute(builder: (_) => const RestaurantListPage()); + case AppRoutes.restaurantDetails: + return MaterialPageRoute(builder: (_) => const RestaurantDetailsPage()); + default: + return _errorRoute(); + } + } + + static Route _errorRoute() { + return MaterialPageRoute( + builder: (_) => const Scaffold( + body: Center( + child: Text('Page cannot be found'), + ), + ), + ); + } +} diff --git a/lib/query.dart b/lib/config/utils/restaurants_query.dart similarity index 92% rename from lib/query.dart rename to lib/config/utils/restaurants_query.dart index 7a8993b..27b6f1f 100644 --- a/lib/query.dart +++ b/lib/config/utils/restaurants_query.dart @@ -1,4 +1,4 @@ -String query(int offset) => ''' +String restaurantsQuery(int offset) => ''' query getRestaurants { search(location: "Las Vegas", limit: 20, offset: $offset) { total diff --git a/lib/domain/exception/app_exception.dart b/lib/domain/exception/app_exception.dart new file mode 100644 index 0000000..3202579 --- /dev/null +++ b/lib/domain/exception/app_exception.dart @@ -0,0 +1,13 @@ +// coverage:ignore-file + +enum FetchAppError { notFound, serverError, unknowError, networkError } + +class AppException implements Exception { + final FetchAppError error; + final String message; + + AppException(this.error, this.message); + + @override + String toString() => 'AppException: $message'; +} diff --git a/lib/domain/models/restaurant/gateway/local_storage_gateway.dart b/lib/domain/models/restaurant/gateway/local_storage_gateway.dart new file mode 100644 index 0000000..65660ee --- /dev/null +++ b/lib/domain/models/restaurant/gateway/local_storage_gateway.dart @@ -0,0 +1,8 @@ +import 'package:restaurant_tour/domain/models/restaurant/gateway/restaurant_entity.dart'; + +abstract class LocalStorageGatewayInterface { + Future?> getFavoriteRestaurants(); + Future addFavoriteRestaurant(RestaurantEntity restaurant); + Future deleteFavoriteRestaurant(String restaurantId); + bool isFavorite(String restaurantId); +} diff --git a/lib/domain/models/restaurant/gateway/restaurant_entity.dart b/lib/domain/models/restaurant/gateway/restaurant_entity.dart new file mode 100644 index 0000000..797403d --- /dev/null +++ b/lib/domain/models/restaurant/gateway/restaurant_entity.dart @@ -0,0 +1,67 @@ +// coverage:ignore-file +///Entity defining the data +class RestaurantEntity { + String id; + String name; + String price; + double rating; + List photos; + List categories; + List hours; + List reviews; + Location location; + + RestaurantEntity({ + required this.id, + required this.name, + required this.price, + required this.rating, + required this.photos, + required this.categories, + required this.hours, + required this.reviews, + required this.location, + }); +} + +class Category { + final String title; + Category({required this.title}); +} + +class Hours { + final bool isOpenNow; + Hours({required this.isOpenNow}); +} + +class User { + final String id; + final String imageUrl; + final String name; + + User({ + required this.id, + required this.imageUrl, + required this.name, + }); +} + +class Review { + final String id; + final int rating; + final String text; + final User user; + + Review({ + required this.id, + required this.rating, + required this.text, + required this.user, + }); +} + +class Location { + final String formattedAddress; + + Location({required this.formattedAddress}); +} diff --git a/lib/domain/models/restaurant/gateway/restaurant_gateway.dart b/lib/domain/models/restaurant/gateway/restaurant_gateway.dart new file mode 100644 index 0000000..16c76b7 --- /dev/null +++ b/lib/domain/models/restaurant/gateway/restaurant_gateway.dart @@ -0,0 +1,6 @@ +import 'package:restaurant_tour/domain/models/restaurant/gateway/restaurant_entity.dart'; + +abstract class RestaurantGateway { + Future?> getRestaurants({int offset = 0}); + Future getRestaurant(String id); +} diff --git a/lib/domain/usecase/restaurant/local_storage_use_case.dart b/lib/domain/usecase/restaurant/local_storage_use_case.dart new file mode 100644 index 0000000..a185ddc --- /dev/null +++ b/lib/domain/usecase/restaurant/local_storage_use_case.dart @@ -0,0 +1,24 @@ +import 'package:restaurant_tour/domain/models/restaurant/gateway/local_storage_gateway.dart'; +import 'package:restaurant_tour/domain/models/restaurant/gateway/restaurant_entity.dart'; + +class LocalStorageUseCase { + final LocalStorageGatewayInterface localStorageGatewayInterface; + + LocalStorageUseCase({required this.localStorageGatewayInterface}); + + Future?> getFavoriteRestaurants() { + return localStorageGatewayInterface.getFavoriteRestaurants(); + } + + Future addFavoriteRestaurant(RestaurantEntity restaurant) { + return localStorageGatewayInterface.addFavoriteRestaurant(restaurant); + } + + Future deleteFavoriteRestaurant(String restaurantId) { + return localStorageGatewayInterface.deleteFavoriteRestaurant(restaurantId); + } + + bool isFavorite(String restaurantId) { + return localStorageGatewayInterface.isFavorite(restaurantId); + } +} diff --git a/lib/domain/usecase/restaurant/restaurant_use_case.dart b/lib/domain/usecase/restaurant/restaurant_use_case.dart new file mode 100644 index 0000000..d68ea4d --- /dev/null +++ b/lib/domain/usecase/restaurant/restaurant_use_case.dart @@ -0,0 +1,26 @@ +import 'package:restaurant_tour/domain/models/restaurant/gateway/restaurant_entity.dart'; +import 'package:restaurant_tour/domain/models/restaurant/gateway/restaurant_gateway.dart'; + +class RestaurantUseCase { + final RestaurantGateway restaurantGateway; + + RestaurantUseCase({required this.restaurantGateway}); + + Future?> fetchRestaurants({int offset = 0}) async { + try { + return await restaurantGateway.getRestaurants(offset: offset); + } catch (e) { + print('Error fetching restaurants: $e'); + return null; + } + } + + Future fetchRestaurant(String id) async { + try { + return await restaurantGateway.getRestaurant(id); + } catch (e) { + print('Error fetching restaurant: $e'); + return null; + } + } +} diff --git a/lib/infrastructure/driven_adapters/api/local_storage_api.dart b/lib/infrastructure/driven_adapters/api/local_storage_api.dart new file mode 100644 index 0000000..c4d2cbb --- /dev/null +++ b/lib/infrastructure/driven_adapters/api/local_storage_api.dart @@ -0,0 +1,63 @@ +import 'dart:convert'; +import 'package:restaurant_tour/domain/models/restaurant/gateway/local_storage_gateway.dart'; +import 'package:restaurant_tour/domain/models/restaurant/gateway/restaurant_entity.dart'; +import 'package:restaurant_tour/infrastructure/helpers/mappers/restaurant_data_to_restaurants.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:restaurant_tour/config/constants/constants.dart'; +import 'package:restaurant_tour/infrastructure/helpers/mappers/restaurant.dart'; + +class LocalStorageApi implements LocalStorageGatewayInterface { + final SharedPreferences prefs; + + LocalStorageApi({required this.prefs}); + + @override + Future addFavoriteRestaurant(RestaurantEntity restaurant) async { + final currentFavorites = await getFavoriteRestaurants(); + currentFavorites!.add(restaurant); + + final favoriteRestaurantsJson = currentFavorites.map((restaurant) { + final restaurantInfra = RestaurantMapper.toInfrastructure(restaurant); + return jsonEncode(restaurantInfra.toJson()); + }).toList(); + + prefs.setStringList(AppConstants.keyFavoriteRestaurants, favoriteRestaurantsJson); + } + + @override + Future deleteFavoriteRestaurant(String restaurantId) async { + final currentFavorites = await getFavoriteRestaurants(); + currentFavorites!.removeWhere((restaurant) => restaurant.id == restaurantId); + + final favoriteRestaurantsJson = currentFavorites.map((restaurant) { + final restaurantInfra = RestaurantMapper.toInfrastructure(restaurant); + return jsonEncode(restaurantInfra.toJson()); + }).toList(); + + prefs.setStringList(AppConstants.keyFavoriteRestaurants, favoriteRestaurantsJson); + } + + @override + Future?> getFavoriteRestaurants() async { + final List? favoriteRestaurantsJson = prefs.getStringList(AppConstants.keyFavoriteRestaurants); + if (favoriteRestaurantsJson != null) { + return favoriteRestaurantsJson.map((json) { + final restaurantInfra = Restaurant.fromJson(jsonDecode(json)); + return RestaurantMapper.fromInfrastructure(restaurantInfra); + }).toList(); + } + return []; + } + + @override + bool isFavorite(String restaurantId) { + final currentFavorites = prefs.getStringList(AppConstants.keyFavoriteRestaurants); + if (currentFavorites != null) { + return currentFavorites.any((json) { + final restaurant = Restaurant.fromJson(jsonDecode(json)); + return restaurant.id == restaurantId; + }); + } + return false; + } +} diff --git a/lib/infrastructure/driven_adapters/api/restaurant_api.dart b/lib/infrastructure/driven_adapters/api/restaurant_api.dart new file mode 100644 index 0000000..4449c97 --- /dev/null +++ b/lib/infrastructure/driven_adapters/api/restaurant_api.dart @@ -0,0 +1,68 @@ +import 'package:restaurant_tour/domain/exception/app_exception.dart'; +import 'package:restaurant_tour/domain/models/restaurant/gateway/restaurant_gateway.dart'; +import 'package:dio/dio.dart'; +import 'package:restaurant_tour/infrastructure/helpers/mappers/restaurant.dart'; +import 'package:restaurant_tour/infrastructure/helpers/mappers/restaurant_data_to_restaurants.dart'; +import 'package:restaurant_tour/config/utils/restaurants_query.dart'; +import 'package:restaurant_tour/domain/models/restaurant/gateway/restaurant_entity.dart'; + +class RestaurantApi extends RestaurantGateway { + final Dio dio; + RestaurantApi({required this.dio}); + + @override + Future getRestaurant(String id) async { + try { + final response = await dio.get('/v3/graphql', queryParameters: {'id': id}); + if (response.statusCode == 200) { + final data = response.data as Map; + final queryResult = RestaurantDetailQueryResult.fromJson(data['data']); + final restaurant = queryResult.restaurant; + if (restaurant != null) { + final restaurantEntity = RestaurantMapper.fromInfrastructure(restaurant); + return restaurantEntity; + } else { + throw AppException(FetchAppError.notFound, 'Restaurant not found'); + } + } else { + throw _handleErrorResponse(response.statusCode); + } + } catch (e) { + throw AppException(FetchAppError.networkError, 'Error fetching restaurant: $e'); + } + } + + @override + Future?> getRestaurants({int offset = 0}) async { + try { + final response = await dio.post('v3/graphql', data: restaurantsQuery(offset)); + if (response.statusCode == 200) { + final data = response.data as Map; + final restaurantsData = data['data']['search']['business']; + if (restaurantsData == null || restaurantsData.isEmpty) { + return []; + } + return restaurantsData + .map((r) => RestaurantMapper.fromInfrastructure(Restaurant.fromJson(r))) + .toList(); + } else { + throw _handleErrorResponse(response.statusCode); + } + } on AppException catch (_) { + rethrow; + } catch (e) { + throw AppException(FetchAppError.networkError, 'Error fetching restaurants: $e'); + } + } + + AppException _handleErrorResponse(int? statusCode) { + switch (statusCode) { + case 404: + return AppException(FetchAppError.notFound, 'Restaurants not found'); + case 500: + return AppException(FetchAppError.serverError, 'Server error'); + default: + return AppException(FetchAppError.unknowError, 'Unknown error occurred'); + } + } +} diff --git a/lib/infrastructure/driven_adapters/api/restaurant_fake.dart b/lib/infrastructure/driven_adapters/api/restaurant_fake.dart new file mode 100644 index 0000000..0d988d1 --- /dev/null +++ b/lib/infrastructure/driven_adapters/api/restaurant_fake.dart @@ -0,0 +1,926 @@ +// coverage:ignore-file +import 'package:restaurant_tour/domain/models/restaurant/gateway/restaurant_entity.dart'; +import 'package:restaurant_tour/domain/models/restaurant/gateway/restaurant_gateway.dart'; +import 'package:restaurant_tour/infrastructure/helpers/mappers/restaurant.dart'; +import 'package:restaurant_tour/infrastructure/helpers/mappers/restaurant_data_to_restaurants.dart'; + +class RestaurantFake extends RestaurantGateway { + @override + Future getRestaurant(String id) { + final restaurantJson = { + "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!", + "user": {"id": "VHG6QeWwufacGY0M1ohJ3A", "image_url": null, "name": "Cheryl K."}, + } + ], + "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", + }, + }; + final restaurantInfra = Restaurant.fromJson(restaurantJson); + final restaurantEntity = RestaurantMapper.fromInfrastructure(restaurantInfra); + + return Future.value(restaurantEntity); + } + + @override + Future?> getRestaurants({int offset = 0}) { + final restaurantsJson = [ + { + "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": "uTH-r_iOB03pfN-8vI9q2w", + "rating": 5, + "text": + "The line looked huge but we were seated in a timely manner and the food was delicious! Server was very patient and the food was delivered often and fresh! I...", + "user": { + "id": "_ossdCovKvLNJvvH0XbPrQ", + "image_url": null, + "name": "Jack M.", + }, + } + ], + "categories": [ + { + "title": "Barbeque", + "alias": "bbq", + }, + { + "title": "Japanese", + "alias": "japanese", + } + ], + "hours": [ + { + "is_open_now": false, + } + ], + "location": { + "formatted_address": "3550 S Decatur Blvd\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": "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.", + }, + }, + { + "id": "-725DOCli9uaE4AmByHwLA", + "rating": 5, + "text": + "Absolutely amazing desert! The food was super good too! Alexia and Ursula were wonderful and super kind and responsive! Great staff and a very nice manager!...", + "user": { + "id": "eUhgwQHJN1h1_JkNrfPN4w", + "image_url": null, + "name": "Alex B.", + }, + } + ], + "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": "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": "5PIdVMRd-3OngPsOmvDIpg", + "rating": 5, + "text": + "What We Ordered:\n\n-Chicken Chow Mein (Hand Pulled) \$14.97\n-Pork Rib Noodle (Hand Pulled) \$14.97\n-Spicy Beef Noodle (Sauce on Side) (Hand Pulled)...", + "user": { + "id": "hYHkr9DBesvZfDrLn5n69g", + "image_url": "https://s3-media4.fl.yelpcdn.com/photo/UfFnm-1ZxCuxOSJpTWHzRg/o.jpg", + "name": "Erik G.", + }, + }, + { + "id": "ywLEEbAoQxnnBmx6PfjyRQ", + "rating": 5, + "text": + "i always have to come here when i visit vegas and i'm always left satisfied! Their portion sizes are amazing and food came to us super quick! \n\nI got the...", + "user": { + "id": "wy37YyKD1pnLdxT8m8DvnQ", + "image_url": "https://s3-media2.fl.yelpcdn.com/photo/XESSmJx2Wt8F1na9op6aHg/o.jpg", + "name": "Chenghui W.", + }, + } + ], + "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": "gOOfBSBZlffCkQ7dr7cpdw", + "name": "CHICA", + "price": "\$\$", + "rating": 4.3, + "photos": [ + "https://s3-media2.fl.yelpcdn.com/bphoto/FxmtjuzPDiL7vx5KyceWuQ/o.jpg", + ], + "reviews": [ + { + "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": "rGQO2YvGdAhRLp4dEndGTg", + "rating": 5, + "text": + "I found this restaurant while doing an extensive search of great seafood, grilled octopus and a nice environment. Let me just say this did NOT...", + "user": { + "id": "0wFQH6qZAKaw00ByDmhHdQ", + "image_url": "https://s3-media2.fl.yelpcdn.com/photo/U4fbhriK5hDxZnN2z_Nr0Q/o.jpg", + "name": "Delicia B.", + }, + }, + { + "id": "TuKLZav5Fce2iRZtB-K54A", + "rating": 5, + "text": + "Edgar was amazing and extremely helpful. The food was delicious, this is our last day, I wish I knew about this spot 3 days ago, I would've made several...", + "user": { + "id": "TVQ6klO7KNxnGQStZinu6A", + "image_url": "https://s3-media2.fl.yelpcdn.com/photo/8sE_Headyqi9PA7Tbn5Mng/o.jpg", + "name": "Kieva S.", + }, + } + ], + "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": "3kdSl5mo9dWC4clrQjEDGg", + "name": "Egg & I", + "price": "\$\$", + "rating": 4.5, + "photos": [ + "https://s3-media1.fl.yelpcdn.com/bphoto/z4rdxoc6xaM4dmdPovPBDg/o.jpg", + ], + "reviews": [ + { + "id": "UJEQG8N-FC0Hc8G9Bdh5QQ", + "rating": 5, + "text": + "Huge servings. Waiters are nice and service is quick! Food is delicious. Banana muffins are a must have!", + "user": { + "id": "mKHzEUlshby64kREjoHIzA", + "image_url": null, + "name": "Fauve P.", + }, + }, + { + "id": "P6yj97cmXiHzXJV3B0L7xA", + "rating": 5, + "text": + "The food was delicious and just how i ordered it. I had the chicken sandwich with fries. Our waitress was Crystal, even though it was only her fourth day...", + "user": { + "id": "SxuhAYL2h6441S51H9tlmQ", + "image_url": null, + "name": "Bhlasian G.", + }, + }, + { + "id": "ZKtZnI-a-ulWzIBYTgl7wg", + "rating": 5, + "text": + "the food was fantastic! service was fast and polite. they have such a cute train inside and the menu has so many options!", + "user": { + "id": "iyaxl9fZySkfRthWTZwn-w", + "image_url": null, + "name": "Shylene S.", + }, + } + ], + "categories": [ + { + "title": "Breakfast & Brunch", + "alias": "breakfast_brunch", + }, + { + "title": "Burgers", + "alias": "burgers", + }, + { + "title": "American", + "alias": "tradamerican", + } + ], + "hours": [ + { + "is_open_now": true, + } + ], + "location": { + "formatted_address": "4533 W Sahara Ave\nSte 5\nLas Vegas, NV 89102", + }, + }, + { + "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": false, + } + ], + "location": { + "formatted_address": "124 S 6th St\nSte 100\nLas Vegas, NV 89101", + }, + }, + { + "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": "uYrXHV5g1itgn_yBuBoDyQ", + "rating": 5, + "text": + "We haven't been here in a while, but this place never disappoints. We came here for my Mom's birthday and had a reservation. We sat outside on the patio...", + "user": { + "id": "H_KCkMeFblrh_owAYU9GmQ", + "image_url": "https://s3-media1.fl.yelpcdn.com/photo/CAFp2hKUCcrpUZ47dZJxtA/o.jpg", + "name": "Marissa L.", + }, + }, + { + "id": "ToLyAnrJ55aD_DJfFyFouQ", + "rating": 4, + "text": + "Well, a very busy restaurant the staff were very attentive to everyone.in our party of 10. All meals were delicious! 4 different steaks, all cooked...", + "user": { + "id": "rHxbiOPq-zLI9VaG2Ai8NA", + "image_url": "https://s3-media1.fl.yelpcdn.com/photo/Iy8so1sFgW_m-6nHngw_DA/o.jpg", + "name": "WB D.", + }, + } + ], + "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": "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": "SVGApDPNdpFlEjwRQThCxA", + "name": "Juan's Flaming Fajitas & Cantina - Tropicana", + "price": "\$\$", + "rating": 4.6, + "photos": [ + "https://s3-media3.fl.yelpcdn.com/bphoto/a8L9bQZ2XW8etXLomKKdDw/o.jpg", + ], + "reviews": [ + { + "id": "OhNNxqDs-u01FgD2lDo3bg", + "rating": 5, + "text": "Great food! Amazing service would definitely come back again. Server Valentin was awesome..", + "user": { + "id": "_afUkXzAKFmL7k78H7CQkQ", + "image_url": null, + "name": "Jo G.", + }, + }, + { + "id": "QrqQBcKENE83z4sWBdQyrg", + "rating": 5, + "text": + "My FAVORITE Mexican food in Vegas!! From the service to the food I'd give 5 stars all around!!! Everyone is always friendly and welcoming!!! \n\nI can't ever...", + "user": { + "id": "YvEyOqT0PUyFwZ9NUj_A0A", + "image_url": "https://s3-media4.fl.yelpcdn.com/photo/kra2v9Z6sd7W9S1enUDJrA/o.jpg", + "name": "Christy G.", + }, + }, + { + "id": "_m_7QpxiUMGWniUdxYR39Q", + "rating": 4, + "text": + "Been here a couple of times now, and the service and food here is very good. The place is always busy, so be sure to make a reservation. \nWe got the pork...", + "user": { + "id": "Rg2J4V438Tmpl5W317D3oQ", + "image_url": "https://s3-media2.fl.yelpcdn.com/photo/6V_8yLDPkBNG8nwzREbXpw/o.jpg", + "name": "Francine H.", + }, + } + ], + "categories": [ + { + "title": "Mexican", + "alias": "mexican", + }, + { + "title": "Breakfast & Brunch", + "alias": "breakfast_brunch", + }, + { + "title": "Cocktail Bars", + "alias": "cocktailbars", + } + ], + "hours": [ + { + "is_open_now": true, + } + ], + "location": { + "formatted_address": "9640 W Tropicana\nSte 101\nLas Vegas, NV 89147", + }, + }, + { + "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": "Y26nTAgVICnoWFYXv6WADQ", + "rating": 5, + "text": + "Mon Ami Gabi is a must-visit when in Las Vegas.\n\nThe ambiance is beautiful, with gorgeous décor and outdoor seating that offers a lovely view of the Strip...", + "user": { + "id": "A2dHesqWIIUIwRjXfzxnoQ", + "image_url": "https://s3-media1.fl.yelpcdn.com/photo/WCdlBRwgRdY5NKdAk2tFJQ/o.jpg", + "name": "Tori 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": "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!", + "user": {"id": "VHG6QeWwufacGY0M1ohJ3A", "image_url": null, "name": "Cheryl K."}, + } + ], + "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", + }, + } + ]; + final restaurantEntities = + restaurantsJson.map((json) => RestaurantMapper.fromInfrastructure(Restaurant.fromJson(json))).toList(); + + return Future.value(restaurantEntities); + } +} diff --git a/lib/models/restaurant.dart b/lib/infrastructure/helpers/mappers/restaurant.dart similarity index 80% rename from lib/models/restaurant.dart rename to lib/infrastructure/helpers/mappers/restaurant.dart index 1c7ad2f..454aa06 100644 --- a/lib/models/restaurant.dart +++ b/lib/infrastructure/helpers/mappers/restaurant.dart @@ -1,3 +1,4 @@ +// coverage:ignore-file import 'package:json_annotation/json_annotation.dart'; part 'restaurant.g.dart'; @@ -12,8 +13,7 @@ class Category { this.title, }); - factory Category.fromJson(Map json) => - _$CategoryFromJson(json); + factory Category.fromJson(Map json) => _$CategoryFromJson(json); Map toJson() => _$CategoryToJson(this); } @@ -78,8 +78,7 @@ class Location { this.formattedAddress, }); - factory Location.fromJson(Map json) => - _$LocationFromJson(json); + factory Location.fromJson(Map json) => _$LocationFromJson(json); Map toJson() => _$LocationToJson(this); } @@ -108,8 +107,7 @@ class Restaurant { this.location, }); - factory Restaurant.fromJson(Map json) => - _$RestaurantFromJson(json); + factory Restaurant.fromJson(Map json) => _$RestaurantFromJson(json); Map toJson() => _$RestaurantToJson(this); @@ -150,8 +148,22 @@ class RestaurantQueryResult { this.restaurants, }); - factory RestaurantQueryResult.fromJson(Map json) => - _$RestaurantQueryResultFromJson(json); + factory RestaurantQueryResult.fromJson(Map json) => _$RestaurantQueryResultFromJson(json); Map toJson() => _$RestaurantQueryResultToJson(this); } + +@JsonSerializable() +class RestaurantDetailQueryResult { + @JsonKey(name: 'business') + final Restaurant? restaurant; + + const RestaurantDetailQueryResult({ + this.restaurant, + }); + + factory RestaurantDetailQueryResult.fromJson(Map json) => + _$RestaurantDetailQueryResultFromJson(json); + + Map toJson() => _$RestaurantDetailQueryResultToJson(this); +} diff --git a/lib/models/restaurant.g.dart b/lib/infrastructure/helpers/mappers/restaurant.g.dart similarity index 58% rename from lib/models/restaurant.g.dart rename to lib/infrastructure/helpers/mappers/restaurant.g.dart index 3ed33f9..1ba6c8f 100644 --- a/lib/models/restaurant.g.dart +++ b/lib/infrastructure/helpers/mappers/restaurant.g.dart @@ -1,3 +1,4 @@ +// coverage:ignore-file // GENERATED CODE - DO NOT MODIFY BY HAND part of 'restaurant.dart'; @@ -38,15 +39,15 @@ Map _$UserToJson(User instance) => { 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), + 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, }; @@ -63,24 +64,15 @@ Restaurant _$RestaurantFromJson(Map json) => Restaurant( 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), + 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) => - { +Map _$RestaurantToJson(Restaurant instance) => { 'id': instance.id, 'name': instance.name, 'price': instance.price, @@ -92,18 +84,22 @@ Map _$RestaurantToJson(Restaurant instance) => '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(), +RestaurantQueryResult _$RestaurantQueryResultFromJson(Map json) => RestaurantQueryResult( + total: (json['total'] as num?)?.toInt(), + restaurants: + (json['business'] as List?)?.map((e) => Restaurant.fromJson(e as Map)).toList(), ); -Map _$RestaurantQueryResultToJson( - RestaurantQueryResult instance) => - { +Map _$RestaurantQueryResultToJson(RestaurantQueryResult instance) => { 'total': instance.total, 'business': instance.restaurants, }; + +RestaurantDetailQueryResult _$RestaurantDetailQueryResultFromJson(Map json) => + RestaurantDetailQueryResult( + restaurant: json['business'] == null ? null : Restaurant.fromJson(json['business'] as Map), + ); + +Map _$RestaurantDetailQueryResultToJson(RestaurantDetailQueryResult instance) => { + 'business': instance.restaurant, + }; diff --git a/lib/infrastructure/helpers/mappers/restaurant_data_to_restaurants.dart b/lib/infrastructure/helpers/mappers/restaurant_data_to_restaurants.dart new file mode 100644 index 0000000..abf487e --- /dev/null +++ b/lib/infrastructure/helpers/mappers/restaurant_data_to_restaurants.dart @@ -0,0 +1,67 @@ +// coverage:ignore-file +import 'package:restaurant_tour/domain/models/restaurant/gateway/restaurant_entity.dart'; +import 'package:restaurant_tour/infrastructure/helpers/mappers/restaurant.dart' as infrastructure; + +class RestaurantMapper { + static RestaurantEntity fromInfrastructure(infrastructure.Restaurant restaurant) { + return RestaurantEntity( + id: restaurant.id ?? '', + name: restaurant.name ?? '', + price: restaurant.price ?? '', + rating: restaurant.rating ?? 0.0, + photos: restaurant.photos ?? [], + categories: restaurant.categories?.map((c) => Category(title: c.title ?? '')).toList() ?? [], + hours: restaurant.hours?.map((h) => Hours(isOpenNow: h.isOpenNow ?? false)).toList() ?? [], + reviews: restaurant.reviews + ?.map( + (r) => Review( + id: r.id ?? '', + rating: r.rating ?? 0, + text: r.text ?? '', + user: User( + id: r.user?.id ?? '', + imageUrl: r.user?.imageUrl ?? '', + name: r.user?.name ?? '', + ), + ), + ) + .toList() ?? + [], + location: Location(formattedAddress: restaurant.location?.formattedAddress ?? ''), + ); + } + + static infrastructure.Restaurant toInfrastructure(RestaurantEntity domain) { + return infrastructure.Restaurant( + id: domain.id, + name: domain.name, + price: domain.price, + rating: domain.rating, + photos: domain.photos, + categories: domain.categories.map((c) => infrastructure.Category(title: c.title)).toList(), + hours: domain.hours.map((h) => infrastructure.Hours(isOpenNow: h.isOpenNow)).toList(), + reviews: domain.reviews + .map( + (r) => infrastructure.Review( + id: r.id, + rating: r.rating, + text: r.text, + user: infrastructure.User( + id: r.user.id, + imageUrl: r.user.imageUrl, + name: r.user.name, + ), + ), + ) + .toList(), + location: infrastructure.Location(formattedAddress: domain.location.formattedAddress), + ); + } +} + +RestaurantEntity? restaurantDataToRestaurant(infrastructure.RestaurantQueryResult restaurantData, String id) { + final restaurant = restaurantData.restaurants?.firstWhere( + (restaurant) => restaurant.id == id, + ); + return restaurant != null ? RestaurantMapper.fromInfrastructure(restaurant) : null; +} diff --git a/lib/main.dart b/lib/main.dart index ae7012a..b6c052f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,15 +1,42 @@ -import 'dart:convert'; - +import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; -import 'package:http/http.dart' as http; -import 'package:restaurant_tour/models/restaurant.dart'; -import 'package:restaurant_tour/query.dart'; - -const _apiKey = ''; -const _baseUrl = 'https://api.yelp.com/v3/graphql'; - -void main() { - runApp(const RestaurantTour()); +import 'package:provider/provider.dart'; +import 'package:restaurant_tour/config/constants/constants.dart'; +import 'package:restaurant_tour/config/environment.dart'; +import 'package:restaurant_tour/config/providers/favorites_provider.dart'; +import 'package:restaurant_tour/config/providers/restaurant_providers.dart'; +import 'package:restaurant_tour/config/routes/app_routes.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:restaurant_tour/infrastructure/driven_adapters/api/local_storage_api.dart'; +import 'package:restaurant_tour/infrastructure/driven_adapters/api/restaurant_api.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() async { + await dotenv.load(fileName: AppConstants.env); + final sharedPreferences = await SharedPreferences.getInstance(); + + runApp( + MultiProvider( + providers: [ + Provider( + create: (_) => Environment.baseDioClient(), + ), + ChangeNotifierProvider( + create: (context) => RestaurantProvider( + restaurantGateway: RestaurantApi( + dio: Provider.of(context, listen: false), + ), + ), + ), + ChangeNotifierProvider( + create: (context) => FavoritesProvider( + localStorageGateway: LocalStorageApi(prefs: sharedPreferences), + ), + ), + ], + child: const RestaurantTour(), + ), + ); } class RestaurantTour extends StatelessWidget { @@ -18,70 +45,9 @@ class RestaurantTour extends StatelessWidget { @override Widget build(BuildContext context) { return const MaterialApp( - title: 'Restaurant Tour', - home: HomePage(), - ); - } -} - -// TODO: Architect code -// This is just a POC of the API integration -class HomePage extends StatelessWidget { - const HomePage({super.key}); - - Future getRestaurants({int offset = 0}) async { - final headers = { - 'Authorization': 'Bearer $_apiKey', - 'Content-Type': 'application/graphql', - }; - - try { - final response = await http.post( - Uri.parse(_baseUrl), - headers: headers, - body: query(offset), - ); - - if (response.statusCode == 200) { - return RestaurantQueryResult.fromJson( - jsonDecode(response.body)['data']['search'], - ); - } else { - print('Failed to load restaurants: ${response.statusCode}'); - return null; - } - } catch (e) { - print('Error fetching restaurants: $e'); - return null; - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text('Restaurant Tour'), - ElevatedButton( - child: const Text('Fetch Restaurants'), - onPressed: () async { - try { - final result = await getRestaurants(); - if (result != null) { - print('Fetched ${result.restaurants!.length} restaurants'); - } else { - print('No restaurants fetched'); - } - } catch (e) { - print('Failed to fetch restaurants: $e'); - } - }, - ), - ], - ), - ), + title: AppConstants.appName, + initialRoute: AppRoutes.home, + onGenerateRoute: RouterManager.generateRoute, ); } } diff --git a/lib/typography.dart b/lib/typography.dart deleted file mode 100644 index e165260..0000000 --- a/lib/typography.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'package:flutter/material.dart'; - -class AppTextStyles { - ////----- Lora -----// - static const loraRegularHeadline = TextStyle( - fontFamily: 'Lora', - fontWeight: FontWeight.w700, - fontSize: 18.0, - ); - static const loraRegularTitle = TextStyle( - fontFamily: 'Lora', - fontWeight: FontWeight.w500, - fontSize: 16.0, - ); - - //----- Open Sans -----// - static const openRegularHeadline = TextStyle( - fontFamily: 'OpenSans', - fontWeight: FontWeight.w400, - fontSize: 16.0, - color: Colors.black, - ); - static const openRegularTitleSemiBold = TextStyle( - fontFamily: 'OpenSans', - fontWeight: FontWeight.w600, - fontSize: 14.0, - color: Colors.black, - ); - static const openRegularTitle = TextStyle( - fontFamily: 'OpenSans', - fontWeight: FontWeight.w400, - fontSize: 14.0, - color: Colors.black, - ); - static const openRegularText = TextStyle( - fontFamily: 'OpenSans', - fontWeight: FontWeight.w400, - fontSize: 12.0, - color: Colors.black, - ); - - static const openRegularItalic = TextStyle( - fontFamily: 'OpenSans', - fontWeight: FontWeight.w400, - fontStyle: FontStyle.italic, - fontSize: 12.0, - color: Colors.black, - ); -} diff --git a/lib/ui/foundations/colors.dart b/lib/ui/foundations/colors.dart new file mode 100644 index 0000000..c3bc2b7 --- /dev/null +++ b/lib/ui/foundations/colors.dart @@ -0,0 +1,50 @@ +// coverage:ignore-file +import 'package:flutter/material.dart'; +import './../ui.dart'; + +/// A class that consolidates foundation color definitions for the app. +class AppColorsFundation { + AppColorsFundation._(); // Private constructor to prevent instantiation. + + //Background Colors + + /// The background color used for light themes + static const Color bgWhite = OsColors.light; + + /// The primary color + static const Color primaryColor = OsColors.primaryColor; + + /// The secondary color + static const Color secondaryColor = OsColors.secondaryColor; + + /// The primary color + static const MaterialColor primaryColorMat = Colors.red; + + // Button colors + /// The primary button color + static const Color colorButtonPrimary = OsColors.primaryColor; + + /// The primary button color + static const Color colorBorderButtonPrimary = OsColors.light; + + /// The secondary button color + static const Color colorButtonSecondary = OsColors.light; + + /// The background color for warning elements. + static const Color starBgColor = OsColors.star; + + /// The background color for success elements. + static const Color succcessBgColor = OsColors.statusSuccess; + + /// The background color for danger elements. + static const Color dangerBgColor = OsColors.statusError; + + /// The background color for error elements. + static const Color errorBgColor = OsColors.statusError; + + /// The background color for body Text Color. + static const Color bodyTextColor = OsColors.bodyTextColor; + + /// The background color for ligh Text Color. + static const Color lightTextColor = OsColors.lightTextColor; +} diff --git a/lib/ui/foundations/sizes.dart b/lib/ui/foundations/sizes.dart new file mode 100644 index 0000000..95c8b43 --- /dev/null +++ b/lib/ui/foundations/sizes.dart @@ -0,0 +1,16 @@ +// coverage:ignore-file +import './../ui.dart'; + +/// A class that consolidates foundation size definitions for the app. +class SizesFoundation { + SizesFoundation._(); // Private constructor to prevent instantiation. + // Widget sizes + /// The size for cards + static const double sizeCard = AppSSizes.sizeLG; + + /// The size for action button circles + static const double sizeCircleAccionableCard = AppSSizes.sizeMD; + + /// The base size for spacing between elements + static const double baseSepareted = AppSSizes.sizeSM; +} diff --git a/lib/ui/foundations/typography.dart b/lib/ui/foundations/typography.dart new file mode 100644 index 0000000..261e9df --- /dev/null +++ b/lib/ui/foundations/typography.dart @@ -0,0 +1,70 @@ +// coverage:ignore-file +import 'package:flutter/material.dart'; +import './../ui.dart'; + +class AppTextStyles { + AppTextStyles._(); // Private constructor to prevent instantiation. + ////----- Lora -----// + static const loraRegularHeadline = TextStyle( + fontFamily: AppTypography.familyLora, + fontWeight: FontWeight.w700, + fontSize: AppTypography.body, + ); + static const loraRegularTitle = TextStyle( + fontFamily: AppTypography.familyLora, + fontWeight: FontWeight.w700, + fontSize: AppTypography.h5, + ); + + static const loraRegularTitle2 = TextStyle( + fontFamily: AppTypography.familyLora, + fontWeight: FontWeight.w700, + fontSize: AppTypography.h4, + ); + + static const loraRegularSubTitle1 = TextStyle( + fontFamily: AppTypography.familyLora, + fontWeight: FontWeight.w500, + fontSize: AppTypography.h6, + ); + + //----- Open Sans -----// + static const openRegularHeadline = TextStyle( + fontFamily: AppTypography.familyOpenSans, + fontWeight: FontWeight.w400, + fontSize: AppTypography.h6, + color: AppColorsFundation.bodyTextColor, + ); + static const openRegularTitleSemiBold = TextStyle( + fontFamily: AppTypography.familyOpenSans, + fontWeight: FontWeight.w600, + fontSize: AppTypography.title, + color: AppColorsFundation.bodyTextColor, + ); + static const openRegularLightSemiBold = TextStyle( + fontFamily: AppTypography.familyOpenSans, + fontWeight: FontWeight.w600, + fontSize: AppTypography.title, + color: AppColorsFundation.lightTextColor, + ); + static const openRegularTitle = TextStyle( + fontFamily: AppTypography.familyOpenSans, + fontWeight: FontWeight.w400, + fontSize: AppTypography.title, + color: AppColorsFundation.bodyTextColor, + ); + static const openRegularText = TextStyle( + fontFamily: AppTypography.familyOpenSans, + fontWeight: FontWeight.w400, + fontSize: AppTypography.body, + color: AppColorsFundation.bodyTextColor, + ); + + static const openRegularItalic = TextStyle( + fontFamily: AppTypography.familyOpenSans, + fontWeight: FontWeight.w400, + fontStyle: FontStyle.italic, + fontSize: AppTypography.body, + color: AppColorsFundation.bodyTextColor, + ); +} diff --git a/lib/ui/pages/detail_restaurant/restaurant_details_page.dart b/lib/ui/pages/detail_restaurant/restaurant_details_page.dart new file mode 100644 index 0000000..80a7af6 --- /dev/null +++ b/lib/ui/pages/detail_restaurant/restaurant_details_page.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; +import 'package:restaurant_tour/domain/models/restaurant/gateway/restaurant_entity.dart'; +import 'package:restaurant_tour/ui/foundations/typography.dart'; +import 'package:restaurant_tour/ui/pages/detail_restaurant/widgets/detail_info_widget.dart'; +import 'package:restaurant_tour/ui/pages/detail_restaurant/widgets/fav_btn_widget.dart'; +import 'package:restaurant_tour/ui/pages/detail_restaurant/widgets/review_list_widget.dart'; +import 'package:restaurant_tour/ui/tokens/colors.dart'; +import 'package:restaurant_tour/ui/widgets/image_widget.dart'; + +class RestaurantDetailsPage extends StatelessWidget { + final RestaurantEntity? restaurant; + const RestaurantDetailsPage({super.key, this.restaurant}); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: OsColors.bgColor, + appBar: AppBar( + leading: IconButton( + onPressed: () { + Navigator.of(context).pop(); + }, + icon: const Icon(Icons.arrow_back), + ), + shadowColor: OsColors.shadowColor, + surfaceTintColor: OsColors.bgColor, + title: Text( + restaurant!.name, + style: AppTextStyles.loraRegularTitle, + overflow: TextOverflow.ellipsis, + ), + backgroundColor: OsColors.light, + actions: [FavoriteBtnWidget(restaurant: restaurant!)], + ), + body: CustomScrollView( + slivers: [ + SliverToBoxAdapter( + child: ImageWidget( + id: restaurant!.id, + imageUrl: restaurant!.photos.first, + height: 361, + withd: double.infinity, + ), + ), + _restaurantInfo(), + ReviewListWidget( + restaurant: restaurant!, + ), + ], + ), + ); + } + + Widget _restaurantInfo() { + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 24, 24, 0), + child: Column( + children: [ + DetailInfoWidget( + price: restaurant!.price, + category: restaurant!.categories.first.title, + isOpen: restaurant!.hours.isNotEmpty && restaurant!.hours.first.isOpenNow, + address: restaurant!.location.formattedAddress, + rating: restaurant!.rating, + ), + ], + ), + ), + ); + } +} diff --git a/lib/ui/pages/detail_restaurant/widgets/detail_info_widget.dart b/lib/ui/pages/detail_restaurant/widgets/detail_info_widget.dart new file mode 100644 index 0000000..31eb0ea --- /dev/null +++ b/lib/ui/pages/detail_restaurant/widgets/detail_info_widget.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:restaurant_tour/config/constants/constants.dart'; +import 'package:restaurant_tour/ui/foundations/typography.dart'; +import 'package:restaurant_tour/ui/widgets/availability_widget.dart'; +import 'package:restaurant_tour/ui/widgets/divider_widget.dart'; + +class DetailInfoWidget extends StatelessWidget { + final String price; + final String category; + final bool isOpen; + final String address; + final double rating; + + const DetailInfoWidget({ + super.key, + required this.price, + required this.category, + required this.isOpen, + required this.address, + required this.rating, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: _getBody(), + ); + } + + List _getBody() { + return [ + Row( + children: [ + Text( + '$price $category', + style: AppTextStyles.openRegularText, + ), + const Spacer(), + AvailabilityWidget( + isRestaurantOpen: isOpen, + ), + ], + ), + const DividerWidget(), + const Text(AppConstants.addres, style: AppTextStyles.openRegularText), + const SizedBox(height: 16.0), + Text(address, style: AppTextStyles.openRegularTitleSemiBold), + const DividerWidget(), + const Text(AppConstants.overrallRating, style: AppTextStyles.openRegularText), + const SizedBox(height: 16.0), + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text(rating.toString(), style: AppTextStyles.loraRegularTitle2), + Padding( + padding: const EdgeInsets.only(top: 4.0), + child: SvgPicture.asset( + 'assets/images/star.svg', + width: 12, + height: 12, + ), + ), + ], + ), + const DividerWidget(), + ]; + } +} diff --git a/lib/ui/pages/detail_restaurant/widgets/fav_btn_widget.dart b/lib/ui/pages/detail_restaurant/widgets/fav_btn_widget.dart new file mode 100644 index 0000000..bab942c --- /dev/null +++ b/lib/ui/pages/detail_restaurant/widgets/fav_btn_widget.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:restaurant_tour/config/providers/favorites_provider.dart'; +import 'package:restaurant_tour/domain/models/restaurant/gateway/restaurant_entity.dart'; +import 'package:restaurant_tour/ui/tokens/colors.dart'; + +class FavoriteBtnWidget extends StatelessWidget { + final RestaurantEntity restaurant; + + const FavoriteBtnWidget({super.key, required this.restaurant}); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, favoritesProvider, child) { + final isFavoriteRestaurant = favoritesProvider.isFavorite(restaurant.id); + return IconButton( + onPressed: () { + favoritesProvider.toggleFavorite(restaurant); + }, + icon: AnimatedSwitcher( + duration: const Duration(milliseconds: 500), + child: isFavoriteRestaurant + ? const Icon( + Icons.favorite, + color: OsColors.secondaryColor, + ) + : const Icon( + Icons.favorite_outline, + color: OsColors.secondaryColor, + ), + ), + ); + }, + ); + } +} diff --git a/lib/ui/pages/detail_restaurant/widgets/review_list_widget.dart b/lib/ui/pages/detail_restaurant/widgets/review_list_widget.dart new file mode 100644 index 0000000..2a22859 --- /dev/null +++ b/lib/ui/pages/detail_restaurant/widgets/review_list_widget.dart @@ -0,0 +1,84 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:restaurant_tour/config/constants/constants.dart'; +import 'package:restaurant_tour/domain/models/restaurant/gateway/restaurant_entity.dart'; +import 'package:restaurant_tour/ui/foundations/typography.dart'; +import 'package:restaurant_tour/ui/tokens/colors.dart'; +import 'package:restaurant_tour/ui/widgets/divider_widget.dart'; +import 'package:restaurant_tour/ui/widgets/rating_widget.dart'; + +class ReviewListWidget extends StatelessWidget { + final RestaurantEntity restaurant; + const ReviewListWidget({super.key, required this.restaurant}); + + @override + Widget build(BuildContext context) { + return SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + final review = restaurant.reviews[index]; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0), + child: _buildReviewCard(review), + ); + }, + childCount: restaurant.reviews.length, + ), + ); + } + + Widget _buildReviewCard(Review review) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Visibility( + visible: review == restaurant.reviews.first, + child: Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Text('${restaurant.reviews.length} ${AppConstants.reviews}', style: AppTextStyles.openRegularText), + ), + ), + const SizedBox(height: 8.0), + Row( + children: [ + RatingWidget(rating: review.rating), + ], + ), + const SizedBox(height: 8.0), + Text( + review.text, + style: AppTextStyles.openRegularText, + ), + const SizedBox(height: 12.0), + Row( + children: [ + CircleAvatar( + radius: 24.0, + backgroundColor: OsColors.shadowColor, + child: CachedNetworkImage( + imageUrl: review.user.imageUrl, + imageBuilder: (context, imageProvider) => CircleAvatar( + radius: 24, + backgroundImage: imageProvider, + ), + placeholder: (context, url) => const CircularProgressIndicator(), + errorWidget: (context, url, error) => const Icon( + Icons.error, + color: OsColors.light, + ), + ), + ), + const SizedBox( + width: 8.0, + ), + Text( + review.user.name, + style: AppTextStyles.openRegularText, + ), + ], + ), + const DividerWidget(), + ], + ); + } +} diff --git a/lib/ui/pages/favorites/favorites_restaurants_page.dart b/lib/ui/pages/favorites/favorites_restaurants_page.dart new file mode 100644 index 0000000..0d80c60 --- /dev/null +++ b/lib/ui/pages/favorites/favorites_restaurants_page.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:restaurant_tour/config/constants/constants.dart'; +import 'package:restaurant_tour/config/providers/favorites_provider.dart'; +import 'package:restaurant_tour/ui/pages/home/widgets/card_item.dart'; +import 'package:restaurant_tour/ui/ui.dart'; + +class FavoritesRestaurantsPage extends StatelessWidget { + const FavoritesRestaurantsPage({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: OsColors.bgColor, + body: Consumer( + builder: (context, favoritesProvider, child) { + final favoriteRestaurant = favoritesProvider.favoriteRestaurants; + + if (favoriteRestaurant.isEmpty) { + return const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(AppConstants.noFavoriteRestaurants, style: AppTextStyles.openRegularText), + Padding( + padding: EdgeInsets.all(8.0), + child: Icon(Icons.favorite_outline_outlined), + ), + ], + ), + ); + } + return CustomScrollView( + physics: const BouncingScrollPhysics( + parent: AlwaysScrollableScrollPhysics(), + ), + slivers: [ + const SliverPadding(padding: EdgeInsets.only(top: 10.0)), + SliverList( + delegate: SliverChildBuilderDelegate( + childCount: favoriteRestaurant.length, + (context, index) { + final restaurant = favoriteRestaurant[index]; + bool isOpenNow = false; + if (restaurant.hours.isNotEmpty) { + isOpenNow = restaurant.hours.first.isOpenNow; + } + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0), + child: CardItem( + restaurant: restaurant, + isRestaurantOpen: isOpenNow, + ), + ); + }, + ), + ), + const SliverPadding(padding: EdgeInsets.only(top: 16.0)), + ], + ); + }, + ), + ); + } +} diff --git a/lib/ui/pages/home/home_page.dart b/lib/ui/pages/home/home_page.dart new file mode 100644 index 0000000..1c489b5 --- /dev/null +++ b/lib/ui/pages/home/home_page.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; +import 'package:restaurant_tour/ui/pages/favorites/favorites_restaurants_page.dart'; +import 'package:restaurant_tour/ui/pages/home/widgets/app_bar.dart'; +import 'package:restaurant_tour/ui/pages/restaurants/restaurant_list_page.dart'; + +class HomePage extends StatefulWidget { + const HomePage({super.key}); + + @override + State createState() => _HomePageState(); +} + +class _HomePageState extends State with SingleTickerProviderStateMixin { + late TabController _tabController; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 2, vsync: this); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: HomeAppBar( + tabController: _tabController, + ), + body: TabBarView( + controller: _tabController, + children: const [ + RestaurantListPage(), + FavoritesRestaurantsPage(), + ], + ), + ); + } +} diff --git a/lib/ui/pages/home/widgets/app_bar.dart b/lib/ui/pages/home/widgets/app_bar.dart new file mode 100644 index 0000000..cd94f85 --- /dev/null +++ b/lib/ui/pages/home/widgets/app_bar.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; +import 'package:restaurant_tour/config/constants/constants.dart'; +import 'package:restaurant_tour/ui/foundations/typography.dart'; +import 'package:restaurant_tour/ui/tokens/colors.dart'; + +///Widget that containt the home AppBar +class HomeAppBar extends StatelessWidget implements PreferredSizeWidget { + final TabController tabController; + + const HomeAppBar({super.key, required this.tabController}); + + @override + Widget build(BuildContext context) { + return AppBar( + title: const Text(AppConstants.appName, style: AppTextStyles.loraRegularTitle), + elevation: 4, + surfaceTintColor: OsColors.light, + shadowColor: OsColors.secondaryColor.withOpacity(0.4), + backgroundColor: OsColors.light, + bottom: TabBar( + controller: tabController, + tabs: const [ + Tab(text: AppConstants.allRestaurants), + Tab(text: AppConstants.myFavorites), + ], + labelPadding: const EdgeInsets.symmetric(horizontal: 16.0), + indicatorColor: OsColors.bodyTextColor, + labelColor: OsColors.bodyTextColor, + dividerColor: Colors.transparent, + labelStyle: AppTextStyles.openRegularLightSemiBold, + indicatorSize: TabBarIndicatorSize.label, + tabAlignment: TabAlignment.center, + unselectedLabelStyle: AppTextStyles.openRegularLightSemiBold, + ), + ); + } + + @override + Size get preferredSize => const Size.fromHeight(kToolbarHeight + kTextTabBarHeight + 10); +} diff --git a/lib/ui/pages/home/widgets/card_item.dart b/lib/ui/pages/home/widgets/card_item.dart new file mode 100644 index 0000000..21ef6bb --- /dev/null +++ b/lib/ui/pages/home/widgets/card_item.dart @@ -0,0 +1,114 @@ +import 'package:flutter/material.dart'; +import 'package:restaurant_tour/domain/models/restaurant/gateway/restaurant_entity.dart'; +import 'package:restaurant_tour/ui/pages/detail_restaurant/restaurant_details_page.dart'; +import 'package:restaurant_tour/ui/ui.dart'; +import 'package:restaurant_tour/ui/widgets/availability_widget.dart'; +import 'package:restaurant_tour/ui/widgets/image_widget.dart'; +import 'package:restaurant_tour/ui/widgets/rating_widget.dart'; + +/// [Molecule] A customizable action card with Illustration, title description, star rating, hour text +class CardItem extends StatelessWidget { + final RestaurantEntity restaurant; + final bool isRestaurantOpen; + const CardItem({super.key, required this.restaurant, required this.isRestaurantOpen}); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => RestaurantDetailsPage( + restaurant: restaurant, + ), + ), + ); + }, + child: Container( + height: 104, + decoration: const BoxDecoration( + borderRadius: BorderRadius.all(Radius.circular(8.0)), + color: OsColors.light, + boxShadow: [ + BoxShadow( + color: OsColors.shadowColor, + blurRadius: 5.0, + offset: Offset(0, 1), + spreadRadius: 0, + ), + ], + ), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: _getBody(), + ), + ), + ), + ); + } + + List _getBody() { + return [ + Column( + mainAxisSize: MainAxisSize.min, + children: [ + restaurant.photos.isNotEmpty + ? ImageWidget( + imageUrl: restaurant.photos.first, + id: restaurant.id, + height: 88.0, + withd: 88.0, + rounded: true, + ) + : const Icon(Icons.image_not_supported, size: 88), + ], + ), + const SizedBox(width: 12.0), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + restaurant.name, + style: AppTextStyles.loraRegularSubTitle1, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const Spacer(), + Column( + children: [ + Row( + children: [ + Text( + restaurant.price, + style: AppTextStyles.openRegularText, + ), + const SizedBox(width: 4.0), + Text( + restaurant.categories.isNotEmpty ? restaurant.categories.first.title : '', + style: AppTextStyles.openRegularText, + overflow: TextOverflow.ellipsis, + ), + ], + ), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + RatingWidget( + rating: restaurant.rating.toInt(), + ), + const Spacer(), + AvailabilityWidget(isRestaurantOpen: isRestaurantOpen), + ], + ), + ], + ), + ], + ), + ), + ]; + } +} diff --git a/lib/ui/pages/restaurants/restaurant_list_page.dart b/lib/ui/pages/restaurants/restaurant_list_page.dart new file mode 100644 index 0000000..06e4baf --- /dev/null +++ b/lib/ui/pages/restaurants/restaurant_list_page.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:restaurant_tour/config/constants/constants.dart'; +import 'package:restaurant_tour/config/providers/restaurant_providers.dart'; +import 'package:restaurant_tour/ui/pages/home/widgets/card_item.dart'; +import 'package:restaurant_tour/ui/tokens/colors.dart'; +import 'package:restaurant_tour/ui/ui.dart'; + +/// The restaurant list +class RestaurantListPage extends StatefulWidget { + const RestaurantListPage({super.key}); + + @override + State createState() => _RestaurantListPageState(); +} + +class _RestaurantListPageState extends State { + late RestaurantProvider _restaurantProvider; + final ScrollController _scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _restaurantProvider = Provider.of(context, listen: false); + _restaurantProvider.getRestaurants(); + }); + + _scrollController.addListener(() { + if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent && + !_restaurantProvider.isLoading && + _restaurantProvider.hasMore) { + _restaurantProvider.loadMoreRestaurants(); + } + }); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + /// The Pull refresh function + Future _onRefresh() async { + await _restaurantProvider.getRestaurants(offset: 0); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: OsColors.bgColor, + body: RefreshIndicator( + onRefresh: _onRefresh, + child: Consumer( + builder: (context, provider, child) { + if (provider.isLoading && provider.restaurants == null) { + return const Center( + child: CircularProgressIndicator( + color: OsColors.secondaryColor, + ), + ); + } else if (provider.errorMessage != null) { + return Center(child: Text('${AppConstants.error}: ${provider.errorMessage}')); + } else if (provider.restaurants == null || provider.restaurants!.isEmpty) { + return const Center(child: Text(AppConstants.noRestaurantsAvailable)); + } else { + final restaurants = provider.restaurants!; + return CustomScrollView( + controller: _scrollController, + physics: const BouncingScrollPhysics( + parent: AlwaysScrollableScrollPhysics(), + ), + slivers: [ + const SliverPadding(padding: EdgeInsets.only(top: 10.0)), + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + if (index == restaurants.length) { + return provider.isLoading + ? const Padding( + padding: EdgeInsets.all(8.0), + child: Center( + child: CircularProgressIndicator( + color: OsColors.secondaryColor, + ), + ), + ) + : const SizedBox.shrink(); + } + + final restaurant = restaurants[index]; + bool isOpenNow = false; + if (restaurant.hours.isNotEmpty) { + isOpenNow = restaurant.hours.first.isOpenNow; + } + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0), + child: CardItem( + restaurant: restaurant, + isRestaurantOpen: isOpenNow, + ), + ); + }, + childCount: restaurants.length + 1, + ), + ), + const SliverPadding(padding: EdgeInsets.only(top: 16.0)), + ], + ); + } + }, + ), + ), + ); + } +} diff --git a/lib/ui/tokens/colors.dart b/lib/ui/tokens/colors.dart new file mode 100644 index 0000000..9481aae --- /dev/null +++ b/lib/ui/tokens/colors.dart @@ -0,0 +1,39 @@ +// coverage:ignore-file +import 'package:flutter/material.dart'; + +class OsColors { + OsColors._(); // Private constructor to prevent instantiation. + + /// The primary light color of the app + static const light = Color(0xFFFFFFFF); + + ///The primary brand color the app + static const primaryColor = Color(0xFFFFFFFF); + + ///The secondary brand color the app + static const secondaryColor = Color(0xFF000000); + + /// The color used to indicate an error state. + static const statusError = Color(0xFFEA5E5E); + + /// The color used to indicate a success state. + static const statusSuccess = Color(0xFF5CD313); + + /// The color used to indicate a star + static const star = Color(0xFFFFB800); + + /// The color for body text content + static const Color bodyTextColor = Color(0xFF000000); + + /// The color for primary text + static const lightTextColor = Color(0xFF606060); + + ///The color for shadow card + static const shadowColor = Color.fromARGB(51, 105, 113, 123); + + ///The color for bg + static const bgColor = Color(0xFFfafafa); + + ///The color for divider + static const dividerColor = Color(0xFFEEEEEE); +} diff --git a/lib/ui/tokens/sizes.dart b/lib/ui/tokens/sizes.dart new file mode 100644 index 0000000..e1f5595 --- /dev/null +++ b/lib/ui/tokens/sizes.dart @@ -0,0 +1,29 @@ +// coverage:ignore-file +/// A class containing pre-defined sizes for various UI elements. +class AppSSizes { + AppSSizes._(); // Private constructor to prevent instantiation. + + /// The extra extra small size (XXS) - 30 pixels. + static const double sizeXXS = 30; + + /// The extra small size (XS) - 50 pixels. + static const double sizeXS = 50; + + /// The small size (SM) - 80 pixels. + static const double sizeSM = 80; + + /// The small-large size (SL) - 130 pixels. + static const double sizeSL = 130; + + /// The medium size (MD) - 210 pixels. + static const double sizeMD = 210; + + /// The large size (LG) - 340 pixels. + static const double sizeLG = 340; + + /// The extra large size (XL) - 550 pixels. + static const double sizeXL = 550; + + /// The extra extra large size (XXL) - 890 pixels. + static const double sizeXXL = 890; +} diff --git a/lib/ui/tokens/typography.dart b/lib/ui/tokens/typography.dart new file mode 100644 index 0000000..633a359 --- /dev/null +++ b/lib/ui/tokens/typography.dart @@ -0,0 +1,34 @@ +// coverage:ignore-file +/// A class containing the typography definitions for the app. +class AppTypography { + AppTypography._(); // Private constructor to prevent instantiation. + + /// The name of the primary font family used throughout the app. + static const String familyLora = 'Lora'; + + static const String familyOpenSans = 'OpenSans'; + + /// The base font size for body text. + static const double body = 12; + + /// The base font size for title text. + static const double title = 14; + + /// The font size for H6 headings (usually the smallest). + static const double h6 = 16; + + /// The font size for H5 headings. + static const double h5 = 18; + + /// The font size for H4 headings. + static const double h4 = 28; + + /// The font size for H3 headings. + static const double h3 = 36; + + /// The font size for H2 headings. + static const double h2 = 45; + + /// The font size for H1 headings (usually the largest). + static const double h1 = 54; +} diff --git a/lib/ui/ui.dart b/lib/ui/ui.dart new file mode 100644 index 0000000..ab9fe09 --- /dev/null +++ b/lib/ui/ui.dart @@ -0,0 +1,9 @@ +//Tokens +export './tokens/colors.dart'; +export './tokens/typography.dart'; +export './tokens/sizes.dart'; + +//Foundations +export './foundations/colors.dart'; +export './foundations/typography.dart'; +export './foundations/sizes.dart'; diff --git a/lib/ui/widgets/availability_widget.dart b/lib/ui/widgets/availability_widget.dart new file mode 100644 index 0000000..09bc81e --- /dev/null +++ b/lib/ui/widgets/availability_widget.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:restaurant_tour/config/constants/constants.dart'; +import 'package:restaurant_tour/ui/foundations/typography.dart'; +import 'package:restaurant_tour/ui/tokens/colors.dart'; + +/// Row widget with availability status and colored status circle +class AvailabilityWidget extends StatelessWidget { + final bool isRestaurantOpen; + const AvailabilityWidget({super.key, required this.isRestaurantOpen}); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Text( + isRestaurantOpen ? AppConstants.openNow : AppConstants.closed, + style: AppTextStyles.openRegularItalic, + overflow: TextOverflow.ellipsis, + ), + Padding( + padding: const EdgeInsets.fromLTRB(8, 2, 2, 0), + child: SizedBox.square( + dimension: 8, + child: DecoratedBox( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isRestaurantOpen ? OsColors.statusSuccess : OsColors.statusError, + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/ui/widgets/divider_widget.dart b/lib/ui/widgets/divider_widget.dart new file mode 100644 index 0000000..e02f468 --- /dev/null +++ b/lib/ui/widgets/divider_widget.dart @@ -0,0 +1,17 @@ +import 'package:flutter/material.dart'; +import 'package:restaurant_tour/ui/tokens/colors.dart'; + +class DividerWidget extends StatelessWidget { + const DividerWidget({super.key}); + + @override + Widget build(BuildContext context) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 16.0), + child: Divider( + thickness: 1.0, + color: OsColors.dividerColor, + ), + ); + } +} diff --git a/lib/ui/widgets/image_widget.dart b/lib/ui/widgets/image_widget.dart new file mode 100644 index 0000000..376124f --- /dev/null +++ b/lib/ui/widgets/image_widget.dart @@ -0,0 +1,42 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:restaurant_tour/ui/tokens/colors.dart'; + +/// Widget to load imageUrl with Hero effect and cacheNetworkImage +class ImageWidget extends StatelessWidget { + final String imageUrl; + final String id; + final double? height; + final double? withd; + final bool? rounded; + + const ImageWidget({ + super.key, + required this.imageUrl, + required this.id, + this.height, + this.withd, + this.rounded = false, + }); + + @override + Widget build(BuildContext context) { + return ClipRRect( + borderRadius: BorderRadius.circular(rounded! ? 8.0 : 1.0), + child: Hero( + tag: id, + child: CachedNetworkImage( + imageUrl: imageUrl, + width: withd, + height: height, + fit: BoxFit.cover, + placeholder: (context, url) => const CircularProgressIndicator( + strokeWidth: 2, + color: OsColors.secondaryColor, + ), + errorWidget: (context, url, error) => const Icon(Icons.error), + ), + ), + ); + } +} diff --git a/lib/ui/widgets/rating_widget.dart b/lib/ui/widgets/rating_widget.dart new file mode 100644 index 0000000..3829154 --- /dev/null +++ b/lib/ui/widgets/rating_widget.dart @@ -0,0 +1,22 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/svg.dart'; + +/// Widget to display the number of star ratings +class RatingWidget extends StatelessWidget { + final int rating; + const RatingWidget({super.key, required this.rating}); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: List.generate(rating, (index) { + return SvgPicture.asset( + 'assets/images/star.svg', + width: 12, + height: 12, + ); + }), + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index bc8a205..e5010ce 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -15,17 +15,28 @@ dependencies: sdk: flutter http: ^1.2.2 json_annotation: ^4.9.0 + flutter_dotenv: ^5.1.0 + dio: ^5.7.0 + provider: ^6.1.2 + cached_network_image: ^3.4.1 + flutter_svg: ^2.0.10+1 + shared_preferences: ^2.3.2 dev_dependencies: + integration_test: + sdk: flutter flutter_test: sdk: flutter flutter_lints: ^4.0.0 build_runner: ^2.4.10 json_serializable: ^6.8.0 - + mocktail: ^1.0.4 flutter: generate: true uses-material-design: true + assets: + - assets/images/ + - .env fonts: - family: Lora fonts: diff --git a/screenshots/folders.png b/screenshots/folders.png new file mode 100644 index 0000000..2c28bc6 Binary files /dev/null and b/screenshots/folders.png differ diff --git a/screenshots/restaurant_tour_demo.gif b/screenshots/restaurant_tour_demo.gif new file mode 100644 index 0000000..6ecb894 Binary files /dev/null and b/screenshots/restaurant_tour_demo.gif differ diff --git a/test/config/providers/favorites_provider_test.dart b/test/config/providers/favorites_provider_test.dart new file mode 100644 index 0000000..739f882 --- /dev/null +++ b/test/config/providers/favorites_provider_test.dart @@ -0,0 +1,142 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:restaurant_tour/config/constants/constants.dart'; +import 'package:restaurant_tour/config/providers/favorites_provider.dart'; +import 'package:restaurant_tour/domain/models/restaurant/gateway/local_storage_gateway.dart'; +import 'package:restaurant_tour/domain/models/restaurant/gateway/restaurant_entity.dart'; + +class MockRestaurantEntity extends Mock implements RestaurantEntity {} + +class MockLocalStorageGateway extends Mock implements LocalStorageGatewayInterface {} + +void main() { + late FavoritesProvider favoritesProvider; + late RestaurantEntity mockRestaurant; + late MockLocalStorageGateway mockLocalStorageGateway; + + Map> fakePrefsData = {}; + + setUpAll(() { + registerFallbackValue(MockRestaurantEntity()); + }); + + setUp(() async { + mockLocalStorageGateway = MockLocalStorageGateway(); + mockRestaurant = MockRestaurantEntity(); + when(() => mockRestaurant.id).thenReturn('2'); + when(() => mockRestaurant.name).thenReturn('Test Restaurant 2'); + when(() => mockRestaurant.price).thenReturn('\$\$'); + when(() => mockRestaurant.rating).thenReturn(4.5); + when(() => mockRestaurant.photos).thenReturn(['https://example.com/photo.jpg']); + when(() => mockRestaurant.categories).thenReturn([]); + when(() => mockRestaurant.hours).thenReturn([]); + when(() => mockRestaurant.reviews).thenReturn([]); + when(() => mockRestaurant.location).thenReturn(Location(formattedAddress: '')); + fakePrefsData[AppConstants.keyFavoriteRestaurants] = [ + jsonEncode({ + 'id': '1', + 'name': 'Test Restaurant', + 'price': '\$\$', + 'rating': 4.5, + 'photos': ['https://example.com/photo.jpg'], + 'categories': [], + 'hours': [], + 'location': {'formatted_address': 'Test Address'}, + 'reviews': [], + }), + ]; + + when(() => mockLocalStorageGateway.getFavoriteRestaurants()).thenAnswer( + (_) async => + fakePrefsData[AppConstants.keyFavoriteRestaurants] + ?.map( + (json) => RestaurantEntity( + id: '1', + name: 'Test Restaurant', + price: '\$\$', + rating: 4.5, + categories: [], + photos: ['https://example.com/photo.jpg'], + hours: [], + reviews: [], + location: Location(formattedAddress: 'Test Address'), + ), + ) + .toList() ?? + [], + ); + + when(() => mockLocalStorageGateway.addFavoriteRestaurant(any())).thenAnswer((invocation) async { + final restaurant = invocation.positionalArguments[0] as RestaurantEntity; + final jsonData = jsonEncode({ + 'id': restaurant.id, + 'name': restaurant.name, + 'price': restaurant.price, + 'rating': restaurant.rating, + 'photos': restaurant.photos, + 'categories': restaurant.categories, + 'hours': restaurant.hours, + 'location': {'formatted_address': restaurant.location.formattedAddress}, + 'reviews': restaurant.reviews, + }); + fakePrefsData[AppConstants.keyFavoriteRestaurants] ??= []; + fakePrefsData[AppConstants.keyFavoriteRestaurants]!.add(jsonData); + }); + + when(() => mockLocalStorageGateway.deleteFavoriteRestaurant(any())).thenAnswer((invocation) async { + final restaurantId = invocation.positionalArguments[0] as String; + fakePrefsData[AppConstants.keyFavoriteRestaurants]!.removeWhere((json) { + final decoded = jsonDecode(json) as Map; + return decoded['id'] == restaurantId; + }); + }); + when(() => mockLocalStorageGateway.addFavoriteRestaurant(any())).thenAnswer((_) async { + final restaurant = _.positionalArguments[0] as RestaurantEntity; + final jsonData = jsonEncode({ + 'id': restaurant.id, + 'name': restaurant.name, + 'price': restaurant.price, + 'rating': restaurant.rating, + 'photos': restaurant.photos, + 'categories': restaurant.categories, + 'hours': restaurant.hours, + 'location': {'formatted_address': restaurant.location.formattedAddress}, + 'reviews': restaurant.reviews, + }); + fakePrefsData[AppConstants.keyFavoriteRestaurants] ??= []; + fakePrefsData[AppConstants.keyFavoriteRestaurants]!.add(jsonData); + }); + + favoritesProvider = FavoritesProvider(localStorageGateway: mockLocalStorageGateway); + }); + + group('FavoritesProvider Tests', () { + test('should load favorites from SharedPreferences', () async { + when(() => mockRestaurant.id).thenReturn('1'); + await Future.delayed(Duration.zero); + expect(favoritesProvider.isFavorite('1'), isTrue); + expect(favoritesProvider.favoriteRestaurants.length, 1); + }); + + test('should toggle favorite and add restaurant', () async { + expect(favoritesProvider.isFavorite('2'), isFalse); + await favoritesProvider.toggleFavorite(mockRestaurant); + + expect(favoritesProvider.isFavorite('2'), isTrue); + expect(favoritesProvider.favoriteRestaurants.length, 2); + + final savedFavorites = fakePrefsData[AppConstants.keyFavoriteRestaurants]; + expect(savedFavorites, isNotNull); + expect(savedFavorites!.length, 2); + }); + + test('should remove restaurant from favorites', () async { + when(() => mockRestaurant.id).thenReturn('1'); + await favoritesProvider.toggleFavorite(mockRestaurant); + expect(favoritesProvider.isFavorite('1'), isFalse); + expect(favoritesProvider.favoriteRestaurants.length, 0); + }); + }); +} diff --git a/test/config/providers/restaurant_provider_test.dart b/test/config/providers/restaurant_provider_test.dart new file mode 100644 index 0000000..857b9ad --- /dev/null +++ b/test/config/providers/restaurant_provider_test.dart @@ -0,0 +1,107 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:restaurant_tour/config/providers/restaurant_providers.dart'; +import 'package:restaurant_tour/domain/models/restaurant/gateway/restaurant_entity.dart'; +import 'package:restaurant_tour/domain/models/restaurant/gateway/restaurant_gateway.dart'; + +class MockRestaurantGateway extends Mock implements RestaurantGateway {} + +void main() { + late RestaurantProvider restaurantProvider; + late MockRestaurantGateway mockRestaurantGateway; + + setUp(() { + mockRestaurantGateway = MockRestaurantGateway(); + restaurantProvider = RestaurantProvider(restaurantGateway: mockRestaurantGateway); + }); + + group('RestaurantProvider Tests', () { + test('should fetch and load restaurants successfully with pagination', () async { + // Arrange + final List mockRestaurantsPage1 = [ + RestaurantEntity( + id: '1', + name: 'Restaurant 1', + price: '\$\$', + rating: 4.5, + categories: [], + hours: [], + location: Location(formattedAddress: 'Address 1'), + photos: ['https://example.com/photo1.jpg'], + reviews: [], + ), + ]; + final List mockRestaurantsPage2 = [ + RestaurantEntity( + id: '2', + name: 'Restaurant 2', + price: '\$\$', + rating: 4.5, + categories: [], + hours: [], + location: Location(formattedAddress: 'Address 2'), + photos: ['https://example.com/photo2.jpg'], + reviews: [], + ), + ]; + + when(() => mockRestaurantGateway.getRestaurants(offset: 0)).thenAnswer((_) async => mockRestaurantsPage1); + when(() => mockRestaurantGateway.getRestaurants(offset: 1)).thenAnswer((_) async => mockRestaurantsPage2); + + // Act + await restaurantProvider.getRestaurants(); + + // Assert + expect(restaurantProvider.restaurants, mockRestaurantsPage1); + expect(restaurantProvider.restaurants?.length, 1); + + expect(restaurantProvider.isLoading, isFalse); + expect(restaurantProvider.errorMessage, isNull); + }); + + test('should handle error when fetching restaurants', () async { + // Arrange + when(() => mockRestaurantGateway.getRestaurants(offset: 0)).thenThrow(Exception('Failed to fetch restaurants')); + + // Act + await restaurantProvider.getRestaurants(); + + // Assert + expect(restaurantProvider.restaurants, isEmpty); + expect(restaurantProvider.isLoading, isFalse); + }); + + test('should indicate loading state while fetching restaurants', () async { + // Arrange + final List mockRestaurants = [ + RestaurantEntity( + id: '1', + name: 'Restaurant 1', + price: '\$\$', + rating: 4.5, + categories: [], + hours: [], + location: Location(formattedAddress: 'Address 1'), + photos: ['https://example.com/photo1.jpg'], + reviews: [], + ), + ]; + + when(() => mockRestaurantGateway.getRestaurants(offset: 0)).thenAnswer((_) async { + await Future.delayed(const Duration(seconds: 1)); + return mockRestaurants; + }); + + // Act + final future = restaurantProvider.getRestaurants(); + + // Assert + expect(restaurantProvider.isLoading, isTrue); + await future; + + // Assert + expect(restaurantProvider.isLoading, isFalse); + expect(restaurantProvider.restaurants, mockRestaurants); + }); + }); +} diff --git a/test/domain/usecase/restaurant/restaurant_use_case_test.dart b/test/domain/usecase/restaurant/restaurant_use_case_test.dart new file mode 100644 index 0000000..e48403e --- /dev/null +++ b/test/domain/usecase/restaurant/restaurant_use_case_test.dart @@ -0,0 +1,107 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:restaurant_tour/domain/models/restaurant/gateway/restaurant_entity.dart'; +import 'package:restaurant_tour/domain/models/restaurant/gateway/restaurant_gateway.dart'; +import 'package:restaurant_tour/domain/usecase/restaurant/restaurant_use_case.dart'; + +class MockRestaurantGateway extends Mock implements RestaurantGateway {} + +class FakeRestaurantEntity extends Fake implements RestaurantEntity {} + +void main() { + setUpAll(() { + registerFallbackValue(FakeRestaurantEntity()); + }); + + group('RestaurantUseCase Tests', () { + late RestaurantUseCase restaurantUseCase; + late MockRestaurantGateway mockRestaurantGateway; + + setUp(() { + mockRestaurantGateway = MockRestaurantGateway(); + restaurantUseCase = RestaurantUseCase(restaurantGateway: mockRestaurantGateway); + }); + + test('fetchRestaurants returns a list of restaurants', () async { + final mockRestaurants = [ + RestaurantEntity( + id: '1', + name: 'Restaurant A', + price: '\$', + rating: 4.5, + photos: [], + categories: [], + hours: [], + reviews: [], + location: Location(formattedAddress: '123 Main St'), + ), + RestaurantEntity( + id: '2', + name: 'Restaurant B', + price: '\$\$', + rating: 4.0, + photos: [], + categories: [], + hours: [], + reviews: [], + location: Location(formattedAddress: '456 Elm St'), + ), + ]; + + when(() => mockRestaurantGateway.getRestaurants(offset: any(named: 'offset'))) + .thenAnswer((_) async => mockRestaurants); + //act + final result = await restaurantUseCase.fetchRestaurants(); + //assert + expect(result, equals(mockRestaurants)); + expect(result, isNotNull); + verify(() => mockRestaurantGateway.getRestaurants(offset: any(named: 'offset'))).called(1); + }); + + test('fetchRestaurant returns a restaurant by id', () async { + final mockRestaurant = RestaurantEntity( + id: '1', + name: 'Restaurant A', + price: '\$', + rating: 4.5, + photos: [], + categories: [], + hours: [], + reviews: [], + location: Location(formattedAddress: '123 Main St'), + ); + when(() => mockRestaurantGateway.getRestaurant('1')).thenAnswer((_) async => mockRestaurant); + //act + final result = await restaurantUseCase.fetchRestaurant('1'); + //assert + expect(result, equals(mockRestaurant)); + verify(() => mockRestaurantGateway.getRestaurant('1')).called(1); + }); + + test('fetchRestaurants returns null when gateway returns null', () async { + when(() => mockRestaurantGateway.getRestaurants(offset: any(named: 'offset'))).thenAnswer((_) async => null); + //act + final result = await restaurantUseCase.fetchRestaurants(); + //assert + expect(result, isNull); + verify(() => mockRestaurantGateway.getRestaurants(offset: any(named: 'offset'))).called(1); + }); + + test('fetchRestaurant returns null when gateway returns null', () async { + when(() => mockRestaurantGateway.getRestaurant('1')).thenAnswer((_) async => null); + //act + final result = await restaurantUseCase.fetchRestaurant('1'); + //assert + expect(result, isNull); + verify(() => mockRestaurantGateway.getRestaurant('1')).called(1); + }); + + test('fetchRestaurants returns null on exception', () async { + when(() => mockRestaurantGateway.getRestaurants(offset: any(named: 'offset'))) + .thenAnswer((_) async => throw Exception('Error fetching restaurants')); + final result = await restaurantUseCase.fetchRestaurants(); + expect(result, isNull); + verify(() => mockRestaurantGateway.getRestaurants(offset: any(named: 'offset'))).called(1); + }); + }); +} diff --git a/test/infrastructure/driven_adapters/api/restaurant_api_test.dart b/test/infrastructure/driven_adapters/api/restaurant_api_test.dart new file mode 100644 index 0000000..ffeb80a --- /dev/null +++ b/test/infrastructure/driven_adapters/api/restaurant_api_test.dart @@ -0,0 +1,208 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:restaurant_tour/domain/exception/app_exception.dart'; +import 'package:restaurant_tour/domain/models/restaurant/gateway/restaurant_entity.dart'; +import 'package:restaurant_tour/infrastructure/driven_adapters/api/restaurant_api.dart'; + +import '../../../domain/usecase/restaurant/restaurant_use_case_test.dart'; + +class MockDio extends Mock implements Dio {} + +// class MockResponse extends Mock implements Response {} + +void main() { + group('RestaurantApi Tests', () { + late RestaurantApi restaurantApi; + late MockDio mockDio; + setUpAll(() { + registerFallbackValue(FakeRestaurantEntity()); + }); + setUp(() { + mockDio = MockDio(); + restaurantApi = RestaurantApi(dio: mockDio); + }); + + test('returns RestaurantEntity when the response is successful', () async { + final mockData = { + 'data': { + 'business': { + 'id': '1', + 'name': 'Test Restaurant', + }, + }, + }; + //arrange + final mockResponse = Response( + requestOptions: RequestOptions(path: '/v3/graphql'), + statusCode: 200, + data: mockData, + ); + when( + () => mockDio.get( + any(), + queryParameters: any(named: 'queryParameters'), + ), + ).thenAnswer((_) async => mockResponse); + //act + final result = await restaurantApi.getRestaurant('1'); + + //assert + expect(result, isNotNull); + expect(result?.id, '1'); + expect(result?.name, 'Test Restaurant'); + verify( + () => mockDio.get( + '/v3/graphql', + queryParameters: {'id': '1'}, + ), + ).called(1); + }); + test('getRestaurants server error', () async { + when(() => mockDio.post(any(), data: any(named: 'data'))).thenAnswer( + (_) async => Response( + statusCode: 500, + requestOptions: RequestOptions(path: ''), + ), + ); + + expect( + () => restaurantApi.getRestaurants(), + throwsA( + isA().having( + (e) => e.error, + 'error', + FetchAppError.serverError, + ), + ), + ); + }); + + test('getRestaurants network error', () async { + when(() => mockDio.post(any(), data: ('data'))).thenThrow( + DioException( + requestOptions: RequestOptions(path: ''), + error: 'Network error', + ), + ); + + expect( + () => restaurantApi.getRestaurants(), + throwsA( + isA().having( + (e) => e.error, + 'error', + FetchAppError.networkError, + ), + ), + ); + }); + // }); + group('getRestaurants', () { + test('returns list of RestaurantEntity when the response is successful', () async { + final mockData = { + 'data': { + 'search': { + 'business': [ + {'id': '1', 'name': 'Restaurant A'}, + {'id': '2', 'name': 'Restaurant B'}, + ], + }, + }, + }; + //arrange + final mockResponse = Response( + requestOptions: RequestOptions(path: '/v3/graphql'), + statusCode: 200, + data: mockData, + ); + + when( + () => mockDio.post( + any(), + data: any(named: 'data'), + ), + ).thenAnswer((_) async => mockResponse); + + //act + final result = await restaurantApi.getRestaurants(); + //assert + expect(result, isA>()); + expect(result?.length, 2); + expect(result?[0].id, '1'); + expect(result?[1].id, '2'); + verify( + () => mockDio.post( + any(), + data: any(named: 'data'), + ), + ).called(1); + }); + + test('returns empty list when restaurantsData is null or empty', () async { + final mockData = { + 'data': { + 'search': { + 'business': [], + }, + }, + }; + //arrange + final mockResponse = Response( + requestOptions: RequestOptions(path: '/v3/graphql'), + statusCode: 200, + data: (mockData), + ); + when( + () => mockDio.post( + any(), + data: any(named: 'data'), + ), + ).thenAnswer((_) async => mockResponse); + //act + final result = await restaurantApi.getRestaurants(); + //assert + expect(result, isA>()); + expect(result, isEmpty); + verify( + () => mockDio.post( + any(), + data: any(named: 'data'), + ), + ).called(1); + }); + + test('throws AppException on DioError', () async { + when( + () => mockDio.post( + any(), + data: any(named: 'data'), + ), + ).thenThrow( + DioException( + requestOptions: RequestOptions(path: '/v3/graphql'), + error: 'Network error', + type: DioExceptionType.unknown, + ), + ); + //assert + expect( + () => restaurantApi.getRestaurants(), + throwsA( + isA().having( + (e) => e.error, + 'type', + FetchAppError.networkError, + ), + ), + ); + verify( + () => mockDio.post( + any(), + data: any(named: 'data'), + ), + ).called(1); + }); + }); + }); +} diff --git a/test/ui/pages/home/goldens/home_page_golden.png b/test/ui/pages/home/goldens/home_page_golden.png new file mode 100644 index 0000000..363af2e Binary files /dev/null and b/test/ui/pages/home/goldens/home_page_golden.png differ diff --git a/test/ui/pages/home/home_page_test.dart b/test/ui/pages/home/home_page_test.dart new file mode 100644 index 0000000..f23026e --- /dev/null +++ b/test/ui/pages/home/home_page_test.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:provider/provider.dart'; +import 'package:restaurant_tour/config/providers/favorites_provider.dart'; +import 'package:restaurant_tour/config/providers/restaurant_providers.dart'; +import 'package:restaurant_tour/ui/pages/favorites/favorites_restaurants_page.dart'; +import 'package:restaurant_tour/ui/pages/home/home_page.dart'; +import 'package:restaurant_tour/ui/pages/restaurants/restaurant_list_page.dart'; + +class MockRestaurantProvider extends Mock implements RestaurantProvider {} + +class MockFavoritesProvider extends Mock implements FavoritesProvider {} + +void main() { + testWidgets('HomePage renders TabBar and switches tabs correctly', (WidgetTester tester) async { + // Arrange + final mockProvider = MockRestaurantProvider(); + final mockFavorites = MockFavoritesProvider(); + when(() => mockProvider.isLoading).thenReturn(false); + when(() => mockProvider.restaurants).thenReturn([]); + when(() => mockProvider.getRestaurants()).thenAnswer((_) async => Future.value()); + when(() => mockFavorites.favoriteRestaurants).thenReturn([]); + + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: mockProvider), + ChangeNotifierProvider.value(value: mockFavorites), + ], + child: const MaterialApp( + home: HomePage(), + ), + ), + ); + // Assert + expect(find.byType(TabBar), findsOneWidget); + expect(find.byType(RestaurantListPage), findsOneWidget); + expect(find.byType(FavoritesRestaurantsPage), findsNothing); + + await tester.tap(find.text('My Favorites')); + await tester.pumpAndSettle(); + + expect(find.byType(FavoritesRestaurantsPage), findsOneWidget); + expect(find.byType(RestaurantListPage), findsNothing); + + await expectLater( + find.byType(HomePage), + matchesGoldenFile('goldens/home_page_golden.png'), + ); + }); +} diff --git a/test/ui/pages/restaurants/goldens/restaurant_list_loading.png b/test/ui/pages/restaurants/goldens/restaurant_list_loading.png new file mode 100644 index 0000000..1359208 Binary files /dev/null and b/test/ui/pages/restaurants/goldens/restaurant_list_loading.png differ diff --git a/test/ui/pages/restaurants/goldens/restaurant_list_with_data.png b/test/ui/pages/restaurants/goldens/restaurant_list_with_data.png new file mode 100644 index 0000000..a250f3c Binary files /dev/null and b/test/ui/pages/restaurants/goldens/restaurant_list_with_data.png differ diff --git a/test/ui/pages/restaurants/restaurant_list_page_test.dart b/test/ui/pages/restaurants/restaurant_list_page_test.dart new file mode 100644 index 0000000..b989303 --- /dev/null +++ b/test/ui/pages/restaurants/restaurant_list_page_test.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:provider/provider.dart'; +import 'package:restaurant_tour/config/constants/constants.dart'; +import 'package:restaurant_tour/config/providers/restaurant_providers.dart'; +import 'package:restaurant_tour/domain/models/restaurant/gateway/restaurant_entity.dart'; +import 'package:restaurant_tour/ui/pages/home/widgets/card_item.dart'; +import 'package:restaurant_tour/ui/pages/restaurants/restaurant_list_page.dart'; + +class MockRestaurantProvider extends Mock implements RestaurantProvider {} + +void main() { + setUpAll(() { + registerFallbackValue(MockRestaurantProvider()); + }); + + group('RestaurantListPage widget tests', () { + testWidgets('Shows loading state', (WidgetTester tester) async { + // Arrange + final mockProvider = MockRestaurantProvider(); + + when(() => mockProvider.isLoading).thenReturn(true); + when(() => mockProvider.getRestaurants()).thenAnswer((_) async => Future.value()); + when(() => mockProvider.restaurants).thenReturn(null); + when(() => mockProvider.errorMessage).thenReturn(null); + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: mockProvider, + child: const MaterialApp( + home: RestaurantListPage(), + ), + ), + ); + + // Act + await tester.pump(); + + // Assert + expect(find.byType(CircularProgressIndicator), findsOneWidget); + + //Add Golden test for loading state + await expectLater( + find.byType(RestaurantListPage), + matchesGoldenFile('goldens/restaurant_list_loading.png'), + ); + }); + + testWidgets('Shows list of restaurants', (WidgetTester tester) async { + // Arrange + final mockProvider = MockRestaurantProvider(); + final restaurant = RestaurantEntity( + id: '123', + name: 'Test Restaurant', + price: '\$\$', + rating: 4.5, + categories: [], + photos: [], + hours: [Hours(isOpenNow: true)], + reviews: [], + location: Location(formattedAddress: '123 Test St.'), + ); + + when(() => mockProvider.isLoading).thenReturn(false); + when(() => mockProvider.getRestaurants()).thenAnswer((_) async => Future.value()); + when(() => mockProvider.restaurants).thenReturn([restaurant]); + when(() => mockProvider.errorMessage).thenReturn(null); + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: mockProvider, + child: const MaterialApp( + home: RestaurantListPage(), + ), + ), + ); + + // Act + await tester.pump(); + + // Assert + expect(find.text('Test Restaurant'), findsOneWidget); + expect(find.byType(CardItem), findsOneWidget); + + // Add golden test for list state + await expectLater( + find.byType(RestaurantListPage), + matchesGoldenFile('goldens/restaurant_list_with_data.png'), + ); + }); + + testWidgets('Shows "No restaurants available" message when list is empty', (WidgetTester tester) async { + // Arrange + final mockProvider = MockRestaurantProvider(); + when(() => mockProvider.isLoading).thenReturn(false); + when(() => mockProvider.getRestaurants()).thenAnswer((_) async => Future.value()); + when(() => mockProvider.restaurants).thenReturn([]); + when(() => mockProvider.errorMessage).thenReturn(null); + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: mockProvider, + child: const MaterialApp( + home: RestaurantListPage(), + ), + ), + ); + + // Act + await tester.pump(); + + // Assert + expect(find.text(AppConstants.noRestaurantsAvailable), findsOneWidget); + }); + }); +} diff --git a/test/ui/tokens/availability_widget_test.dart b/test/ui/tokens/availability_widget_test.dart new file mode 100644 index 0000000..aa23eec --- /dev/null +++ b/test/ui/tokens/availability_widget_test.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:restaurant_tour/config/constants/constants.dart'; +import 'package:restaurant_tour/ui/tokens/colors.dart'; +import 'package:restaurant_tour/ui/widgets/availability_widget.dart'; + +void main() { + testWidgets('AvailabilityWidget shows "Open Now" when restaurant is open', (WidgetTester tester) async { + // Arrange + const isRestaurantOpen = true; + + // Act + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: AvailabilityWidget(isRestaurantOpen: isRestaurantOpen), + ), + ), + ); + + // Assert + expect(find.text(AppConstants.openNow), findsOneWidget); + final circle = find.byType(DecoratedBox); + final decoratedBox = tester.widget(circle); + final color = (decoratedBox.decoration as BoxDecoration).color; + expect(color, OsColors.statusSuccess); + }); + + testWidgets('AvailabilityWidget shows "Closed" when restaurant is closed', (WidgetTester tester) async { + // Arrange + const isRestaurantOpen = false; + + // Act + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: AvailabilityWidget(isRestaurantOpen: isRestaurantOpen), + ), + ), + ); + + // Assert + expect(find.text(AppConstants.closed), findsOneWidget); + final circle = find.byType(DecoratedBox); + final decoratedBox = tester.widget(circle); + final color = (decoratedBox.decoration as BoxDecoration).color; + expect(color, OsColors.statusError); + }); +} 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); - }); -}