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.
+
+
+
+
+## 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);
- });
-}