diff --git a/.fvmrc b/.fvmrc new file mode 100644 index 0000000..e03e940 --- /dev/null +++ b/.fvmrc @@ -0,0 +1,4 @@ +{ + "flutter": "3.22.3", + "flavors": {} +} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 1be2d87..7ff13d6 100644 --- a/.gitignore +++ b/.gitignore @@ -46,4 +46,8 @@ app.*.map.json /android/app/release # fvm -.fvm/flutter_sdk \ No newline at end of file +.fvm/flutter_sdk + +# environments +environments/.env.dev +environments/.env.prod diff --git a/.vscode/launch.json b/.vscode/launch.json index 5d0f1d3..b927197 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -5,9 +5,21 @@ "version": "0.2.0", "configurations": [ { - "name": "app", + "name": "dev", "request": "launch", - "type": "dart" + "type": "dart", + "args": [ + "--dart-define=environment=dev" + ] + }, + { + "name": "prod", + "request": "launch", + "type": "dart", + "args": [ + "--release", + "--dart-define=environment=prod" + ] } ] } \ No newline at end of file diff --git a/environments/.env.example b/environments/.env.example new file mode 100644 index 0000000..d4b0f9c --- /dev/null +++ b/environments/.env.example @@ -0,0 +1,2 @@ +API_KEY=YOUR_API_KEY +API_URL=YOUR_API_URL \ No newline at end of file diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig index 592ceee..ec97fc6 100644 --- a/ios/Flutter/Debug.xcconfig +++ b/ios/Flutter/Debug.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "Generated.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig index 592ceee..c4855bf 100644 --- a/ios/Flutter/Release.xcconfig +++ b/ios/Flutter/Release.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "Generated.xcconfig" diff --git a/ios/Podfile b/ios/Podfile new file mode 100644 index 0000000..d97f17e --- /dev/null +++ b/ios/Podfile @@ -0,0 +1,44 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '12.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + use_modular_headers! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/lib/core/database/app_database.dart b/lib/core/database/app_database.dart new file mode 100644 index 0000000..9f6fdcb --- /dev/null +++ b/lib/core/database/app_database.dart @@ -0,0 +1,37 @@ +import 'dart:io'; + +import 'package:path/path.dart' as path; +import 'package:path_provider/path_provider.dart'; +import 'package:sembast/sembast.dart'; +import 'package:sembast/sembast_io.dart'; + +class AppDatabase { + static final AppDatabase _instance = AppDatabase._internal(); + static final DatabaseFactory _dbFactory = databaseFactoryIo; + static Database? _database; + + factory AppDatabase() => _instance; + + AppDatabase._internal(); + + Future get database async { + if (_database != null) return _database!; + + _database = await _initDatabase(); + return _database!; + } + + Future _initDatabase() async { + Directory appDocDir = await getApplicationDocumentsDirectory(); + String dbPath = path.join(appDocDir.path, 'flutter_test.db'); + Database database = await _dbFactory.openDatabase(dbPath); + return database; + } + + Future closeDatabase() async { + if (_database != null) { + await _database!.close(); + _database = null; + } + } +} diff --git a/lib/core/database/database.dart b/lib/core/database/database.dart new file mode 100644 index 0000000..0bcdf68 --- /dev/null +++ b/lib/core/database/database.dart @@ -0,0 +1 @@ +export 'favorite_restaurants_dao.dart'; diff --git a/lib/core/database/favorite_restaurants_dao.dart b/lib/core/database/favorite_restaurants_dao.dart new file mode 100644 index 0000000..f948ad4 --- /dev/null +++ b/lib/core/database/favorite_restaurants_dao.dart @@ -0,0 +1,36 @@ +import 'dart:async'; + +import 'package:sembast/sembast.dart'; + +import 'package:restaurant_tour/core/database/app_database.dart'; +import 'package:restaurant_tour/core/models/restaurant.dart'; + +class FavoriteRestaurantsDao { + static const String favoriteRestaurantsStoreName = 'favorite_restaurants'; + + final _favoriteRestaurantsStore = + intMapStoreFactory.store(favoriteRestaurantsStoreName); + + Future get _db async => AppDatabase().database; + + Future insert(Restaurant restaurant) async { + await _favoriteRestaurantsStore.add(await _db, restaurant.toJson()); + } + + Future> getAll() async { + final list = await _favoriteRestaurantsStore.find(await _db); + return list.map((e) => Restaurant.fromJson(e.value)).toList(); + } + + Future delete({required String restaurantId}) async { + await _favoriteRestaurantsStore.delete( + await _db, + finder: Finder( + filter: Filter.equals( + 'id', + restaurantId, + ), + ), + ); + } +} diff --git a/lib/models/restaurant.dart b/lib/core/models/restaurant.dart similarity index 100% rename from lib/models/restaurant.dart rename to lib/core/models/restaurant.dart diff --git a/lib/models/restaurant.g.dart b/lib/core/models/restaurant.g.dart similarity index 100% rename from lib/models/restaurant.g.dart rename to lib/core/models/restaurant.g.dart diff --git a/lib/core/routes.dart b/lib/core/routes.dart new file mode 100644 index 0000000..476544a --- /dev/null +++ b/lib/core/routes.dart @@ -0,0 +1,16 @@ +import 'package:flutter/material.dart'; + +import 'package:restaurant_tour/modules/home/home_page.dart'; +import 'package:restaurant_tour/modules/restaurant_detail/restaurant_detail_page.dart'; + +class RoutePaths { + static const String initial = '/'; + static const String restaurantDetail = 'restaurant-detail'; +} + +Map getRoutes() { + return { + RoutePaths.initial: (_) => const HomePage(), + RoutePaths.restaurantDetail: (_) => const RestaurantDetailPage(), + }; +} diff --git a/lib/core/services/dio_service.dart b/lib/core/services/dio_service.dart new file mode 100644 index 0000000..a70ed3f --- /dev/null +++ b/lib/core/services/dio_service.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; + +import 'package:dio/dio.dart'; + +import 'package:restaurant_tour/core/services/dotenv_service.dart'; +import 'package:restaurant_tour/design_system/design_system.dart'; + +late Dio dio; + +class DioService { + static final BaseOptions dioOptions = BaseOptions( + baseUrl: DotenvService.apiUrl, + ); + + static init({ + required String apiKey, + required GlobalKey snackbarKey, + }) { + dio = Dio(dioOptions); + + dio.interceptors.add( + LogInterceptor( + requestBody: true, + responseBody: true, + ), + ); + + dio.interceptors.add( + InterceptorsWrapper( + onRequest: (options, handler) { + options.headers['Authorization'] = 'Bearer $apiKey'; + return handler.next(options); + }, + onResponse: (response, handler) => handler.next(response), + onError: (error, handler) async { + if (error.response?.statusCode == 401) { + showSnackBar( + snackbarKey: snackbarKey, + message: 'Invalid credentials', + ); + } + + return handler.next(error); + }, + ), + ); + } + + static showSnackBar({ + required GlobalKey snackbarKey, + required String message, + }) { + final snackBar = SnackBar( + backgroundColor: AppColors.error, + content: Text( + message, + style: const TextStyle( + color: DsColors.white, + ), + ), + ); + snackbarKey.currentState?.showSnackBar(snackBar); + } +} diff --git a/lib/core/services/dotenv_service.dart b/lib/core/services/dotenv_service.dart new file mode 100644 index 0000000..e0b34bf --- /dev/null +++ b/lib/core/services/dotenv_service.dart @@ -0,0 +1,24 @@ +import 'package:flutter_dotenv/flutter_dotenv.dart'; + +class DotenvService { + static final DotenvService _instance = DotenvService._internal(); + + factory DotenvService() => _instance; + + DotenvService._internal(); + + late String _environment; + + Future init() async { + _environment = _getEnvironment(); + await dotenv.load(fileName: 'environments/.env.$_environment'); + } + + String _getEnvironment() => const String.fromEnvironment( + 'environment', + defaultValue: 'dev', + ); + + static final apiKey = dotenv.get('API_KEY'); + static final apiUrl = dotenv.get('API_URL'); +} diff --git a/lib/design_system/atoms/atoms.dart b/lib/design_system/atoms/atoms.dart new file mode 100644 index 0000000..a07589e --- /dev/null +++ b/lib/design_system/atoms/atoms.dart @@ -0,0 +1,3 @@ +export 'ds_rating.dart'; +export 'ds_texts.dart'; +export 'ds_image_network.dart'; diff --git a/lib/design_system/atoms/ds_image_network.dart b/lib/design_system/atoms/ds_image_network.dart new file mode 100644 index 0000000..833685c --- /dev/null +++ b/lib/design_system/atoms/ds_image_network.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; + +class DsImageNetwork extends StatelessWidget { + const DsImageNetwork({ + super.key, + this.urlImage, + this.emptyIcon, + this.errorIcon, + this.fit, + this.height, + this.width = 100, + this.isRounded, + }); + + final String? urlImage; + final Widget? emptyIcon; + final Widget? errorIcon; + final BoxFit? fit; + final double? height; + final double width; + final bool? isRounded; + + @override + Widget build(BuildContext context) { + return urlImage == null + ? Icon( + Icons.image, + color: Colors.grey.shade300, + size: width, + ) + : _RoundedBorderImage( + isRounded: isRounded ?? false, + child: Image.network( + urlImage!, + fit: fit ?? BoxFit.fill, + width: width, + height: height ?? width, + loadingBuilder: (context, child, loadingProgress) => + loadingProgress == null + ? child + : Center( + child: SizedBox( + width: width, + height: height ?? width, + child: const CircularProgressIndicator(), + ), + ), + errorBuilder: (context, error, stackTrace) => + errorIcon ?? + Icon( + Icons.image, + size: width, + ), + ), + ); + } +} + +class _RoundedBorderImage extends StatelessWidget { + const _RoundedBorderImage({ + required this.isRounded, + required this.child, + }); + + final bool isRounded; + final Widget child; + + @override + Widget build(BuildContext context) { + return isRounded + ? Padding( + padding: const EdgeInsets.all(16.0), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: child, + ), + ) + : child; + } +} diff --git a/lib/design_system/atoms/ds_rating.dart b/lib/design_system/atoms/ds_rating.dart new file mode 100644 index 0000000..0f2746e --- /dev/null +++ b/lib/design_system/atoms/ds_rating.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_rating_bar/flutter_rating_bar.dart'; + +class DsRating extends StatelessWidget { + const DsRating({ + super.key, + required this.initialRating, + required this.itemCount, + this.onRatingUpdate, + this.minRating, + this.itemSize, + this.gapHorizontal, + this.icon, + this.iconColor, + }); + + final double initialRating; + final int itemCount; + final Function(double value)? onRatingUpdate; + final double? minRating; + final double? itemSize; + final double? gapHorizontal; + final IconData? icon; + final Color? iconColor; + + @override + Widget build(BuildContext context) { + return RatingBar.builder( + initialRating: initialRating, + minRating: minRating ?? 0, + direction: Axis.horizontal, + allowHalfRating: true, + itemCount: itemCount, + itemSize: itemSize ?? 16, + itemPadding: EdgeInsets.symmetric(horizontal: gapHorizontal ?? 2), + itemBuilder: (context, index) => Icon( + icon ?? Icons.star, + color: iconColor ?? Colors.amber, + ), + onRatingUpdate: onRatingUpdate ?? (value) => print(value), + ); + } +} diff --git a/lib/design_system/atoms/ds_texts.dart b/lib/design_system/atoms/ds_texts.dart new file mode 100644 index 0000000..0ab0714 --- /dev/null +++ b/lib/design_system/atoms/ds_texts.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; + +import 'package:restaurant_tour/design_system/foundations/foundations.dart'; +import 'package:restaurant_tour/design_system/tokens/tokens.dart'; + +enum TextVariant { + /// fontSize: 18 + title(fontSize: 24), + + /// fontSize: 16 + subTitle(fontSize: 18), + + /// fontSize: 14 + medium(fontSize: 14), + + /// fontSize: 12 + caption(fontSize: 12), + + /// fontSize: 10 + small(fontSize: 10); + + const TextVariant({required this.fontSize}); + + final double fontSize; +} + +class DsText extends StatelessWidget { + const DsText( + this.text, { + super.key, + this.textVariant = TextVariant.medium, + this.isBold, + this.fontFamily, + this.textAlign, + this.color, + this.fontWeight, + this.fontStyle, + }); + + final String text; + final TextVariant textVariant; + final bool? isBold; + final String? fontFamily; + final TextAlign? textAlign; + final Color? color; + final FontWeight? fontWeight; + final FontStyle? fontStyle; + + @override + Widget build(BuildContext context) { + return _BasicText( + text: text, + fontSize: textVariant.fontSize, + fontWeight: + fontWeight ?? (isBold == true ? FontWeight.w700 : FontWeight.w400), + color: color ?? DsColors.black, + fontFamily: fontFamily ?? AppFonts.primary, + textAlign: textAlign, + fontStyle: fontStyle, + ); + } +} + +class _BasicText extends StatelessWidget { + const _BasicText({ + required this.text, + this.textAlign, + required this.fontSize, + required this.fontWeight, + required this.color, + required this.fontFamily, + this.fontStyle, + }); + + final String text; + final TextAlign? textAlign; + final double fontSize; + final FontWeight fontWeight; + final Color color; + final String fontFamily; + final FontStyle? fontStyle; + + @override + Widget build(BuildContext context) { + return Text( + text, + textAlign: textAlign, + style: TextStyle( + fontFamily: fontFamily, + fontSize: fontSize, + fontWeight: fontWeight, + color: color, + fontStyle: fontStyle, + ), + ); + } +} diff --git a/lib/design_system/components/components.dart b/lib/design_system/components/components.dart new file mode 100644 index 0000000..eca17f5 --- /dev/null +++ b/lib/design_system/components/components.dart @@ -0,0 +1,3 @@ +export 'status_open.dart'; +export 'user_avatar.dart'; +export 'skeleton/skeleton.dart'; diff --git a/lib/design_system/components/skeleton/ds_image_skeleton.dart b/lib/design_system/components/skeleton/ds_image_skeleton.dart new file mode 100644 index 0000000..8cc94fe --- /dev/null +++ b/lib/design_system/components/skeleton/ds_image_skeleton.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; + +import 'package:shimmer/shimmer.dart'; + +class DsImageSkeleton extends StatelessWidget { + const DsImageSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return Shimmer.fromColors( + baseColor: Colors.grey.shade300, + highlightColor: Colors.grey.shade100, + child: Container( + width: 80, + height: 80, + decoration: const BoxDecoration( + borderRadius: BorderRadius.all( + Radius.circular( + 16, + ), + ), + color: Colors.white, + ), + ), + ); + } +} diff --git a/lib/design_system/components/skeleton/ds_line_skeleton.dart b/lib/design_system/components/skeleton/ds_line_skeleton.dart new file mode 100644 index 0000000..8d057a9 --- /dev/null +++ b/lib/design_system/components/skeleton/ds_line_skeleton.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; + +import 'package:shimmer/shimmer.dart'; + +class DsLineSkeleton extends StatelessWidget { + const DsLineSkeleton({ + super.key, + required this.width, + }); + + final double width; + + @override + Widget build(BuildContext context) { + return Shimmer.fromColors( + baseColor: Colors.grey.shade300, + highlightColor: Colors.grey.shade100, + child: Container( + width: width, + height: 15, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular( + 10, + ), + color: Colors.white, + ), + ), + ); + } +} diff --git a/lib/design_system/components/skeleton/ds_list_item_skeleton.dart b/lib/design_system/components/skeleton/ds_list_item_skeleton.dart new file mode 100644 index 0000000..58a844e --- /dev/null +++ b/lib/design_system/components/skeleton/ds_list_item_skeleton.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; + +import 'package:restaurant_tour/design_system/components/skeleton/skeleton.dart'; +import 'package:restaurant_tour/design_system/tokens/tokens.dart'; + +class DsListItemSkeleton extends StatelessWidget { + const DsListItemSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + ), + padding: const EdgeInsets.all(16), + margin: const EdgeInsets.all(8), + child: Row( + children: [ + const DsImageSkeleton(), + const SizedBox(width: DsSizes.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + DsLineSkeleton( + width: MediaQuery.sizeOf(context).width * 0.9, + ), + const SizedBox(height: DsSizes.md), + const Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + DsLineSkeleton(width: 100), + DsLineSkeleton(width: 100), + ], + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/design_system/components/skeleton/skeleton.dart b/lib/design_system/components/skeleton/skeleton.dart new file mode 100644 index 0000000..b7fef39 --- /dev/null +++ b/lib/design_system/components/skeleton/skeleton.dart @@ -0,0 +1,3 @@ +export 'ds_image_skeleton.dart'; +export 'ds_line_skeleton.dart'; +export 'ds_list_item_skeleton.dart'; diff --git a/lib/design_system/components/status_open.dart b/lib/design_system/components/status_open.dart new file mode 100644 index 0000000..c134086 --- /dev/null +++ b/lib/design_system/components/status_open.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; + +import 'package:restaurant_tour/design_system/atoms/atoms.dart'; +import 'package:restaurant_tour/design_system/foundations/foundations.dart'; +import 'package:restaurant_tour/design_system/tokens/tokens.dart'; + +class StatusOpen extends StatelessWidget { + const StatusOpen({ + super.key, + required this.isOpenNow, + }); + + final bool isOpenNow; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + DsText( + isOpenNow ? 'Open now' : 'Closed', + fontStyle: FontStyle.italic, + ), + const SizedBox(width: DsSizes.xxs), + Icon( + Icons.circle, + size: DsSizes.md, + color: isOpenNow ? AppColors.success : AppColors.error, + ), + ], + ); + } +} diff --git a/lib/design_system/components/user_avatar.dart b/lib/design_system/components/user_avatar.dart new file mode 100644 index 0000000..da610a1 --- /dev/null +++ b/lib/design_system/components/user_avatar.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; + +import 'package:restaurant_tour/design_system/atoms/atoms.dart'; +import 'package:restaurant_tour/design_system/tokens/tokens.dart'; + +class UserAvatar extends StatelessWidget { + const UserAvatar({ + super.key, + required this.name, + this.urlImage, + }); + + final String name; + final String? urlImage; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + CircleAvatar( + child: urlImage != null + ? ClipOval( + child: DsImageNetwork( + urlImage: urlImage, + ), + ) + : const Icon(Icons.person), + ), + const SizedBox(width: DsSizes.xxs), + DsText(name), + ], + ); + } +} diff --git a/lib/design_system/design_system.dart b/lib/design_system/design_system.dart new file mode 100644 index 0000000..9660a33 --- /dev/null +++ b/lib/design_system/design_system.dart @@ -0,0 +1,5 @@ +export 'atoms/atoms.dart'; +export 'components/components.dart'; +export 'extensions/extensions.dart'; +export 'foundations/foundations.dart'; +export 'tokens/tokens.dart'; diff --git a/lib/design_system/extensions/ds_snack_bar.dart b/lib/design_system/extensions/ds_snack_bar.dart new file mode 100644 index 0000000..63202bf --- /dev/null +++ b/lib/design_system/extensions/ds_snack_bar.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; + +import 'package:restaurant_tour/design_system/design_system.dart'; + +enum SnackBarType { + success( + backgroundColor: AppColors.success, + textColor: DsColors.white, + ), + error( + backgroundColor: AppColors.error, + textColor: DsColors.white, + ); + + const SnackBarType({ + required this.backgroundColor, + required this.textColor, + }); + + final Color backgroundColor; + final Color textColor; +} + +extension SnackBarExtension on BuildContext { + void showDsSnackBar({ + required String message, + required SnackBarType type, + bool? showCloseIcon, + SnackBarBehavior? behavior, + Duration? duration, + }) { + ScaffoldMessenger.of(this).showSnackBar( + SnackBar( + showCloseIcon: showCloseIcon, + closeIconColor: DsColors.white, + behavior: behavior ?? SnackBarBehavior.fixed, + backgroundColor: type.backgroundColor, + duration: duration ?? const Duration(milliseconds: 4000), + content: Text( + message, + style: TextStyle( + color: type.textColor, + ), + ), + ), + ); + } +} diff --git a/lib/design_system/extensions/extensions.dart b/lib/design_system/extensions/extensions.dart new file mode 100644 index 0000000..7da0213 --- /dev/null +++ b/lib/design_system/extensions/extensions.dart @@ -0,0 +1 @@ +export 'ds_snack_bar.dart'; diff --git a/lib/design_system/foundations/app_colors.dart b/lib/design_system/foundations/app_colors.dart new file mode 100644 index 0000000..74aac6b --- /dev/null +++ b/lib/design_system/foundations/app_colors.dart @@ -0,0 +1,14 @@ +import 'package:restaurant_tour/design_system/tokens/tokens.dart'; + +class AppColors { + AppColors._(); + + /// DsColors.harlequinGreen (#5CD313) + static const success = DsColors.harlequinGreen; + + /// DsColors.fireOpal (#EA5E5E) + static const error = DsColors.fireOpal; + + /// DsColors.americanYellow (#F8B803) + static const stars = DsColors.americanYellow; +} diff --git a/lib/design_system/foundations/app_fonts.dart b/lib/design_system/foundations/app_fonts.dart new file mode 100644 index 0000000..10a0cc3 --- /dev/null +++ b/lib/design_system/foundations/app_fonts.dart @@ -0,0 +1,11 @@ +import 'package:restaurant_tour/design_system/tokens/tokens.dart'; + +class AppFonts { + AppFonts._(); + + /// DsFonts.lora + static const primary = DsFonts.openSans; + + /// DsFonts.openSans + static const secundary = DsFonts.lora; +} diff --git a/lib/design_system/foundations/foundations.dart b/lib/design_system/foundations/foundations.dart new file mode 100644 index 0000000..325fa0b --- /dev/null +++ b/lib/design_system/foundations/foundations.dart @@ -0,0 +1,2 @@ +export 'app_colors.dart'; +export 'app_fonts.dart'; diff --git a/lib/design_system/tokens/ds_colors.dart b/lib/design_system/tokens/ds_colors.dart new file mode 100644 index 0000000..949803f --- /dev/null +++ b/lib/design_system/tokens/ds_colors.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; + +/// the colors names are from https://www.color-name.com + +class DsColors { + DsColors._(); + + /// #5CD313 + static const harlequinGreen = Color(0xFF5CD313); + + /// #EA5E5E + static const fireOpal = Color(0xFFEA5E5E); + + /// #F8B803 + static const americanYellow = Color(0xFFF8B803); + + /// Colors.white + static const white = Colors.white; + + /// Colors.black + static const black = Colors.black; + + /// #D6D6D6 + static const lightSilver = Color(0xFFD6D6D6); +} diff --git a/lib/design_system/tokens/ds_fonts.dart b/lib/design_system/tokens/ds_fonts.dart new file mode 100644 index 0000000..3132b3a --- /dev/null +++ b/lib/design_system/tokens/ds_fonts.dart @@ -0,0 +1,9 @@ +class DsFonts { + DsFonts._(); + + /// lora + static const lora = 'Lora'; + + /// openSans + static const openSans = 'OpenSans'; +} diff --git a/lib/design_system/tokens/ds_sizes.dart b/lib/design_system/tokens/ds_sizes.dart new file mode 100644 index 0000000..66926aa --- /dev/null +++ b/lib/design_system/tokens/ds_sizes.dart @@ -0,0 +1,27 @@ +class DsSizes { + DsSizes._(); + + /// xlg: 24 + static const xlg = 24.0; + + /// md: 18 + static const lg = 18.0; + + /// md: 16 + static const md = 16.0; + + /// sm: 14 + static const sm = 14.0; + + /// xs: 12 + static const xs = 12.0; + + /// xxs: 10 + static const xxs = 10.0; + + /// xxxs: 8 + static const xxxs = 8.0; + + /// min: 4 + static const min = 4.0; +} diff --git a/lib/design_system/tokens/tokens.dart b/lib/design_system/tokens/tokens.dart new file mode 100644 index 0000000..4717ac5 --- /dev/null +++ b/lib/design_system/tokens/tokens.dart @@ -0,0 +1,3 @@ +export 'ds_colors.dart'; +export 'ds_fonts.dart'; +export 'ds_sizes.dart'; diff --git a/lib/main.dart b/lib/main.dart index ae7012a..0f47b90 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,87 +1,35 @@ -import 'dart:convert'; - import 'package:flutter/material.dart'; -import 'package:http/http.dart' as http; -import 'package:restaurant_tour/models/restaurant.dart'; -import 'package:restaurant_tour/query.dart'; - -const _apiKey = ''; -const _baseUrl = 'https://api.yelp.com/v3/graphql'; -void main() { - runApp(const RestaurantTour()); +import 'package:restaurant_tour/core/routes.dart'; +import 'package:restaurant_tour/core/services/dio_service.dart'; +import 'package:restaurant_tour/core/services/dotenv_service.dart'; + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + final snackbarKey = GlobalKey(); + // dotenv + final dotenvService = DotenvService(); + await dotenvService.init(); + // dio + DioService.init( + apiKey: DotenvService.apiKey, + snackbarKey: snackbarKey, + ); + + runApp( + const MyApp(), + ); } -class RestaurantTour extends StatelessWidget { - const RestaurantTour({super.key}); - - @override - Widget build(BuildContext context) { - return const MaterialApp( - title: 'Restaurant Tour', - home: HomePage(), - ); - } -} - -// TODO: Architect code -// This is just a POC of the API integration -class HomePage extends StatelessWidget { - const HomePage({super.key}); - - Future getRestaurants({int offset = 0}) async { - final headers = { - 'Authorization': 'Bearer $_apiKey', - 'Content-Type': 'application/graphql', - }; - - try { - final response = await http.post( - Uri.parse(_baseUrl), - headers: headers, - body: query(offset), - ); - - if (response.statusCode == 200) { - return RestaurantQueryResult.fromJson( - jsonDecode(response.body)['data']['search'], - ); - } else { - print('Failed to load restaurants: ${response.statusCode}'); - return null; - } - } catch (e) { - print('Error fetching restaurants: $e'); - return null; - } - } +class MyApp extends StatelessWidget { + const MyApp({super.key}); @override Widget build(BuildContext context) { - return Scaffold( - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text('Restaurant Tour'), - ElevatedButton( - child: const Text('Fetch Restaurants'), - onPressed: () async { - try { - final result = await getRestaurants(); - if (result != null) { - print('Fetched ${result.restaurants!.length} restaurants'); - } else { - print('No restaurants fetched'); - } - } catch (e) { - print('Failed to fetch restaurants: $e'); - } - }, - ), - ], - ), - ), + return MaterialApp( + title: 'Flutter Test', + initialRoute: RoutePaths.initial, + routes: getRoutes(), ); } } diff --git a/lib/modules/home/bloc/home_bloc.dart b/lib/modules/home/bloc/home_bloc.dart new file mode 100644 index 0000000..3806d52 --- /dev/null +++ b/lib/modules/home/bloc/home_bloc.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; + +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:restaurant_tour/core/models/restaurant.dart'; +import 'package:restaurant_tour/modules/home/repository/home_repository.dart'; + +part 'home_event.dart'; +part 'home_state.dart'; + +class HomeBloc extends Bloc { + HomeBloc({required this.homeRepository}) + : super( + const HomeInitialState( + Model( + initialIndex: 0, + ), + ), + ) { + on(_onLoadRestaurantsEvent); + } + + final HomeRepository homeRepository; + + _onLoadRestaurantsEvent( + LoadRestaurantsEvent event, + Emitter emit, + ) async { + emit(LoadingRestaurantsState(state.model)); + try { + final onlyFavorites = event.onlyFavorites; + List restaurants = state.model.restaurants ?? []; + if (!onlyFavorites) { + restaurants = await homeRepository.getRestaurants(); + } + + final favoriteRestaurants = await homeRepository.getFavoriteRestaurants(); + + emit( + LoadedRestaurantsState( + state.model.copyWith( + restaurants: restaurants, + favoriteRestaurants: favoriteRestaurants, + initialIndex: onlyFavorites ? 1 : 0, + ), + ), + ); + } catch (e, stacktrace) { + debugPrint(e.toString()); + debugPrint(stacktrace.toString()); + emit(ErrorLoadRestaurantsState(state.model)); + } + } +} diff --git a/lib/modules/home/bloc/home_event.dart b/lib/modules/home/bloc/home_event.dart new file mode 100644 index 0000000..91056dc --- /dev/null +++ b/lib/modules/home/bloc/home_event.dart @@ -0,0 +1,17 @@ +part of 'home_bloc.dart'; + +sealed class HomeEvent extends Equatable { + const HomeEvent(); + + @override + List get props => []; +} + +class LoadRestaurantsEvent extends HomeEvent { + final bool onlyFavorites; + + const LoadRestaurantsEvent({this.onlyFavorites = false}); + + @override + List get props => [onlyFavorites]; +} diff --git a/lib/modules/home/bloc/home_state.dart b/lib/modules/home/bloc/home_state.dart new file mode 100644 index 0000000..1cc4704 --- /dev/null +++ b/lib/modules/home/bloc/home_state.dart @@ -0,0 +1,56 @@ +part of 'home_bloc.dart'; + +sealed class HomeState extends Equatable { + final Model model; + + const HomeState(this.model); + + @override + List get props => [model]; +} + +final class HomeInitialState extends HomeState { + const HomeInitialState(super.model); +} + +final class LoadingRestaurantsState extends HomeState { + const LoadingRestaurantsState(super.model); +} + +final class LoadedRestaurantsState extends HomeState { + const LoadedRestaurantsState(super.model); +} + +final class ErrorLoadRestaurantsState extends HomeState { + const ErrorLoadRestaurantsState(super.model); +} + +class Model extends Equatable { + final List? restaurants; + final List? favoriteRestaurants; + final int? initialIndex; + + const Model({ + this.restaurants, + this.favoriteRestaurants, + this.initialIndex, + }); + + Model copyWith({ + List? restaurants, + List? favoriteRestaurants, + int? initialIndex, + }) => + Model( + restaurants: restaurants ?? this.restaurants, + favoriteRestaurants: favoriteRestaurants ?? this.favoriteRestaurants, + initialIndex: initialIndex ?? this.initialIndex, + ); + + @override + List get props => [ + restaurants, + favoriteRestaurants, + initialIndex, + ]; +} diff --git a/lib/modules/home/home_page.dart b/lib/modules/home/home_page.dart new file mode 100644 index 0000000..58aca83 --- /dev/null +++ b/lib/modules/home/home_page.dart @@ -0,0 +1,114 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:restaurant_tour/core/models/restaurant.dart'; +import 'package:restaurant_tour/core/routes.dart'; +import 'package:restaurant_tour/design_system/design_system.dart'; +import 'package:restaurant_tour/modules/home/bloc/home_bloc.dart'; +import 'package:restaurant_tour/modules/home/repository/home_repository.dart'; +import 'package:restaurant_tour/modules/home/widgets/restaurant_list.dart'; +import 'package:restaurant_tour/modules/restaurant_detail/models/detail_page_arguments.dart'; + +class HomePage extends StatelessWidget { + const HomePage({super.key}); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => HomeBloc( + homeRepository: HomeRepository(), + )..add( + const LoadRestaurantsEvent(), + ), + child: const _Content(), + ); + } +} + +class _Content extends StatelessWidget { + const _Content(); + + @override + Widget build(BuildContext context) { + return BlocConsumer( + listener: (context, state) { + if (state is ErrorLoadRestaurantsState) { + context.showDsSnackBar( + message: 'Sorry! Error when load restaurants', + type: SnackBarType.error, + ); + } + }, + builder: (context, state) { + return DefaultTabController( + length: 2, + initialIndex: state.model.initialIndex ?? 0, + child: Scaffold( + appBar: AppBar( + title: const DsText( + 'Restaurant Tour', + textVariant: TextVariant.title, + isBold: true, + fontFamily: AppFonts.secundary, + ), + centerTitle: true, + bottom: const TabBar( + tabs: [ + Tab(text: 'All Restaurants'), + Tab(text: 'My Favorites'), + ], + ), + ), + body: TabBarView( + children: [ + RestaurantList( + loading: state is LoadingRestaurantsState, + restaurants: state.model.restaurants ?? [], + onSelected: (restaurant) => _goDetail( + context, + restaurant: restaurant, + isFromFavorite: false, + ), + ), + RestaurantList( + loading: state is LoadingRestaurantsState, + restaurants: state.model.favoriteRestaurants ?? [], + onSelected: (restaurant) => _goDetail( + context, + restaurant: restaurant, + isFromFavorite: true, + ), + ), + ], + ), + ), + ); + }, + ); + } + + _goDetail( + BuildContext context, { + required Restaurant restaurant, + required bool isFromFavorite, + }) { + Navigator.of(context) + .pushNamed( + RoutePaths.restaurantDetail, + arguments: DetailPageArgurments( + restaurant: restaurant, + isFromFavorite: isFromFavorite, + ), + ) + .then((value) { + if (value == true) { + context.read().add( + LoadRestaurantsEvent( + onlyFavorites: isFromFavorite, + ), + ); + } + }); + } +} diff --git a/lib/modules/home/repository/home_repository.dart b/lib/modules/home/repository/home_repository.dart new file mode 100644 index 0000000..e8dde70 --- /dev/null +++ b/lib/modules/home/repository/home_repository.dart @@ -0,0 +1,30 @@ +import 'package:dio/dio.dart'; + +import 'package:restaurant_tour/core/database/database.dart'; +import 'package:restaurant_tour/core/models/restaurant.dart'; +import 'package:restaurant_tour/core/services/dio_service.dart'; +import 'package:restaurant_tour/modules/home/repository/query.dart'; + +class HomeRepository { + final _favoriteRestaurantsDao = FavoriteRestaurantsDao(); + + Future> getRestaurants({int offset = 0}) async { + final result = await dio.post( + '/graphql', + data: query(offset), + options: Options( + headers: { + Headers.contentTypeHeader: 'application/graphql', + }, + ), + ); + final data = RestaurantQueryResult.fromJson( + result.data['data']['search'], + ); + return data.restaurants ?? []; + } + + Future> getFavoriteRestaurants() async { + return await _favoriteRestaurantsDao.getAll(); + } +} diff --git a/lib/query.dart b/lib/modules/home/repository/query.dart similarity index 100% rename from lib/query.dart rename to lib/modules/home/repository/query.dart diff --git a/lib/modules/home/widgets/list_skeleton.dart b/lib/modules/home/widgets/list_skeleton.dart new file mode 100644 index 0000000..9f33f7e --- /dev/null +++ b/lib/modules/home/widgets/list_skeleton.dart @@ -0,0 +1,15 @@ +import 'package:flutter/material.dart'; + +import 'package:restaurant_tour/design_system/components/components.dart'; + +class ListSkeleton extends StatelessWidget { + const ListSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return ListView.builder( + itemCount: 5, + itemBuilder: (context, index) => const DsListItemSkeleton(), + ); + } +} diff --git a/lib/modules/home/widgets/restaurant_card.dart b/lib/modules/home/widgets/restaurant_card.dart new file mode 100644 index 0000000..37c8c12 --- /dev/null +++ b/lib/modules/home/widgets/restaurant_card.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; + +import 'package:restaurant_tour/core/models/restaurant.dart'; +import 'package:restaurant_tour/design_system/design_system.dart'; + +class RestaurantCard extends StatelessWidget { + const RestaurantCard({ + super.key, + required this.restaurant, + }); + + final Restaurant restaurant; + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + ), + padding: const EdgeInsets.all(2), + margin: const EdgeInsets.all(8), + child: Row( + children: [ + DsImageNetwork( + urlImage: restaurant.heroImage, + isRounded: true, + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + DsText( + restaurant.name ?? 'No name', + textVariant: TextVariant.subTitle, + isBold: true, + fontFamily: AppFonts.secundary, + ), + const SizedBox(height: DsSizes.md), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DsText( + restaurant.price ?? '---', + ), + DsRating( + initialRating: restaurant.rating ?? 0, + itemCount: 5, + ), + ], + ), + StatusOpen(isOpenNow: restaurant.isOpen), + ], + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/modules/home/widgets/restaurant_list.dart b/lib/modules/home/widgets/restaurant_list.dart new file mode 100644 index 0000000..e6670ed --- /dev/null +++ b/lib/modules/home/widgets/restaurant_list.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; + +import 'package:restaurant_tour/core/models/restaurant.dart'; +import 'package:restaurant_tour/modules/home/widgets/list_skeleton.dart'; +import 'package:restaurant_tour/modules/home/widgets/restaurant_card.dart'; + +class RestaurantList extends StatelessWidget { + const RestaurantList({ + super.key, + required this.loading, + required this.restaurants, + required this.onSelected, + }); + + final bool loading; + final List restaurants; + final Function(Restaurant restaurant) onSelected; + + @override + Widget build(BuildContext context) { + if (loading) { + return const ListSkeleton(); + } + + return ListView.builder( + itemCount: restaurants.length, + itemBuilder: (context, index) { + final currentRestaurant = restaurants[index]; + return InkWell( + onTap: () => onSelected(currentRestaurant), + child: RestaurantCard( + restaurant: currentRestaurant, + ), + ); + }, + ); + } +} diff --git a/lib/modules/restaurant_detail/bloc/restaurant_detail_bloc.dart b/lib/modules/restaurant_detail/bloc/restaurant_detail_bloc.dart new file mode 100644 index 0000000..ece362f --- /dev/null +++ b/lib/modules/restaurant_detail/bloc/restaurant_detail_bloc.dart @@ -0,0 +1,81 @@ +import 'package:flutter/material.dart'; + +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:restaurant_tour/core/models/restaurant.dart'; +import 'package:restaurant_tour/modules/restaurant_detail/models/detail_page_arguments.dart'; +import 'package:restaurant_tour/modules/restaurant_detail/restaurant_detail_repository.dart'; + +part 'restaurant_detail_event.dart'; +part 'restaurant_detail_state.dart'; + +class RestaurantDetailBloc + extends Bloc { + RestaurantDetailBloc({ + required this.restaurantDetailRepository, + }) : super( + const RestaurantDetailInitialState(Model()), + ) { + on(_onLoadDetailEvent); + on(_onSetFavoriteEvent); + } + + final RestaurantDetailRepository restaurantDetailRepository; + + _onLoadDetailEvent( + LoadDetailEvent event, + Emitter emit, + ) { + emit(LoadingDetailState(state.model)); + final restaurant = event.detailPageArgurments?.restaurant; + final isFromFavorite = event.detailPageArgurments?.isFromFavorite ?? false; + + if (restaurant != null) { + emit( + LoadedDetailState( + state.model.copyWith( + restaurant: restaurant, + isFavorite: isFromFavorite, + ), + ), + ); + return; + } + + emit(ErrorLoadDetailState(state.model)); + } + + _onSetFavoriteEvent( + SetFavoriteEvent event, + Emitter emit, + ) async { + emit(LoadingSetFavoriteState(state.model)); + final isFavorite = event.isFavorite; + final restaurant = state.model.restaurant!; + + try { + if (isFavorite) { + await restaurantDetailRepository.addRestaurant( + restaurant: restaurant, + ); + } else { + await restaurantDetailRepository.deleteRestaurant( + restaurantId: restaurant.id ?? '', + ); + } + + emit( + LoadedSetFavoriteState( + state.model.copyWith( + isFavorite: isFavorite, + ), + ), + ); + } catch (e, stacktrace) { + debugPrint(e.toString()); + debugPrint(stacktrace.toString()); + emit(ErrorSetFavoriteState(state.model)); + } + } +} diff --git a/lib/modules/restaurant_detail/bloc/restaurant_detail_event.dart b/lib/modules/restaurant_detail/bloc/restaurant_detail_event.dart new file mode 100644 index 0000000..b1bb202 --- /dev/null +++ b/lib/modules/restaurant_detail/bloc/restaurant_detail_event.dart @@ -0,0 +1,26 @@ +part of 'restaurant_detail_bloc.dart'; + +sealed class RestaurantDetailEvent extends Equatable { + const RestaurantDetailEvent(); + + @override + List get props => []; +} + +class LoadDetailEvent extends RestaurantDetailEvent { + final DetailPageArgurments? detailPageArgurments; + + const LoadDetailEvent({this.detailPageArgurments}); + + @override + List get props => [detailPageArgurments]; +} + +class SetFavoriteEvent extends RestaurantDetailEvent { + final bool isFavorite; + + const SetFavoriteEvent({required this.isFavorite}); + + @override + List get props => [isFavorite]; +} diff --git a/lib/modules/restaurant_detail/bloc/restaurant_detail_state.dart b/lib/modules/restaurant_detail/bloc/restaurant_detail_state.dart new file mode 100644 index 0000000..ebb488c --- /dev/null +++ b/lib/modules/restaurant_detail/bloc/restaurant_detail_state.dart @@ -0,0 +1,56 @@ +part of 'restaurant_detail_bloc.dart'; + +sealed class RestaurantDetailState extends Equatable { + final Model model; + + const RestaurantDetailState(this.model); + + @override + List get props => [model]; +} + +final class RestaurantDetailInitialState extends RestaurantDetailState { + const RestaurantDetailInitialState(super.model); +} + +final class LoadingDetailState extends RestaurantDetailState { + const LoadingDetailState(super.model); +} + +final class LoadedDetailState extends RestaurantDetailState { + const LoadedDetailState(super.model); +} + +final class ErrorLoadDetailState extends RestaurantDetailState { + const ErrorLoadDetailState(super.model); +} + +final class LoadingSetFavoriteState extends RestaurantDetailState { + const LoadingSetFavoriteState(super.model); +} + +final class LoadedSetFavoriteState extends RestaurantDetailState { + const LoadedSetFavoriteState(super.model); +} + +final class ErrorSetFavoriteState extends RestaurantDetailState { + const ErrorSetFavoriteState(super.model); +} + +class Model extends Equatable { + final Restaurant? restaurant; + final bool? isFavorite; + + const Model({this.isFavorite, this.restaurant}); + + Model copyWith({bool? isFavorite, Restaurant? restaurant}) => Model( + isFavorite: isFavorite ?? this.isFavorite, + restaurant: restaurant ?? this.restaurant, + ); + + @override + List get props => [ + isFavorite, + restaurant, + ]; +} diff --git a/lib/modules/restaurant_detail/models/detail_page_arguments.dart b/lib/modules/restaurant_detail/models/detail_page_arguments.dart new file mode 100644 index 0000000..66375f5 --- /dev/null +++ b/lib/modules/restaurant_detail/models/detail_page_arguments.dart @@ -0,0 +1,11 @@ +import 'package:restaurant_tour/core/models/restaurant.dart'; + +class DetailPageArgurments { + final Restaurant restaurant; + final bool isFromFavorite; + + DetailPageArgurments({ + required this.restaurant, + required this.isFromFavorite, + }); +} diff --git a/lib/modules/restaurant_detail/restaurant_detail_page.dart b/lib/modules/restaurant_detail/restaurant_detail_page.dart new file mode 100644 index 0000000..07d6a6d --- /dev/null +++ b/lib/modules/restaurant_detail/restaurant_detail_page.dart @@ -0,0 +1,147 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:restaurant_tour/design_system/design_system.dart'; +import 'package:restaurant_tour/modules/restaurant_detail/bloc/restaurant_detail_bloc.dart'; +import 'package:restaurant_tour/modules/restaurant_detail/models/detail_page_arguments.dart'; +import 'package:restaurant_tour/modules/restaurant_detail/restaurant_detail_repository.dart'; +import 'package:restaurant_tour/modules/restaurant_detail/widgets/review_list.dart'; + +class RestaurantDetailPage extends StatelessWidget { + const RestaurantDetailPage({super.key}); + + @override + Widget build(BuildContext context) { + return const _Content(); + } +} + +class _Content extends StatelessWidget { + const _Content(); + + @override + Widget build(BuildContext context) { + final args = ModalRoute.of(context)?.settings.arguments; + final detailPageArgurments = + args != null ? args as DetailPageArgurments : null; + + return BlocProvider( + create: (context) => RestaurantDetailBloc( + restaurantDetailRepository: RestaurantDetailRepository(), + )..add( + LoadDetailEvent( + detailPageArgurments: detailPageArgurments, + ), + ), + child: BlocConsumer( + listener: (context, state) { + if (state is ErrorLoadDetailState) { + context.showDsSnackBar( + message: 'Error when loaded details', + type: SnackBarType.error, + ); + } + }, + builder: (context, state) { + final restaurant = state.model.restaurant; + final isFavorite = state.model.isFavorite ?? false; + + return Scaffold( + appBar: AppBar( + title: DsText( + restaurant?.name ?? 'No name', + textVariant: TextVariant.subTitle, + isBold: true, + ), + centerTitle: true, + actions: [ + IconButton( + onPressed: () => context.read().add( + SetFavoriteEvent( + isFavorite: !isFavorite, + ), + ), + icon: Icon( + isFavorite + ? Icons.favorite + : Icons.favorite_outline_rounded, + ), + ), + ], + ), + body: state is LoadingDetailState + ? const Center( + child: CircularProgressIndicator(), + ) + : SingleChildScrollView( + child: Column( + children: [ + Container( + height: MediaQuery.sizeOf(context).height * 0.4, + width: double.infinity, + color: Colors.grey, + child: DsImageNetwork( + urlImage: restaurant?.heroImage, + ), + ), + Padding( + padding: const EdgeInsets.all(DsSizes.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 16), + Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + DsText(restaurant?.price ?? '---'), + StatusOpen( + isOpenNow: restaurant?.isOpen ?? false, + ), + ], + ), + const SizedBox(height: DsSizes.md), + const Divider(), + const SizedBox(height: DsSizes.md), + const DsText('Address'), + DsText( + restaurant?.location?.formattedAddress ?? + 'No address', + isBold: true, + ), + const SizedBox(height: DsSizes.md), + const Divider(), + const SizedBox(height: DsSizes.md), + const DsText('Overral Rating'), + Row( + children: [ + DsText( + (restaurant?.rating ?? 0).toString(), + textVariant: TextVariant.title, + isBold: true, + ), + const Icon( + Icons.star, + color: AppColors.stars, + ), + ], + ), + const SizedBox(height: DsSizes.md), + const Divider(), + DsText( + '${restaurant?.reviews?.length ?? 0} reviews', + ), + ReviewList(reviews: restaurant?.reviews ?? []), + ], + ), + ), + ], + ), + ), + ); + }, + ), + ); + } +} diff --git a/lib/modules/restaurant_detail/restaurant_detail_repository.dart b/lib/modules/restaurant_detail/restaurant_detail_repository.dart new file mode 100644 index 0000000..c5f5849 --- /dev/null +++ b/lib/modules/restaurant_detail/restaurant_detail_repository.dart @@ -0,0 +1,14 @@ +import 'package:restaurant_tour/core/database/database.dart'; +import 'package:restaurant_tour/core/models/restaurant.dart'; + +class RestaurantDetailRepository { + final _favoriteRestaurantsDao = FavoriteRestaurantsDao(); + + Future addRestaurant({required Restaurant restaurant}) async { + await _favoriteRestaurantsDao.insert(restaurant); + } + + Future deleteRestaurant({required String restaurantId}) async { + await _favoriteRestaurantsDao.delete(restaurantId: restaurantId); + } +} diff --git a/lib/modules/restaurant_detail/widgets/review_list.dart b/lib/modules/restaurant_detail/widgets/review_list.dart new file mode 100644 index 0000000..0cd6c6d --- /dev/null +++ b/lib/modules/restaurant_detail/widgets/review_list.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; + +import 'package:restaurant_tour/core/models/restaurant.dart'; +import 'package:restaurant_tour/design_system/design_system.dart'; + +class ReviewList extends StatelessWidget { + const ReviewList({ + super.key, + required this.reviews, + }); + + final List reviews; + + @override + Widget build(BuildContext context) { + return ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + separatorBuilder: (context, index) => const Divider(), + padding: const EdgeInsets.symmetric( + vertical: DsSizes.md, + ), + itemCount: reviews.length, + itemBuilder: (context, index) { + final currentReview = reviews[index]; + print(currentReview.text); + + return Padding( + padding: const EdgeInsets.symmetric( + vertical: DsSizes.xxxs, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DsRating( + initialRating: currentReview.rating?.toDouble() ?? 0.0, + itemCount: 5, + ), + const SizedBox(height: DsSizes.xxxs), + if (currentReview.text != null) + DsText( + currentReview.text ?? 'No content', + ), + const SizedBox(height: DsSizes.xxxs), + UserAvatar( + name: '${currentReview.user?.name}', + urlImage: currentReview.user?.imageUrl, + ), + ], + ), + ); + }, + ); + } +} diff --git a/lib/typography.dart b/lib/typography.dart deleted file mode 100644 index e165260..0000000 --- a/lib/typography.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'package:flutter/material.dart'; - -class AppTextStyles { - ////----- Lora -----// - static const loraRegularHeadline = TextStyle( - fontFamily: 'Lora', - fontWeight: FontWeight.w700, - fontSize: 18.0, - ); - static const loraRegularTitle = TextStyle( - fontFamily: 'Lora', - fontWeight: FontWeight.w500, - fontSize: 16.0, - ); - - //----- Open Sans -----// - static const openRegularHeadline = TextStyle( - fontFamily: 'OpenSans', - fontWeight: FontWeight.w400, - fontSize: 16.0, - color: Colors.black, - ); - static const openRegularTitleSemiBold = TextStyle( - fontFamily: 'OpenSans', - fontWeight: FontWeight.w600, - fontSize: 14.0, - color: Colors.black, - ); - static const openRegularTitle = TextStyle( - fontFamily: 'OpenSans', - fontWeight: FontWeight.w400, - fontSize: 14.0, - color: Colors.black, - ); - static const openRegularText = TextStyle( - fontFamily: 'OpenSans', - fontWeight: FontWeight.w400, - fontSize: 12.0, - color: Colors.black, - ); - - static const openRegularItalic = TextStyle( - fontFamily: 'OpenSans', - fontWeight: FontWeight.w400, - fontStyle: FontStyle.italic, - fontSize: 12.0, - color: Colors.black, - ); -} diff --git a/pubspec.lock b/pubspec.lock index f95a63e..2a9314a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -33,6 +33,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.11.0" + bloc: + dependency: transitive + description: + name: bloc + sha256: "106842ad6569f0b60297619e9e0b1885c2fb9bf84812935490e6c5275777804e" + url: "https://pub.dev" + source: hosted + version: "8.1.4" + bloc_test: + dependency: "direct dev" + description: + name: bloc_test + sha256: "165a6ec950d9252ebe36dc5335f2e6eb13055f33d56db0eeb7642768849b43d2" + url: "https://pub.dev" + source: hosted + version: "9.1.7" boolean_selector: dependency: transitive description: @@ -161,6 +177,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.1" + coverage: + dependency: transitive + description: + name: coverage + sha256: "3945034e86ea203af7a056d98e98e42a5518fff200d6e8e6647e1886b07e936e" + url: "https://pub.dev" + source: hosted + version: "1.8.0" crypto: dependency: transitive description: @@ -177,6 +201,38 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.2" + diff_match_patch: + dependency: transitive + description: + name: diff_match_patch + sha256: "2efc9e6e8f449d0abe15be240e2c2a3bcd977c8d126cfd70598aee60af35c0a4" + url: "https://pub.dev" + source: hosted + version: "0.4.1" + dio: + dependency: "direct main" + description: + name: dio + sha256: "5598aa796bbf4699afd5c67c0f5f6e2ed542afc956884b9cd58c306966efc260" + url: "https://pub.dev" + source: hosted + version: "5.7.0" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "33259a9276d6cea88774a0000cfae0d861003497755969c92faa223108620dc8" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + equatable: + dependency: "direct main" + description: + name: equatable + sha256: c2b87cb7756efdf69892005af546c56c0b5037f54d2a88269b4f347a505e3ca2 + url: "https://pub.dev" + source: hosted + version: "2.0.5" fake_async: dependency: transitive description: @@ -185,6 +241,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.1" + ffi: + dependency: transitive + description: + name: ffi + sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" + url: "https://pub.dev" + source: hosted + version: "2.1.3" file: dependency: transitive description: @@ -206,6 +270,22 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_bloc: + dependency: "direct main" + description: + name: flutter_bloc + sha256: b594505eac31a0518bdcb4b5b79573b8d9117b193cc80cc12e17d639b10aa27a + url: "https://pub.dev" + source: hosted + version: "8.1.6" + flutter_dotenv: + dependency: "direct main" + description: + name: flutter_dotenv + sha256: "9357883bdd153ab78cbf9ffa07656e336b8bbb2b5a3ca596b0b27e119f7c7d77" + url: "https://pub.dev" + source: hosted + version: "5.1.0" flutter_lints: dependency: "direct dev" description: @@ -214,6 +294,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" + flutter_rating_bar: + dependency: "direct main" + description: + name: flutter_rating_bar + sha256: d2af03469eac832c591a1eba47c91ecc871fe5708e69967073c043b2d775ed93 + url: "https://pub.dev" + source: hosted + version: "4.0.1" flutter_test: dependency: "direct dev" description: flutter @@ -279,10 +367,10 @@ packages: dependency: transitive description: name: js - sha256: d9bdfd70d828eeb352390f81b18d6a354ef2044aa28ef25682079797fa7cd174 + sha256: c1b2e9b5ea78c45e1a0788d29606ba27dc5f71f019f32ca5140f61ef071838cf url: "https://pub.dev" source: hosted - version: "0.6.3" + version: "0.7.1" json_annotation: dependency: "direct main" description: @@ -371,6 +459,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.1" + mocktail: + dependency: "direct dev" + description: + name: mocktail + sha256: "890df3f9688106f25755f26b1c60589a92b3ab91a22b8b224947ad041bf172d8" + url: "https://pub.dev" + source: hosted + version: "1.0.4" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" package_config: dependency: transitive description: @@ -380,13 +492,77 @@ packages: source: hosted version: "2.0.2" path: - dependency: transitive + dependency: "direct main" description: name: path sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" url: "https://pub.dev" source: hosted version: "1.9.0" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: fec0d61223fba3154d87759e3cc27fe2c8dc498f6386c6d6fc80d1afdd1bf378 + url: "https://pub.dev" + source: hosted + version: "2.1.4" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "6f01f8e37ec30b07bc424b4deabac37cacb1bc7e2e515ad74486039918a37eb7" + url: "https://pub.dev" + source: hosted + version: "2.2.10" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: f234384a3fdd67f989b4d54a5d73ca2a6c422fa55ae694381ae0f4375cd1ea16 + url: "https://pub.dev" + source: hosted + version: "2.4.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: "9b71283fc13df574056616011fb138fd3b793ea47cc509c189a6c3fa5f8a1a65" + url: "https://pub.dev" + source: hosted + version: "3.1.5" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" pool: dependency: transitive description: @@ -395,6 +571,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.0" + provider: + dependency: transitive + description: + name: provider + sha256: c8a055ee5ce3fd98d6fc872478b03823ffdb448699c6ebdbbc71d59b596fd48c + url: "https://pub.dev" + source: hosted + version: "6.1.2" pub_semver: dependency: transitive description: @@ -411,6 +595,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.0" + sembast: + dependency: "direct main" + description: + name: sembast + sha256: a49ce14fb0d81bee9f8941061a38f4b790d19c0ab01abe35a529c1fcef0512a1 + url: "https://pub.dev" + source: hosted + version: "3.7.2" shelf: dependency: transitive description: @@ -419,6 +611,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.0" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.dev" + source: hosted + version: "1.1.3" shelf_web_socket: dependency: transitive description: @@ -427,6 +635,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.1" + shimmer: + dependency: "direct main" + description: + name: shimmer + sha256: "5f88c883a22e9f9f299e5ba0e4f7e6054857224976a5d9f839d4ebdc94a14ac9" + url: "https://pub.dev" + source: hosted + version: "3.0.0" sky_engine: dependency: transitive description: flutter @@ -448,6 +664,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.4" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "708b3f6b97248e5781f493b765c3337db11c5d2c81c3094f10904bfa8004c703" + url: "https://pub.dev" + source: hosted + version: "0.10.12" source_span: dependency: transitive description: @@ -488,6 +720,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.0" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "539ef412b170d65ecdafd780f924e5be3f60032a1128df156adad6c5b373d558" + url: "https://pub.dev" + source: hosted + version: "3.1.0+1" term_glyph: dependency: transitive description: @@ -496,6 +736,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.1" + test: + dependency: transitive + description: + name: test + sha256: "7ee446762c2c50b3bd4ea96fe13ffac69919352bd3b4b17bac3f3465edc58073" + url: "https://pub.dev" + source: hosted + version: "1.25.2" test_api: dependency: transitive description: @@ -504,6 +752,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.0" + test_core: + dependency: transitive + description: + name: test_core + sha256: "2bc4b4ecddd75309300d8096f781c0e3280ca1ef85beda558d33fcbedc2eead4" + url: "https://pub.dev" + source: hosted + version: "0.6.0" timing: dependency: transitive description: @@ -560,6 +816,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: faea9dee56b520b55a566385b84f2e8de55e7496104adada9962e0bd11bcff1d + url: "https://pub.dev" + source: hosted + version: "1.0.4" yaml: dependency: transitive description: @@ -570,4 +842,4 @@ packages: version: "3.1.0" sdks: dart: ">=3.4.0 <4.0.0" - flutter: ">=3.19.6" + flutter: ">=3.22.0" diff --git a/pubspec.yaml b/pubspec.yaml index bc8a205..b0e98c3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -11,10 +11,19 @@ environment: flutter: ">=3.19.6" dependencies: + dio: ^5.7.0 + equatable: ^2.0.5 flutter: sdk: flutter + flutter_bloc: ^8.1.6 + flutter_dotenv: ^5.1.0 + flutter_rating_bar: ^4.0.1 http: ^1.2.2 json_annotation: ^4.9.0 + path: ^1.9.0 + path_provider: ^2.1.4 + sembast: ^3.7.2 + shimmer: ^3.0.0 dev_dependencies: flutter_test: @@ -22,10 +31,14 @@ dev_dependencies: flutter_lints: ^4.0.0 build_runner: ^2.4.10 json_serializable: ^6.8.0 + bloc_test: ^9.1.7 + mocktail: ^1.0.4 flutter: generate: true uses-material-design: true + assets: + - environments/ fonts: - family: Lora fonts: diff --git a/test/modules/home/bloc/home_bloc_test.dart b/test/modules/home/bloc/home_bloc_test.dart new file mode 100644 index 0000000..f079fdf --- /dev/null +++ b/test/modules/home/bloc/home_bloc_test.dart @@ -0,0 +1,92 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +import 'package:restaurant_tour/core/models/restaurant.dart'; +import 'package:restaurant_tour/modules/home/bloc/home_bloc.dart'; +import 'package:restaurant_tour/modules/home/repository/home_repository.dart'; + +class MockHomeRepository extends Mock implements HomeRepository { + @override + Future> getRestaurants({int offset = 0}) { + return Future.value([]); + } + + @override + Future> getFavoriteRestaurants() { + return Future.value([]); + } +} + +class MockHomeRepositoryWithError extends Mock implements HomeRepository { + @override + Future> getRestaurants({int offset = 0}) { + return Future.error(Error()); + } + + @override + Future> getFavoriteRestaurants() { + return Future.error(Error()); + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group( + 'homeBlocTest', + () { + late MockHomeRepository mockHomeRepository; + late MockHomeRepositoryWithError mockHomeRepositoryWithError; + + setUp( + () => { + mockHomeRepository = MockHomeRepository(), + mockHomeRepositoryWithError = MockHomeRepositoryWithError(), + }, + ); + + blocTest( + 'should emit LoadedRestaurantsState when LoadRestaurantsEvent is called and onlyFavorites is false', + build: () => HomeBloc( + homeRepository: mockHomeRepository, + ), + act: (bloc) => bloc.add( + const LoadRestaurantsEvent(), + ), + expect: () => [ + isA(), + isA(), + ], + ); + + blocTest( + 'should emit LoadedRestaurantsState when LoadRestaurantsEvent is called and onlyFavorites is true', + build: () => HomeBloc( + homeRepository: mockHomeRepository, + ), + act: (bloc) => bloc.add( + const LoadRestaurantsEvent(onlyFavorites: true), + ), + expect: () => [ + isA(), + isA(), + ], + ); + + blocTest( + 'should emit ErrorLoadRestaurantsState when LoadRestaurantsEvent is called', + build: () => HomeBloc( + homeRepository: mockHomeRepositoryWithError, + ), + act: (bloc) => bloc.add( + const LoadRestaurantsEvent(), + ), + expect: () => [ + isA(), + isA(), + ], + ); + }, + ); +} diff --git a/test/modules/restaurant_detail/bloc/restaurant_detail_bloc_test.dart b/test/modules/restaurant_detail/bloc/restaurant_detail_bloc_test.dart new file mode 100644 index 0000000..254adc3 --- /dev/null +++ b/test/modules/restaurant_detail/bloc/restaurant_detail_bloc_test.dart @@ -0,0 +1,151 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +import 'package:restaurant_tour/core/models/restaurant.dart'; +import 'package:restaurant_tour/modules/restaurant_detail/bloc/restaurant_detail_bloc.dart'; +import 'package:restaurant_tour/modules/restaurant_detail/models/detail_page_arguments.dart'; +import 'package:restaurant_tour/modules/restaurant_detail/restaurant_detail_repository.dart'; + +class MockRestaurantDetailRepository extends Mock + implements RestaurantDetailRepository { + @override + Future addRestaurant({required Restaurant restaurant}) { + return Future.value(); + } + + @override + Future deleteRestaurant({required String restaurantId}) { + return Future.value(); + } +} + +class MockRestaurantDetailRepositoryWithError extends Mock + implements RestaurantDetailRepository { + @override + Future addRestaurant({required Restaurant restaurant}) { + return Future.error(Error()); + } + + @override + Future deleteRestaurant({required String restaurantId}) { + return Future.error(Error()); + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group( + 'restaurantDetailBlocTest', + () { + late MockRestaurantDetailRepository mockRestaurantDetailRepository; + late MockRestaurantDetailRepositoryWithError + mockRestaurantDetailRepositoryWithError; + + setUp( + () => { + mockRestaurantDetailRepository = MockRestaurantDetailRepository(), + mockRestaurantDetailRepositoryWithError = + MockRestaurantDetailRepositoryWithError(), + }, + ); + + blocTest( + 'should emit LoadedDetailState when LoadDetailEvent is called', + build: () => RestaurantDetailBloc( + restaurantDetailRepository: mockRestaurantDetailRepository, + ), + act: (bloc) => bloc.add( + LoadDetailEvent( + detailPageArgurments: DetailPageArgurments( + restaurant: const Restaurant(), + isFromFavorite: true, + ), + ), + ), + expect: () => [ + isA(), + isA(), + ], + ); + + blocTest( + 'should emit ErrorLoadDetailState when LoadDetailEvent is called', + build: () => RestaurantDetailBloc( + restaurantDetailRepository: mockRestaurantDetailRepository, + ), + act: (bloc) => bloc.add( + const LoadDetailEvent(), + ), + expect: () => [ + isA(), + isA(), + ], + ); + + blocTest( + 'should emit LoadedSetFavoriteState when SetFavoriteEvent is called and isFavorite is true', + build: () => RestaurantDetailBloc( + restaurantDetailRepository: mockRestaurantDetailRepository, + ), + seed: () => const RestaurantDetailInitialState( + Model( + restaurant: Restaurant(), + ), + ), + act: (bloc) => bloc.add( + const SetFavoriteEvent( + isFavorite: true, + ), + ), + expect: () => [ + isA(), + isA(), + ], + ); + + blocTest( + 'should emit LoadedSetFavoriteState when SetFavoriteEvent is called and isFavorite is false', + build: () => RestaurantDetailBloc( + restaurantDetailRepository: mockRestaurantDetailRepository, + ), + seed: () => const RestaurantDetailInitialState( + Model( + restaurant: Restaurant(), + ), + ), + act: (bloc) => bloc.add( + const SetFavoriteEvent( + isFavorite: false, + ), + ), + expect: () => [ + isA(), + isA(), + ], + ); + + blocTest( + 'should emit ErrorSetFavoriteState when SetFavoriteEvent is called', + build: () => RestaurantDetailBloc( + restaurantDetailRepository: mockRestaurantDetailRepositoryWithError, + ), + seed: () => const RestaurantDetailInitialState( + Model( + restaurant: Restaurant(), + ), + ), + act: (bloc) => bloc.add( + const SetFavoriteEvent( + isFavorite: false, + ), + ), + expect: () => [ + isA(), + isA(), + ], + ); + }, + ); +} diff --git a/test/widget_test.dart b/test/widget_test.dart deleted file mode 100644 index b729d48..0000000 --- a/test/widget_test.dart +++ /dev/null @@ -1,19 +0,0 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility that Flutter provides. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - -import 'package:flutter_test/flutter_test.dart'; -import 'package:restaurant_tour/main.dart'; - -void main() { - testWidgets('Page loads', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const RestaurantTour()); - - // Verify that tests will run - expect(find.text('Fetch Restaurants'), findsOneWidget); - }); -}