From b28ff34598107fc4f1fbdfd37432155e8e8d704e Mon Sep 17 00:00:00 2001 From: Jackson Cuevas Date: Wed, 14 Feb 2024 19:55:53 -0400 Subject: [PATCH 01/12] =?UTF-8?q?feat:=20Created=20the=20home=20view=20to?= =?UTF-8?q?=20show=20the=20tabs.=20I=E2=80=99m=20using=20MVVM.=20=20BREAKI?= =?UTF-8?q?NG=20CHANGE:=20I=20need=20to=20remove=20the=20homePage=20with?= =?UTF-8?q?=20the=20Fetch=20button=20because=20I=20reached=20the=20limit?= =?UTF-8?q?=20from=20Yelp=20GraphQL=20API.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/common/utils.dart | 66 +++++++++++++++++++ .../home/view_models/home_view_model.dart | 19 ++++++ lib/modules/home/views/home_view.dart | 64 ++++++++++++++++++ 3 files changed, 149 insertions(+) create mode 100644 lib/common/utils.dart create mode 100644 lib/modules/home/view_models/home_view_model.dart create mode 100644 lib/modules/home/views/home_view.dart diff --git a/lib/common/utils.dart b/lib/common/utils.dart new file mode 100644 index 00000000..5e44c9b6 --- /dev/null +++ b/lib/common/utils.dart @@ -0,0 +1,66 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:path_provider/path_provider.dart'; + +import '../../../models/restaurant.dart'; + +class Utils { + static Future get _localPath async { + final directory = await getApplicationDocumentsDirectory(); + return directory.path; + } + + static Future get _localFile async { + final path = await _localPath; + return File('$path/favorites.json'); + } + + static Future> loadFavorites() async { + try { + final file = await _localFile; + if (await file.exists()) { + final contents = await file.readAsString(); + final List jsonData = json.decode(contents); + return jsonData.map((item) => Restaurant.fromJson(item)).toList(); + } + return []; + } catch (e) { + // Handle the exception, possibly logging or showing a message to the user + return []; + } + } + + static Future addFavorite(Restaurant restaurant) async { + final favorites = await loadFavorites(); + if (!favorites.any((element) => element.id == restaurant.id)) { + favorites.add(restaurant); + await _saveToFile(favorites); + } + } + + static Future removeFavorite(String id) async { + final favorites = await loadFavorites(); + favorites.removeWhere((restaurant) => restaurant.id == id); + await _saveToFile(favorites); + } + + static Future _saveToFile(List restaurants) async { + final file = await _localFile; + final String jsonString = + json.encode(restaurants.map((e) => e.toJson()).toList()); + await file.writeAsString(jsonString); + } + + static Future toggleFavorite(Restaurant restaurant) async { + final favorites = await loadFavorites(); + final existingIndex = favorites.indexWhere((r) => r.id == restaurant.id); + + if (existingIndex >= 0) { + // Restaurant is already a favorite, remove it + await removeFavorite(restaurant.id!); + } else { + // Restaurant is not a favorite, add it + await addFavorite(restaurant); + } + } +} diff --git a/lib/modules/home/view_models/home_view_model.dart b/lib/modules/home/view_models/home_view_model.dart new file mode 100644 index 00000000..be64d59c --- /dev/null +++ b/lib/modules/home/view_models/home_view_model.dart @@ -0,0 +1,19 @@ +import 'package:flutter/material.dart'; +import 'package:stacked/stacked.dart'; + +class HomeViewModel extends BaseViewModel { + late BuildContext context; + HomeViewModel(this.context); + + bool _isLoading = false; + bool get isLoading => _isLoading; + set isLoading(bool value) { + _isLoading = value; + notifyListeners(); + } + + ready() async { + isLoading = true; + isLoading = false; + } +} diff --git a/lib/modules/home/views/home_view.dart b/lib/modules/home/views/home_view.dart new file mode 100644 index 00000000..da87bf04 --- /dev/null +++ b/lib/modules/home/views/home_view.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; +import 'package:restaurantour/common/constants.dart'; +import 'package:stacked/stacked.dart'; + +import '../../restaurant/views/favorites_restaurants_list_view.dart'; +import '../../restaurant/views/restaurants_list_view.dart'; +import '../view_models/home_view_model.dart'; + +class HomeView extends StatefulWidget { + const HomeView({Key? key}) : super(key: key); + + @override + State createState() => _HomeViewState(); +} + +class _HomeViewState extends State + with SingleTickerProviderStateMixin { + late TabController _tabController; + + @override + void initState() { + super.initState(); + _tabController = TabController(vsync: this, length: 2); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ViewModelBuilder.reactive( + viewModelBuilder: () => HomeViewModel(context), + onViewModelReady: (HomeViewModel viewModel) => viewModel.ready(), + builder: (context, HomeViewModel viewModel, child) => Scaffold( + appBar: AppBar( + title: const Text(Constants.appName), + bottom: TabBar( + indicatorSize: TabBarIndicatorSize.label, + controller: _tabController, + tabAlignment: TabAlignment.center, + tabs: const [ + Tab( + text: Constants.allrestaurants, + ), + Tab( + text: Constants.myFavorites, + ), + ], + ), + ), + body: TabBarView( + controller: _tabController, + children: const [ + RestaurantsListView(), + FavoritesRestaurantsListView() + ], + ), + ), + ); + } +} From dbf10f47ace96062915436918ce1fb4c14fb2b0e Mon Sep 17 00:00:00 2001 From: Jackson Cuevas Date: Wed, 14 Feb 2024 19:57:09 -0400 Subject: [PATCH 02/12] =?UTF-8?q?=20feat:=20Created=20the=20restaurant=20l?= =?UTF-8?q?ist=20view,=20view=20model=20to=20show=20the=20list=20of=20the?= =?UTF-8?q?=20restaurants.=20I=E2=80=99m=20using=20MVVM.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../view_models/restaurant_view_model.dart | 41 ++++++ .../favorites_restaurants_list_view.dart | 59 ++++++++ .../views/restaurants_list_view.dart | 67 +++++++++ lib/widgets/restaurant_card_widget.dart | 135 ++++++++++++++++++ 4 files changed, 302 insertions(+) create mode 100644 lib/modules/restaurant/view_models/restaurant_view_model.dart create mode 100644 lib/modules/restaurant/views/favorites_restaurants_list_view.dart create mode 100644 lib/modules/restaurant/views/restaurants_list_view.dart create mode 100644 lib/widgets/restaurant_card_widget.dart diff --git a/lib/modules/restaurant/view_models/restaurant_view_model.dart b/lib/modules/restaurant/view_models/restaurant_view_model.dart new file mode 100644 index 00000000..20b0c67e --- /dev/null +++ b/lib/modules/restaurant/view_models/restaurant_view_model.dart @@ -0,0 +1,41 @@ +import 'package:restaurantour/models/restaurant.dart'; +import 'package:stacked/stacked.dart'; + +import '../../../repositories/yelp_repository.dart'; + +class RestaurantViewModel extends BaseViewModel { + YelpRepository yelpRepo; + + Set _favoriteRestaurantIds = {}; + Set get favoriteRestaurantIds => _favoriteRestaurantIds; + + List? restaurants; + + RestaurantViewModel({required this.yelpRepo}); + + ready() async {} + + Future> fetchData() async { + try { + final result = await yelpRepo.getRestaurants(); + return result!.restaurants!; + } catch (e) { + throw Exception(e); + } + } + + Future toggleFavorite(String restaurantId) async { + if (_favoriteRestaurantIds.contains(restaurantId)) { + _favoriteRestaurantIds.remove(restaurantId); + } else { + _favoriteRestaurantIds.add(restaurantId); + } + notifyListeners(); + + // final prefs = await SharedPreferences.getInstance(); + // await prefs.setStringList('favorites', _favoriteRestaurantIds.toList()); + } + + bool isFavorite(String restaurantId) => + _favoriteRestaurantIds.contains(restaurantId); +} diff --git a/lib/modules/restaurant/views/favorites_restaurants_list_view.dart b/lib/modules/restaurant/views/favorites_restaurants_list_view.dart new file mode 100644 index 00000000..52eef350 --- /dev/null +++ b/lib/modules/restaurant/views/favorites_restaurants_list_view.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import 'package:restaurantour/common/utils.dart'; + +import '../../../models/restaurant.dart'; +import '../../../widgets/restaurant_card_widget.dart'; +import 'restaurant_detail_view.dart'; + +class FavoritesRestaurantsListView extends StatelessWidget { + const FavoritesRestaurantsListView({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return FutureBuilder>( + future: Utils.loadFavorites(), + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const Center( + child: CircularProgressIndicator( + color: Colors.black, + )); + } + + if (snapshot.hasData) { + return ListView.builder( + itemCount: snapshot.data!.length, + itemBuilder: (context, index) { + var restaurant = snapshot.data![index]; + return InkWell( + onTap: () => { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => RestaurantDetailView( + restaurantIndex: index, + restaurant: restaurant, + ), + ), + ), + }, + child: RestaurantCardWidget( + restaurantIndex: index, + restaurant: restaurant, + ), + ); + }, + ); + } + + if (snapshot.hasError) { + return Center(child: Text('Error: ${snapshot.error}')); + } + + return const Center( + child: Text('Press a button to start fetching data'), + ); + }, + ); + } +} diff --git a/lib/modules/restaurant/views/restaurants_list_view.dart b/lib/modules/restaurant/views/restaurants_list_view.dart new file mode 100644 index 00000000..6f93c7ee --- /dev/null +++ b/lib/modules/restaurant/views/restaurants_list_view.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; +import 'package:stacked/stacked.dart'; + +import '../../../models/restaurant.dart'; +import '../../../repositories/yelp_repository.dart'; +import '../../../widgets/restaurant_card_widget.dart'; +import '../view_models/restaurant_view_model.dart'; +import 'restaurant_detail_view.dart'; + +class RestaurantsListView extends StatelessWidget { + const RestaurantsListView({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return ViewModelBuilder.reactive( + viewModelBuilder: () => RestaurantViewModel(yelpRepo: YelpRepository()), + onViewModelReady: (RestaurantViewModel viewModel) => viewModel.ready(), + builder: (context, RestaurantViewModel viewModel, child) => + FutureBuilder>( + future: viewModel.fetchData(), + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const Center( + child: CircularProgressIndicator( + color: Colors.black, + ), + ); + } + + if (snapshot.hasData) { + return ListView.builder( + itemCount: snapshot.data!.length, + itemBuilder: (context, index) { + var restaurant = snapshot.data![index]; + return InkWell( + onTap: () => { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => RestaurantDetailView( + restaurantIndex: index, + restaurant: restaurant, + ), + ), + ), + }, + child: RestaurantCardWidget( + restaurantIndex: index, + restaurant: restaurant, + ), + ); + }, + ); + } + + if (snapshot.hasError) { + return Center(child: Text('Error: ${snapshot.error}')); + } + + return const Center( + child: Text('Press a button to start fetching data'), + ); + }, + ), + ); + } +} diff --git a/lib/widgets/restaurant_card_widget.dart b/lib/widgets/restaurant_card_widget.dart new file mode 100644 index 00000000..c7d076f0 --- /dev/null +++ b/lib/widgets/restaurant_card_widget.dart @@ -0,0 +1,135 @@ +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:restaurantour/common/constants.dart'; +import 'package:restaurantour/models/restaurant.dart'; + +class RestaurantCardWidget extends StatelessWidget { + final int restaurantIndex; + + final Restaurant restaurant; + + const RestaurantCardWidget({ + Key? key, + required this.restaurantIndex, + required this.restaurant, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Card( + elevation: 1, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10.0), + ), + child: Container( + width: 350, + height: 135, + padding: const EdgeInsets.all(16.0), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8.0), + child: Hero( + tag: 'hero-restaurant-image-$restaurantIndex', + child: Image.network( + restaurant.heroImage, + width: 100, + height: 120, + fit: BoxFit.cover, + ), + ), + ), + const SizedBox(width: 16.0), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + restaurant.name!, + style: const TextStyle( + fontSize: 16.0, + fontWeight: FontWeight.bold, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0), + child: Text( + '\$${restaurant.price}', + style: const TextStyle( + fontSize: 14.0, + color: Colors.grey, + ), + ), + ), + const Gap(20), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: List.generate(5, (index) { + return const Icon( + Icons.star, + color: Colors.amber, + size: 20.0, + ); + }), + ), + restaurant.isOpen + ? Row( + children: [ + const Text( + Constants.openNow, + style: TextStyle( + color: Colors.black, + fontWeight: FontWeight.normal, + ), + ), + const SizedBox(width: 4.0), + Container( + width: 10.0, + height: 10.0, + decoration: const BoxDecoration( + color: Colors.green, + shape: BoxShape.circle, + ), + ), + ], + ) + : Row( + children: [ + const Text( + Constants.closed, + style: TextStyle( + color: Colors.black, + fontWeight: FontWeight.normal, + ), + ), + const SizedBox(width: 4.0), + Container( + width: 10.0, + height: 10.0, + decoration: const BoxDecoration( + color: Colors.red, + shape: BoxShape.circle, + ), + ), + ], + ), + ], + ), + ], + ), + ], + ), + ), + ], + ), + ), + ); + } +} From 79dfa43dabded2d3e4645dbef248f3e7413e0cb0 Mon Sep 17 00:00:00 2001 From: Jackson Cuevas Date: Wed, 14 Feb 2024 19:58:59 -0400 Subject: [PATCH 03/12] feat: Added the theme file for a global design and constants file for strings BREAKING CHANGE: I removed the default theme design from the project --- lib/common/app_theme.dart | 31 +++++++++++++++++++++++++++++++ lib/common/constants.dart | 9 +++++++++ lib/main.dart | 13 ++++++++----- 3 files changed, 48 insertions(+), 5 deletions(-) create mode 100644 lib/common/app_theme.dart create mode 100644 lib/common/constants.dart diff --git a/lib/common/app_theme.dart b/lib/common/app_theme.dart new file mode 100644 index 00000000..b7e2a042 --- /dev/null +++ b/lib/common/app_theme.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; + +final ThemeData lightTheme = ThemeData( + brightness: Brightness.light, + primaryColor: Colors.white, + fontFamily: 'Montserrat', + textTheme: const TextTheme( + displayLarge: TextStyle(fontSize: 22.0, fontWeight: FontWeight.bold), + titleLarge: TextStyle(fontSize: 16.0, fontStyle: FontStyle.italic), + bodyMedium: TextStyle(fontSize: 14.0, fontFamily: 'Hind'), + ), + appBarTheme: const AppBarTheme( + color: Colors.white, + elevation: 0, + iconTheme: IconThemeData(color: Colors.black), + titleTextStyle: TextStyle( + color: Colors.black, fontSize: 20.0, fontWeight: FontWeight.bold), + ), + tabBarTheme: const TabBarTheme( + labelColor: Colors.black, + unselectedLabelColor: Colors.grey, + indicator: UnderlineTabIndicator( + borderSide: BorderSide(color: Colors.black, width: 2.0), + ), + ), + cardTheme: CardTheme( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15.0)), + elevation: 4.0, + margin: const EdgeInsets.all(10.0), + ), +); diff --git a/lib/common/constants.dart b/lib/common/constants.dart new file mode 100644 index 00000000..14a86659 --- /dev/null +++ b/lib/common/constants.dart @@ -0,0 +1,9 @@ +abstract class Constants { + static const appName = 'RestauranTour'; + static const allrestaurants = 'All Restaurant'; + static const myFavorites = 'My Favorites'; + static const openNow = 'Open Now'; + static const closed = 'Closed'; + static const address = 'Address'; + static const overallRating = 'Overall Rating'; +} diff --git a/lib/main.dart b/lib/main.dart index c6ce7473..7e6ca6ae 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,7 +1,12 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; +import 'package:restaurantour/modules/home/views/home_view.dart'; import 'package:restaurantour/repositories/yelp_repository.dart'; -void main() { +import 'common/app_theme.dart'; + +void main() async { runApp(const Restaurantour()); } @@ -13,10 +18,8 @@ class Restaurantour extends StatelessWidget { Widget build(BuildContext context) { return MaterialApp( title: 'RestauranTour', - theme: ThemeData( - visualDensity: VisualDensity.adaptivePlatformDensity, - ), - home: const HomePage(), + theme: lightTheme, + home: const HomeView(), ); } } From 3b87babf95084c0228457db5fe42c4cd0c781a51 Mon Sep 17 00:00:00 2001 From: Jackson Cuevas Date: Wed, 14 Feb 2024 20:01:20 -0400 Subject: [PATCH 04/12] feat: Create the restaurant details with the ability to add favorite restaurants --- .../views/restaurant_detail_view.dart | 165 ++++++++++++++++++ lib/widgets/review_item_widget.dart | 41 +++++ 2 files changed, 206 insertions(+) create mode 100644 lib/modules/restaurant/views/restaurant_detail_view.dart create mode 100644 lib/widgets/review_item_widget.dart diff --git a/lib/modules/restaurant/views/restaurant_detail_view.dart b/lib/modules/restaurant/views/restaurant_detail_view.dart new file mode 100644 index 00000000..f2906d4e --- /dev/null +++ b/lib/modules/restaurant/views/restaurant_detail_view.dart @@ -0,0 +1,165 @@ +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:restaurantour/common/constants.dart'; + +import '../../../common/utils.dart'; +import '../../../models/restaurant.dart'; +import '../../../widgets/review_item_widget.dart'; + +class RestaurantDetailView extends StatefulWidget { + final int restaurantIndex; + final Restaurant restaurant; + + const RestaurantDetailView({ + Key? key, + required this.restaurantIndex, + required this.restaurant, + }) : super(key: key); + + @override + State createState() => _RestaurantDetailViewState(); +} + +class _RestaurantDetailViewState extends State { + late bool isFavorite = false; + + @override + void initState() { + super.initState(); + _checkFavoriteStatus(); + } + + Future _checkFavoriteStatus() async { + final favorites = await Utils.loadFavorites(); + setState(() { + isFavorite = favorites.any((r) => r.id == widget.restaurant.id); + }); + } + + Future _toggleFavorite() async { + await Utils.toggleFavorite(widget.restaurant); + _checkFavoriteStatus(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(widget.restaurant.name!), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () { + Navigator.pop(context); + }, + ), + actions: [ + IconButton( + icon: Icon( + isFavorite ? Icons.favorite : Icons.favorite_border, + color: isFavorite ? Colors.red : Colors.black, + ), + onPressed: _toggleFavorite, + ), + ], + ), + body: ListView( + children: [ + Hero( + tag: 'hero-restaurant-image-${widget.restaurantIndex}', + child: Image.network( + widget.restaurant.heroImage, + width: 300, + height: 300, + fit: BoxFit.cover, + ), + ), + ListTile( + title: Row( + children: [ + Text('\$${widget.restaurant.price}'), + const Gap(5), + Text(widget.restaurant.displayCategory), + ], + ), + trailing: widget.restaurant.isOpen + ? SizedBox( + width: 100, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + const Text( + Constants.openNow, + ), + Container( + width: 10.0, + height: 10.0, + decoration: const BoxDecoration( + color: Colors.green, + shape: BoxShape.circle, + ), + ), + ], + ), + ) + : SizedBox( + width: 100, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + const Text( + Constants.closed, + ), + Container( + width: 10.0, + height: 10.0, + decoration: const BoxDecoration( + color: Colors.red, + shape: BoxShape.circle, + ), + ), + ], + ), + ), + ), + const Divider(), + ListTile( + title: const Text(Constants.address), + subtitle: Text(widget.restaurant.location!.formattedAddress!), + ), + ListTile( + title: const Text(Constants.overallRating), + subtitle: Row( + children: [ + Text( + widget.restaurant.rating.toString(), + style: const TextStyle( + fontSize: 34, + fontWeight: FontWeight.bold, + color: Colors.black, + ), + ), + const Icon(Icons.star, color: Colors.amber), + ], + ), + ), + const Divider(), + SizedBox( + height: 400, + child: ListView.builder( + itemCount: widget.restaurant.reviews!.length, + itemBuilder: (context, index) { + var review = widget.restaurant.reviews![index]; + return ReviewListTile( + stars: review.rating!, + reviewText: review.user!.id!, + userName: review.user!.name!, + userImageUrl: review.user!.imageUrl!, + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/review_item_widget.dart b/lib/widgets/review_item_widget.dart new file mode 100644 index 00000000..b4273bfd --- /dev/null +++ b/lib/widgets/review_item_widget.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; + +class ReviewListTile extends StatelessWidget { + final int stars; + final String reviewText; + final String userName; + final String userImageUrl; + + const ReviewListTile({ + Key? key, + required this.stars, + required this.reviewText, + required this.userName, + required this.userImageUrl, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return ListTile( + leading: CircleAvatar( + backgroundImage: NetworkImage(userImageUrl), + ), + title: Row( + mainAxisSize: MainAxisSize.min, + children: List.generate( + stars, + (_) => const Icon(Icons.star, color: Colors.amber, size: 20.0), + ), + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(reviewText), + const Gap(5), + Text(userName), + ], + ), + ); + } +} From 949dd71b102166bcc44bc3829e194b842d86439c Mon Sep 17 00:00:00 2001 From: Jackson Cuevas Date: Wed, 14 Feb 2024 20:02:36 -0400 Subject: [PATCH 05/12] feat: Some units and integrations test for Restaurant View Model and Restaurant Detail for testing widgets --- test/MockYelpRepository.dart | 155 +++++++++++++++++++++++++++++ test/RestaurantViewModel_test.dart | 23 +++++ test/restaurant_detail_test.dart | 55 ++++++++++ 3 files changed, 233 insertions(+) create mode 100644 test/MockYelpRepository.dart create mode 100644 test/RestaurantViewModel_test.dart create mode 100644 test/restaurant_detail_test.dart diff --git a/test/MockYelpRepository.dart b/test/MockYelpRepository.dart new file mode 100644 index 00000000..dd53f903 --- /dev/null +++ b/test/MockYelpRepository.dart @@ -0,0 +1,155 @@ +import 'package:mockito/mockito.dart'; +import 'package:restaurantour/models/restaurant.dart'; +import 'package:restaurantour/repositories/yelp_repository.dart'; + +class MockYelpRepository extends Mock implements YelpRepository { + @override + var data = { + "data": { + "search": { + "total": 5056, + "business": [ + { + "id": "faPVqws-x-5k2CQKDNtHxw", + "name": "Yardbird Southern Table & Bar", + "price": "\$\$", + "rating": 4.5, + "photos": [ + "https://i.pravatar.cc", + ], + "location": { + "formatted_address": '102 Lakeside Ave Seattle, WA 98122', + }, + "reviews": [ + { + "id": "sjZoO8wcK1NeGJFDk5i82Q", + "rating": 5, + "user": { + "id": "BuBCkWFNT_O2dbSnBZvpoQ", + "image_url": "https://i.pravatar.cc", + "name": "Gina T.", + }, + }, + { + "id": "okpO9hfpxQXssbTZTKq9hA", + "rating": 5, + "user": { + "id": "0x9xu_b0Ct_6hG6jaxpztw", + "image_url": "https://i.pravatar.cc", + "name": "Crystal L.", + }, + } + ], + }, + { + "id": "faPVqws-x-5k2CQKDNtHxw", + "name": "Yardbird Southern Table & Bar", + "price": "\$\$", + "rating": 4.5, + "photos": [ + "https://i.pravatar.cc", + ], + "location": { + "formatted_address": '102 Lakeside Ave\nSeattle, WA 98122', + }, + "reviews": [ + { + "id": "sjZoO8wcK1NeGJFDk5i82Q", + "rating": 5, + "user": { + "id": "BuBCkWFNT_O2dbSnBZvpoQ", + "image_url": "https://i.pravatar.cc", + "name": "Gina T.", + }, + }, + { + "id": "okpO9hfpxQXssbTZTKq9hA", + "rating": 5, + "user": { + "id": "0x9xu_b0Ct_6hG6jaxpztw", + "image_url": "https://i.pravatar.cc", + "name": "Crystal L.", + }, + } + ], + }, + { + "id": "faPVqws-x-5k2CQKDNtHxw", + "name": "Yardbird Southern Table & Bar", + "price": "\$\$", + "rating": 4.5, + "photos": [ + "https://i.pravatar.cc", + ], + "location": { + "formatted_address": '102 Lakeside Ave\nSeattle, WA 98122', + }, + "reviews": [ + { + "id": "sjZoO8wcK1NeGJFDk5i82Q", + "rating": 5, + "user": { + "id": "BuBCkWFNT_O2dbSnBZvpoQ", + "image_url": "https://i.pravatar.cc", + "name": "Gina T.", + }, + }, + { + "id": "okpO9hfpxQXssbTZTKq9hA", + "rating": 5, + "user": { + "id": "0x9xu_b0Ct_6hG6jaxpztw", + "image_url": "https://i.pravatar.cc", + "name": "Crystal L.", + }, + } + ], + }, + { + "id": "faPVqws-x-5k2CQKDNtHxw", + "name": "Yardbird Southern Table & Bar", + "price": "\$\$", + "rating": 4.5, + "photos": [ + "https://i.pravatar.cc", + ], + "location": { + "formatted_address": '102 Lakeside Ave\nSeattle, WA 98122', + }, + "reviews": [ + { + "id": "sjZoO8wcK1NeGJFDk5i82Q", + "rating": 5, + "user": { + "id": "BuBCkWFNT_O2dbSnBZvpoQ", + "image_url": "https://i.pravatar.cc", + "name": "Gina T.", + }, + }, + { + "id": "okpO9hfpxQXssbTZTKq9hA", + "rating": 5, + "user": { + "id": "0x9xu_b0Ct_6hG6jaxpztw", + "image_url": "https://i.pravatar.cc", + "name": "Crystal L.", + }, + } + ], + } + ], + }, + }, + }; + + @override + Future getRestaurants({int offset = 0}) async { + try { + return RestaurantQueryResult.fromJson( + data['data']!['search'] as Map, + ); + } catch (e) { + return null; + } + } +} diff --git a/test/RestaurantViewModel_test.dart b/test/RestaurantViewModel_test.dart new file mode 100644 index 00000000..67311256 --- /dev/null +++ b/test/RestaurantViewModel_test.dart @@ -0,0 +1,23 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:restaurantour/models/restaurant.dart'; + +import 'package:restaurantour/modules/restaurant/view_models/restaurant_view_model.dart'; + +import 'MockYelpRepository.dart'; + +void main() { + group('RestaurantViewModel Test', () { + // Create a mock YelpRepository instance + final mockYelpRepo = MockYelpRepository(); + final viewModel = RestaurantViewModel(yelpRepo: mockYelpRepo); + + test('fetchData returns a list of restaurants', () async { + // Fetch data + final restaurants = await viewModel.fetchData(); + + // Assert that a list of restaurants is returned + expect(restaurants, isA>()); + expect(restaurants.isNotEmpty, isTrue); + }); + }); +} diff --git a/test/restaurant_detail_test.dart b/test/restaurant_detail_test.dart new file mode 100644 index 00000000..f357e7dc --- /dev/null +++ b/test/restaurant_detail_test.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:restaurantour/models/restaurant.dart'; +import 'package:restaurantour/modules/restaurant/views/restaurant_detail_view.dart'; +import 'package:restaurantour/widgets/review_item_widget.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + // Mock restaurant data + final Restaurant mockRestaurant = Restaurant( + name: "Mock Restaurant", + price: "30-50", + photos: ["https://i.pravatar.cc", "https://i.pravatar.cc"], + location: Location(formattedAddress: "123 Mock Street"), + rating: 4.5, + reviews: [ + const Review( + rating: 5, + user: User(name: "John Doe", imageUrl: "https://i.pravatar.cc"), + ), + ], + ); + + testWidgets('RestaurantDetailView displays restaurant data correctly', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: RestaurantDetailView( + restaurantIndex: 1, + restaurant: mockRestaurant, + ), + ), + ); + + // Verify that the restaurant's name, price, open status, and address are displayed. + expect(find.text('Mock Restaurant'), findsOneWidget); + expect(find.text('\$ 30-50'), findsOneWidget); + expect(find.text('123 Mock Street'), findsOneWidget); + expect(find.text('4.5'), findsOneWidget); + expect(find.byType(Icon), findsWidgets); + expect(find.byType(Hero), findsOneWidget); + // expect(find.byType(Image), findsWidgets); + expect( + find.byType(ReviewListTile), + findsNWidgets(mockRestaurant.reviews!.length), + ); + + expect(find.byIcon(Icons.favorite_border), findsOneWidget); + + final Hero heroWidget = tester.firstWidget(find.byType(Hero)) as Hero; + expect(heroWidget.tag, equals('hero-restaurant-image-1')); + }); +} From dcde9c0d10bb223596370bfb671f2c1fc617eb19 Mon Sep 17 00:00:00 2001 From: Jackson Cuevas Date: Wed, 14 Feb 2024 20:04:06 -0400 Subject: [PATCH 06/12] feat: Updated the pubspec tile with the necessaries packages for this test --- pubspec.yaml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index be3055e0..4a4ccbcc 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -10,13 +10,19 @@ environment: sdk: ">=2.12.0 <3.0.0" dependencies: + integration_test: + sdk: flutter flutter: sdk: flutter cupertino_icons: ^1.0.6 dio: ^5.4.0 json_annotation: ^4.8.1 flutter_svg: ^2.0.9 - + gap: ^3.0.1 + stacked: ^3.4.2 + mockito: ^5.4.4 + path_provider: ^2.0.0 + dev_dependencies: flutter_test: sdk: flutter From c301ca7d3c0431c200413b3442442dad7041d55c Mon Sep 17 00:00:00 2001 From: Jackson Cuevas Date: Wed, 14 Feb 2024 20:05:22 -0400 Subject: [PATCH 07/12] =?UTF-8?q?feat:=20Updated=20the=20yelp=20repository?= =?UTF-8?q?=20and=20using=20the=20data=20examples=20because=20I=20didn?= =?UTF-8?q?=E2=80=99t=20know=20the=20yelp=20had=20limit=20request.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/repositories/yelp_repository.dart | 208 ++++++++++++++++++++------ 1 file changed, 165 insertions(+), 43 deletions(-) diff --git a/lib/repositories/yelp_repository.dart b/lib/repositories/yelp_repository.dart index f251d7b4..dc966bf5 100644 --- a/lib/repositories/yelp_repository.dart +++ b/lib/repositories/yelp_repository.dart @@ -2,7 +2,8 @@ import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; import 'package:restaurantour/models/restaurant.dart'; -const _apiKey = ''; +const _apiKey = + 'zdiwGUIV61NgdbWT-kCTA3VrkSxhHefvqX7JkfA_7QrtplqQlsHOoNVGcZGEdEjU5Q4ehGtbZt3nh_6fzAei1bFWjn6vW_HQirTRtKqvla1jG5hCwmbY-cb0GADNZXYx'; class YelpRepository { late Dio dio; @@ -20,51 +21,172 @@ class YelpRepository { ), ); - /// Returns a response in this shape - /// { - /// "data": { - /// "search": { - /// "total": 5056, - /// "business": [ - /// { - /// "id": "faPVqws-x-5k2CQKDNtHxw", - /// "name": "Yardbird Southern Table & Bar", - /// "price": "$$", - /// "rating": 4.5, - /// "photos": [ - /// "https:///s3-media4.fl.yelpcdn.com/bphoto/_zXRdYX4r1OBfF86xKMbDw/o.jpg" - /// ], - /// "reviews": [ - /// { - /// "id": "sjZoO8wcK1NeGJFDk5i82Q", - /// "rating": 5, - /// "user": { - /// "id": "BuBCkWFNT_O2dbSnBZvpoQ", - /// "image_url": "https:///s3-media2.fl.yelpcdn.com/photo/v8tbTjYaFvkzh1d7iE-pcQ/o.jpg", - /// "name": "Gina T." - /// } - /// }, - /// { - /// "id": "okpO9hfpxQXssbTZTKq9hA", - /// "rating": 5, - /// "user": { - /// "id": "0x9xu_b0Ct_6hG6jaxpztw", - /// "image_url": "https:///s3-media3.fl.yelpcdn.com/photo/gjz8X6tqE3e4praK4HfCiA/o.jpg", - /// "name": "Crystal L." - /// } - /// }, - /// ... - /// ] - /// } - /// } - /// + var data = { + "data": { + "search": { + "total": 5056, + "business": [ + { + "id": "faPVqws-x-5k2CQKDNtHxw", + "name": "Yardbird Southern Table & Bar", + "price": "120.00", + "rating": 4.5, + "location": { + "formatted_address": '102 Lakeside Ave Seattle, WA 98122', + }, + "photos": [ + "https://i.pravatar.cc", + "https://i.pravatar.cc", + ], + "categories": [ + {"alias": "Italian", "title": "Italian"}, + ], + "reviews": [ + { + "id": "sjZoO8wcK1NeGJFDk5i82Q", + "rating": 5, + "user": { + "id": "BuBCkWFNT_O2dbSnBZvpoQ", + "image_url": "https://i.pravatar.cc", + "name": "Gina T.", + }, + }, + { + "id": "okpO9hfpxQXssbTZTKq9hA", + "rating": 5, + "user": { + "id": "0x9xu_b0Ct_6hG6jaxpztw", + "image_url": "https://i.pravatar.cc", + "name": "Crystal L.", + }, + } + ], + }, + { + "id": "faPVqws-x-5k2CQKDNtHxf", + "name": "Yardbird Southern Table & Bar", + "price": "120.00", + "rating": 4.5, + "photos": [ + "https://i.pravatar.cc", + "https://i.pravatar.cc", + ], + "categories": [ + {"alias": "Italian", "title": "Italian"}, + ], + "location": { + "formatted_address": '102 Lakeside Ave\nSeattle, WA 98122', + }, + "reviews": [ + { + "id": "sjZoO8wcK1NeGJFDk5i82Q", + "rating": 5, + "user": { + "id": "BuBCkWFNT_O2dbSnBZvpoQ", + "image_url": "https://i.pravatar.cc", + "name": "Gina T.", + }, + }, + { + "id": "okpO9hfpxQXssbTZTKq9hA", + "rating": 5, + "user": { + "id": "0x9xu_b0Ct_6hG6jaxpztw", + "image_url": "https://i.pravatar.cc", + "name": "Crystal L.", + }, + } + ], + }, + { + "id": "faPVqws-x-5k2CQKDNtHxg", + "name": "Yardbird Southern Table & Bar", + "price": "120.00", + "rating": 4.5, + "photos": [ + "https://i.pravatar.cc", + "https://i.pravatar.cc", + ], + "categories": [ + {"alias": "Italian", "title": "Italian"}, + ], + "location": { + "formatted_address": '102 Lakeside Ave\nSeattle, WA 98122', + }, + "reviews": [ + { + "id": "sjZoO8wcK1NeGJFDk5i82Q", + "rating": 5, + "user": { + "id": "BuBCkWFNT_O2dbSnBZvpoQ", + "image_url": "https://i.pravatar.cc", + "name": "Gina T.", + }, + }, + { + "id": "okpO9hfpxQXssbTZTKq9hA", + "rating": 5, + "user": { + "id": "0x9xu_b0Ct_6hG6jaxpztw", + "image_url": "https://i.pravatar.cc", + "name": "Crystal L.", + }, + } + ], + }, + { + "id": "faPVqws-x-5k2CQKDNtHxh", + "name": "Yardbird Southern Table & Bar", + "price": "120.00", + "rating": 4.5, + "photos": [ + "https://i.pravatar.cc", + "https://i.pravatar.cc", + ], + "categories": [ + {"alias": "Italian", "title": "Italian"}, + ], + "location": { + "formatted_address": '102 Lakeside Ave\nSeattle, WA 98122', + }, + "reviews": [ + { + "id": "sjZoO8wcK1NeGJFDk5i82Q", + "rating": 5, + "user": { + "id": "BuBCkWFNT_O2dbSnBZvpoQ", + "image_url": "https://i.pravatar.cc", + "name": "Gina T.", + }, + }, + { + "id": "okpO9hfpxQXssbTZTKq9hA", + "rating": 5, + "user": { + "id": "0x9xu_b0Ct_6hG6jaxpztw", + "image_url": "https://i.pravatar.cc", + "name": "Crystal L.", + }, + } + ], + } + ], + }, + }, + }; + Future getRestaurants({int offset = 0}) async { try { - final response = await dio.post>( - '/v3/graphql', - data: _getQuery(offset), + //I REACHED THE LIMIT. 🥲. SORRY. + + // final response = await dio.post>( + // '/v3/graphql', + // data: _getQuery(offset), + // ); + // //return RestaurantQueryResult.fromJson(response.data!['data']['search']); + return RestaurantQueryResult.fromJson( + data['data']!['search'] as Map, ); - return RestaurantQueryResult.fromJson(response.data!['data']['search']); } catch (e) { return null; } From 128dd272c676554ec75a5ee73689dc354d3e0630 Mon Sep 17 00:00:00 2001 From: Jackson Cuevas Date: Wed, 14 Feb 2024 20:20:44 -0400 Subject: [PATCH 08/12] fix: refactored the code. --- .../view_models/restaurant_view_model.dart | 19 ------------------- lib/repositories/yelp_repository.dart | 3 +-- 2 files changed, 1 insertion(+), 21 deletions(-) diff --git a/lib/modules/restaurant/view_models/restaurant_view_model.dart b/lib/modules/restaurant/view_models/restaurant_view_model.dart index 20b0c67e..5647a359 100644 --- a/lib/modules/restaurant/view_models/restaurant_view_model.dart +++ b/lib/modules/restaurant/view_models/restaurant_view_model.dart @@ -5,10 +5,6 @@ import '../../../repositories/yelp_repository.dart'; class RestaurantViewModel extends BaseViewModel { YelpRepository yelpRepo; - - Set _favoriteRestaurantIds = {}; - Set get favoriteRestaurantIds => _favoriteRestaurantIds; - List? restaurants; RestaurantViewModel({required this.yelpRepo}); @@ -23,19 +19,4 @@ class RestaurantViewModel extends BaseViewModel { throw Exception(e); } } - - Future toggleFavorite(String restaurantId) async { - if (_favoriteRestaurantIds.contains(restaurantId)) { - _favoriteRestaurantIds.remove(restaurantId); - } else { - _favoriteRestaurantIds.add(restaurantId); - } - notifyListeners(); - - // final prefs = await SharedPreferences.getInstance(); - // await prefs.setStringList('favorites', _favoriteRestaurantIds.toList()); - } - - bool isFavorite(String restaurantId) => - _favoriteRestaurantIds.contains(restaurantId); } diff --git a/lib/repositories/yelp_repository.dart b/lib/repositories/yelp_repository.dart index dc966bf5..05624b11 100644 --- a/lib/repositories/yelp_repository.dart +++ b/lib/repositories/yelp_repository.dart @@ -2,8 +2,7 @@ import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; import 'package:restaurantour/models/restaurant.dart'; -const _apiKey = - 'zdiwGUIV61NgdbWT-kCTA3VrkSxhHefvqX7JkfA_7QrtplqQlsHOoNVGcZGEdEjU5Q4ehGtbZt3nh_6fzAei1bFWjn6vW_HQirTRtKqvla1jG5hCwmbY-cb0GADNZXYx'; +const _apiKey = ''; class YelpRepository { late Dio dio; From d0ec6ef5f643b6ad8eb6e7e6a016e3c36a1f3532 Mon Sep 17 00:00:00 2001 From: Jackson Cuevas Date: Thu, 15 Feb 2024 09:55:52 -0400 Subject: [PATCH 09/12] feat: Addded some hardcode texts just to show the comments space and the restaurant name space --- lib/repositories/yelp_repository.dart | 8 ++++---- lib/widgets/restaurant_card_widget.dart | 9 +++++---- lib/widgets/review_item_widget.dart | 19 +++++++++++++------ 3 files changed, 22 insertions(+), 14 deletions(-) diff --git a/lib/repositories/yelp_repository.dart b/lib/repositories/yelp_repository.dart index 05624b11..44355077 100644 --- a/lib/repositories/yelp_repository.dart +++ b/lib/repositories/yelp_repository.dart @@ -27,7 +27,7 @@ class YelpRepository { "business": [ { "id": "faPVqws-x-5k2CQKDNtHxw", - "name": "Yardbird Southern Table & Bar", + "name": "Restaurant Name Goes Here And Wraps 2 Lines", "price": "120.00", "rating": 4.5, "location": { @@ -63,7 +63,7 @@ class YelpRepository { }, { "id": "faPVqws-x-5k2CQKDNtHxf", - "name": "Yardbird Southern Table & Bar", + "name": "Restaurant Name Goes Here And Wraps 2 Lines", "price": "120.00", "rating": 4.5, "photos": [ @@ -99,7 +99,7 @@ class YelpRepository { }, { "id": "faPVqws-x-5k2CQKDNtHxg", - "name": "Yardbird Southern Table & Bar", + "name": "Restaurant Name Goes Here And Wraps 2 Lines", "price": "120.00", "rating": 4.5, "photos": [ @@ -135,7 +135,7 @@ class YelpRepository { }, { "id": "faPVqws-x-5k2CQKDNtHxh", - "name": "Yardbird Southern Table & Bar", + "name": "Restaurant Name Goes Here And Wraps 2 Lines", "price": "120.00", "rating": 4.5, "photos": [ diff --git a/lib/widgets/restaurant_card_widget.dart b/lib/widgets/restaurant_card_widget.dart index c7d076f0..fbc3bbdd 100644 --- a/lib/widgets/restaurant_card_widget.dart +++ b/lib/widgets/restaurant_card_widget.dart @@ -53,20 +53,21 @@ class RestaurantCardWidget extends StatelessWidget { restaurant.name!, style: const TextStyle( fontSize: 16.0, - fontWeight: FontWeight.bold, + fontWeight: FontWeight.normal, ), ), + const Gap(10), Padding( padding: const EdgeInsets.symmetric(vertical: 4.0), child: Text( '\$${restaurant.price}', style: const TextStyle( fontSize: 14.0, - color: Colors.grey, + fontWeight: FontWeight.normal, + color: Colors.black, ), ), ), - const Gap(20), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -86,7 +87,7 @@ class RestaurantCardWidget extends StatelessWidget { Constants.openNow, style: TextStyle( color: Colors.black, - fontWeight: FontWeight.normal, + fontWeight: FontWeight.w100, ), ), const SizedBox(width: 4.0), diff --git a/lib/widgets/review_item_widget.dart b/lib/widgets/review_item_widget.dart index b4273bfd..c23cbfcc 100644 --- a/lib/widgets/review_item_widget.dart +++ b/lib/widgets/review_item_widget.dart @@ -18,11 +18,8 @@ class ReviewListTile extends StatelessWidget { @override Widget build(BuildContext context) { return ListTile( - leading: CircleAvatar( - backgroundImage: NetworkImage(userImageUrl), - ), title: Row( - mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, children: List.generate( stars, (_) => const Icon(Icons.star, color: Colors.amber, size: 20.0), @@ -31,9 +28,19 @@ class ReviewListTile extends StatelessWidget { subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(reviewText), + const Text( + "Review text goes here. Review text goes here. Review text goes here. This is a review. This is a review. This is a review that is 4 lines long."), const Gap(5), - Text(userName), + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + CircleAvatar( + backgroundImage: NetworkImage(userImageUrl), + ), + const Gap(5), + Text(userName), + ], + ), ], ), ); From 9c6ea2176f4874389af9d550ee72a4b2e2f12e41 Mon Sep 17 00:00:00 2001 From: Jackson Cuevas Date: Thu, 15 Feb 2024 17:21:46 -0400 Subject: [PATCH 10/12] Feature: Implemented a caching system to eliminate redundant data retrieval each time the user switches tabs. Additionally, a RefreshIndicator has been integrated to facilitate the updating of in-memory data. --- lib/common/shared_pref_helper.dart | 33 +++++++++++ lib/main.dart | 5 ++ .../home/view_models/home_view_model.dart | 19 ------- lib/modules/home/views/home_view.dart | 43 ++++++-------- .../view_models/restaurant_view_model.dart | 35 +++++++++--- .../views/restaurants_list_view.dart | 57 ++++++++++--------- pubspec.yaml | 1 + test/RestaurantViewModel_test.dart | 2 +- 8 files changed, 113 insertions(+), 82 deletions(-) create mode 100644 lib/common/shared_pref_helper.dart delete mode 100644 lib/modules/home/view_models/home_view_model.dart diff --git a/lib/common/shared_pref_helper.dart b/lib/common/shared_pref_helper.dart new file mode 100644 index 00000000..36cf1f6f --- /dev/null +++ b/lib/common/shared_pref_helper.dart @@ -0,0 +1,33 @@ +import 'dart:convert'; + +import 'package:shared_preferences/shared_preferences.dart'; + +import '../models/restaurant.dart'; + +class SharedPreferencesHelper { + static final SharedPreferencesHelper instance = SharedPreferencesHelper._(); + static late final SharedPreferences sharedPreferences; + + factory SharedPreferencesHelper() => instance; + + SharedPreferencesHelper._(); + + static Future initializeSharedPreference() async { + sharedPreferences = await SharedPreferences.getInstance(); + } + + Future cacheRestaurants(List restaurants) async { + String jsonString = jsonEncode( + restaurants.map((restaurant) => restaurant.toJson()).toList()); + await sharedPreferences.setString('restaurants', jsonString); + } + + Future> getRestaurants() async { + String? jsonString = sharedPreferences.getString('restaurants'); + if (jsonString == null) return []; + List jsonResponse = jsonDecode(jsonString); + return jsonResponse + .map((restaurantMap) => Restaurant.fromJson(restaurantMap)) + .toList(); + } +} diff --git a/lib/main.dart b/lib/main.dart index 7e6ca6ae..d7b67b07 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -5,8 +5,13 @@ import 'package:restaurantour/modules/home/views/home_view.dart'; import 'package:restaurantour/repositories/yelp_repository.dart'; import 'common/app_theme.dart'; +import 'common/shared_pref_helper.dart'; void main() async { + WidgetsFlutterBinding.ensureInitialized(); + + await SharedPreferencesHelper.initializeSharedPreference(); + runApp(const Restaurantour()); } diff --git a/lib/modules/home/view_models/home_view_model.dart b/lib/modules/home/view_models/home_view_model.dart deleted file mode 100644 index be64d59c..00000000 --- a/lib/modules/home/view_models/home_view_model.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stacked/stacked.dart'; - -class HomeViewModel extends BaseViewModel { - late BuildContext context; - HomeViewModel(this.context); - - bool _isLoading = false; - bool get isLoading => _isLoading; - set isLoading(bool value) { - _isLoading = value; - notifyListeners(); - } - - ready() async { - isLoading = true; - isLoading = false; - } -} diff --git a/lib/modules/home/views/home_view.dart b/lib/modules/home/views/home_view.dart index da87bf04..f4050bb9 100644 --- a/lib/modules/home/views/home_view.dart +++ b/lib/modules/home/views/home_view.dart @@ -1,10 +1,8 @@ import 'package:flutter/material.dart'; import 'package:restaurantour/common/constants.dart'; -import 'package:stacked/stacked.dart'; import '../../restaurant/views/favorites_restaurants_list_view.dart'; import '../../restaurant/views/restaurants_list_view.dart'; -import '../view_models/home_view_model.dart'; class HomeView extends StatefulWidget { const HomeView({Key? key}) : super(key: key); @@ -31,34 +29,27 @@ class _HomeViewState extends State @override Widget build(BuildContext context) { - return ViewModelBuilder.reactive( - viewModelBuilder: () => HomeViewModel(context), - onViewModelReady: (HomeViewModel viewModel) => viewModel.ready(), - builder: (context, HomeViewModel viewModel, child) => Scaffold( - appBar: AppBar( - title: const Text(Constants.appName), - bottom: TabBar( - indicatorSize: TabBarIndicatorSize.label, - controller: _tabController, - tabAlignment: TabAlignment.center, - tabs: const [ - Tab( - text: Constants.allrestaurants, - ), - Tab( - text: Constants.myFavorites, - ), - ], - ), - ), - body: TabBarView( + return Scaffold( + appBar: AppBar( + title: const Text(Constants.appName), + bottom: TabBar( + indicatorSize: TabBarIndicatorSize.label, controller: _tabController, - children: const [ - RestaurantsListView(), - FavoritesRestaurantsListView() + tabAlignment: TabAlignment.center, + tabs: const [ + Tab( + text: Constants.allrestaurants, + ), + Tab( + text: Constants.myFavorites, + ), ], ), ), + body: TabBarView( + controller: _tabController, + children: const [RestaurantsListView(), FavoritesRestaurantsListView()], + ), ); } } diff --git a/lib/modules/restaurant/view_models/restaurant_view_model.dart b/lib/modules/restaurant/view_models/restaurant_view_model.dart index 5647a359..5fa5dbf4 100644 --- a/lib/modules/restaurant/view_models/restaurant_view_model.dart +++ b/lib/modules/restaurant/view_models/restaurant_view_model.dart @@ -1,22 +1,41 @@ -import 'package:restaurantour/models/restaurant.dart'; import 'package:stacked/stacked.dart'; +import '../../../common/shared_pref_helper.dart'; +import '../../../models/restaurant.dart'; import '../../../repositories/yelp_repository.dart'; class RestaurantViewModel extends BaseViewModel { - YelpRepository yelpRepo; - List? restaurants; + final YelpRepository yelpRepo; + final SharedPreferencesHelper sharedPrefHelper = + SharedPreferencesHelper.instance; + + List restaurants = []; + String? errorMessage; RestaurantViewModel({required this.yelpRepo}); - ready() async {} + Future ready() async { + setBusy(true); + var cachedRestaurants = await sharedPrefHelper.getRestaurants(); + if (cachedRestaurants.isNotEmpty) { + restaurants = cachedRestaurants; + } else { + await fetchAndCacheRestaurants(); + } + setBusy(false); + } - Future> fetchData() async { + Future fetchAndCacheRestaurants() async { try { - final result = await yelpRepo.getRestaurants(); - return result!.restaurants!; + var response = await yelpRepo.getRestaurants(); + if (response!.restaurants!.isNotEmpty) { + restaurants = response.restaurants!; + await sharedPrefHelper.cacheRestaurants(restaurants); + } + notifyListeners(); } catch (e) { - throw Exception(e); + errorMessage = e.toString(); + notifyListeners(); } } } diff --git a/lib/modules/restaurant/views/restaurants_list_view.dart b/lib/modules/restaurant/views/restaurants_list_view.dart index 6f93c7ee..0a15b05e 100644 --- a/lib/modules/restaurant/views/restaurants_list_view.dart +++ b/lib/modules/restaurant/views/restaurants_list_view.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:stacked/stacked.dart'; -import '../../../models/restaurant.dart'; import '../../../repositories/yelp_repository.dart'; import '../../../widgets/restaurant_card_widget.dart'; import '../view_models/restaurant_view_model.dart'; @@ -12,26 +11,29 @@ class RestaurantsListView extends StatelessWidget { @override Widget build(BuildContext context) { - return ViewModelBuilder.reactive( + return ViewModelBuilder.reactive( viewModelBuilder: () => RestaurantViewModel(yelpRepo: YelpRepository()), onViewModelReady: (RestaurantViewModel viewModel) => viewModel.ready(), - builder: (context, RestaurantViewModel viewModel, child) => - FutureBuilder>( - future: viewModel.fetchData(), - builder: (context, snapshot) { - if (snapshot.connectionState != ConnectionState.done) { - return const Center( - child: CircularProgressIndicator( - color: Colors.black, - ), - ); - } + builder: (context, viewModel, child) { + if (viewModel.isBusy) { + return const Center( + child: CircularProgressIndicator( + color: Colors.black, + ), + ); + } - if (snapshot.hasData) { - return ListView.builder( - itemCount: snapshot.data!.length, + if (viewModel.errorMessage != null) { + return Center(child: Text('Error: ${viewModel.errorMessage}')); + } + + if (viewModel.restaurants.isNotEmpty) { + return RefreshIndicator( + onRefresh: viewModel.fetchAndCacheRestaurants, + child: ListView.builder( + itemCount: viewModel.restaurants.length, itemBuilder: (context, index) { - var restaurant = snapshot.data![index]; + var restaurant = viewModel.restaurants[index]; return InkWell( onTap: () => { Navigator.push( @@ -50,18 +52,17 @@ class RestaurantsListView extends StatelessWidget { ), ); }, - ); - } - - if (snapshot.hasError) { - return Center(child: Text('Error: ${snapshot.error}')); - } - - return const Center( - child: Text('Press a button to start fetching data'), + ), ); - }, - ), + } + + return Center( + child: ElevatedButton( + child: const Text('Fetch Restaurants'), + onPressed: () => {viewModel.fetchAndCacheRestaurants()}, + ), + ); + }, ); } } diff --git a/pubspec.yaml b/pubspec.yaml index 4a4ccbcc..5de840a8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -22,6 +22,7 @@ dependencies: stacked: ^3.4.2 mockito: ^5.4.4 path_provider: ^2.0.0 + shared_preferences: ^2.2.2 dev_dependencies: flutter_test: diff --git a/test/RestaurantViewModel_test.dart b/test/RestaurantViewModel_test.dart index 67311256..3e28072d 100644 --- a/test/RestaurantViewModel_test.dart +++ b/test/RestaurantViewModel_test.dart @@ -13,7 +13,7 @@ void main() { test('fetchData returns a list of restaurants', () async { // Fetch data - final restaurants = await viewModel.fetchData(); + final restaurants = await viewModel.fetchAndCacheRestaurants(); // Assert that a list of restaurants is returned expect(restaurants, isA>()); From b0eb2d88d0671014358b112be2fcae25b12021c3 Mon Sep 17 00:00:00 2001 From: Jackson Cuevas Date: Fri, 16 Feb 2024 09:23:54 -0400 Subject: [PATCH 11/12] feat: Added the dotnet package. --- .env | 1 + lib/main.dart | 2 ++ pubspec.yaml | 12 +++++++----- 3 files changed, 10 insertions(+), 5 deletions(-) create mode 100644 .env diff --git a/.env b/.env new file mode 100644 index 00000000..57986b9b --- /dev/null +++ b/.env @@ -0,0 +1 @@ +yelp_api_key='zdiwGUIV61NgdbWT-kCTA3VrkSxhHefvqX7JkfA_7QrtplqQlsHOoNVGcZGEdEjU5Q4ehGtbZt3nh_6fzAei1bFWjn6vW_HQirTRtKqvla1jG5hCwmbY-cb0GADNZXYx' \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index d7b67b07..5f4cf4f9 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:restaurantour/modules/home/views/home_view.dart'; import 'package:restaurantour/repositories/yelp_repository.dart'; @@ -9,6 +10,7 @@ import 'common/shared_pref_helper.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); + await dotenv.load(fileName: '.env'); await SharedPreferencesHelper.initializeSharedPreference(); diff --git a/pubspec.yaml b/pubspec.yaml index 5de840a8..cfdcda54 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -10,8 +10,6 @@ environment: sdk: ">=2.12.0 <3.0.0" dependencies: - integration_test: - sdk: flutter flutter: sdk: flutter cupertino_icons: ^1.0.6 @@ -20,18 +18,22 @@ dependencies: flutter_svg: ^2.0.9 gap: ^3.0.1 stacked: ^3.4.2 - mockito: ^5.4.4 path_provider: ^2.0.0 shared_preferences: ^2.2.2 + flutter_dotenv: ^5.1.0 dev_dependencies: + integration_test: + sdk: flutter flutter_test: sdk: flutter flutter_lints: ^1.0.2 build_runner: ^2.4.8 json_serializable: ^6.7.1 + mockito: ^5.4.4 flutter: uses-material-design: true -# assets: -# - assets/svg/ \ No newline at end of file + + assets: + - .env \ No newline at end of file From 3d91c0aa87c8a6eb911eda802b84e7c2800fa7bf Mon Sep 17 00:00:00 2001 From: Jackson Cuevas Date: Fri, 16 Feb 2024 09:25:15 -0400 Subject: [PATCH 12/12] fix: Improve the code and some refactored. --- lib/common/constants.dart | 6 ++++ lib/common/shared_pref_helper.dart | 3 +- lib/common/utils.dart | 3 +- lib/models/restaurant.dart | 2 ++ .../views/restaurant_detail_view.dart | 21 +++++++----- lib/repositories/yelp_repository.dart | 32 ++++++++++++------- lib/widgets/review_item_widget.dart | 3 +- ...ository.dart => mock_yelp_repository.dart} | 0 ...t.dart => restaurant_view_model_test.dart} | 2 +- 9 files changed, 45 insertions(+), 27 deletions(-) rename test/{MockYelpRepository.dart => mock_yelp_repository.dart} (100%) rename test/{RestaurantViewModel_test.dart => restaurant_view_model_test.dart} (95%) diff --git a/lib/common/constants.dart b/lib/common/constants.dart index 14a86659..10130209 100644 --- a/lib/common/constants.dart +++ b/lib/common/constants.dart @@ -1,3 +1,5 @@ +import 'package:flutter/material.dart'; + abstract class Constants { static const appName = 'RestauranTour'; static const allrestaurants = 'All Restaurant'; @@ -6,4 +8,8 @@ abstract class Constants { static const closed = 'Closed'; static const address = 'Address'; static const overallRating = 'Overall Rating'; + static const placeholder = 'https://i.pravatar.cc'; + static const reviewText = + 'Review text goes here. Review text goes here. Review text goes here. This is a review. This is a review. This is a review that is 4 lines long.'; + static const anonymous = 'Anonymous'; } diff --git a/lib/common/shared_pref_helper.dart b/lib/common/shared_pref_helper.dart index 36cf1f6f..d3121776 100644 --- a/lib/common/shared_pref_helper.dart +++ b/lib/common/shared_pref_helper.dart @@ -1,9 +1,8 @@ import 'dart:convert'; +import 'package:restaurantour/models/restaurant.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import '../models/restaurant.dart'; - class SharedPreferencesHelper { static final SharedPreferencesHelper instance = SharedPreferencesHelper._(); static late final SharedPreferences sharedPreferences; diff --git a/lib/common/utils.dart b/lib/common/utils.dart index 5e44c9b6..10ab31ce 100644 --- a/lib/common/utils.dart +++ b/lib/common/utils.dart @@ -1,8 +1,7 @@ import 'dart:convert'; import 'dart:io'; import 'package:path_provider/path_provider.dart'; - -import '../../../models/restaurant.dart'; +import 'package:restaurantour/models/restaurant.dart'; class Utils { static Future get _localPath async { diff --git a/lib/models/restaurant.dart b/lib/models/restaurant.dart index 87c7aab5..6e89e97a 100644 --- a/lib/models/restaurant.dart +++ b/lib/models/restaurant.dart @@ -55,11 +55,13 @@ class Review { final String? id; final int? rating; final User? user; + final String? reviewText; const Review({ this.id, this.rating, this.user, + this.reviewText, }); factory Review.fromJson(Map json) => _$ReviewFromJson(json); diff --git a/lib/modules/restaurant/views/restaurant_detail_view.dart b/lib/modules/restaurant/views/restaurant_detail_view.dart index f2906d4e..e5cdfe42 100644 --- a/lib/modules/restaurant/views/restaurant_detail_view.dart +++ b/lib/modules/restaurant/views/restaurant_detail_view.dart @@ -146,15 +146,20 @@ class _RestaurantDetailViewState extends State { SizedBox( height: 400, child: ListView.builder( - itemCount: widget.restaurant.reviews!.length, + itemCount: widget.restaurant.reviews?.length ?? 0, itemBuilder: (context, index) { - var review = widget.restaurant.reviews![index]; - return ReviewListTile( - stars: review.rating!, - reviewText: review.user!.id!, - userName: review.user!.name!, - userImageUrl: review.user!.imageUrl!, - ); + var review = widget.restaurant.reviews?[index]; + if (review != null) { + return ReviewListTile( + stars: review.rating ?? 0, + reviewText: review.reviewText ?? Constants.reviewText, + userName: review.user?.name ?? Constants.anonymous, + userImageUrl: + review.user?.imageUrl ?? Constants.placeholder, + ); + } else { + return const SizedBox.shrink(); + } }, ), ), diff --git a/lib/repositories/yelp_repository.dart b/lib/repositories/yelp_repository.dart index 44355077..0fc5be78 100644 --- a/lib/repositories/yelp_repository.dart +++ b/lib/repositories/yelp_repository.dart @@ -1,9 +1,8 @@ import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:restaurantour/models/restaurant.dart'; -const _apiKey = ''; - class YelpRepository { late Dio dio; @@ -14,7 +13,7 @@ class YelpRepository { BaseOptions( baseUrl: 'https://api.yelp.com', headers: { - 'Authorization': 'Bearer $_apiKey', + 'Authorization': 'Bearer ${dotenv.env['yelp_api_key']}', 'Content-Type': 'application/graphql', }, ), @@ -53,6 +52,8 @@ class YelpRepository { { "id": "okpO9hfpxQXssbTZTKq9hA", "rating": 5, + "reviewText": + "Review text goes here. Review text goes here. Review text goes here. This is a review. This is a review. This is a review that is 4 lines long.", "user": { "id": "0x9xu_b0Ct_6hG6jaxpztw", "image_url": "https://i.pravatar.cc", @@ -80,6 +81,8 @@ class YelpRepository { { "id": "sjZoO8wcK1NeGJFDk5i82Q", "rating": 5, + "reviewText": + "Review text goes here. Review text goes here. Review text goes here. This is a review. This is a review. This is a review that is 4 lines long.", "user": { "id": "BuBCkWFNT_O2dbSnBZvpoQ", "image_url": "https://i.pravatar.cc", @@ -89,6 +92,8 @@ class YelpRepository { { "id": "okpO9hfpxQXssbTZTKq9hA", "rating": 5, + "reviewText": + "Review text goes here. Review text goes here. Review text goes here. This is a review. This is a review. This is a review that is 4 lines long.", "user": { "id": "0x9xu_b0Ct_6hG6jaxpztw", "image_url": "https://i.pravatar.cc", @@ -116,6 +121,8 @@ class YelpRepository { { "id": "sjZoO8wcK1NeGJFDk5i82Q", "rating": 5, + "reviewText": + "Review text goes here. Review text goes here. Review text goes here. This is a review. This is a review. This is a review that is 4 lines long.", "user": { "id": "BuBCkWFNT_O2dbSnBZvpoQ", "image_url": "https://i.pravatar.cc", @@ -125,6 +132,8 @@ class YelpRepository { { "id": "okpO9hfpxQXssbTZTKq9hA", "rating": 5, + "reviewText": + "Review text goes here. Review text goes here. Review text goes here. This is a review. This is a review. This is a review that is 4 lines long.", "user": { "id": "0x9xu_b0Ct_6hG6jaxpztw", "image_url": "https://i.pravatar.cc", @@ -152,6 +161,8 @@ class YelpRepository { { "id": "sjZoO8wcK1NeGJFDk5i82Q", "rating": 5, + "reviewText": + "Review text goes here. Review text goes here. Review text goes here. This is a review. This is a review. This is a review that is 4 lines long.", "user": { "id": "BuBCkWFNT_O2dbSnBZvpoQ", "image_url": "https://i.pravatar.cc", @@ -161,6 +172,8 @@ class YelpRepository { { "id": "okpO9hfpxQXssbTZTKq9hA", "rating": 5, + "reviewText": + "Review text goes here. Review text goes here. Review text goes here. This is a review. This is a review. This is a review that is 4 lines long.", "user": { "id": "0x9xu_b0Ct_6hG6jaxpztw", "image_url": "https://i.pravatar.cc", @@ -176,16 +189,11 @@ class YelpRepository { Future getRestaurants({int offset = 0}) async { try { - //I REACHED THE LIMIT. 🥲. SORRY. - - // final response = await dio.post>( - // '/v3/graphql', - // data: _getQuery(offset), - // ); - // //return RestaurantQueryResult.fromJson(response.data!['data']['search']); - return RestaurantQueryResult.fromJson( - data['data']!['search'] as Map, + final response = await dio.post>( + '/v3/graphql', + data: _getQuery(offset), ); + return RestaurantQueryResult.fromJson(response.data!['data']['search']); } catch (e) { return null; } diff --git a/lib/widgets/review_item_widget.dart b/lib/widgets/review_item_widget.dart index c23cbfcc..da8b5af0 100644 --- a/lib/widgets/review_item_widget.dart +++ b/lib/widgets/review_item_widget.dart @@ -28,8 +28,7 @@ class ReviewListTile extends StatelessWidget { subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( - "Review text goes here. Review text goes here. Review text goes here. This is a review. This is a review. This is a review that is 4 lines long."), + Text(reviewText), const Gap(5), Row( crossAxisAlignment: CrossAxisAlignment.center, diff --git a/test/MockYelpRepository.dart b/test/mock_yelp_repository.dart similarity index 100% rename from test/MockYelpRepository.dart rename to test/mock_yelp_repository.dart diff --git a/test/RestaurantViewModel_test.dart b/test/restaurant_view_model_test.dart similarity index 95% rename from test/RestaurantViewModel_test.dart rename to test/restaurant_view_model_test.dart index 3e28072d..50da180d 100644 --- a/test/RestaurantViewModel_test.dart +++ b/test/restaurant_view_model_test.dart @@ -3,7 +3,7 @@ import 'package:restaurantour/models/restaurant.dart'; import 'package:restaurantour/modules/restaurant/view_models/restaurant_view_model.dart'; -import 'MockYelpRepository.dart'; +import 'mock_yelp_repository.dart'; void main() { group('RestaurantViewModel Test', () {