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/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..10130209 --- /dev/null +++ b/lib/common/constants.dart @@ -0,0 +1,15 @@ +import 'package:flutter/material.dart'; + +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'; + 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 new file mode 100644 index 00000000..d3121776 --- /dev/null +++ b/lib/common/shared_pref_helper.dart @@ -0,0 +1,32 @@ +import 'dart:convert'; + +import 'package:restaurantour/models/restaurant.dart'; +import 'package:shared_preferences/shared_preferences.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/common/utils.dart b/lib/common/utils.dart new file mode 100644 index 00000000..10ab31ce --- /dev/null +++ b/lib/common/utils.dart @@ -0,0 +1,65 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:path_provider/path_provider.dart'; +import 'package:restaurantour/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/main.dart b/lib/main.dart index c6ce7473..5f4cf4f9 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,7 +1,19 @@ +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'; -void main() { +import 'common/app_theme.dart'; +import 'common/shared_pref_helper.dart'; + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + await dotenv.load(fileName: '.env'); + + await SharedPreferencesHelper.initializeSharedPreference(); + runApp(const Restaurantour()); } @@ -13,10 +25,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(), ); } } 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/home/views/home_view.dart b/lib/modules/home/views/home_view.dart new file mode 100644 index 00000000..f4050bb9 --- /dev/null +++ b/lib/modules/home/views/home_view.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:restaurantour/common/constants.dart'; + +import '../../restaurant/views/favorites_restaurants_list_view.dart'; +import '../../restaurant/views/restaurants_list_view.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 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()], + ), + ); + } +} 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..5fa5dbf4 --- /dev/null +++ b/lib/modules/restaurant/view_models/restaurant_view_model.dart @@ -0,0 +1,41 @@ +import 'package:stacked/stacked.dart'; + +import '../../../common/shared_pref_helper.dart'; +import '../../../models/restaurant.dart'; +import '../../../repositories/yelp_repository.dart'; + +class RestaurantViewModel extends BaseViewModel { + final YelpRepository yelpRepo; + final SharedPreferencesHelper sharedPrefHelper = + SharedPreferencesHelper.instance; + + List restaurants = []; + String? errorMessage; + + RestaurantViewModel({required this.yelpRepo}); + + Future ready() async { + setBusy(true); + var cachedRestaurants = await sharedPrefHelper.getRestaurants(); + if (cachedRestaurants.isNotEmpty) { + restaurants = cachedRestaurants; + } else { + await fetchAndCacheRestaurants(); + } + setBusy(false); + } + + Future fetchAndCacheRestaurants() async { + try { + var response = await yelpRepo.getRestaurants(); + if (response!.restaurants!.isNotEmpty) { + restaurants = response.restaurants!; + await sharedPrefHelper.cacheRestaurants(restaurants); + } + notifyListeners(); + } catch (e) { + errorMessage = e.toString(); + notifyListeners(); + } + } +} 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/restaurant_detail_view.dart b/lib/modules/restaurant/views/restaurant_detail_view.dart new file mode 100644 index 00000000..e5cdfe42 --- /dev/null +++ b/lib/modules/restaurant/views/restaurant_detail_view.dart @@ -0,0 +1,170 @@ +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 ?? 0, + itemBuilder: (context, index) { + 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/modules/restaurant/views/restaurants_list_view.dart b/lib/modules/restaurant/views/restaurants_list_view.dart new file mode 100644 index 00000000..0a15b05e --- /dev/null +++ b/lib/modules/restaurant/views/restaurants_list_view.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; +import 'package:stacked/stacked.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, viewModel, child) { + if (viewModel.isBusy) { + return const Center( + child: CircularProgressIndicator( + color: Colors.black, + ), + ); + } + + 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 = viewModel.restaurants[index]; + return InkWell( + onTap: () => { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => RestaurantDetailView( + restaurantIndex: index, + restaurant: restaurant, + ), + ), + ), + }, + child: RestaurantCardWidget( + restaurantIndex: index, + restaurant: restaurant, + ), + ); + }, + ), + ); + } + + return Center( + child: ElevatedButton( + child: const Text('Fetch Restaurants'), + onPressed: () => {viewModel.fetchAndCacheRestaurants()}, + ), + ); + }, + ); + } +} diff --git a/lib/repositories/yelp_repository.dart b/lib/repositories/yelp_repository.dart index f251d7b4..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,50 +13,180 @@ class YelpRepository { BaseOptions( baseUrl: 'https://api.yelp.com', headers: { - 'Authorization': 'Bearer $_apiKey', + 'Authorization': 'Bearer ${dotenv.env['yelp_api_key']}', 'Content-Type': 'application/graphql', }, ), ); - /// Returns a response in this shape - /// { - /// "data": { - /// "search": { - /// "total": 5056, - /// "business": [ - /// { - /// "id": "faPVqws-x-5k2CQKDNtHxw", - /// "name": "Yardbird Southern Table & Bar", - /// "price": "$$", - /// "rating": 4.5, - /// "photos": [ - /// "https:///s3-media4.fl.yelpcdn.com/bphoto/_zXRdYX4r1OBfF86xKMbDw/o.jpg" - /// ], - /// "reviews": [ - /// { - /// "id": "sjZoO8wcK1NeGJFDk5i82Q", - /// "rating": 5, - /// "user": { - /// "id": "BuBCkWFNT_O2dbSnBZvpoQ", - /// "image_url": "https:///s3-media2.fl.yelpcdn.com/photo/v8tbTjYaFvkzh1d7iE-pcQ/o.jpg", - /// "name": "Gina T." - /// } - /// }, - /// { - /// "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": "Restaurant Name Goes Here And Wraps 2 Lines", + "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, + "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", + "name": "Crystal L.", + }, + } + ], + }, + { + "id": "faPVqws-x-5k2CQKDNtHxf", + "name": "Restaurant Name Goes Here And Wraps 2 Lines", + "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, + "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", + "name": "Gina T.", + }, + }, + { + "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", + "name": "Crystal L.", + }, + } + ], + }, + { + "id": "faPVqws-x-5k2CQKDNtHxg", + "name": "Restaurant Name Goes Here And Wraps 2 Lines", + "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, + "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", + "name": "Gina T.", + }, + }, + { + "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", + "name": "Crystal L.", + }, + } + ], + }, + { + "id": "faPVqws-x-5k2CQKDNtHxh", + "name": "Restaurant Name Goes Here And Wraps 2 Lines", + "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, + "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", + "name": "Gina T.", + }, + }, + { + "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", + "name": "Crystal L.", + }, + } + ], + } + ], + }, + }, + }; + Future getRestaurants({int offset = 0}) async { try { final response = await dio.post>( diff --git a/lib/widgets/restaurant_card_widget.dart b/lib/widgets/restaurant_card_widget.dart new file mode 100644 index 00000000..fbc3bbdd --- /dev/null +++ b/lib/widgets/restaurant_card_widget.dart @@ -0,0 +1,136 @@ +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.normal, + ), + ), + const Gap(10), + Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0), + child: Text( + '\$${restaurant.price}', + style: const TextStyle( + fontSize: 14.0, + fontWeight: FontWeight.normal, + color: Colors.black, + ), + ), + ), + 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.w100, + ), + ), + 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, + ), + ), + ], + ), + ], + ), + ], + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/review_item_widget.dart b/lib/widgets/review_item_widget.dart new file mode 100644 index 00000000..da8b5af0 --- /dev/null +++ b/lib/widgets/review_item_widget.dart @@ -0,0 +1,47 @@ +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( + title: Row( + crossAxisAlignment: CrossAxisAlignment.start, + 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), + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + CircleAvatar( + backgroundImage: NetworkImage(userImageUrl), + ), + const Gap(5), + Text(userName), + ], + ), + ], + ), + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index be3055e0..cfdcda54 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,15 +16,24 @@ dependencies: dio: ^5.4.0 json_annotation: ^4.8.1 flutter_svg: ^2.0.9 - + gap: ^3.0.1 + stacked: ^3.4.2 + 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 diff --git a/test/mock_yelp_repository.dart b/test/mock_yelp_repository.dart new file mode 100644 index 00000000..dd53f903 --- /dev/null +++ b/test/mock_yelp_repository.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/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')); + }); +} diff --git a/test/restaurant_view_model_test.dart b/test/restaurant_view_model_test.dart new file mode 100644 index 00000000..50da180d --- /dev/null +++ b/test/restaurant_view_model_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 'mock_yelp_repository.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.fetchAndCacheRestaurants(); + + // Assert that a list of restaurants is returned + expect(restaurants, isA>()); + expect(restaurants.isNotEmpty, isTrue); + }); + }); +}