diff --git a/lib/Modules/Details/view/detail_injection.dart b/lib/Modules/Details/view/detail_injection.dart new file mode 100644 index 00000000..56ba7e2c --- /dev/null +++ b/lib/Modules/Details/view/detail_injection.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:restaurantour/Modules/Details/view/detail_view.dart'; +import 'package:restaurantour/Modules/Details/viewmodel/detail_viewmodel.dart'; +import 'package:restaurantour/adapter/shared_preferences/shared_preferences_adapter.dart'; +import 'package:restaurantour/adapter/shared_preferences/shared_preferences_service.dart'; +import 'package:restaurantour/models/restaurant.dart'; + +class DetailInjection extends StatelessWidget { + const DetailInjection({ + Key? key, + required this.restaurant, + required this.favoriteRestaurants, + }) : super(key: key); + + final Restaurant restaurant; + final RestaurantQueryResult favoriteRestaurants; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => DetailViewmodel( + restaurant: restaurant, + favoriteRestaurants: favoriteRestaurants, + sharedPreferencesService: SharedPreferencesService( + SharedpreferencesAdapter(), + ), + ), + child: const DetailView(), + ); + } +} diff --git a/lib/Modules/Details/view/detail_view.dart b/lib/Modules/Details/view/detail_view.dart new file mode 100644 index 00000000..3095d907 --- /dev/null +++ b/lib/Modules/Details/view/detail_view.dart @@ -0,0 +1,171 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:restaurantour/Modules/Details/view/widgets/review_card.dart'; +import 'package:restaurantour/Modules/Details/viewmodel/detail_state.dart'; +import 'package:restaurantour/Modules/Details/viewmodel/detail_viewmodel.dart'; +import 'package:restaurantour/Modules/Home/tabs/widgets/categorie_text.dart'; +import 'package:restaurantour/Modules/Home/tabs/widgets/is_open_label.dart'; +import 'package:restaurantour/Modules/Home/tabs/widgets/price_text.dart'; + +class DetailView extends StatelessWidget { + const DetailView({ + Key? key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final DetailViewmodel detailViewmodel = + BlocProvider.of(context); + + return Scaffold( + appBar: AppBar( + title: Center( + child: Text( + detailViewmodel.restaurant.name!, + overflow: TextOverflow.ellipsis, + ), + ), + actions: [ + BlocBuilder( + builder: (context, state) { + return IconButton( + onPressed: () async { + await detailViewmodel.invertFavoriteValue(); + await detailViewmodel.addtoFavorites(); + }, + icon: (detailViewmodel.restaurant.isFavorite) + ? const Icon(Icons.favorite) + : const Icon(Icons.favorite_outline), + ); + }, + ), + ], + ), + body: SingleChildScrollView( + child: Column( + children: [ + SizedBox( + width: double.infinity, + height: 300, + child: detailViewmodel.restaurant.photos != null + ? Image.network( + detailViewmodel.restaurant.photos!.first, + fit: BoxFit.cover, + ) + : const Text('no photo'), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + PriceText( + text: detailViewmodel.restaurant.price, + // text: 'homeViewmodel.restaurants.restaurants![index].price', + ), + CategorieText( + text: detailViewmodel.restaurant.categories?[0].title, + ), + const Expanded( + child: IsOpenLabel( + isOpen: true, + ), + ), + // 'text: homeViewmodel.restaurants.restaurants![index].categories?[0].title') + ], + ), + const SizedBox(height: 32), + Container( + width: double.infinity, + height: 1, + color: Colors.grey.withOpacity(0.2), + ), + const SizedBox(height: 32), + const Text('Adress'), + const SizedBox(height: 32), + Text( + detailViewmodel.restaurant.location?.formattedAddress ?? '', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 32), + Container( + width: double.infinity, + height: 1, + color: Colors.grey.withOpacity(0.2), + ), + const SizedBox(height: 32), + const Text('Overall Rating'), + const SizedBox(height: 18), + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + detailViewmodel.restaurant.rating.toString(), + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 32, + ), + ), + const Padding( + padding: EdgeInsets.only(bottom: 6), + child: Icon( + Icons.star, + size: 14, + color: Color.fromARGB(255, 255, 205, 40), + ), + ), + ], + ), + const SizedBox(height: 32), + Container( + width: double.infinity, + height: 1, + color: Colors.grey.withOpacity(0.2), + ), + const SizedBox(height: 32), + Text( + detailViewmodel.restaurant.reviews!.length.toString() + + (detailViewmodel.restaurant.reviews!.length > 1 + ? ' Reviews' + : 'Review'), + ), + const SizedBox(height: 32), + SizedBox( + height: (200.toInt() * + detailViewmodel.restaurant.reviews!.length) + .toDouble(), + child: ListView.builder( + primary: false, + shrinkWrap: true, + itemCount: detailViewmodel.restaurant.reviews?.length, + padding: const EdgeInsets.only(top: 10), + itemBuilder: (BuildContext context, int index) { + return Column( + children: [ + ReviewCard( + review: + detailViewmodel.restaurant.reviews![index], + ), + const SizedBox(height: 18), + Container( + width: double.infinity, + height: 1, + color: Colors.grey.withOpacity(0.2), + ), + const SizedBox(height: 32), + ], + ); + }, + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/Modules/Details/view/widgets/review_card.dart b/lib/Modules/Details/view/widgets/review_card.dart new file mode 100644 index 00000000..8a389300 --- /dev/null +++ b/lib/Modules/Details/view/widgets/review_card.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:restaurantour/Modules/Home/tabs/widgets/rating_stars_icon.dart'; +import 'package:restaurantour/models/restaurant.dart'; + +class ReviewCard extends StatelessWidget { + const ReviewCard({Key? key, required this.review}) : super(key: key); + final Review review; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + RatingStarsIcon( + amount: review.rating!.toDouble(), + ), + const SizedBox(height: 10), + const Text( + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi ornare sit amet nisl vitae pellentesque. Suspendisse libero est, pulvinar ut molestie dignissim, aliquet vel neque.', + style: TextStyle( + fontWeight: FontWeight.w300, + fontSize: 14, + ), + overflow: TextOverflow.ellipsis, + maxLines: 4, + ), + const SizedBox(height: 8), + Row( + children: [ + const CircleAvatar( + radius: 24, // Image radius + ), + const SizedBox(width: 8), + Text( + review.user!.name!, + style: const TextStyle( + fontWeight: FontWeight.w400, + fontSize: 12, + ), + ), + ], + ), + ], + ); + } +} diff --git a/lib/Modules/Details/viewmodel/detail_state.dart b/lib/Modules/Details/viewmodel/detail_state.dart new file mode 100644 index 00000000..369ac6a4 --- /dev/null +++ b/lib/Modules/Details/viewmodel/detail_state.dart @@ -0,0 +1,12 @@ +abstract class DetailState {} + +class InitialState extends DetailState {} + +class LoadingState extends DetailState {} + +class LoadedState extends DetailState {} + +class ErrorState extends DetailState { + ErrorState(this.message); + final String message; +} diff --git a/lib/Modules/Details/viewmodel/detail_viewmodel.dart b/lib/Modules/Details/viewmodel/detail_viewmodel.dart new file mode 100644 index 00000000..1ecb2558 --- /dev/null +++ b/lib/Modules/Details/viewmodel/detail_viewmodel.dart @@ -0,0 +1,47 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:restaurantour/Modules/Details/viewmodel/detail_state.dart'; +import 'package:restaurantour/adapter/shared_preferences/shared_preferences_keys.dart'; +import 'package:restaurantour/adapter/shared_preferences/shared_preferences_service.dart'; +import 'package:restaurantour/models/favorites.dart'; +import 'package:restaurantour/models/restaurant.dart'; + +class DetailViewmodel extends Cubit { + DetailViewmodel({ + required this.restaurant, + required this.favoriteRestaurants, + required this.sharedPreferencesService, + }) : super(InitialState()); + + final Restaurant restaurant; + RestaurantQueryResult favoriteRestaurants; + final SharedPreferencesService sharedPreferencesService; + Favorites fav = Favorites(ids: ['']); + + invertFavoriteValue() { + emit(LoadingState()); + restaurant.isFavorite = !restaurant.isFavorite; + emit(LoadedState()); + } + + Future addtoFavorites() async { + try { + emit(LoadingState()); + fav = Favorites.fromJson( + await sharedPreferencesService + .get(SharedPreferencesKeys.favoriteRestaurants), + ); + if (fav.ids.contains(restaurant.id)) { + fav.ids.remove(restaurant.id); + } else { + fav.ids.add(restaurant.id!); + } + await sharedPreferencesService.set( + SharedPreferencesKeys.favoriteRestaurants, + Favorites.toJson(fav), + ); + emit(LoadedState()); + } catch (e) { + emit(ErrorState('something went wrong')); + } + } +} diff --git a/lib/Modules/Home/tabs/all_restaurants_tab.dart b/lib/Modules/Home/tabs/all_restaurants_tab.dart new file mode 100644 index 00000000..502adea2 --- /dev/null +++ b/lib/Modules/Home/tabs/all_restaurants_tab.dart @@ -0,0 +1,116 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:restaurantour/Modules/Details/view/detail_injection.dart'; +import 'package:restaurantour/Modules/Details/view/detail_view.dart'; +import 'package:restaurantour/Modules/Home/tabs/widgets/categorie_text.dart'; +import 'package:restaurantour/Modules/Home/tabs/widgets/image_frame.dart'; +import 'package:restaurantour/Modules/Home/tabs/widgets/is_open_label.dart'; +import 'package:restaurantour/Modules/Home/tabs/widgets/price_text.dart'; +import 'package:restaurantour/Modules/Home/tabs/widgets/rating_stars_icon.dart'; +import 'package:restaurantour/Modules/Home/tabs/widgets/restaurant_card.dart'; +import 'package:restaurantour/Modules/Home/tabs/widgets/title_text.dart'; +import 'package:restaurantour/Modules/Home/viewmodel/home_state.dart'; +import 'package:restaurantour/Modules/Home/viewmodel/home_viewmodel.dart'; + +class AllRestaurantsTab extends StatelessWidget { + const AllRestaurantsTab({Key? key, required this.homeViewmodel}) + : super(key: key); + final HomeViewmodel homeViewmodel; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + if (state is LoadingState) { + return const Center(child: CircularProgressIndicator()); + } + if (state is ErrorState) { + return Center(child: Text(state.message)); + } + if (state is LoadedState) { + return ListView.builder( + itemCount: homeViewmodel.restaurants.restaurants!.length, + padding: const EdgeInsets.only(top: 10), + itemBuilder: (BuildContext context, int index) { + return GestureDetector( + onTap: () => { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => DetailInjection( + restaurant: + homeViewmodel.restaurants.restaurants![index], + favoriteRestaurants: homeViewmodel.favoriteRestaurants, + ), + ), + ) + }, + child: RestaurantCard( + child: Row( + children: [ + ImageFrame( + imageUrl: homeViewmodel + .restaurants.restaurants![index].photos![0], + ), + const SizedBox(width: 8), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TitleText( + text: homeViewmodel + .restaurants.restaurants![index].name, + ), + const SizedBox( + height: 12, + ), + Row( + children: [ + PriceText( + text: homeViewmodel + .restaurants.restaurants![index].price, + ), + CategorieText( + text: homeViewmodel + .restaurants + .restaurants![index] + .categories?[0] + .title) + ], + ), + Row( + children: [ + RatingStarsIcon( + amount: homeViewmodel.restaurants + .restaurants![index].rating!), + Expanded( + child: IsOpenLabel( + isOpen: homeViewmodel + .restaurants + .restaurants![index] + .hours?[0] + .isOpenNow, + ), + ), + const SizedBox( + width: 5, + ), + ], + ), + ], + ), + ) + ], + ), + ), + ); + }, + ); + } + return const Center( + child: Text("something went wrong, try again"), + ); + }, + ); + } +} diff --git a/lib/Modules/Home/tabs/my_favorites_tab/view/my_favorites_tab.dart b/lib/Modules/Home/tabs/my_favorites_tab/view/my_favorites_tab.dart new file mode 100644 index 00000000..f5652f10 --- /dev/null +++ b/lib/Modules/Home/tabs/my_favorites_tab/view/my_favorites_tab.dart @@ -0,0 +1,128 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:restaurantour/Modules/Details/view/detail_injection.dart'; +import 'package:restaurantour/Modules/Home/tabs/my_favorites_tab/viewmodel/my_favorites_state.dart'; +import 'package:restaurantour/Modules/Home/tabs/my_favorites_tab/viewmodel/my_favorites_viemodel.dart'; +import 'package:restaurantour/Modules/Home/tabs/widgets/categorie_text.dart'; +import 'package:restaurantour/Modules/Home/tabs/widgets/image_frame.dart'; +import 'package:restaurantour/Modules/Home/tabs/widgets/is_open_label.dart'; +import 'package:restaurantour/Modules/Home/tabs/widgets/price_text.dart'; +import 'package:restaurantour/Modules/Home/tabs/widgets/rating_stars_icon.dart'; +import 'package:restaurantour/Modules/Home/tabs/widgets/restaurant_card.dart'; +import 'package:restaurantour/Modules/Home/tabs/widgets/title_text.dart'; + +class MyFavoritesTab extends StatelessWidget { + const MyFavoritesTab({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final MyFavoritesViewmodel myFavoritesViewmodel = + BlocProvider.of(context); + + return BlocBuilder( + builder: (context, state) { + if (state is InitialState) { + myFavoritesViewmodel.fetchFavoriteRestaurants(); + } + if (state is LoadingState) { + return const Center(child: CircularProgressIndicator()); + } + if (state is LoadedState) { + return myFavoritesViewmodel.favoriteRestaurants.restaurants != null + ? ListView.builder( + itemCount: myFavoritesViewmodel + .favoriteRestaurants.restaurants!.length, + padding: const EdgeInsets.only(top: 10), + itemBuilder: (BuildContext context, int index) { + return GestureDetector( + onTap: () => { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => DetailInjection( + restaurant: myFavoritesViewmodel + .favoriteRestaurants.restaurants![index], + favoriteRestaurants: myFavoritesViewmodel + .homeViewmodel.favoriteRestaurants, + ), + ), + ), + }, + child: RestaurantCard( + child: Row( + children: [ + ImageFrame( + imageUrl: myFavoritesViewmodel.favoriteRestaurants + .restaurants![index].photos![0], + ), + const SizedBox(width: 8), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TitleText( + text: myFavoritesViewmodel + .favoriteRestaurants + .restaurants![index] + .name, + ), + const SizedBox( + height: 12, + ), + Row( + children: [ + PriceText( + text: myFavoritesViewmodel + .favoriteRestaurants + .restaurants![index] + .price, + ), + CategorieText( + text: myFavoritesViewmodel + .favoriteRestaurants + .restaurants![index] + .categories?[0] + .title, + ), + ], + ), + Row( + children: [ + RatingStarsIcon( + amount: myFavoritesViewmodel + .favoriteRestaurants + .restaurants![index] + .rating!, + ), + Expanded( + child: IsOpenLabel( + isOpen: myFavoritesViewmodel + .favoriteRestaurants + .restaurants![index] + .hours?[0] + .isOpenNow, + ), + ), + const SizedBox( + width: 5, + ), + ], + ), + ], + ), + ), + ], + ), + ), + ); + }, + ) + : const Center(child: Text("Add restaurants to favorite")); + } + return const Center( + child: Text("something went wrong, try again"), + ); + }, + ); + } +} diff --git a/lib/Modules/Home/tabs/my_favorites_tab/view/my_favorites_tab_injection.dart b/lib/Modules/Home/tabs/my_favorites_tab/view/my_favorites_tab_injection.dart new file mode 100644 index 00000000..c941a6be --- /dev/null +++ b/lib/Modules/Home/tabs/my_favorites_tab/view/my_favorites_tab_injection.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:restaurantour/Modules/Home/tabs/my_favorites_tab/view/my_favorites_tab.dart'; +import 'package:restaurantour/Modules/Home/tabs/my_favorites_tab/viewmodel/my_favorites_viemodel.dart'; +import 'package:restaurantour/Modules/Home/viewmodel/home_viewmodel.dart'; +import 'package:restaurantour/adapter/shared_preferences/shared_preferences_adapter.dart'; +import 'package:restaurantour/adapter/shared_preferences/shared_preferences_service.dart'; + +class MyFavoritesInjection extends StatelessWidget { + const MyFavoritesInjection({Key? key, required this.homeViewmodel}) + : super(key: key); + + final HomeViewmodel homeViewmodel; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => MyFavoritesViewmodel( + homeViewmodel, + SharedPreferencesService( + SharedpreferencesAdapter(), + ), + ), + child: const MyFavoritesTab(), + ); + } +} diff --git a/lib/Modules/Home/tabs/my_favorites_tab/viewmodel/my_favorites_state.dart b/lib/Modules/Home/tabs/my_favorites_tab/viewmodel/my_favorites_state.dart new file mode 100644 index 00000000..a3ac8eff --- /dev/null +++ b/lib/Modules/Home/tabs/my_favorites_tab/viewmodel/my_favorites_state.dart @@ -0,0 +1,12 @@ +abstract class MyFavoritesState {} + +class InitialState extends MyFavoritesState {} + +class LoadingState extends MyFavoritesState {} + +class LoadedState extends MyFavoritesState {} + +class ErrorState extends MyFavoritesState { + ErrorState(this.message); + final String message; +} diff --git a/lib/Modules/Home/tabs/my_favorites_tab/viewmodel/my_favorites_viemodel.dart b/lib/Modules/Home/tabs/my_favorites_tab/viewmodel/my_favorites_viemodel.dart new file mode 100644 index 00000000..b5bc09b3 --- /dev/null +++ b/lib/Modules/Home/tabs/my_favorites_tab/viewmodel/my_favorites_viemodel.dart @@ -0,0 +1,42 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:restaurantour/Modules/Home/tabs/my_favorites_tab/viewmodel/my_favorites_state.dart'; +import 'package:restaurantour/Modules/Home/viewmodel/home_viewmodel.dart'; +import 'package:restaurantour/adapter/shared_preferences/shared_preferences_keys.dart'; +import 'package:restaurantour/adapter/shared_preferences/shared_preferences_service.dart'; +import 'package:restaurantour/models/favorites.dart'; +import 'package:restaurantour/models/restaurant.dart'; + +class MyFavoritesViewmodel extends Cubit { + MyFavoritesViewmodel(this.homeViewmodel, this.sharedPreferencesService) + : super(InitialState()); + + final HomeViewmodel homeViewmodel; + final SharedPreferencesService sharedPreferencesService; + + late List restaurants; + Favorites fav = Favorites(ids: ['']); + RestaurantQueryResult favoriteRestaurants = + // ignore: prefer_const_constructors + RestaurantQueryResult(restaurants: []); + + Future fetchFavoriteRestaurants() async { + try { + emit(LoadingState()); + restaurants = homeViewmodel.restaurants.restaurants!; + + fav = Favorites.fromJson( + await sharedPreferencesService.get( + SharedPreferencesKeys.favoriteRestaurants, + ), + ); + for (Restaurant restaurant in restaurants!) { + if (fav.ids.contains(restaurant.id)) { + favoriteRestaurants.restaurants!.add(restaurant); + } + } + emit(LoadedState()); + } catch (e) { + emit(ErrorState('something went wrong')); + } + } +} diff --git a/lib/Modules/Home/tabs/widgets/categorie_text.dart b/lib/Modules/Home/tabs/widgets/categorie_text.dart new file mode 100644 index 00000000..8f624b15 --- /dev/null +++ b/lib/Modules/Home/tabs/widgets/categorie_text.dart @@ -0,0 +1,17 @@ +import 'package:flutter/material.dart'; + +class CategorieText extends StatelessWidget { + const CategorieText({Key? key, required this.text}) : super(key: key); + + final String? text; + + @override + Widget build(BuildContext context) { + return Text( + ' ' + (text ?? ''), + style: const TextStyle( + fontSize: 12, + ), + ); + } +} diff --git a/lib/Modules/Home/tabs/widgets/image_frame.dart b/lib/Modules/Home/tabs/widgets/image_frame.dart new file mode 100644 index 00000000..32a2f2db --- /dev/null +++ b/lib/Modules/Home/tabs/widgets/image_frame.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; + +class ImageFrame extends StatelessWidget { + const ImageFrame({Key? key, required this.imageUrl}) : super(key: key); + + final String? imageUrl; + + @override + Widget build(BuildContext context) { + return Container( + width: 80, + height: 80, + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular( + 8, + ), + ), + child: imageUrl != null + ? Image.network( + imageUrl!, + fit: BoxFit.cover, + ) + : const Text('no photo'), + ); + } +} diff --git a/lib/Modules/Home/tabs/widgets/is_open_label.dart b/lib/Modules/Home/tabs/widgets/is_open_label.dart new file mode 100644 index 00000000..5b5c5e40 --- /dev/null +++ b/lib/Modules/Home/tabs/widgets/is_open_label.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; + +class IsOpenLabel extends StatelessWidget { + const IsOpenLabel({Key? key, this.isOpen}) : super(key: key); + + final bool? isOpen; + + @override + Widget build(BuildContext context) { + return Builder( + builder: (context) => isOpen != null + ? (isOpen! + ? Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + const Text( + 'Open Now', + key: Key('open_text'), + textAlign: TextAlign.right, + style: TextStyle(fontStyle: FontStyle.italic), + ), + const SizedBox(width: 8), + Container( + height: 10, + width: 10, + decoration: BoxDecoration( + border: Border.all( + color: Colors.green, + ), + borderRadius: BorderRadius.circular(100), + color: Colors.green, + ), + ), + ], + ) + : Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + const Text( + 'Closed', + key: Key('closed_text'), + textAlign: TextAlign.right, + style: TextStyle(fontStyle: FontStyle.italic), + ), + const SizedBox(width: 8), + Container( + height: 10, + width: 10, + decoration: BoxDecoration( + border: Border.all( + color: Colors.red, + ), + borderRadius: BorderRadius.circular(100), + color: Colors.red, + ), + ), + ], + )) + : const Text( + 'not informed', + key: Key('not_informed_text'), + textAlign: TextAlign.right, + style: TextStyle(fontStyle: FontStyle.italic), + ), + ); + } +} diff --git a/lib/Modules/Home/tabs/widgets/price_text.dart b/lib/Modules/Home/tabs/widgets/price_text.dart new file mode 100644 index 00000000..fc4a175c --- /dev/null +++ b/lib/Modules/Home/tabs/widgets/price_text.dart @@ -0,0 +1,18 @@ +import 'package:flutter/material.dart'; + +class PriceText extends StatelessWidget { + const PriceText({Key? key, required this.text}) : super(key: key); + + final String? text; + + @override + Widget build(BuildContext context) { + return Text( + text ?? 'Not informed', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ); + } +} diff --git a/lib/Modules/Home/tabs/widgets/rating_stars_icon.dart b/lib/Modules/Home/tabs/widgets/rating_stars_icon.dart new file mode 100644 index 00000000..6909232d --- /dev/null +++ b/lib/Modules/Home/tabs/widgets/rating_stars_icon.dart @@ -0,0 +1,19 @@ +import 'package:flutter/material.dart'; + +class RatingStarsIcon extends StatelessWidget { + const RatingStarsIcon({Key? key, required this.amount}) : super(key: key); + + final double amount; + + @override + Widget build(BuildContext context) { + return Row(children: [ + for (int i = 0; i < amount; i++) + const Icon( + Icons.star, + size: 14, + color: Color.fromARGB(255, 255, 205, 40), + ), + ]); + } +} diff --git a/lib/Modules/Home/tabs/widgets/restaurant_card.dart b/lib/Modules/Home/tabs/widgets/restaurant_card.dart new file mode 100644 index 00000000..4ca62c32 --- /dev/null +++ b/lib/Modules/Home/tabs/widgets/restaurant_card.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; + +class RestaurantCard extends StatelessWidget { + const RestaurantCard({ + Key? key, + required this.child, + }) : super(key: key); + + final Widget child; + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + padding: const EdgeInsets.all(3.0), + decoration: BoxDecoration( + border: Border.all( + color: Colors.transparent, + ), + borderRadius: BorderRadius.circular(8), + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.2), + spreadRadius: 1, + blurRadius: 0.5, + offset: const Offset(1, 1), // changes position of shadow + ), + ], + ), + child: child, + ); + } +} diff --git a/lib/Modules/Home/tabs/widgets/title_text.dart b/lib/Modules/Home/tabs/widgets/title_text.dart new file mode 100644 index 00000000..b142a6a7 --- /dev/null +++ b/lib/Modules/Home/tabs/widgets/title_text.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; + +class TitleText extends StatelessWidget { + const TitleText({Key? key, required this.text}) : super(key: key); + + final String? text; + + @override + Widget build(BuildContext context) { + return Text( + text ?? 'unkown', + textAlign: TextAlign.left, + overflow: TextOverflow.ellipsis, + maxLines: 2, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ); + } +} diff --git a/lib/Modules/Home/view/home_injection.dart b/lib/Modules/Home/view/home_injection.dart new file mode 100644 index 00000000..a2ca9918 --- /dev/null +++ b/lib/Modules/Home/view/home_injection.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:restaurantour/Modules/Home/view/home_view.dart'; +import 'package:restaurantour/Modules/Home/viewmodel/home_viewmodel.dart'; +import 'package:restaurantour/adapter/shared_preferences/shared_preferences_adapter.dart'; +import 'package:restaurantour/adapter/shared_preferences/shared_preferences_service.dart'; +import 'package:restaurantour/repositories/restaurants/restaurants_repository.dart'; +import 'package:restaurantour/repositories/yelp_repository.dart'; + +class HomeInjection extends StatelessWidget { + const HomeInjection({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => HomeViewmodel( + RestaurantsRepository( + yelpRepository: YelpRepository(), + sharedPreferences: SharedPreferencesService( + SharedpreferencesAdapter(), + ), + ), + SharedPreferencesService( + SharedpreferencesAdapter(), + ), + ), + child: const HomeView(), + ); + } +} diff --git a/lib/Modules/Home/view/home_view.dart b/lib/Modules/Home/view/home_view.dart new file mode 100644 index 00000000..c6bc1ec4 --- /dev/null +++ b/lib/Modules/Home/view/home_view.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:restaurantour/Modules/Home/tabs/all_restaurants_tab.dart'; +import 'package:restaurantour/Modules/Home/tabs/my_favorites_tab/view/my_favorites_tab_injection.dart'; +import 'package:restaurantour/Modules/Home/viewmodel/home_state.dart'; +import 'package:restaurantour/Modules/Home/viewmodel/home_viewmodel.dart'; + +class HomeView extends StatelessWidget { + const HomeView({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final HomeViewmodel homeViewmodel = BlocProvider.of(context); + homeViewmodel.fetchRestaurants(); + + return BlocBuilder( + builder: (context, state) { + return DefaultTabController( + length: 2, + child: Scaffold( + appBar: AppBar( + backgroundColor: Colors.white, + bottom: TabBar( + indicatorColor: Colors.black, + indicatorSize: TabBarIndicatorSize.label, + tabs: [ + Container( + alignment: Alignment.centerRight, + child: const Tab( + child: Text( + 'All Restaurants', + style: TextStyle(color: Colors.black), + ), + ), + ), + Container( + alignment: Alignment.centerLeft, + child: const Tab( + child: Text( + 'My Favorites', + style: TextStyle(color: Colors.black), + ), + ), + ), + ], + ), + title: const Center( + child: Text( + 'RestauranTour', + style: TextStyle(color: Colors.black), + ), + ), + ), + body: TabBarView( + children: [ + AllRestaurantsTab(homeViewmodel: homeViewmodel), + // Text('data1'), + // Text('data'), + MyFavoritesInjection(homeViewmodel: homeViewmodel), + ], + ), + ), + ); + }, + ); + } +} diff --git a/lib/Modules/Home/viewmodel/home_state.dart b/lib/Modules/Home/viewmodel/home_state.dart new file mode 100644 index 00000000..0ec60b3b --- /dev/null +++ b/lib/Modules/Home/viewmodel/home_state.dart @@ -0,0 +1,12 @@ +abstract class HomeState {} + +class InitialState extends HomeState {} + +class LoadingState extends HomeState {} + +class LoadedState extends HomeState {} + +class ErrorState extends HomeState { + ErrorState(this.message); + final String message; +} diff --git a/lib/Modules/Home/viewmodel/home_viewmodel.dart b/lib/Modules/Home/viewmodel/home_viewmodel.dart new file mode 100644 index 00000000..41182983 --- /dev/null +++ b/lib/Modules/Home/viewmodel/home_viewmodel.dart @@ -0,0 +1,31 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:restaurantour/Modules/Home/viewmodel/home_state.dart'; +import 'package:restaurantour/adapter/shared_preferences/shared_preferences_service.dart'; +import 'package:restaurantour/errors/not_found_exception.dart'; +import 'package:restaurantour/models/favorites.dart'; +import 'package:restaurantour/models/restaurant.dart'; +import 'package:restaurantour/repositories/restaurants/restaurants_repository.dart'; + +class HomeViewmodel extends Cubit { + HomeViewmodel(this.repository, this.sharedPreferencesService) + : super(InitialState()); + final RestaurantsRepository repository; + RestaurantQueryResult restaurants = const RestaurantQueryResult(); + + RestaurantQueryResult favoriteRestaurants = + // ignore: prefer_const_constructors + RestaurantQueryResult(restaurants: []); + + final SharedPreferencesService sharedPreferencesService; + Favorites fav = Favorites(ids: ['']); + + Future fetchRestaurants() async { + try { + emit(LoadingState()); + restaurants = await repository.getRestaurants(); + emit(LoadedState()); + } on NotFoundException catch (e) { + emit(ErrorState(e.message ?? '')); + } + } +} diff --git a/lib/adapter/shared_preferences/shared_preferences_adapter.dart b/lib/adapter/shared_preferences/shared_preferences_adapter.dart new file mode 100644 index 00000000..cf2d937e --- /dev/null +++ b/lib/adapter/shared_preferences/shared_preferences_adapter.dart @@ -0,0 +1,24 @@ +import 'package:restaurantour/adapter/shared_preferences/shared_preferences_interface.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class SharedpreferencesAdapter implements SharedPreferencesInterface { + final Future _prefs = SharedPreferences.getInstance(); + + @override + Future get(String key) async { + final SharedPreferences prefs = await _prefs; + return prefs.getString(key); + } + + @override + Future remove(String key) async { + final SharedPreferences prefs = await _prefs; + prefs.remove(key); + } + + @override + Future set(String key, String value) async { + final SharedPreferences prefs = await _prefs; + prefs.setString(key, value); + } +} diff --git a/lib/adapter/shared_preferences/shared_preferences_interface.dart b/lib/adapter/shared_preferences/shared_preferences_interface.dart new file mode 100644 index 00000000..516e263a --- /dev/null +++ b/lib/adapter/shared_preferences/shared_preferences_interface.dart @@ -0,0 +1,5 @@ +abstract class SharedPreferencesInterface { + Future set(String key, String value); + Future remove(String key); + Future get(String key); +} diff --git a/lib/adapter/shared_preferences/shared_preferences_keys.dart b/lib/adapter/shared_preferences/shared_preferences_keys.dart new file mode 100644 index 00000000..17a8980c --- /dev/null +++ b/lib/adapter/shared_preferences/shared_preferences_keys.dart @@ -0,0 +1,4 @@ +class SharedPreferencesKeys { + static const String restaurants = 'restaurants'; + static const String favoriteRestaurants = 'favorite_restaurants'; +} diff --git a/lib/adapter/shared_preferences/shared_preferences_service.dart b/lib/adapter/shared_preferences/shared_preferences_service.dart new file mode 100644 index 00000000..e7ebdb19 --- /dev/null +++ b/lib/adapter/shared_preferences/shared_preferences_service.dart @@ -0,0 +1,23 @@ +import 'package:restaurantour/adapter/shared_preferences/shared_preferences_interface.dart'; +import 'package:restaurantour/errors/not_found_exception.dart'; + +class SharedPreferencesService { + SharedPreferencesService(this.sharedPreferences); + SharedPreferencesInterface sharedPreferences; + + Future set(String key, String value) async { + await sharedPreferences.set(key, value); + } + + Future get(String key) async { + String? value = await sharedPreferences.get(key); + if (value == null) { + throw NotFoundException(); + } + return value; + } + + Future remove(String key) async { + await sharedPreferences.remove(key); + } +} diff --git a/lib/errors/general_exception.dart b/lib/errors/general_exception.dart new file mode 100644 index 00000000..a85ada29 --- /dev/null +++ b/lib/errors/general_exception.dart @@ -0,0 +1,5 @@ +class GeneralException { + final String? message; + + GeneralException({this.message}); +} diff --git a/lib/errors/not_found_exception.dart b/lib/errors/not_found_exception.dart new file mode 100644 index 00000000..84b3fac0 --- /dev/null +++ b/lib/errors/not_found_exception.dart @@ -0,0 +1,5 @@ +import 'package:restaurantour/errors/general_exception.dart'; + +class NotFoundException extends GeneralException { + NotFoundException({String? message}) : super(message: message); +} diff --git a/lib/main.dart b/lib/main.dart index c6ce7473..93fb1177 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; -import 'package:restaurantour/repositories/yelp_repository.dart'; +import 'package:restaurantour/Modules/Home/view/home_injection.dart'; -void main() { +void main() async { runApp(const Restaurantour()); } @@ -12,6 +12,7 @@ class Restaurantour extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( + debugShowCheckedModeBanner: false, title: 'RestauranTour', theme: ThemeData( visualDensity: VisualDensity.adaptivePlatformDensity, @@ -26,32 +27,8 @@ class HomePage extends StatelessWidget { @override Widget build(BuildContext context) { - return Scaffold( - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text('Restaurantour'), - ElevatedButton( - child: const Text('Fetch Restaurants'), - onPressed: () async { - final yelpRepo = YelpRepository(); - - try { - final result = await yelpRepo.getRestaurants(); - if (result != null) { - print('Fetched ${result.restaurants!.length} restaurants'); - } else { - print('No restaurants fetched'); - } - } catch (e) { - print('Failed to fetch restaurants: $e'); - } - }, - ), - ], - ), - ), + return const Scaffold( + body: HomeInjection(), ); } } diff --git a/lib/models/favorites.dart b/lib/models/favorites.dart new file mode 100644 index 00000000..dba8d2ab --- /dev/null +++ b/lib/models/favorites.dart @@ -0,0 +1,20 @@ +import 'dart:convert'; + +class Favorites { + final List ids; + + Favorites({required this.ids}); + + static String toJson(Favorites favorites) { + return jsonEncode({ + 'ids': favorites.ids, + }); + } + + static Favorites fromJson(String jsonString) { + Map jsonMap = jsonDecode(jsonString); + return Favorites( + ids: List.from(jsonMap['ids']), + ); + } +} diff --git a/lib/models/restaurant.dart b/lib/models/restaurant.dart index 87c7aab5..717d533d 100644 --- a/lib/models/restaurant.dart +++ b/lib/models/restaurant.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:json_annotation/json_annotation.dart'; part 'restaurant.g.dart'; @@ -93,8 +95,9 @@ class Restaurant { final List? hours; final List? reviews; final Location? location; + bool isFavorite; - const Restaurant({ + Restaurant({ this.id, this.name, this.price, @@ -104,6 +107,7 @@ class Restaurant { this.hours, this.reviews, this.location, + this.isFavorite = false, }); factory Restaurant.fromJson(Map json) => @@ -152,4 +156,14 @@ class RestaurantQueryResult { _$RestaurantQueryResultFromJson(json); Map toJson() => _$RestaurantQueryResultToJson(this); + + static String encode(List musics) => jsonEncode( + musics.map>((music) => music.toJson()).toList(), + ); + + static List decode(String musics) => + (json.decode(musics) as List) + .map( + (item) => RestaurantQueryResult.fromJson(item)) + .toList(); } diff --git a/lib/repositories/restaurants/restaurants_interface.dart b/lib/repositories/restaurants/restaurants_interface.dart new file mode 100644 index 00000000..f4ea24e8 --- /dev/null +++ b/lib/repositories/restaurants/restaurants_interface.dart @@ -0,0 +1,5 @@ +import 'package:restaurantour/models/restaurant.dart'; + +abstract class RestaurantsRepositoryInterface { + Future getRestaurants(); +} diff --git a/lib/repositories/restaurants/restaurants_repository.dart b/lib/repositories/restaurants/restaurants_repository.dart new file mode 100644 index 00000000..e34ca4ff --- /dev/null +++ b/lib/repositories/restaurants/restaurants_repository.dart @@ -0,0 +1,31 @@ +import 'package:restaurantour/adapter/shared_preferences/shared_preferences_service.dart'; +import 'package:restaurantour/errors/not_found_exception.dart'; +import 'package:restaurantour/models/restaurant.dart'; +import 'package:restaurantour/repositories/restaurants/restaurants_interface.dart'; +import 'package:restaurantour/repositories/yelp_repository.dart'; + +class RestaurantsRepository implements RestaurantsRepositoryInterface { + RestaurantsRepository({ + required this.yelpRepository, + required this.sharedPreferences, + }); + + final YelpRepository yelpRepository; + final SharedPreferencesService sharedPreferences; + + @override + Future getRestaurants() async { + try { + final result = await yelpRepository.getRestaurants(); + if (result != null) { + return result; + } else { + throw NotFoundException(); + } + } on NotFoundException { + throw 'No restaurants found'; + } catch (e) { + throw 'error: $e'; + } + } +} diff --git a/pubspec.yaml b/pubspec.yaml index be3055e0..cf1a70d9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,6 +16,14 @@ dependencies: dio: ^5.4.0 json_annotation: ^4.8.1 flutter_svg: ^2.0.9 + flutter_bloc: ^8.1.1 + shared_preferences: ^2.0.15 + file: ^6.1.4 + sqflite: ^2.3.2 + path: ^1.8.3 + path_provider: ^2.0.11 + mobx: ^2.3.0+1 + dev_dependencies: flutter_test: @@ -23,6 +31,7 @@ dev_dependencies: flutter_lints: ^1.0.2 build_runner: ^2.4.8 json_serializable: ^6.7.1 + mockito: ^5.0.0 flutter: uses-material-design: true diff --git a/test/Modules/Home/tabs/widgets/is_open_label_test.dart b/test/Modules/Home/tabs/widgets/is_open_label_test.dart new file mode 100644 index 00000000..8376a272 --- /dev/null +++ b/test/Modules/Home/tabs/widgets/is_open_label_test.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:restaurantour/Modules/Home/tabs/widgets/is_open_label.dart'; + +void main() { + testWidgets( + 'IsOpenLabel should display correct text and color based on isOpen value', + (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: IsOpenLabel(isOpen: true), + ), + ), + ); + + await tester.pumpAndSettle(); + + // Verify if the widget displays "Open Now" text + expect(find.text('Open Now'), findsOneWidget); + + // Verify if the widget displays a green circle indicating open status + var greenColor = Colors.green.value; + expect( + find.byWidgetPredicate( + (widget) => + widget is Container && + widget.decoration is BoxDecoration && + (widget.decoration as BoxDecoration).color!.value == greenColor, + ), + findsOneWidget, + ); + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: IsOpenLabel(isOpen: false), + ), + ), + ); + + await tester.pumpAndSettle(); + + // Verify if the widget displays "Closed" text + expect(find.text('Closed'), findsOneWidget); + + // Verify if the widget displays a red circle indicating closed status + var redColor = Colors.red.value; + expect( + find.byWidgetPredicate( + (widget) => + widget is Container && + widget.decoration is BoxDecoration && + (widget.decoration as BoxDecoration).color!.value == redColor, + ), + findsOneWidget, + ); + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: IsOpenLabel(isOpen: null), + ), + ), + ); + + await tester.pumpAndSettle(); + + // Verify if the widget displays "not informed" text + expect(find.text('not informed'), findsOneWidget); + }); +} diff --git a/test/models/favorites_test.dart b/test/models/favorites_test.dart new file mode 100644 index 00000000..a379db4c --- /dev/null +++ b/test/models/favorites_test.dart @@ -0,0 +1,18 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:restaurantour/models/favorites.dart'; + +void main() { + group('Favorites', () { + test('toJson should encode Favorites object to JSON string', () { + final favorites = Favorites(ids: ['id1', 'id2', 'id3']); + final jsonString = Favorites.toJson(favorites); + expect(jsonString, '{"ids":["id1","id2","id3"]}'); + }); + + test('fromJson should decode JSON string to Favorites object', () { + const jsonString = '{"ids":["id1","id2","id3"]}'; + final favorites = Favorites.fromJson(jsonString); + expect(favorites.ids, ['id1', 'id2', 'id3']); + }); + }); +}