diff --git a/lib/main.dart b/lib/main.dart index ae7012a..0b6b173 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,15 +1,9 @@ -import 'dart:convert'; - import 'package:flutter/material.dart'; -import 'package:http/http.dart' as http; -import 'package:restaurant_tour/models/restaurant.dart'; -import 'package:restaurant_tour/query.dart'; - -const _apiKey = ''; -const _baseUrl = 'https://api.yelp.com/v3/graphql'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:restaurant_tour/src/features/restaurant_tour/presentation/pages/restaurant_tour_page.dart'; void main() { - runApp(const RestaurantTour()); + runApp(const ProviderScope(child: RestaurantTour())); } class RestaurantTour extends StatelessWidget { @@ -24,63 +18,14 @@ class RestaurantTour extends StatelessWidget { } } -// 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'); - } - }, - ), - ], - ), + return const MaterialApp( + home: Scaffold( + body: RestaurantTourPage(), ), ); } diff --git a/lib/src/constants/constants.dart b/lib/src/constants/constants.dart new file mode 100644 index 0000000..de0651a --- /dev/null +++ b/lib/src/constants/constants.dart @@ -0,0 +1,3 @@ +const String apiKey = + 'y0RvKbozyu07RfpByqrdTJGyAOzhaNZH9T5X5pzBOoSh9uqOULc8h6yx89Z5nPjYtNaPHp9aqX0ZKF5pHSuYTeWcrYJS9r4EoHb7WmVLKPSmPW-L0FloXZJUInTkZnYx'; +const String baseUrl = 'https://api.yelp.com/v3/graphql'; diff --git a/lib/query.dart b/lib/src/constants/query.dart similarity index 100% rename from lib/query.dart rename to lib/src/constants/query.dart diff --git a/lib/src/constants/strings.dart b/lib/src/constants/strings.dart new file mode 100644 index 0000000..54bdb94 --- /dev/null +++ b/lib/src/constants/strings.dart @@ -0,0 +1,15 @@ +// Application strings +const String addressText = 'Address'; +const String allRestaurants = 'All Restaurants'; +const String closedText = 'Closed'; +const String failedDataText = + 'Failed to load data: You have reached the GraphQL daily points limit for this API key'; +const String myFavorites = 'My Favorites'; +const String noFavoriteRestaurantsText = 'You do not have favorite restaurants'; +const String overallRatingText = 'Overall Rating'; +const String openNowText = 'Open Now'; +const String reviewsText = 'Reviews'; +const String restaurantAddedText = 'Restaurant added to favorites'; +const String restaurantDeletedText = 'Restaurant deleted from favorites'; +const String sorryText = '¡Sorry!'; +const String titleApp = 'Restaurant Tour'; diff --git a/lib/typography.dart b/lib/src/constants/typography.dart similarity index 100% rename from lib/typography.dart rename to lib/src/constants/typography.dart diff --git a/lib/src/features/restaurant_tour/data/mock.dart b/lib/src/features/restaurant_tour/data/mock.dart new file mode 100644 index 0000000..19e49ba --- /dev/null +++ b/lib/src/features/restaurant_tour/data/mock.dart @@ -0,0 +1,176 @@ +import 'package:restaurant_tour/src/features/restaurant_tour/models/restaurant.dart'; + +RestaurantQueryResult? mockQueryResult = RestaurantQueryResult( + total: 10, + restaurants: mockRestaurants, +); + +List mockRestaurants = [ + Restaurant( + id: '100', + name: 'Pasta Paradise', + price: '\$\$', + rating: 4.5, + photos: [ + 'https://i.pinimg.com/originals/b5/90/a7/b590a70a53f5712d4abfd6bf938d054d.jpg', + 'https://www.metacritic.com/a/img/catalog/provider/6/12/6-1-764252-52.jpg', + ], + categories: [ + Category(alias: 'italian', title: 'Italian'), + ], + hours: [ + const Hours(isOpenNow: true), + ], + reviews: [ + const Review( + id: 'r1', + rating: 5, + text: + 'Amazing food! Highly recommended. Amazing food! Highly recommended.', + user: User( + id: 'u1', + imageUrl: 'https://randomuser.me/api/portraits/men/1.jpg', + name: 'John Doe', + ), + ), + const Review( + id: 'r2', + rating: 2, + text: + 'Review text goes there. Review text goes here. This is a review. This is a review that is 3 lines long.', + user: User( + id: 'u1', + imageUrl: 'https://randomuser.me/api/portraits/men/2.jpg', + name: 'John Test', + ), + ), + ], + location: Location( + formattedAddress: '123 Pasta Lane, Food City, FC 12345', + ), + ), + Restaurant( + id: '200', + name: 'Burger Bonanza', + price: '\$\$', + rating: 4.0, + photos: [ + 'https://cdn.hobbyconsolas.com/sites/navi.axelspringer.es/public/media/image/2022/07/marvels-avengers-2748987.jpg', + 'https://images.unsplash.com/photo-1567439204-e8a1c3c1ea80', + ], + categories: [ + Category(alias: 'american', title: 'American'), + ], + hours: [ + const Hours(isOpenNow: false), + ], + reviews: [ + const Review( + id: 'r2', + rating: 4, + text: 'Great burgers but a bit expensive.', + user: User( + id: 'u2', + imageUrl: 'https://randomuser.me/api/portraits/women/2.jpg', + name: 'Jane Smith', + ), + ), + ], + location: Location( + formattedAddress: '456 Burger Blvd, Meat Town, MT 67890', + ), + ), + Restaurant( + id: '300', + name: 'Sushi World', + price: '\$\$\$', + rating: 4.8, + photos: [ + 'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcSqQrl5EGU85_IW-T1FQcfTexUPr8htRkBzIw&s', + 'https://images.unsplash.com/photo-1534503829050-1c8b38c9d137', + ], + categories: [ + Category(alias: 'japanese', title: 'Japanese'), + ], + hours: [ + const Hours(isOpenNow: true), + ], + reviews: [ + const Review( + id: 'r3', + rating: 5, + text: 'Best sushi in town. Fresh and delicious!', + user: User( + id: 'u3', + imageUrl: 'https://randomuser.me/api/portraits/men/3.jpg', + name: 'Alice Cooper', + ), + ), + ], + location: Location( + formattedAddress: '789 Sushi St, Roll City, RC 23456', + ), + ), + Restaurant( + id: '400', + name: 'Taco Haven', + price: '\$', + rating: 4.2, + photos: [ + 'https://phantom-marca.unidadeditorial.es/3d02ae4f69cb70e76206ba49e8de0fbc/resize/828/f/jpg/assets/multimedia/imagenes/2024/02/23/17087165658783.jpg', + 'https://images.unsplash.com/photo-1582960715727-4a7db5283a7f', + ], + categories: [ + Category(alias: 'mexican', title: 'Mexican'), + ], + hours: [ + const Hours(isOpenNow: true), + ], + reviews: [ + const Review( + id: 'r4', + rating: 4, + text: 'Tacos are great, but the service is slow.', + user: User( + id: 'u4', + imageUrl: 'https://randomuser.me/api/portraits/men/4.jpg', + name: 'Bob Brown', + ), + ), + ], + location: Location( + formattedAddress: '321 Taco Ave, Spice City, SC 34567', + ), + ), + Restaurant( + id: '500', + name: 'Vegan Delight', + price: '\$\$', + rating: 4.7, + photos: [ + 'https://pics.filmaffinity.com/harry_potter_and_the_sorcerer_s_stone-154820574-mmed.jpg', + 'https://images.unsplash.com/photo-1556914182-4ad1a5d33cfc', + ], + categories: [ + Category(alias: 'vegan', title: 'Vegan'), + ], + hours: [ + const Hours(isOpenNow: false), + ], + reviews: [ + const Review( + id: 'r5', + rating: 5, + text: 'Excellent vegan options. A must-visit for vegans!', + user: User( + id: 'u5', + imageUrl: 'https://randomuser.me/api/portraits/women/5.jpg', + name: 'Emma Davis', + ), + ), + ], + location: Location( + formattedAddress: '654 Vegan Way, Herb City, HC 45678', + ), + ), +]; diff --git a/lib/src/features/restaurant_tour/domain/datasources/restaurants_datasource.dart b/lib/src/features/restaurant_tour/domain/datasources/restaurants_datasource.dart new file mode 100644 index 0000000..59ff81a --- /dev/null +++ b/lib/src/features/restaurant_tour/domain/datasources/restaurants_datasource.dart @@ -0,0 +1,5 @@ +import 'package:restaurant_tour/src/features/restaurant_tour/models/restaurant.dart'; + +abstract class RestaurantsDatasource { + Future getRestaurants(); +} diff --git a/lib/src/features/restaurant_tour/domain/repositories/restaurant_repository.dart b/lib/src/features/restaurant_tour/domain/repositories/restaurant_repository.dart new file mode 100644 index 0000000..7c92487 --- /dev/null +++ b/lib/src/features/restaurant_tour/domain/repositories/restaurant_repository.dart @@ -0,0 +1,5 @@ +import 'package:restaurant_tour/src/features/restaurant_tour/models/restaurant.dart'; + +abstract class RestaurantRepository { + Future getRestaurants(); +} diff --git a/lib/src/features/restaurant_tour/infrastructure/datasources/restaurant_api_datasource.dart b/lib/src/features/restaurant_tour/infrastructure/datasources/restaurant_api_datasource.dart new file mode 100644 index 0000000..bf63de9 --- /dev/null +++ b/lib/src/features/restaurant_tour/infrastructure/datasources/restaurant_api_datasource.dart @@ -0,0 +1,46 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:restaurant_tour/src/constants/constants.dart'; +import 'package:restaurant_tour/src/constants/query.dart'; +import 'package:restaurant_tour/src/features/restaurant_tour/domain/datasources/restaurants_datasource.dart'; +import 'package:restaurant_tour/src/features/restaurant_tour/models/restaurant.dart'; + +class RestaurantApiDatasource extends RestaurantsDatasource { + static const _apiKey = apiKey; + static const _baseUrl = baseUrl; + + @override + 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) { + final restaurantResponse = RestaurantQueryResult.fromJson( + jsonDecode(response.body)['data']['search'], + ); + return restaurantResponse; + } else { + return const RestaurantQueryResult(restaurants: [], total: 0); + } + // TODO: Uncomment if you want to use the app with Mocks + // final restaurantsResponse = RestaurantQueryResult( + // restaurants: mockRestaurants, + // total: 5, + // ); + // return restaurantsResponse; + } catch (e) { + print('Error fetching restaurants: $e'); + rethrow; + } + } +} diff --git a/lib/src/features/restaurant_tour/infrastructure/repositories/restaurant_repository_impl.dart b/lib/src/features/restaurant_tour/infrastructure/repositories/restaurant_repository_impl.dart new file mode 100644 index 0000000..642fca0 --- /dev/null +++ b/lib/src/features/restaurant_tour/infrastructure/repositories/restaurant_repository_impl.dart @@ -0,0 +1,14 @@ +import 'package:restaurant_tour/src/features/restaurant_tour/domain/datasources/restaurants_datasource.dart'; +import 'package:restaurant_tour/src/features/restaurant_tour/domain/repositories/restaurant_repository.dart'; +import 'package:restaurant_tour/src/features/restaurant_tour/models/restaurant.dart'; + +class RestaurantRepositoryImpl extends RestaurantRepository { + final RestaurantsDatasource datasource; + + RestaurantRepositoryImpl(this.datasource); + + @override + Future getRestaurants() { + return datasource.getRestaurants(); + } +} diff --git a/lib/models/restaurant.dart b/lib/src/features/restaurant_tour/models/restaurant.dart similarity index 100% rename from lib/models/restaurant.dart rename to lib/src/features/restaurant_tour/models/restaurant.dart diff --git a/lib/models/restaurant.g.dart b/lib/src/features/restaurant_tour/models/restaurant.g.dart similarity index 100% rename from lib/models/restaurant.g.dart rename to lib/src/features/restaurant_tour/models/restaurant.g.dart diff --git a/lib/src/features/restaurant_tour/presentation/pages/restaurant_info_page.dart b/lib/src/features/restaurant_tour/presentation/pages/restaurant_info_page.dart new file mode 100644 index 0000000..1d7d150 --- /dev/null +++ b/lib/src/features/restaurant_tour/presentation/pages/restaurant_info_page.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; +import 'package:restaurant_tour/src/constants/strings.dart'; +import 'package:restaurant_tour/src/constants/typography.dart'; +import 'package:restaurant_tour/src/features/restaurant_tour/models/restaurant.dart'; +import 'package:restaurant_tour/src/features/restaurant_tour/presentation/view/restaurant_info_view.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class RestaurantInfoPage extends StatefulWidget { + const RestaurantInfoPage({super.key, required this.restaurant}); + + final Restaurant restaurant; + + @override + State createState() => _RestaurantInfoPageState(); +} + +class _RestaurantInfoPageState extends State { + bool _isFavorite = false; + + @override + void initState() { + super.initState(); + _loadFavoriteStatus(); + } + + Future _loadFavoriteStatus() async { + final favoriteRestaurant = await SharedPreferences.getInstance(); + final isFavorite = + favoriteRestaurant.getBool(widget.restaurant.id ?? '') ?? false; + setState(() { + _isFavorite = isFavorite; + }); + } + + _showToast() { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + _isFavorite ? restaurantAddedText : restaurantDeletedText, + style: AppTextStyles.openRegularHeadline.copyWith( + color: Colors.white, + ), + ), + duration: const Duration(seconds: 1), + backgroundColor: Colors.black, + ), + ); + } + + Future _toggleFavorite() async { + final preferences = await SharedPreferences.getInstance(); + setState(() { + _isFavorite = !_isFavorite; + _showToast(); + }); + await preferences.setBool(widget.restaurant.id ?? '', _isFavorite); + if (!_isFavorite && mounted) { + Navigator.pop(context, true); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + actions: [ + IconButton( + icon: Icon( + _isFavorite ? Icons.favorite : Icons.favorite_outline, + ), + onPressed: () => _toggleFavorite(), + iconSize: 30, + ), + ], + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () { + Navigator.pop( + context, + false, + ); + }, + iconSize: 30, + ), + title: Text(widget.restaurant.name ?? ''), + ), + body: RestaurantInfoView( + restaurant: widget.restaurant, + ), + ); + } +} diff --git a/lib/src/features/restaurant_tour/presentation/pages/restaurant_tour_page.dart b/lib/src/features/restaurant_tour/presentation/pages/restaurant_tour_page.dart new file mode 100644 index 0000000..81c9a49 --- /dev/null +++ b/lib/src/features/restaurant_tour/presentation/pages/restaurant_tour_page.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; +import 'package:restaurant_tour/src/constants/strings.dart'; +import 'package:restaurant_tour/src/features/restaurant_tour/presentation/view/all_restaurants_view.dart'; +import 'package:restaurant_tour/src/features/restaurant_tour/presentation/view/favorite_restaurants_view.dart'; + +class RestaurantTourPage extends StatefulWidget { + const RestaurantTourPage({super.key}); + + @override + State createState() => _RestaurantTourPageState(); +} + +class _RestaurantTourPageState extends State { + @override + Widget build(BuildContext context) { + return DefaultTabController( + length: 2, + child: Scaffold( + appBar: AppBar( + title: const Text(titleApp), + bottom: const TabBar( + indicatorColor: Colors.black, + labelColor: Colors.black, + tabs: [ + Tab( + child: Text(allRestaurants), + ), + Tab( + child: Text(myFavorites), + ), + ], + ), + ), + body: const TabBarView( + children: [ + AllRestaurantsView(), + FavoriteRestaurantsView(), + ], + ), + ), + ); + } +} diff --git a/lib/src/features/restaurant_tour/presentation/providers/restaurants_provider.dart b/lib/src/features/restaurant_tour/presentation/providers/restaurants_provider.dart new file mode 100644 index 0000000..11a6e66 --- /dev/null +++ b/lib/src/features/restaurant_tour/presentation/providers/restaurants_provider.dart @@ -0,0 +1,42 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:restaurant_tour/src/features/restaurant_tour/models/restaurant.dart'; +import 'package:restaurant_tour/src/features/restaurant_tour/presentation/providers/restaurants_repository_provider.dart'; + +final getRestaurantsProvider = + StateNotifierProvider( + (ref) { + final restaurantsResponse = + ref.watch(restaurantsRepositoryProvider).getRestaurants; + return RestaurantsNotifier(restaurantsCallBack: restaurantsResponse); + }, +); + +typedef RestaurantsCallBack = Future Function(); + +class RestaurantsNotifier extends StateNotifier { + RestaurantsCallBack restaurantsCallBack; + + RestaurantsNotifier({required this.restaurantsCallBack}) + : super( + const RestaurantQueryResult( + restaurants: [], + total: 0, + ), + ); + Future loadRestaurants() async { + final RestaurantQueryResult restaurants = await restaurantsCallBack(); + state = restaurants; + return state; + } + + List getFavoriteRestaurants(String id) { + var favoriteRestaurants = []; + + for (var restaurant in state.restaurants ?? []) { + if (restaurant.id == id) { + favoriteRestaurants.add(restaurant); + } + } + return favoriteRestaurants; + } +} diff --git a/lib/src/features/restaurant_tour/presentation/providers/restaurants_repository_provider.dart b/lib/src/features/restaurant_tour/presentation/providers/restaurants_repository_provider.dart new file mode 100644 index 0000000..121c212 --- /dev/null +++ b/lib/src/features/restaurant_tour/presentation/providers/restaurants_repository_provider.dart @@ -0,0 +1,7 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:restaurant_tour/src/features/restaurant_tour/infrastructure/datasources/restaurant_api_datasource.dart'; +import 'package:restaurant_tour/src/features/restaurant_tour/infrastructure/repositories/restaurant_repository_impl.dart'; + +final restaurantsRepositoryProvider = Provider((ref) { + return RestaurantRepositoryImpl(RestaurantApiDatasource()); +}); diff --git a/lib/src/features/restaurant_tour/presentation/view/all_restaurants_view.dart b/lib/src/features/restaurant_tour/presentation/view/all_restaurants_view.dart new file mode 100644 index 0000000..9910115 --- /dev/null +++ b/lib/src/features/restaurant_tour/presentation/view/all_restaurants_view.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:restaurant_tour/src/constants/strings.dart'; +import 'package:restaurant_tour/src/features/restaurant_tour/models/restaurant.dart'; +import 'package:restaurant_tour/src/features/restaurant_tour/presentation/providers/restaurants_provider.dart'; +import 'package:restaurant_tour/src/features/restaurant_tour/presentation/widgets/restaurant_card.dart'; + +class AllRestaurantsView extends ConsumerStatefulWidget { + const AllRestaurantsView({super.key}); + + @override + ConsumerState createState() => _AllRestaurantsViewState(); +} + +class _AllRestaurantsViewState extends ConsumerState { + late List restaurants; + + bool _dataNotFound = false; + bool _showProgressIndicator = true; + + @override + void initState() { + super.initState(); + _fetchRestaurants(); + } + + Future _fetchRestaurants() async { + try { + final result = + await ref.read(getRestaurantsProvider.notifier).loadRestaurants(); + if (result.restaurants != null && result.restaurants!.isNotEmpty) { + restaurants = result.restaurants!; + if (mounted) { + setState(() { + _showProgressIndicator = false; + }); + } + } else { + _handleDataNotFound(); + } + } catch (e) { + print('Failed to fetch restaurants: $e'); + _handleDataNotFound(); + } + } + + void _handleDataNotFound() { + if (mounted) { + setState(() { + _showProgressIndicator = false; + _dataNotFound = true; + }); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + failedDataText, + ), + backgroundColor: Colors.black, + ), + ); + } + } + + @override + Widget build(BuildContext context) { + return !_showProgressIndicator + ? _dataNotFound + ? const Center(child: Text('Data not found')) + : ListView.builder( + itemCount: restaurants.length, + itemBuilder: (context, index) { + return Padding( + padding: const EdgeInsets.all(8.0), + child: RestaurantCard( + restaurant: restaurants[index], + ), + ); + }, + ) + : const Center( + child: CircularProgressIndicator( + color: Colors.black, + ), + ); + } +} diff --git a/lib/src/features/restaurant_tour/presentation/view/favorite_restaurants_view.dart b/lib/src/features/restaurant_tour/presentation/view/favorite_restaurants_view.dart new file mode 100644 index 0000000..abceaba --- /dev/null +++ b/lib/src/features/restaurant_tour/presentation/view/favorite_restaurants_view.dart @@ -0,0 +1,110 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:restaurant_tour/src/constants/strings.dart'; +import 'package:restaurant_tour/src/constants/typography.dart'; +import 'package:restaurant_tour/src/features/restaurant_tour/models/restaurant.dart'; +import 'package:restaurant_tour/src/features/restaurant_tour/presentation/pages/restaurant_info_page.dart'; +import 'package:restaurant_tour/src/features/restaurant_tour/presentation/providers/restaurants_provider.dart'; +import 'package:restaurant_tour/src/features/restaurant_tour/presentation/widgets/restaurant_card.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class FavoriteRestaurantsView extends ConsumerStatefulWidget { + const FavoriteRestaurantsView({super.key}); + + @override + ConsumerState createState() => + _FavoriteRestaurantsViewState(); +} + +class _FavoriteRestaurantsViewState + extends ConsumerState { + List _favoriteRestaurants = []; + + @override + void initState() { + super.initState(); + _loadFavorites(); + } + + Widget _emptyFavoritesView() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Icon( + Icons.restaurant, + size: 60, + color: Colors.black, + ), + Text( + sorryText, + style: AppTextStyles.openRegularTitleSemiBold.copyWith( + fontSize: 30, + ), + ), + Text( + noFavoriteRestaurantsText, + style: AppTextStyles.openRegularText.copyWith( + fontSize: 20, + ), + ), + ], + ), + ); + } + + Future _loadFavorites() async { + final favoriteRestaurants = await SharedPreferences.getInstance(); + final keys = favoriteRestaurants.getKeys(); + var accumulatedFavorites = []; + + for (var key in keys) { + final isFavorite = favoriteRestaurants.getBool(key) ?? false; + + if (isFavorite) { + var favoriteRestaurant = ref + .read(getRestaurantsProvider.notifier) + .getFavoriteRestaurants(key); + accumulatedFavorites.addAll(favoriteRestaurant); + } + } + + setState(() { + _favoriteRestaurants = accumulatedFavorites; + }); + } + + Future _onNavigateToRestaurantInfoPage(Restaurant restaurant) async { + final result = await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => RestaurantInfoPage(restaurant: restaurant), + ), + ); + + if (result == true) { + _loadFavorites(); + } + } + + @override + Widget build(BuildContext context) { + return _favoriteRestaurants.isNotEmpty + ? ListView.builder( + itemCount: _favoriteRestaurants.length, + itemBuilder: (context, index) { + return Padding( + padding: const EdgeInsets.all(8.0), + child: RestaurantCard( + restaurant: _favoriteRestaurants[index], + onTap: () => _onNavigateToRestaurantInfoPage( + _favoriteRestaurants[index], + ), + ), + ); + }, + ) + : _emptyFavoritesView(); + } +} diff --git a/lib/src/features/restaurant_tour/presentation/view/restaurant_info_view.dart b/lib/src/features/restaurant_tour/presentation/view/restaurant_info_view.dart new file mode 100644 index 0000000..9eecd36 --- /dev/null +++ b/lib/src/features/restaurant_tour/presentation/view/restaurant_info_view.dart @@ -0,0 +1,169 @@ +import 'package:flutter/material.dart'; +import 'package:restaurant_tour/src/constants/strings.dart'; +import 'package:restaurant_tour/src/constants/typography.dart'; +import 'package:restaurant_tour/src/features/restaurant_tour/models/restaurant.dart'; +import 'package:restaurant_tour/src/features/restaurant_tour/presentation/widgets/restaurant_reviews.dart'; +import 'package:restaurant_tour/src/features/restaurant_tour/presentation/widgets/star_icon.dart'; + +class RestaurantInfoView extends StatefulWidget { + const RestaurantInfoView({ + super.key, + required this.restaurant, + }); + + final Restaurant restaurant; + + @override + State createState() => _RestaurantInfoViewState(); +} + +class _RestaurantInfoViewState extends State { + bool _showProgressIndicator = true; + + @override + void initState() { + super.initState(); + _initializeLoading(); + } + + List _buildAddressSection() { + return [ + const Text( + addressText, + style: AppTextStyles.openRegularText, + ), + const SizedBox(height: 14), + SizedBox( + width: 150, + child: Text( + widget.restaurant.location?.formattedAddress ?? '', + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: AppTextStyles.openRegularTitleSemiBold, + ), + ), + ]; + } + + List _buildAvailabilitySection() { + return [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '${widget.restaurant.price} ${widget.restaurant.categories![0].title}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: AppTextStyles.openRegularText, + ), + const Spacer(), + SizedBox( + child: Text( + widget.restaurant.isOpen ? openNowText : closedText, + style: AppTextStyles.openRegularItalic, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: Icon( + Icons.circle, + color: widget.restaurant.isOpen ? Colors.green : Colors.red, + size: 12.0, + ), + ), + ], + ), + ]; + } + + Widget _buildImage() { + return SizedBox( + height: 280, + width: double.infinity, + child: Image.network( + widget.restaurant.heroImage, + fit: BoxFit.cover, + ), + ); + } + + List _buildOverallRatingSection() { + return [ + const Text( + overallRatingText, + style: AppTextStyles.openRegularText, + ), + const SizedBox(height: 14), + Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Text( + widget.restaurant.rating.toString(), + style: AppTextStyles.loraRegularHeadline.copyWith( + fontSize: 28, + ), + ), + const StarIcon(), + ], + ), + ]; + } + + List _divider() { + return [ + const SizedBox(height: 14), + const Divider(), + const SizedBox(height: 14), + ]; + } + + void _initializeLoading() async { + await Future.delayed(const Duration(seconds: 1)); + if (mounted) { + setState(() { + _showProgressIndicator = false; + }); + } + } + + @override + Widget build(BuildContext context) { + return !_showProgressIndicator + ? SingleChildScrollView( + child: Column( + children: [ + _buildImage(), + Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ..._buildAvailabilitySection(), + ..._divider(), + ..._buildAddressSection(), + ..._divider(), + ..._buildOverallRatingSection(), + ..._divider(), + Text( + '${widget.restaurant.reviews?.length ?? 0} $reviewsText', + style: AppTextStyles.openRegularText, + ), + ...List.generate( + widget.restaurant.reviews?.length ?? 0, + (index) => RestaurantReviews( + reviews: widget.restaurant.reviews![index], + ), + ), + ], + ), + ), + ], + ), + ) + : const Center( + child: CircularProgressIndicator( + color: Colors.black, + ), + ); + } +} diff --git a/lib/src/features/restaurant_tour/presentation/widgets/restaurant_card.dart b/lib/src/features/restaurant_tour/presentation/widgets/restaurant_card.dart new file mode 100644 index 0000000..eaee046 --- /dev/null +++ b/lib/src/features/restaurant_tour/presentation/widgets/restaurant_card.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:restaurant_tour/src/features/restaurant_tour/models/restaurant.dart'; +import 'package:restaurant_tour/src/features/restaurant_tour/presentation/pages/restaurant_info_page.dart'; +import 'package:restaurant_tour/src/features/restaurant_tour/presentation/widgets/restaurant_card_image.dart'; +import 'package:restaurant_tour/src/features/restaurant_tour/presentation/widgets/restaurant_card_info.dart'; + +class RestaurantCard extends StatelessWidget { + const RestaurantCard({ + super.key, + required this.restaurant, + this.onTap, + }); + + final Restaurant restaurant; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap ?? + () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => RestaurantInfoPage( + restaurant: restaurant, + ), + ), + ); + }, + child: Card( + margin: const EdgeInsets.all(4), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.all(8), + child: RestaurantCardImage( + restaurant: restaurant, + ), + ), + const SizedBox(width: 10), + Expanded( + child: RestaurantCardInfo( + restaurant: restaurant, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/src/features/restaurant_tour/presentation/widgets/restaurant_card_image.dart b/lib/src/features/restaurant_tour/presentation/widgets/restaurant_card_image.dart new file mode 100644 index 0000000..6e4a0e6 --- /dev/null +++ b/lib/src/features/restaurant_tour/presentation/widgets/restaurant_card_image.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; +import 'package:restaurant_tour/src/features/restaurant_tour/models/restaurant.dart'; + +class RestaurantCardImage extends StatelessWidget { + const RestaurantCardImage({ + super.key, + required this.restaurant, + }); + + final Restaurant restaurant; + + @override + Widget build(BuildContext context) { + return ClipRRect( + borderRadius: BorderRadius.circular(30), + child: Image.network( + restaurant.heroImage, + height: 120.0, + width: 120.0, + fit: BoxFit.cover, + ), + ); + } +} diff --git a/lib/src/features/restaurant_tour/presentation/widgets/restaurant_card_info.dart b/lib/src/features/restaurant_tour/presentation/widgets/restaurant_card_info.dart new file mode 100644 index 0000000..6f88960 --- /dev/null +++ b/lib/src/features/restaurant_tour/presentation/widgets/restaurant_card_info.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; +import 'package:restaurant_tour/src/constants/strings.dart'; +import 'package:restaurant_tour/src/constants/typography.dart'; +import 'package:restaurant_tour/src/features/restaurant_tour/models/restaurant.dart'; +import 'package:restaurant_tour/src/features/restaurant_tour/presentation/widgets/star_icon.dart'; + +class RestaurantCardInfo extends StatelessWidget { + const RestaurantCardInfo({ + super.key, + required this.restaurant, + }); + + final Restaurant restaurant; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 235, + child: Text( + restaurant.name ?? '', + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: AppTextStyles.loraRegularTitle, + ), + ), + const SizedBox(height: 10), + Text( + '${restaurant.price} ${restaurant.categories![0].title}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: AppTextStyles.openRegularText, + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + ...List.generate( + restaurant.rating!.round(), + (index) => const StarIcon(), + ), + const Spacer(), + SizedBox( + child: Text( + restaurant.isOpen ? openNowText : closedText, + style: AppTextStyles.openRegularItalic, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: Icon( + Icons.circle, + color: restaurant.isOpen ? Colors.green : Colors.red, + size: 12.0, + ), + ), + ], + ), + ], + ); + } +} diff --git a/lib/src/features/restaurant_tour/presentation/widgets/restaurant_reviews.dart b/lib/src/features/restaurant_tour/presentation/widgets/restaurant_reviews.dart new file mode 100644 index 0000000..75bebb8 --- /dev/null +++ b/lib/src/features/restaurant_tour/presentation/widgets/restaurant_reviews.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; +import 'package:restaurant_tour/src/constants/typography.dart'; +import 'package:restaurant_tour/src/features/restaurant_tour/models/restaurant.dart'; +import 'package:restaurant_tour/src/features/restaurant_tour/presentation/widgets/star_icon.dart'; + +class RestaurantReviews extends StatelessWidget { + const RestaurantReviews({ + super.key, + required this.reviews, + }); + + static const _notFoundImage = + 'https://static.vecteezy.com/system/resources/thumbnails/001/840/612/small_2x/picture-profile-icon-male-icon-human-or-people-sign-and-symbol-free-vector.jpg'; + + final Review reviews; + + Row _buildStarIcons() { + return Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + ...List.generate( + reviews.rating ?? 0, + (index) => const StarIcon(), + ), + ], + ); + } + + Text _buildTextSection() { + return Text( + reviews.text ?? '', + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: AppTextStyles.openRegularHeadline, + ); + } + + Row _buildUserInfo() { + return Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(50), + child: Image.network( + reviews.user!.imageUrl ?? _notFoundImage, + fit: BoxFit.cover, + height: 45, + width: 45, + ), + ), + const SizedBox( + width: 15, + ), + Text( + reviews.user!.name!, + style: AppTextStyles.openRegularText, + ), + ], + ); + } + + Widget _space() { + return const SizedBox(height: 14); + } + + @override + Widget build(BuildContext context) { + return Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + _space(), + _buildStarIcons(), + _space(), + _buildTextSection(), + _space(), + _buildUserInfo(), + ], + ); + } +} diff --git a/lib/src/features/restaurant_tour/presentation/widgets/star_icon.dart b/lib/src/features/restaurant_tour/presentation/widgets/star_icon.dart new file mode 100644 index 0000000..77c2b3e --- /dev/null +++ b/lib/src/features/restaurant_tour/presentation/widgets/star_icon.dart @@ -0,0 +1,13 @@ +import 'package:flutter/material.dart'; + +class StarIcon extends StatelessWidget { + const StarIcon({super.key}); + + @override + Widget build(BuildContext context) { + return const Icon( + Icons.star, + color: Color(0xffffb800), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index f95a63e..a4973d4 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -185,14 +185,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.1" - file: + ffi: dependency: transitive + description: + name: ffi + sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + file: + dependency: "direct main" description: name: file - sha256: b69516f2c26a5bcac4eee2e32512e1a5205ab312b3536c1c1227b2b942b5f9ad + sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c" url: "https://pub.dev" source: hosted - version: "6.1.2" + version: "7.0.0" fixnum: dependency: transitive description: @@ -214,11 +222,24 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" + flutter_riverpod: + dependency: "direct main" + description: + name: flutter_riverpod + sha256: "0f1974eff5bbe774bf1d870e406fc6f29e3d6f1c46bd9c58e7172ff68a785d7d" + url: "https://pub.dev" + source: hosted + version: "2.5.1" flutter_test: dependency: "direct dev" description: flutter source: sdk version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" frontend_server_client: dependency: transitive description: @@ -231,10 +252,10 @@ packages: dependency: transitive description: name: glob - sha256: "8321dd2c0ab0683a91a51307fa844c6db4aa8e3981219b78961672aaab434658" + sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63" url: "https://pub.dev" source: hosted - version: "2.0.2" + version: "2.1.2" graphs: dependency: transitive description: @@ -371,6 +392,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.1" + mockito: + dependency: transitive + description: + name: mockito + sha256: "6841eed20a7befac0ce07df8116c8b8233ed1f4486a7647c7fc5a02ae6163917" + url: "https://pub.dev" + source: hosted + version: "5.4.4" + mocktail: + dependency: "direct main" + description: + name: mocktail + sha256: "890df3f9688106f25755f26b1c60589a92b3ab91a22b8b224947ad041bf172d8" + url: "https://pub.dev" + source: hosted + version: "1.0.4" + network_image_mock: + dependency: "direct main" + description: + name: network_image_mock + sha256: "855cdd01d42440e0cffee0d6c2370909fc31b3bcba308a59829f24f64be42db7" + url: "https://pub.dev" + source: hosted + version: "2.1.1" package_config: dependency: transitive description: @@ -387,6 +432,46 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: "9b71283fc13df574056616011fb138fd3b793ea47cc509c189a6c3fa5f8a1a65" + url: "https://pub.dev" + source: hosted + version: "3.1.5" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" pool: dependency: transitive description: @@ -411,6 +496,70 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.0" + riverpod: + dependency: transitive + description: + name: riverpod + sha256: f21b32ffd26a36555e501b04f4a5dca43ed59e16343f1a30c13632b2351dfa4d + url: "https://pub.dev" + source: hosted + version: "2.5.1" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "746e5369a43170c25816cc472ee016d3a66bc13fcf430c0bc41ad7b4b2922051" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "480ba4345773f56acda9abf5f50bd966f581dac5d514e5fc4a18c62976bbba7e" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: c4b35f6cb8f63c147312c054ce7c2254c8066745125264f0c88739c417fc9d9f + url: "https://pub.dev" + source: hosted + version: "2.5.2" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: d2ca4132d3946fec2184261726b355836a82c33d7d5b67af32692aff18a4684e + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" shelf: dependency: transitive description: @@ -464,6 +613,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.11.1" + state_notifier: + dependency: transitive + description: + name: state_notifier + sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb + url: "https://pub.dev" + source: hosted + version: "1.0.0" stream_channel: dependency: transitive description: @@ -560,6 +717,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: faea9dee56b520b55a566385b84f2e8de55e7496104adada9962e0bd11bcff1d + url: "https://pub.dev" + source: hosted + version: "1.0.4" yaml: dependency: transitive description: @@ -570,4 +735,4 @@ packages: version: "3.1.0" sdks: dart: ">=3.4.0 <4.0.0" - flutter: ">=3.19.6" + flutter: ">=3.22.0" diff --git a/pubspec.yaml b/pubspec.yaml index bc8a205..0f84379 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -5,22 +5,26 @@ publish_to: 'none' version: 1.0.0+1 - environment: sdk: ">=3.1.0 <4.0.0" flutter: ">=3.19.6" dependencies: + file: ^7.0.0 flutter: sdk: flutter + flutter_riverpod: ^2.5.1 http: ^1.2.2 json_annotation: ^4.9.0 + mocktail: ^1.0.4 + network_image_mock: ^2.1.1 + shared_preferences: ^2.3.2 dev_dependencies: + build_runner: ^2.4.10 + flutter_lints: ^4.0.0 flutter_test: sdk: flutter - flutter_lints: ^4.0.0 - build_runner: ^2.4.10 json_serializable: ^6.8.0 flutter: @@ -45,4 +49,3 @@ flutter: style: italic - asset: assets/fonts/OpenSans/OpenSans-SemiBold.ttf weight: 600 - diff --git a/test/src/features/restaurant_tour/domain/repositories/restaurant_repository_impl_test.dart b/test/src/features/restaurant_tour/domain/repositories/restaurant_repository_impl_test.dart new file mode 100644 index 0000000..c93d5e4 --- /dev/null +++ b/test/src/features/restaurant_tour/domain/repositories/restaurant_repository_impl_test.dart @@ -0,0 +1,33 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:restaurant_tour/src/features/restaurant_tour/domain/datasources/restaurants_datasource.dart'; +import 'package:restaurant_tour/src/features/restaurant_tour/infrastructure/repositories/restaurant_repository_impl.dart'; +import 'package:restaurant_tour/src/features/restaurant_tour/models/restaurant.dart'; + +class MockRestaurantsDataSource extends Mock implements RestaurantsDatasource {} + +void main() { + group( + 'RestaurantRepositoryImpl tests', + () { + late RestaurantRepositoryImpl restaurantRepositoryImpl; + late MockRestaurantsDataSource mockRestaurantsDataSource; + + setUp(() { + mockRestaurantsDataSource = MockRestaurantsDataSource(); + restaurantRepositoryImpl = + RestaurantRepositoryImpl(mockRestaurantsDataSource); + }); + + test('getRestaurants call datasource', () async { + const restaurantsResponse = + RestaurantQueryResult(restaurants: [], total: 0); + when(() => mockRestaurantsDataSource.getRestaurants()).thenAnswer( + (_) => Future.value(restaurantsResponse), + ); + final result = await restaurantRepositoryImpl.getRestaurants(); + expect(result.restaurants, restaurantsResponse.restaurants); + }); + }, + ); +} diff --git a/test/src/features/restaurant_tour/infrastructure/datasources/restaurant_api_datasource_test.dart b/test/src/features/restaurant_tour/infrastructure/datasources/restaurant_api_datasource_test.dart new file mode 100644 index 0000000..0a7ffd2 --- /dev/null +++ b/test/src/features/restaurant_tour/infrastructure/datasources/restaurant_api_datasource_test.dart @@ -0,0 +1,45 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:mocktail/mocktail.dart'; +import 'package:restaurant_tour/src/features/restaurant_tour/data/mock.dart'; +import 'package:restaurant_tour/src/features/restaurant_tour/infrastructure/datasources/restaurant_api_datasource.dart'; + +class MockClient extends Mock implements http.Client {} + +void main() { + late MockClient mockClient; + late RestaurantApiDatasource datasource; + + setUp(() { + mockClient = MockClient(); + datasource = RestaurantApiDatasource(); + }); + + group('RestaurantApiDatasource', () { + test( + 'returns RestaurantQueryResult if the http call completes successfully', + () async { + when( + () => mockClient.post( + Uri.parse('https://example.com'), + headers: any(named: 'headers'), + body: any(named: 'body'), + ), + ).thenAnswer( + (_) async => http.Response( + jsonEncode({ + mockQueryResult, + }), + 200, + ), + ); + + final result = await datasource.getRestaurants(); + + expect(result.restaurants, isEmpty); + expect(result.total, 0); + }); + }); +} diff --git a/test/src/features/restaurant_tour/models/restaurant_test.dart b/test/src/features/restaurant_tour/models/restaurant_test.dart new file mode 100644 index 0000000..47c703c --- /dev/null +++ b/test/src/features/restaurant_tour/models/restaurant_test.dart @@ -0,0 +1,141 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:restaurant_tour/src/features/restaurant_tour/models/restaurant.dart'; + +void main() { + group('Category', () { + test('fromJson and toJson should work correctly', () { + final json = {'alias': 'mex', 'title': 'Mexican'}; + final category = Category.fromJson(json); + expect(category.alias, 'mex'); + expect(category.title, 'Mexican'); + expect(category.toJson(), json); + }); + }); + + group('Hours', () { + test('fromJson and toJson should work correctly', () { + final json = {'is_open_now': true}; + final hours = Hours.fromJson(json); + expect(hours.isOpenNow, true); + expect(hours.toJson(), json); + }); + }); + + group('User', () { + test('fromJson and toJson should work correctly', () { + final json = { + 'id': 'user1', + 'image_url': 'http://example.com/img.jpg', + 'name': 'John Doe', + }; + final user = User.fromJson(json); + expect(user.id, 'user1'); + expect(user.imageUrl, 'http://example.com/img.jpg'); + expect(user.name, 'John Doe'); + expect(user.toJson(), json); + }); + }); + + group('Review', () { + test('fromJson and toJson should work correctly', () { + final json = { + 'id': 'review1', + 'rating': 5, + 'text': 'Great place!', + 'user': { + 'id': 'user1', + 'image_url': 'http://example.com/img.jpg', + 'name': 'John Doe', + }, + }; + final review = Review.fromJson(json); + expect(review.id, 'review1'); + expect(review.rating, 5); + expect(review.user?.id, 'user1'); + expect(review.user?.imageUrl, 'http://example.com/img.jpg'); + expect(review.user?.name, 'John Doe'); + }); + }); + + group('Location', () { + test('fromJson and toJson should work correctly', () { + final json = {'formatted_address': '123 Main St, Anytown, USA'}; + final location = Location.fromJson(json); + expect(location.formattedAddress, '123 Main St, Anytown, USA'); + expect(location.toJson(), json); + }); + }); + + group('Restaurant', () { + test('fromJson and toJson should work correctly', () { + final json = { + 'id': 'restaurant1', + 'name': 'Test Restaurant', + 'price': '\$\$\$', + 'rating': 4.5, + 'photos': ['http://example.com/photo1.jpg'], + 'categories': [ + {'alias': 'mex', 'title': 'Mexican'}, + ], + 'hours': [ + {'is_open_now': true}, + ], + 'reviews': [ + { + 'id': 'review1', + 'rating': 5, + 'text': 'Great!', + 'user': {'id': 'user1'}, + } + ], + 'location': {'formatted_address': '123 Main St, Anytown, USA'}, + }; + final restaurant = Restaurant.fromJson(json); + expect(restaurant.id, 'restaurant1'); + expect(restaurant.name, 'Test Restaurant'); + expect(restaurant.price, '\$\$\$'); + expect(restaurant.rating, 4.5); + expect(restaurant.photos, ['http://example.com/photo1.jpg']); + expect(restaurant.categories?.first.alias, 'mex'); + expect(restaurant.categories?.first.title, 'Mexican'); + expect(restaurant.hours?.first.isOpenNow, true); + expect(restaurant.reviews?.first.id, 'review1'); + expect(restaurant.reviews?.first.rating, 5); + expect( + restaurant.location?.formattedAddress, + '123 Main St, Anytown, USA', + ); + }); + + test('displayCategory should return the first category title', () { + final restaurant = Restaurant(categories: [Category(title: 'Mexican')]); + expect(restaurant.displayCategory, 'Mexican'); + }); + + test('heroImage should return the first photo URL', () { + const restaurant = Restaurant(photos: ['http://example.com/photo1.jpg']); + expect(restaurant.heroImage, 'http://example.com/photo1.jpg'); + }); + + test('isOpen should return the status of the first hour', () { + const restaurant = Restaurant(hours: [Hours(isOpenNow: true)]); + expect(restaurant.isOpen, true); + }); + // }); + + group('RestaurantQueryResult', () { + test('fromJson and toJson should work correctly', () { + final json = { + 'total': 10, + 'business': [ + {'id': 'restaurant1', 'name': 'Test Restaurant'}, + ], + }; + final result = RestaurantQueryResult.fromJson(json); + expect(result.total, 10); + expect(result.restaurants?.first.id, 'restaurant1'); + expect(result.restaurants?.first.name, 'Test Restaurant'); + }); + }); + }); +} diff --git a/test/src/features/restaurant_tour/presentation/pages/restaurant_info_page_test.dart b/test/src/features/restaurant_tour/presentation/pages/restaurant_info_page_test.dart new file mode 100644 index 0000000..04103f2 --- /dev/null +++ b/test/src/features/restaurant_tour/presentation/pages/restaurant_info_page_test.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:network_image_mock/network_image_mock.dart'; +import 'package:restaurant_tour/src/features/restaurant_tour/data/mock.dart'; +import 'package:restaurant_tour/src/features/restaurant_tour/models/restaurant.dart'; +import 'package:restaurant_tour/src/features/restaurant_tour/presentation/pages/restaurant_info_page.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class MockSharedPreferences extends Mock implements SharedPreferences {} + +void main() { + late MockSharedPreferences mockPreferences; + late Restaurant testRestaurant; + + setUp(() { + mockPreferences = MockSharedPreferences(); + SharedPreferences.setMockInitialValues({}); + testRestaurant = mockRestaurants[0]; + + when(() => mockPreferences.getBool(testRestaurant.id ?? '')) + .thenReturn(false); + }); + + Future buildPage(WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: RestaurantInfoPage(restaurant: testRestaurant), + ), + ); + } + + group('Restaurant Info Page tests', () { + testWidgets('Should display restaurant name in the AppBar', + (WidgetTester tester) async { + await buildPage(tester); + await mockNetworkImagesFor(() => tester.pumpAndSettle()); + expect(find.text('Pasta Paradise'), findsOneWidget); + }); + + testWidgets('Should display favorite icon as not selected initially', + (WidgetTester tester) async { + await buildPage(tester); + await mockNetworkImagesFor(() => tester.pumpAndSettle()); + final favoriteIcon = find.byIcon(Icons.favorite_outline); + expect(favoriteIcon, findsOneWidget); + }); + + testWidgets('Should toggle favorite status when icon is tapped', + (WidgetTester tester) async { + // Simulate that initially, the restaurant is not a favorite + when(() => mockPreferences.getBool(testRestaurant.id ?? '')) + .thenReturn(false); + when(() => mockPreferences.setBool(testRestaurant.id ?? '', true)) + .thenAnswer((_) async => true); + when(() => mockPreferences.setBool(testRestaurant.id ?? '', false)) + .thenAnswer((_) async => true); + + await buildPage(tester); + + final favoriteIconButton = find.byIcon(Icons.favorite_outline); + expect(favoriteIconButton, findsOneWidget); + + // Tap the favorite icon to toggle the favorite status + await tester.tap(favoriteIconButton); + await mockNetworkImagesFor( + () => tester.pumpAndSettle(), + ); + }); + }); +} diff --git a/test/src/features/restaurant_tour/presentation/providers/restaurants_provider_test.dart b/test/src/features/restaurant_tour/presentation/providers/restaurants_provider_test.dart new file mode 100644 index 0000000..26d7793 --- /dev/null +++ b/test/src/features/restaurant_tour/presentation/providers/restaurants_provider_test.dart @@ -0,0 +1,57 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:restaurant_tour/src/features/restaurant_tour/data/mock.dart'; +import 'package:restaurant_tour/src/features/restaurant_tour/domain/repositories/restaurant_repository.dart'; +import 'package:restaurant_tour/src/features/restaurant_tour/presentation/providers/restaurants_provider.dart'; + +class MockRestaurantRepository extends Mock implements RestaurantRepository {} + +void main() { + group('RestaurantNotifier', () { + late MockRestaurantRepository mockRepository; + + setUp(() { + mockRepository = MockRestaurantRepository(); + }); + test('currentCoffeeImageProvider returns correct CoffeeResponse', () { + var response = mockQueryResult; + + final container = ProviderContainer( + overrides: [], + ); + + container.read(getRestaurantsProvider); + + expect(mockQueryResult, response); + }); + + test('load restaurants correctly', () async { + final notifier = RestaurantsNotifier( + restaurantsCallBack: () { + return Future.value(mockQueryResult); + }, + ); + final response = mockQueryResult; + when(() => mockRepository.getRestaurants()) + .thenAnswer((_) => Future.value(response)); + await notifier.loadRestaurants(); + + expect(notifier.state, response); + }); + + test('get favorite restaurants correctly', () async { + final notifier = RestaurantsNotifier( + restaurantsCallBack: () { + return Future.value(mockQueryResult); + }, + ); + final response = mockQueryResult; + when(() => mockRepository.getRestaurants()) + .thenAnswer((_) => Future.value(response)); + var favorite = notifier.getFavoriteRestaurants('100'); + + expect(notifier.state.restaurants, favorite); + }); + }); +} diff --git a/test/src/features/restaurant_tour/presentation/providers/restaurants_repository_provider_test.dart b/test/src/features/restaurant_tour/presentation/providers/restaurants_repository_provider_test.dart new file mode 100644 index 0000000..9b6c0aa --- /dev/null +++ b/test/src/features/restaurant_tour/presentation/providers/restaurants_repository_provider_test.dart @@ -0,0 +1,20 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:restaurant_tour/src/features/restaurant_tour/infrastructure/datasources/restaurant_api_datasource.dart'; +import 'package:restaurant_tour/src/features/restaurant_tour/infrastructure/repositories/restaurant_repository_impl.dart'; +import 'package:restaurant_tour/src/features/restaurant_tour/presentation/providers/restaurants_repository_provider.dart'; + +void main() { + test( + 'restaurantsRepositoryProvider returns RestaurantRepositoryImpl instance', + () { + final container = ProviderContainer(); + + final repository = container.read(restaurantsRepositoryProvider); + + expect(repository, isA()); + + final datasource = (repository).datasource; + expect(datasource, isA()); + }); +} diff --git a/test/src/features/restaurant_tour/presentation/view/favorite_restaurants_view_test.dart b/test/src/features/restaurant_tour/presentation/view/favorite_restaurants_view_test.dart new file mode 100644 index 0000000..491bbe0 --- /dev/null +++ b/test/src/features/restaurant_tour/presentation/view/favorite_restaurants_view_test.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:network_image_mock/network_image_mock.dart'; +import 'package:restaurant_tour/src/constants/strings.dart'; +import 'package:restaurant_tour/src/features/restaurant_tour/data/mock.dart'; +import 'package:restaurant_tour/src/features/restaurant_tour/presentation/pages/restaurant_info_page.dart'; +import 'package:restaurant_tour/src/features/restaurant_tour/presentation/view/favorite_restaurants_view.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class MockSharedPreferences extends Mock implements SharedPreferences {} + +class MockNavigatorObserver extends Mock implements NavigatorObserver {} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late MockSharedPreferences mockSharedPreferences; + late MockNavigatorObserver mockNavigatorObserver; + + setUp(() { + mockSharedPreferences = MockSharedPreferences(); + mockNavigatorObserver = MockNavigatorObserver(); + SharedPreferences.setMockInitialValues({}); + }); + + group('Favorite Restaurants View tests', () { + testWidgets( + 'FavoriteRestaurantsView displays empty message when no favorites', + (WidgetTester tester) async { + when(() => mockSharedPreferences.getKeys()).thenReturn({}); + when(() => mockSharedPreferences.getBool(any())).thenReturn(false); + + await tester.pumpWidget( + MaterialApp( + home: const Scaffold( + body: FavoriteRestaurantsView(), + ), + navigatorObservers: [mockNavigatorObserver], + ), + ); + + expect(find.byIcon(Icons.restaurant), findsOneWidget); + expect(find.text(sorryText), findsOneWidget); + expect(find.text(noFavoriteRestaurantsText), findsOneWidget); + }, + ); + + testWidgets( + 'Should call _loadFavorites when navigating back with result true', + (WidgetTester tester) async { + when(() => mockSharedPreferences.getKeys()) + .thenReturn({'restaurant_id'}); + when(() => mockSharedPreferences.getBool('restaurant_id')) + .thenReturn(true); + + await mockNetworkImagesFor( + () => tester.pumpWidget( + MaterialApp( + home: const Scaffold( + body: FavoriteRestaurantsView(), + ), + navigatorObservers: [mockNavigatorObserver], + ), + ), + ); + + final testRestaurant = mockRestaurants[0]; + await mockNetworkImagesFor( + () => tester.pumpAndSettle(const Duration(seconds: 2)), + ); + + await mockNetworkImagesFor( + () => tester.pumpWidget( + MaterialApp( + home: RestaurantInfoPage(restaurant: testRestaurant), + ), + ), + ); + Navigator.pop(tester.element(find.byType(RestaurantInfoPage)), true); + await mockNetworkImagesFor( + () => tester.pumpAndSettle(const Duration(seconds: 2)), + ); + }, + ); + }); +} diff --git a/test/src/features/restaurant_tour/presentation/widgets/restaurant_card_test.dart b/test/src/features/restaurant_tour/presentation/widgets/restaurant_card_test.dart new file mode 100644 index 0000000..c5c00dc --- /dev/null +++ b/test/src/features/restaurant_tour/presentation/widgets/restaurant_card_test.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:network_image_mock/network_image_mock.dart'; +import 'package:restaurant_tour/src/features/restaurant_tour/data/mock.dart'; +import 'package:restaurant_tour/src/features/restaurant_tour/presentation/pages/restaurant_info_page.dart'; +import 'package:restaurant_tour/src/features/restaurant_tour/presentation/widgets/restaurant_card.dart'; +import 'package:restaurant_tour/src/features/restaurant_tour/presentation/widgets/restaurant_card_image.dart'; +import 'package:restaurant_tour/src/features/restaurant_tour/presentation/widgets/restaurant_card_info.dart'; + +void main() { + group( + 'Restaurant Card tests', + () { + testWidgets('RestaurantCard displays correctly and navigates on tap', + (WidgetTester tester) async { + final restaurant = mockRestaurants[0]; + + await mockNetworkImagesFor( + () => tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: RestaurantCard(restaurant: restaurant), + ), + ), + ), + ); + + expect(find.text('Pasta Paradise'), findsOneWidget); + expect(find.byType(RestaurantCardImage), findsOneWidget); + expect(find.byType(RestaurantCardInfo), findsOneWidget); + + // Simulate a tap on the RestaurantCard + await tester.tap(find.byType(InkWell)); + await tester.pumpAndSettle(); + + expect(find.byType(RestaurantInfoPage), findsOneWidget); + }); + + testWidgets('RestaurantCard calls onTap callback when tapped', + (WidgetTester tester) async { + final restaurant = mockRestaurants[0]; + + bool wasTapped = false; + + // Build the widget with an onTap callback + await mockNetworkImagesFor( + () => tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: RestaurantCard( + restaurant: restaurant, + onTap: () { + wasTapped = true; + }, + ), + ), + ), + ), + ); + + // Simulate a tap on the RestaurantCard + await tester.tap(find.byType(InkWell)); + await tester.pump(); + expect(wasTapped, true); + }); + }, + ); +} diff --git a/test/src/features/restaurant_tour/presentation/widgets/restaurant_reviews_test.dart b/test/src/features/restaurant_tour/presentation/widgets/restaurant_reviews_test.dart new file mode 100644 index 0000000..eb56ef6 --- /dev/null +++ b/test/src/features/restaurant_tour/presentation/widgets/restaurant_reviews_test.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:network_image_mock/network_image_mock.dart'; +import 'package:restaurant_tour/src/features/restaurant_tour/models/restaurant.dart'; +import 'package:restaurant_tour/src/features/restaurant_tour/presentation/widgets/restaurant_reviews.dart'; +import 'package:restaurant_tour/src/features/restaurant_tour/presentation/widgets/star_icon.dart'; + +void main() { + testWidgets('RestaurantReviews displays correctly', + (WidgetTester tester) async { + const review = Review( + rating: 3, + text: 'Great restaurant!', + user: User( + name: 'John Doe', + imageUrl: 'https://example.com/image.jpg', + ), + ); + + await mockNetworkImagesFor( + () => tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: RestaurantReviews(reviews: review), + ), + ), + ), + ); + + // Verify the number of StarIcons + final starIcons = find.byType(StarIcon); + expect(starIcons, findsNWidgets(3)); + + // Verify the review text is displayed correctly + final textFinder = find.text('Great restaurant!'); + expect(textFinder, findsOneWidget); + + // Verify the user's image is displayed correctly + final imageFinder = find.byType(Image); + expect(imageFinder, findsOneWidget); + + // Verify the user's name is displayed correctly + final nameFinder = find.text('John Doe'); + expect(nameFinder, findsOneWidget); + }); +} diff --git a/test/src/features/restaurant_tour/presentation/widgets/star_icon_test.dart b/test/src/features/restaurant_tour/presentation/widgets/star_icon_test.dart new file mode 100644 index 0000000..565dad0 --- /dev/null +++ b/test/src/features/restaurant_tour/presentation/widgets/star_icon_test.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:restaurant_tour/src/features/restaurant_tour/presentation/widgets/star_icon.dart'; + +void main() { + testWidgets('StarIcon renders correctly', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: StarIcon(), + ), + ), + ); + + final iconFinder = find.byType(Icon); + + expect(iconFinder, findsOneWidget); + + final Icon icon = tester.widget(iconFinder) as Icon; + expect(icon.icon, Icons.star); + expect(icon.color, const Color(0xffffb800)); + }); +} 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); - }); -}