From bb4812ebe6073a45a0f9f71a0aa9daa27feecf58 Mon Sep 17 00:00:00 2001 From: Luis Carlos Date: Fri, 13 Sep 2024 15:09:14 -0500 Subject: [PATCH 1/7] feat(app): implement AppBlocObserver and bootstrap with repository setup - Added AppBlocObserver to handle logging for Bloc changes and errors. - Configured bootstrap to initialize HydratedBloc storage and run the app. - Integrated RestaurantRepositoryImpl and FavoriteCubit within main. --- .gitignore | 2 + ios/Flutter/Debug.xcconfig | 1 + ios/Flutter/Release.xcconfig | 1 + ios/Podfile | 44 +++++++ ios/Podfile.lock | 30 +++++ ios/Runner.xcodeproj/project.pbxproj | 112 ++++++++++++++++++ .../contents.xcworkspacedata | 3 + lib/app/app.dart | 6 + lib/app/cubits/cubits.dart | 1 + .../cubits/favorite_cubit/favorite_cubit.dart | 94 +++++++++++++++ .../cubits/favorite_cubit/favorite_state.dart | 10 ++ lib/app/routes/router.dart | 24 ++++ lib/app/routes/routes.dart | 1 + lib/app/theme/app_theme.dart | 48 ++++++++ lib/app/theme/theme.dart | 1 + lib/app/theme/typography.dart | 51 ++++++++ lib/app/utils/enviroment.dart | 10 ++ lib/app/utils/utils.dart | 1 + lib/app/view/app.dart | 32 +++++ lib/bootstrap.dart | 33 ++++++ lib/main.dart | 103 +++------------- 21 files changed, 523 insertions(+), 85 deletions(-) create mode 100644 ios/Podfile create mode 100644 ios/Podfile.lock create mode 100644 lib/app/app.dart create mode 100644 lib/app/cubits/cubits.dart create mode 100644 lib/app/cubits/favorite_cubit/favorite_cubit.dart create mode 100644 lib/app/cubits/favorite_cubit/favorite_state.dart create mode 100644 lib/app/routes/router.dart create mode 100644 lib/app/routes/routes.dart create mode 100644 lib/app/theme/app_theme.dart create mode 100644 lib/app/theme/theme.dart create mode 100644 lib/app/theme/typography.dart create mode 100644 lib/app/utils/enviroment.dart create mode 100644 lib/app/utils/utils.dart create mode 100644 lib/app/view/app.dart create mode 100644 lib/bootstrap.dart diff --git a/.gitignore b/.gitignore index 1be2d87..3ab913f 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,8 @@ .buildlog/ .history .svn/ +.env +coverage # IntelliJ related *.iml 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/ios/Podfile.lock b/ios/Podfile.lock new file mode 100644 index 0000000..e2deba1 --- /dev/null +++ b/ios/Podfile.lock @@ -0,0 +1,30 @@ +PODS: + - Flutter (1.0.0) + - path_provider_foundation (0.0.1): + - Flutter + - FlutterMacOS + - sqflite (0.0.3): + - Flutter + - FlutterMacOS + +DEPENDENCIES: + - Flutter (from `Flutter`) + - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) + - sqflite (from `.symlinks/plugins/sqflite/darwin`) + +EXTERNAL SOURCES: + Flutter: + :path: Flutter + path_provider_foundation: + :path: ".symlinks/plugins/path_provider_foundation/darwin" + sqflite: + :path: ".symlinks/plugins/sqflite/darwin" + +SPEC CHECKSUMS: + Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 + path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 + sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec + +PODFILE CHECKSUM: 819463e6a0290f5a72f145ba7cde16e8b6ef0796 + +COCOAPODS: 1.15.2 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 182fb57..7a93fc0 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -10,10 +10,12 @@ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 486606C5A2335A949E2E1B0A /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7889E8C8C493D1F8FAAF6D5C /* Pods_RunnerTests.framework */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + CD44F1015A5E50C5F1C47888 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C5803B3028FC732C0565C06F /* Pods_Runner.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -45,23 +47,40 @@ 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 3E81A6320BB1C31BE3C769C3 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7889E8C8C493D1F8FAAF6D5C /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 9772727A7DD74B55D4FAFB1F /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 9EEE8C6194EF2B714CCAD231 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + C5803B3028FC732C0565C06F /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + D4B98B546EFFF77421CA9AA9 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + E5134C8CEB3428258DE6908C /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + E8047F95902053196F412C77 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + 798FA855CCA4B372EB119077 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 486606C5A2335A949E2E1B0A /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 97C146EB1CF9000F007C117D /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + CD44F1015A5E50C5F1C47888 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -76,6 +95,15 @@ path = RunnerTests; sourceTree = ""; }; + 3368EBAE2334FBA8B7D764E8 /* Frameworks */ = { + isa = PBXGroup; + children = ( + C5803B3028FC732C0565C06F /* Pods_Runner.framework */, + 7889E8C8C493D1F8FAAF6D5C /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( @@ -94,6 +122,8 @@ 97C146F01CF9000F007C117D /* Runner */, 97C146EF1CF9000F007C117D /* Products */, 331C8082294A63A400263BE5 /* RunnerTests */, + B189F3F2571372EF9E8C5AE3 /* Pods */, + 3368EBAE2334FBA8B7D764E8 /* Frameworks */, ); sourceTree = ""; }; @@ -121,6 +151,20 @@ path = Runner; sourceTree = ""; }; + B189F3F2571372EF9E8C5AE3 /* Pods */ = { + isa = PBXGroup; + children = ( + D4B98B546EFFF77421CA9AA9 /* Pods-Runner.debug.xcconfig */, + E8047F95902053196F412C77 /* Pods-Runner.release.xcconfig */, + 3E81A6320BB1C31BE3C769C3 /* Pods-Runner.profile.xcconfig */, + 9EEE8C6194EF2B714CCAD231 /* Pods-RunnerTests.debug.xcconfig */, + E5134C8CEB3428258DE6908C /* Pods-RunnerTests.release.xcconfig */, + 9772727A7DD74B55D4FAFB1F /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -128,8 +172,10 @@ isa = PBXNativeTarget; buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( + D51E58F41F7A70C92291E3FE /* [CP] Check Pods Manifest.lock */, 331C807D294A63A400263BE5 /* Sources */, 331C807F294A63A400263BE5 /* Resources */, + 798FA855CCA4B372EB119077 /* Frameworks */, ); buildRules = ( ); @@ -145,12 +191,14 @@ isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( + 4E9AAF39DCC8B5CFB4E4F671 /* [CP] Check Pods Manifest.lock */, 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + 8EF6D21410C70882EAA2A081 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -238,6 +286,45 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; }; + 4E9AAF39DCC8B5CFB4E4F671 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 8EF6D21410C70882EAA2A081 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -253,6 +340,28 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; + D51E58F41F7A70C92291E3FE /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -379,6 +488,7 @@ }; 331C8088294A63A400263BE5 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 9EEE8C6194EF2B714CCAD231 /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -396,6 +506,7 @@ }; 331C8089294A63A400263BE5 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = E5134C8CEB3428258DE6908C /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -411,6 +522,7 @@ }; 331C808A294A63A400263BE5 /* Profile */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 9772727A7DD74B55D4FAFB1F /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata index 1d526a1..21a3cc1 100644 --- a/ios/Runner.xcworkspace/contents.xcworkspacedata +++ b/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -4,4 +4,7 @@ + + diff --git a/lib/app/app.dart b/lib/app/app.dart new file mode 100644 index 0000000..d9ba9fa --- /dev/null +++ b/lib/app/app.dart @@ -0,0 +1,6 @@ +export '../data/data.dart'; +export 'routes/router.dart'; +export 'theme/theme.dart'; +export 'utils/utils.dart'; +export 'view/app.dart'; +export 'cubits/cubits.dart'; diff --git a/lib/app/cubits/cubits.dart b/lib/app/cubits/cubits.dart new file mode 100644 index 0000000..08ee664 --- /dev/null +++ b/lib/app/cubits/cubits.dart @@ -0,0 +1 @@ +export 'favorite_cubit/favorite_cubit.dart'; diff --git a/lib/app/cubits/favorite_cubit/favorite_cubit.dart b/lib/app/cubits/favorite_cubit/favorite_cubit.dart new file mode 100644 index 0000000..b6aed1d --- /dev/null +++ b/lib/app/cubits/favorite_cubit/favorite_cubit.dart @@ -0,0 +1,94 @@ +import 'package:hydrated_bloc/hydrated_bloc.dart'; +import 'package:restaurant_tour/app/app.dart'; + +part 'favorite_state.dart'; + +/// [FavoriteCubit] manages the favorite restaurants state in the app. +/// +/// It extends [HydratedCubit] to persist the favorite restaurants across +/// app sessions using local storage. This allows users to keep their favorite +/// restaurants even after restarting the app. +/// +/// ### State Management: +/// - The cubit starts with an initial state of [FavoriteLoaded] containing an empty list of restaurants. +/// - It supports toggling restaurants as favorites using [toggleFavorite]. +/// - The cubit also provides functionality to check if a restaurant is a favorite using [isFavorite]. +/// +/// ### Persistence: +/// - The cubit persists the state using the [toJson] and [fromJson] methods, +/// allowing it to restore the favorite restaurants list when the app is reopened. +class FavoriteCubit extends HydratedCubit { + /// Initializes the [FavoriteCubit] with an empty list of favorite restaurants. + FavoriteCubit() : super(FavoriteLoaded(restaurants: [])); + + /// Toggles a restaurant as a favorite. + /// + /// If the restaurant is already in the list, it is removed. If it isn't, it is added. + /// Emits a [FavoriteLoaded] state with the updated list of favorite restaurants. + /// If the state is not [FavoriteLoaded], it emits a [FavoriteError] state. + void toggleFavorite(Restaurant restaurant) { + if (state is FavoriteLoaded) { + final currentState = state as FavoriteLoaded; + final List updatedList = List.from(currentState.restaurants); + + Restaurant? existingRestaurant = updatedList.firstWhere( + (r) => r.id == restaurant.id, + orElse: () => const Restaurant(), + ); + + if (existingRestaurant != const Restaurant()) { + updatedList.remove(existingRestaurant); + } else { + updatedList.add(restaurant); + } + + emit(FavoriteLoaded(restaurants: updatedList)); + } else { + emit(FavoriteError()); + } + } + + /// Checks if a restaurant is already in the list of favorite restaurants. + /// + /// Returns `true` if the restaurant is in the favorites list, otherwise `false`. + bool isFavorite(Restaurant restaurant) { + if (state is FavoriteLoaded) { + final currentState = state as FavoriteLoaded; + return currentState.restaurants.any((r) => r.id == restaurant.id); + } + return false; + } + + /// Restores the list of favorite restaurants from a JSON map. + /// + /// This method is used by [HydratedCubit] to restore the cubit's state when + /// the app is restarted. It attempts to deserialize the list of restaurants. + /// If an error occurs, it returns a [FavoriteError] state. + @override + FavoriteState fromJson(Map json) { + try { + final restaurants = (json['restaurants'] as List) + .map((restaurantJson) => Restaurant.fromJson(restaurantJson)) + .toList(); + return FavoriteLoaded(restaurants: restaurants); + } catch (e) { + return FavoriteError(); + } + } + + /// Converts the current state to a JSON map. + /// + /// This method is used by [HydratedCubit] to save the current state to local + /// storage, allowing it to persist across app restarts. It serializes the list + /// of favorite restaurants into a JSON map. + @override + Map? toJson(FavoriteState state) { + if (state is FavoriteLoaded) { + return { + 'restaurants': + state.restaurants.map((restaurant) => restaurant.toJson()).toList(), + }; + } + return null; + } +} diff --git a/lib/app/cubits/favorite_cubit/favorite_state.dart b/lib/app/cubits/favorite_cubit/favorite_state.dart new file mode 100644 index 0000000..631e0b3 --- /dev/null +++ b/lib/app/cubits/favorite_cubit/favorite_state.dart @@ -0,0 +1,10 @@ +part of 'favorite_cubit.dart'; + +abstract class FavoriteState {} + +class FavoriteError extends FavoriteState {} + +class FavoriteLoaded extends FavoriteState { + FavoriteLoaded({required this.restaurants}); + List restaurants; +} diff --git a/lib/app/routes/router.dart b/lib/app/routes/router.dart new file mode 100644 index 0000000..5f145aa --- /dev/null +++ b/lib/app/routes/router.dart @@ -0,0 +1,24 @@ +import 'package:go_router/go_router.dart'; +import 'package:restaurant_tour/app/app.dart'; +import 'package:restaurant_tour/features/home/screen/screen.dart'; +import 'package:restaurant_tour/features/restaurants/restaurant_detail/screen/restaurant_detail_screen.dart'; + +GoRouter getGoRouter() { + return GoRouter( + routes: [ + GoRoute( + name: HomeScreen.routePath, + path: '/', + builder: (context, state) => const HomeScreen(), + ), + GoRoute( + name: RestaurantDetailScreen.routeName, + path: '/${RestaurantDetailScreen.routeName}', + builder: (context, state) { + final restaurant = state.extra! as Restaurant; + return RestaurantDetailScreen(restaurant: restaurant); + }, + ), + ], + ); +} diff --git a/lib/app/routes/routes.dart b/lib/app/routes/routes.dart new file mode 100644 index 0000000..cba0941 --- /dev/null +++ b/lib/app/routes/routes.dart @@ -0,0 +1 @@ +export 'router.dart'; diff --git a/lib/app/theme/app_theme.dart b/lib/app/theme/app_theme.dart new file mode 100644 index 0000000..ce47875 --- /dev/null +++ b/lib/app/theme/app_theme.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; +import 'typography.dart'; + +class AppTheme { + static ThemeData get lightTheme { + return ThemeData( + primarySwatch: Colors.grey, + primaryColor: Colors.black, + scaffoldBackgroundColor: Colors.white, + textTheme: const TextTheme( + displayLarge: AppTextStyles.loraRegularHeadline, + titleLarge: AppTextStyles.openRegularHeadline, + titleMedium: AppTextStyles.openRegularTitleSemiBold, + titleSmall: AppTextStyles.openRegularTitle, + bodyLarge: AppTextStyles.openRegularText, + bodyMedium: AppTextStyles.openRegularItalic, + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + textStyle: AppTextStyles.openRegularTitleSemiBold, + backgroundColor: Colors.black, + foregroundColor: Colors.white, + ), + ), + appBarTheme: const AppBarTheme( + backgroundColor: Colors.white, + titleTextStyle: AppTextStyles.loraRegularHeadline, + iconTheme: IconThemeData(color: Colors.black), + elevation: 0, + ), + tabBarTheme: const TabBarTheme( + labelStyle: AppTextStyles.openRegularTitleSemiBold, + unselectedLabelStyle: AppTextStyles.openRegularTitle, + labelColor: Colors.black, + unselectedLabelColor: Colors.grey, + indicatorColor: Colors.black, + ), + chipTheme: ChipThemeData( + backgroundColor: Colors.green, + disabledColor: Colors.red, + labelStyle: AppTextStyles.openRegularText.copyWith(color: Colors.white), + secondaryLabelStyle: + AppTextStyles.openRegularText.copyWith(color: Colors.white), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + ), + ); + } +} diff --git a/lib/app/theme/theme.dart b/lib/app/theme/theme.dart new file mode 100644 index 0000000..b72f509 --- /dev/null +++ b/lib/app/theme/theme.dart @@ -0,0 +1 @@ +export 'app_theme.dart'; diff --git a/lib/app/theme/typography.dart b/lib/app/theme/typography.dart new file mode 100644 index 0000000..2707632 --- /dev/null +++ b/lib/app/theme/typography.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; + +class AppTextStyles { + ////----- Lora -----// + static const loraRegularHeadline = TextStyle( + fontFamily: 'Lora', + fontWeight: FontWeight.w700, + fontSize: 18.0, + color: Colors.black, + ); + static const loraRegularTitle = TextStyle( + fontFamily: 'Lora', + fontWeight: FontWeight.w500, + fontSize: 16.0, + color: Colors.black, + ); + + //----- 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/lib/app/utils/enviroment.dart b/lib/app/utils/enviroment.dart new file mode 100644 index 0000000..4c66bce --- /dev/null +++ b/lib/app/utils/enviroment.dart @@ -0,0 +1,10 @@ +import 'package:flutter_dotenv/flutter_dotenv.dart'; + +class Environment { + static late String apiKey; + + static Future load() async { + await dotenv.load(); + apiKey = dotenv.env['YELP_API_KEY'] ?? ''; + } +} diff --git a/lib/app/utils/utils.dart b/lib/app/utils/utils.dart new file mode 100644 index 0000000..9afafb1 --- /dev/null +++ b/lib/app/utils/utils.dart @@ -0,0 +1 @@ +export 'enviroment.dart'; diff --git a/lib/app/view/app.dart b/lib/app/view/app.dart new file mode 100644 index 0000000..740b234 --- /dev/null +++ b/lib/app/view/app.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:restaurant_tour/app/app.dart'; + +class App extends StatefulWidget { + const App({super.key}); + @override + State createState() => _AppState(); +} + +class _AppState extends State { + late GoRouter _router; + + @override + void initState() { + _router = getGoRouter(); + super.initState(); + } + + @override + void dispose() { + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return MaterialApp.router( + theme: AppTheme.lightTheme, + routerConfig: _router, + ); + } +} diff --git a/lib/bootstrap.dart b/lib/bootstrap.dart new file mode 100644 index 0000000..9e7cbf2 --- /dev/null +++ b/lib/bootstrap.dart @@ -0,0 +1,33 @@ +import 'dart:async'; +import 'dart:developer'; +import 'package:flutter/widgets.dart'; +import 'package:hydrated_bloc/hydrated_bloc.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:restaurant_tour/app/app.dart'; + +class AppBlocObserver extends BlocObserver { + const AppBlocObserver(); + + @override + void onChange(BlocBase bloc, Change change) { + super.onChange(bloc, change); + log('onChange(${bloc.runtimeType}, $change)'); + } + + @override + void onError(BlocBase bloc, Object error, StackTrace stackTrace) { + log('onError(${bloc.runtimeType}, $error, $stackTrace)'); + super.onError(bloc, error, stackTrace); + } +} + +Future bootstrap({ + required FutureOr Function() builder, +}) async { + WidgetsFlutterBinding.ensureInitialized(); + await Environment.load(); + HydratedBloc.storage = await HydratedStorage.build( + storageDirectory: await getApplicationDocumentsDirectory(), + ); + runApp(await builder()); +} diff --git a/lib/main.dart b/lib/main.dart index ae7012a..3ddff1b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,87 +1,20 @@ -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()); -} - -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; - } - } - - @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'); - } - }, - ), - ], +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:restaurant_tour/app/app.dart'; + +import 'package:restaurant_tour/bootstrap.dart'; + +void main() async { + bootstrap( + builder: () async { + final restaurantRepository = + RestaurantRepositoryImpl(RestaurantExternalDataSource()); + return BlocProvider( + create: (context) => FavoriteCubit(), + child: RepositoryProvider( + create: (context) => restaurantRepository, + child: const App(), ), - ), - ); - } + ); + }, + ); } From a3be87c8f43dc31e465ce203c43a575ff67d87bc Mon Sep 17 00:00:00 2001 From: Luis Carlos Date: Fri, 13 Sep 2024 15:10:21 -0500 Subject: [PATCH 2/7] feat(data): update restaurant data structure and logic - Added new constants and exception handling for restaurant-related data. - Implemented restaurant GraphQL queries and models. - Refactored restaurant repository with repository pattern. - Added external data sources for restaurant data management. --- lib/data/data.dart | 6 + lib/data/restaurant/constants/contants.dart | 1 + .../constants/restaurants_constants.dart | 375 ++++++++++++++++++ .../restaurant/exceptions/exceptions.dart | 1 + .../restaurant_custom_exception.dart | 17 + .../restaurant/graphql/restaurant_query.dart | 34 ++ lib/data/restaurant/models/models.dart | 1 + lib/data/restaurant/models/restaurant.dart | 157 ++++++++ lib/data/restaurant/models/restaurant.g.dart | 111 ++++++ .../restaurant/repository/repository.dart | 2 + .../repository/restaurant_repository.dart | 5 + .../restaurant_repository_impl.dart | 12 + .../restaurant_external_data_source.dart | 39 ++ lib/data/restaurant/sources/sources.dart | 1 + 14 files changed, 762 insertions(+) create mode 100644 lib/data/data.dart create mode 100644 lib/data/restaurant/constants/contants.dart create mode 100644 lib/data/restaurant/constants/restaurants_constants.dart create mode 100644 lib/data/restaurant/exceptions/exceptions.dart create mode 100644 lib/data/restaurant/exceptions/restaurant_custom_exception.dart create mode 100644 lib/data/restaurant/graphql/restaurant_query.dart create mode 100644 lib/data/restaurant/models/models.dart create mode 100644 lib/data/restaurant/models/restaurant.dart create mode 100644 lib/data/restaurant/models/restaurant.g.dart create mode 100644 lib/data/restaurant/repository/repository.dart create mode 100644 lib/data/restaurant/repository/restaurant_repository.dart create mode 100644 lib/data/restaurant/repository/restaurant_repository_impl.dart create mode 100644 lib/data/restaurant/sources/restaurant_external_data_source.dart create mode 100644 lib/data/restaurant/sources/sources.dart diff --git a/lib/data/data.dart b/lib/data/data.dart new file mode 100644 index 0000000..e40734d --- /dev/null +++ b/lib/data/data.dart @@ -0,0 +1,6 @@ +export 'restaurant/graphql/restaurant_query.dart'; +export 'restaurant/models/models.dart'; +export 'restaurant/repository/repository.dart'; +export 'restaurant/sources/sources.dart'; +export 'restaurant/exceptions/exceptions.dart'; +export 'restaurant/constants/contants.dart'; diff --git a/lib/data/restaurant/constants/contants.dart b/lib/data/restaurant/constants/contants.dart new file mode 100644 index 0000000..809db93 --- /dev/null +++ b/lib/data/restaurant/constants/contants.dart @@ -0,0 +1 @@ +export 'restaurants_constants.dart'; diff --git a/lib/data/restaurant/constants/restaurants_constants.dart b/lib/data/restaurant/constants/restaurants_constants.dart new file mode 100644 index 0000000..ea85db6 --- /dev/null +++ b/lib/data/restaurant/constants/restaurants_constants.dart @@ -0,0 +1,375 @@ +const Map fakeRestaurants = { + "total": 10, + "business": [ + { + "id": "1", + "name": "Restaurant Name Goes Here And Wraps 2 Lines One", + "price": "\$", + "rating": 4.5, + "photos": [ + "https://i.natgeofe.com/n/04cf2a79-4a49-45eb-90f8-38356167690d/image00037.jpeg", + ], + "categories": [ + { + "alias": "italian", + "title": "Italian", + } + ], + "hours": [ + { + "is_open_now": true, + } + ], + "reviews": [ + { + "id": "r1", + "rating": 5, + "text": + "Review text goes here. Review text goes here. This is a review. This is a review that is 3 lines long.", + "user": { + "id": "u1", + "image_url": + "https://media.licdn.com/dms/image/v2/C4E03AQFZWrm17QwLIQ/profile-displayphoto-shrink_800_800/profile-displayphoto-shrink_800_800/0/1652147256110?e=1731542400&v=beta&t=gR9A-qKQ63iBuzeyS3YG6qGNH0CrqLmi0VI7ELRJ8Gw", + "name": "John Doe", + }, + }, + { + "id": "r2", + "rating": 2, + "text": + "Review text goes here. Review text goes here. This is a review. This is a review that is 3 lines long.", + "user": { + "id": "u1", + "image_url": "https://example.com/user1.jpg", + "name": "John Doe", + }, + }, + { + "id": "r3", + "rating": 4, + "text": "Amazing food and atmosphere!", + "user": { + "id": "u1", + "image_url": "https://example.com/user1.jpg", + "name": "John Doe", + }, + } + ], + "location": { + "formatted_address": "123 Main St, New York, NY", + }, + }, + { + "id": "2", + "name": "Restaurant Name Goes Here And Wraps 2 Lines Two", + "price": "\$", + "rating": 4.0, + "photos": [ + "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQMh9wiF1vxFv2KBN2QWkxbC1RekcuVeDKFdw&s", + ], + "categories": [ + { + "alias": "mexican", + "title": "Mexican", + } + ], + "hours": [ + { + "is_open_now": false, + } + ], + "reviews": [], + "location": { + "formatted_address": "456 Broadway, Los Angeles, CA", + }, + }, + { + "id": "3", + "name": + "Restaurant Name Goes Here And Wraps 2 Lines Name Goes Here And Wraps 2 Lines Three", + "price": "\$", + "rating": 3.5, + "photos": [ + "https://media.cntraveler.com/photos/654bd5e13892537a8ded0947/16:9/w_2560%2Cc_limit/phy2023.din.oss.Restaurant Name Goes Here And Wraps 2 Lines Name Goes Here And Wraps 2 Lines-lr.jpg", + ], + "categories": [ + { + "alias": "chinese", + "title": "Chinese", + } + ], + "hours": [ + { + "is_open_now": true, + } + ], + "reviews": [ + { + "id": "r3", + "rating": 3, + "text": "Average experience, nothing special.", + "user": { + "id": "u3", + "image_url": "https://example.com/user3.jpg", + "name": "Alice Brown", + }, + } + ], + "location": { + "formatted_address": "789 Market St, San Francisco, CA", + }, + }, + { + "id": "4", + "name": + "Restaurant Name Goes Here And Wraps 2 Lines Name Goes Here And Wraps 2 Lines Four", + "price": "\$", + "rating": 4.8, + "photos": [ + "https://i.natgeofe.com/n/04cf2a79-4a49-45eb-90f8-38356167690d/image00037.jpeg", + ], + "categories": [ + { + "alias": "french", + "title": "French", + } + ], + "hours": [ + { + "is_open_now": false, + } + ], + "reviews": [ + { + "id": "r4", + "rating": 5, + "text": "Exquisite dining experience.", + "user": { + "id": "u4", + "image_url": "https://example.com/user4.jpg", + "name": "Charlie Green", + }, + } + ], + "location": { + "formatted_address": "321 Park Ave, Boston, MA", + }, + }, + { + "id": "5", + "name": + "Restaurant Name Goes Here And Wraps 2 Lines Name Goes Here And Wraps 2 Lines Five", + "price": "\$", + "rating": 4.2, + "photos": [ + "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQMh9wiF1vxFv2KBN2QWkxbC1RekcuVeDKFdw&s", + ], + "categories": [ + { + "alias": "indian", + "title": "Indian", + } + ], + "hours": [ + { + "is_open_now": true, + } + ], + "reviews": [ + { + "id": "r5", + "rating": 4, + "text": "Authentic Indian cuisine, loved it.", + "user": { + "id": "u5", + "image_url": "https://example.com/user5.jpg", + "name": "David Lee", + }, + } + ], + "location": { + "formatted_address": "654 Elm St, Chicago, IL", + }, + }, + { + "id": "6", + "name": + "Restaurant Name Goes Here And Wraps 2 Lines Name Goes Here And Wraps 2 Lines Six", + "price": "\$", + "rating": 4.6, + "photos": [ + "https://media.cntraveler.com/photos/654bd5e13892537a8ded0947/16:9/w_2560%2Cc_limit/phy2023.din.oss.Restaurant Name Goes Here And Wraps 2 Lines Name Goes Here And Wraps 2 Lines-lr.jpg", + ], + "categories": [ + { + "alias": "japanese", + "title": "Japanese", + } + ], + "hours": [ + { + "is_open_now": false, + } + ], + "reviews": [ + { + "id": "r6", + "rating": 5, + "text": "Amazing sushi and service.", + "user": { + "id": "u6", + "image_url": "https://example.com/user6.jpg", + "name": "Emily White", + }, + } + ], + "location": { + "formatted_address": "987 Sunset Blvd, Miami, FL", + }, + }, + { + "id": "7", + "name": + "Restaurant Name Goes Here And Wraps 2 Lines Name Goes Here And Wraps 2 Lines Seven", + "price": "\$", + "rating": 4.3, + "photos": [ + "https://i.natgeofe.com/n/04cf2a79-4a49-45eb-90f8-38356167690d/image00037.jpeg", + ], + "categories": [ + { + "alias": "thai", + "title": "Thai", + } + ], + "hours": [ + { + "is_open_now": true, + } + ], + "reviews": [ + { + "id": "r7", + "rating": 4, + "text": "Great flavors, but service was slow.", + "user": { + "id": "u7", + "image_url": "https://example.com/user7.jpg", + "name": "Michael Brown", + }, + } + ], + "location": { + "formatted_address": "852 Ocean Ave, Seattle, WA", + }, + }, + { + "id": "8", + "name": + "Restaurant Name Goes Here And Wraps 2 Lines Name Goes Here And Wraps 2 Lines Eight", + "price": "\$", + "rating": 3.9, + "photos": [ + "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQMh9wiF1vxFv2KBN2QWkxbC1RekcuVeDKFdw&s", + ], + "categories": [ + { + "alias": "burger", + "title": "Burger", + } + ], + "hours": [ + { + "is_open_now": true, + } + ], + "reviews": [ + { + "id": "r8", + "rating": 4, + "text": "Great burgers for a reasonable price.", + "user": { + "id": "u8", + "image_url": "https://example.com/user8.jpg", + "name": "Jessica Johnson", + }, + } + ], + "location": { + "formatted_address": "753 Central St, Austin, TX", + }, + }, + { + "id": "9", + "name": + "Restaurant Name Goes Here And Wraps 2 Lines Name Goes Here And Wraps 2 Lines Nine", + "price": "\$", + "rating": 4.9, + "photos": [ + "https://media.cntraveler.com/photos/654bd5e13892537a8ded0947/16:9/w_2560%2Cc_limit/phy2023.din.oss.Restaurant Name Goes Here And Wraps 2 Lines Name Goes Here And Wraps 2 Lines-lr.jpg", + ], + "categories": [ + { + "alias": "steakhouse", + "title": "Steakhouse", + } + ], + "hours": [ + { + "is_open_now": false, + } + ], + "reviews": [ + { + "id": "r9", + "rating": 5, + "text": "Best steakhouse in town.", + "user": { + "id": "u9", + "image_url": "https://example.com/user9.jpg", + "name": "Chris Blue", + }, + } + ], + "location": { + "formatted_address": "369 High St, Dallas, TX", + }, + }, + { + "id": "10", + "name": + "Restaurant Name Goes Here And Wraps 2 Lines Name Goes Here And Wraps 2 Lines Ten", + "price": "\$\$", + "rating": 3.8, + "photos": [ + "https://i.natgeofe.com/n/04cf2a79-4a49-45eb-90f8-38356167690d/image00037.jpeg", + ], + "categories": [ + { + "alias": "pizza", + "title": "Pizza", + } + ], + "hours": [ + { + "is_open_now": true, + } + ], + "reviews": [ + { + "id": "r10", + "rating": 3, + "text": "Good pizza, but nothing special.", + "user": { + "id": "u10", + "image_url": "https://example.com/user10.jpg", + "name": "Tom Green", + }, + } + ], + "location": { + "formatted_address": "258 Oak St, Orlando, FL", + }, + } + ], +}; diff --git a/lib/data/restaurant/exceptions/exceptions.dart b/lib/data/restaurant/exceptions/exceptions.dart new file mode 100644 index 0000000..6775a51 --- /dev/null +++ b/lib/data/restaurant/exceptions/exceptions.dart @@ -0,0 +1 @@ +export 'restaurant_custom_exception.dart'; diff --git a/lib/data/restaurant/exceptions/restaurant_custom_exception.dart b/lib/data/restaurant/exceptions/restaurant_custom_exception.dart new file mode 100644 index 0000000..5ce06e8 --- /dev/null +++ b/lib/data/restaurant/exceptions/restaurant_custom_exception.dart @@ -0,0 +1,17 @@ +/// Exception thrown when the request throws a 404 error. +class RestaurantCustomException implements Exception { + /// The error object related to the exception. + final Object error; + + /// An optional message describing the exception. + final String message; + + /// Constructor that takes an [error] object and an optional [message] string. + RestaurantCustomException({required this.error, String? message}) + : message = message ?? 'An unexpected exception occurred'; + + @override + String toString() { + return 'RestaurantCustomException: $message, Error: $error'; + } +} diff --git a/lib/data/restaurant/graphql/restaurant_query.dart b/lib/data/restaurant/graphql/restaurant_query.dart new file mode 100644 index 0000000..67441a0 --- /dev/null +++ b/lib/data/restaurant/graphql/restaurant_query.dart @@ -0,0 +1,34 @@ +String getRestaurantsQuery(int offset) => ''' + query getRestaurants { + search(location: "Las Vegas", limit: 20, offset: $offset) { + total + business { + id + name + price + rating + photos + reviews { + id + rating + text + user { + id + image_url + name + } + } + categories { + title + alias + } + hours { + is_open_now + } + location { + formatted_address + } + } + } + } + '''; diff --git a/lib/data/restaurant/models/models.dart b/lib/data/restaurant/models/models.dart new file mode 100644 index 0000000..d5a0fcf --- /dev/null +++ b/lib/data/restaurant/models/models.dart @@ -0,0 +1 @@ +export 'restaurant.dart'; diff --git a/lib/data/restaurant/models/restaurant.dart b/lib/data/restaurant/models/restaurant.dart new file mode 100644 index 0000000..1c7ad2f --- /dev/null +++ b/lib/data/restaurant/models/restaurant.dart @@ -0,0 +1,157 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'restaurant.g.dart'; + +@JsonSerializable() +class Category { + final String? alias; + final String? title; + + Category({ + this.alias, + this.title, + }); + + factory Category.fromJson(Map json) => + _$CategoryFromJson(json); + + Map toJson() => _$CategoryToJson(this); +} + +@JsonSerializable() +class Hours { + @JsonKey(name: 'is_open_now') + final bool? isOpenNow; + + const Hours({ + this.isOpenNow, + }); + + factory Hours.fromJson(Map json) => _$HoursFromJson(json); + + Map toJson() => _$HoursToJson(this); +} + +@JsonSerializable() +class User { + final String? id; + @JsonKey(name: 'image_url') + final String? imageUrl; + final String? name; + + const User({ + this.id, + this.imageUrl, + this.name, + }); + + factory User.fromJson(Map json) => _$UserFromJson(json); + + Map toJson() => _$UserToJson(this); +} + +@JsonSerializable() +class Review { + final String? id; + final int? rating; + final String? text; + final User? user; + + const Review({ + this.id, + this.rating, + this.user, + this.text, + }); + + factory Review.fromJson(Map json) => _$ReviewFromJson(json); + + Map toJson() => _$ReviewToJson(this); +} + +@JsonSerializable() +class Location { + @JsonKey(name: 'formatted_address') + final String? formattedAddress; + + Location({ + this.formattedAddress, + }); + + factory Location.fromJson(Map json) => + _$LocationFromJson(json); + + Map toJson() => _$LocationToJson(this); +} + +@JsonSerializable() +class Restaurant { + final String? id; + final String? name; + final String? price; + final double? rating; + final List? photos; + final List? categories; + final List? hours; + final List? reviews; + final Location? location; + + const Restaurant({ + this.id, + this.name, + this.price, + this.rating, + this.photos, + this.categories, + this.hours, + this.reviews, + this.location, + }); + + factory Restaurant.fromJson(Map json) => + _$RestaurantFromJson(json); + + Map toJson() => _$RestaurantToJson(this); + + /// Use the first category for the category shown to the user + String get displayCategory { + if (categories != null && categories!.isNotEmpty) { + return categories!.first.title ?? ''; + } + return ''; + } + + /// Use the first image as the image shown to the user + String get heroImage { + if (photos != null && photos!.isNotEmpty) { + return photos!.first; + } + return ''; + } + + /// This logic is probably not correct in all cases but it is ok + /// for this application + bool get isOpen { + if (hours != null && hours!.isNotEmpty) { + return hours!.first.isOpenNow ?? false; + } + return false; + } +} + +@JsonSerializable() +class RestaurantQueryResult { + final int? total; + @JsonKey(name: 'business') + final List? restaurants; + + const RestaurantQueryResult({ + this.total, + this.restaurants, + }); + + factory RestaurantQueryResult.fromJson(Map json) => + _$RestaurantQueryResultFromJson(json); + + Map toJson() => _$RestaurantQueryResultToJson(this); +} diff --git a/lib/data/restaurant/models/restaurant.g.dart b/lib/data/restaurant/models/restaurant.g.dart new file mode 100644 index 0000000..dea6677 --- /dev/null +++ b/lib/data/restaurant/models/restaurant.g.dart @@ -0,0 +1,111 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'restaurant.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Category _$CategoryFromJson(Map json) => Category( + alias: json['alias'] as String?, + title: json['title'] as String?, + ); + +Map _$CategoryToJson(Category instance) => { + 'alias': instance.alias, + 'title': instance.title, + }; + +Hours _$HoursFromJson(Map json) => Hours( + isOpenNow: json['is_open_now'] as bool?, + ); + +Map _$HoursToJson(Hours instance) => { + 'is_open_now': instance.isOpenNow, + }; + +User _$UserFromJson(Map json) => User( + id: json['id'] as String?, + imageUrl: json['image_url'] as String?, + name: json['name'] as String?, + ); + +Map _$UserToJson(User instance) => { + 'id': instance.id, + 'image_url': instance.imageUrl, + 'name': instance.name, + }; + +Review _$ReviewFromJson(Map json) => Review( + id: json['id'] as String?, + rating: (json['rating'] as num?)?.toInt(), + user: json['user'] == null + ? null + : User.fromJson(json['user'] as Map), + text: json['text'] as String?, + ); + +Map _$ReviewToJson(Review instance) => { + 'id': instance.id, + 'rating': instance.rating, + 'text': instance.text, + 'user': instance.user, + }; + +Location _$LocationFromJson(Map json) => Location( + formattedAddress: json['formatted_address'] as String?, + ); + +Map _$LocationToJson(Location instance) => { + 'formatted_address': instance.formattedAddress, + }; + +Restaurant _$RestaurantFromJson(Map json) => Restaurant( + id: json['id'] as String?, + name: json['name'] as String?, + price: json['price'] as String?, + rating: (json['rating'] as num?)?.toDouble(), + photos: + (json['photos'] as List?)?.map((e) => e as String).toList(), + categories: (json['categories'] as List?) + ?.map((e) => Category.fromJson(e as Map)) + .toList(), + hours: (json['hours'] as List?) + ?.map((e) => Hours.fromJson(e as Map)) + .toList(), + reviews: (json['reviews'] as List?) + ?.map((e) => Review.fromJson(e as Map)) + .toList(), + location: json['location'] == null + ? null + : Location.fromJson(json['location'] as Map), + ); + +Map _$RestaurantToJson(Restaurant instance) => + { + 'id': instance.id, + 'name': instance.name, + 'price': instance.price, + 'rating': instance.rating, + 'photos': instance.photos, + 'categories': instance.categories, + 'hours': instance.hours, + 'reviews': instance.reviews, + 'location': instance.location, + }; + +RestaurantQueryResult _$RestaurantQueryResultFromJson( + Map json) => + RestaurantQueryResult( + total: (json['total'] as num?)?.toInt(), + restaurants: (json['business'] as List?) + ?.map((e) => Restaurant.fromJson(e as Map)) + .toList(), + ); + +Map _$RestaurantQueryResultToJson( + RestaurantQueryResult instance) => + { + 'total': instance.total, + 'business': instance.restaurants, + }; diff --git a/lib/data/restaurant/repository/repository.dart b/lib/data/restaurant/repository/repository.dart new file mode 100644 index 0000000..2ccf04d --- /dev/null +++ b/lib/data/restaurant/repository/repository.dart @@ -0,0 +1,2 @@ +export 'restaurant_repository_impl.dart'; +export 'restaurant_repository.dart'; diff --git a/lib/data/restaurant/repository/restaurant_repository.dart b/lib/data/restaurant/repository/restaurant_repository.dart new file mode 100644 index 0000000..ffa4e7e --- /dev/null +++ b/lib/data/restaurant/repository/restaurant_repository.dart @@ -0,0 +1,5 @@ +import 'package:restaurant_tour/app/app.dart'; + +abstract class RestaurantRepository { + Future> fetchRestaurants({int offset = 0}); +} diff --git a/lib/data/restaurant/repository/restaurant_repository_impl.dart b/lib/data/restaurant/repository/restaurant_repository_impl.dart new file mode 100644 index 0000000..51392d1 --- /dev/null +++ b/lib/data/restaurant/repository/restaurant_repository_impl.dart @@ -0,0 +1,12 @@ +import 'package:restaurant_tour/app/app.dart'; + +class RestaurantRepositoryImpl implements RestaurantRepository { + final RestaurantExternalDataSource dataSource; + + RestaurantRepositoryImpl(this.dataSource); + + @override + Future> fetchRestaurants({int offset = 0}) { + return dataSource.fetchRestaurants(offset: offset); + } +} diff --git a/lib/data/restaurant/sources/restaurant_external_data_source.dart b/lib/data/restaurant/sources/restaurant_external_data_source.dart new file mode 100644 index 0000000..b743a9b --- /dev/null +++ b/lib/data/restaurant/sources/restaurant_external_data_source.dart @@ -0,0 +1,39 @@ +import 'dart:convert'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:http/http.dart' as http; +import 'package:restaurant_tour/app/app.dart'; + +class RestaurantExternalDataSource { + static const _baseUrl = 'https://api.yelp.com/v3/graphql'; + + Future> fetchRestaurants({int offset = 0}) async { + await Future.delayed(const Duration(seconds: 5)); + + final useFakeData = dotenv.env['USE_FAKE_DATA'] == 'true'; + + if (useFakeData) { + return RestaurantQueryResult.fromJson(fakeRestaurants).restaurants ?? []; + } + + final response = await http.post( + Uri.parse(_baseUrl), + headers: { + 'Authorization': 'Bearer ${Environment.apiKey}', + 'Content-Type': 'application/graphql', + }, + body: getRestaurantsQuery(offset), + ); + + if (response.statusCode == 200) { + final jsonResponse = jsonDecode(response.body); + final List restaurantList = + jsonResponse['data']['search']['business']; + return restaurantList.map((r) => Restaurant.fromJson(r)).toList(); + } else { + throw RestaurantCustomException( + error: response.body, + message: 'Failed to fetch restaurants.', + ); + } + } +} diff --git a/lib/data/restaurant/sources/sources.dart b/lib/data/restaurant/sources/sources.dart new file mode 100644 index 0000000..0119613 --- /dev/null +++ b/lib/data/restaurant/sources/sources.dart @@ -0,0 +1 @@ +export 'restaurant_external_data_source.dart'; From a66d6de9e93f1cf8643d89493486f56ee39c94a5 Mon Sep 17 00:00:00 2001 From: Luis Carlos Date: Fri, 13 Sep 2024 15:11:53 -0500 Subject: [PATCH 3/7] feat(features): add home, favorites, and restaurant screens and widgets --- .../favorites/content/favorites_content.dart | 28 +++++ .../favorites/screen/favorites_screen.dart | 11 ++ lib/features/home/content/content.dart | 0 lib/features/home/content/home_content.dart | 28 +++++ lib/features/home/cubit/cubit.dart | 1 + lib/features/home/home.dart | 3 + lib/features/home/screen/screen.dart | 25 ++++ .../content/restaurant_detail_content.dart | 50 ++++++++ .../restaurant_detail/restaurant_detail.dart | 1 + .../screen/restaurant_detail_screen.dart | 20 ++++ .../widgets/detail_divider.dart | 16 +++ .../widgets/favorite_button_widget.dart | 27 +++++ .../restaurant_information_widget.dart | 109 ++++++++++++++++++ .../widgets/reviews_list.dart | 74 ++++++++++++ .../restaurant_detail/widgets/widgets.dart | 4 + .../restaurant_list/content/content.dart | 4 + .../content/restaurant_list.dart | 29 +++++ .../content/restaurant_list_content.dart | 30 +++++ .../restaurant_list_error_content.dart | 35 ++++++ .../restaurants_loading_list_content.dart | 17 +++ .../restaurant_list/cubit/cubit.dart | 1 + .../cubit/restaurant_list_cubit.dart | 60 ++++++++++ .../cubit/restaurant_list_state.dart | 18 +++ .../restaurant_list/restaurant_list.dart | 3 + .../screen/restaurant_list_screen.dart | 11 ++ .../restaurant_available_status_widget.dart | 29 +++++ .../widgets/restaurant_list_item_widget.dart | 101 ++++++++++++++++ .../shimmer_restaurant_list_item_widget.dart | 83 +++++++++++++ .../widgets/start_rating_widget.dart | 21 ++++ .../restaurant_list/widgets/widgets.dart | 4 + lib/features/restaurants/restaurants.dart | 2 + 31 files changed, 845 insertions(+) create mode 100644 lib/features/favorites/content/favorites_content.dart create mode 100644 lib/features/favorites/screen/favorites_screen.dart create mode 100644 lib/features/home/content/content.dart create mode 100644 lib/features/home/content/home_content.dart create mode 100644 lib/features/home/cubit/cubit.dart create mode 100644 lib/features/home/home.dart create mode 100644 lib/features/home/screen/screen.dart create mode 100644 lib/features/restaurants/restaurant_detail/content/restaurant_detail_content.dart create mode 100644 lib/features/restaurants/restaurant_detail/restaurant_detail.dart create mode 100644 lib/features/restaurants/restaurant_detail/screen/restaurant_detail_screen.dart create mode 100644 lib/features/restaurants/restaurant_detail/widgets/detail_divider.dart create mode 100644 lib/features/restaurants/restaurant_detail/widgets/favorite_button_widget.dart create mode 100644 lib/features/restaurants/restaurant_detail/widgets/restaurant_information_widget.dart create mode 100644 lib/features/restaurants/restaurant_detail/widgets/reviews_list.dart create mode 100644 lib/features/restaurants/restaurant_detail/widgets/widgets.dart create mode 100644 lib/features/restaurants/restaurant_list/content/content.dart create mode 100644 lib/features/restaurants/restaurant_list/content/restaurant_list.dart create mode 100644 lib/features/restaurants/restaurant_list/content/restaurant_list_content.dart create mode 100644 lib/features/restaurants/restaurant_list/content/restaurant_list_error_content.dart create mode 100644 lib/features/restaurants/restaurant_list/content/restaurants_loading_list_content.dart create mode 100644 lib/features/restaurants/restaurant_list/cubit/cubit.dart create mode 100644 lib/features/restaurants/restaurant_list/cubit/restaurant_list_cubit.dart create mode 100644 lib/features/restaurants/restaurant_list/cubit/restaurant_list_state.dart create mode 100644 lib/features/restaurants/restaurant_list/restaurant_list.dart create mode 100644 lib/features/restaurants/restaurant_list/screen/restaurant_list_screen.dart create mode 100644 lib/features/restaurants/restaurant_list/widgets/restaurant_available_status_widget.dart create mode 100644 lib/features/restaurants/restaurant_list/widgets/restaurant_list_item_widget.dart create mode 100644 lib/features/restaurants/restaurant_list/widgets/shimmer_restaurant_list_item_widget.dart create mode 100644 lib/features/restaurants/restaurant_list/widgets/start_rating_widget.dart create mode 100644 lib/features/restaurants/restaurant_list/widgets/widgets.dart create mode 100644 lib/features/restaurants/restaurants.dart diff --git a/lib/features/favorites/content/favorites_content.dart b/lib/features/favorites/content/favorites_content.dart new file mode 100644 index 0000000..d584a7b --- /dev/null +++ b/lib/features/favorites/content/favorites_content.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:restaurant_tour/app/cubits/cubits.dart'; +import 'package:restaurant_tour/features/restaurants/restaurant_list/restaurant_list.dart'; + +class FavoritesContent extends StatelessWidget { + const FavoritesContent({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return state is FavoriteLoaded + ? state.restaurants.isEmpty + ? const Center(child: Text('No favorites')) + : ListView.builder( + padding: const EdgeInsets.all(12.0), + itemCount: state.restaurants.length, + itemBuilder: (context, index) { + final restaurant = state.restaurants[index]; + return RestaurantListItemWidget(restaurant: restaurant); + }, + ) + : const Center(child: Text('Failed to load favorites')); + }, + ); + } +} diff --git a/lib/features/favorites/screen/favorites_screen.dart b/lib/features/favorites/screen/favorites_screen.dart new file mode 100644 index 0000000..85f3fe3 --- /dev/null +++ b/lib/features/favorites/screen/favorites_screen.dart @@ -0,0 +1,11 @@ +import 'package:flutter/widgets.dart'; +import 'package:restaurant_tour/features/favorites/content/favorites_content.dart'; + +class FavoritesScreen extends StatelessWidget { + const FavoritesScreen({super.key}); + + @override + Widget build(BuildContext context) { + return const FavoritesContent(); + } +} diff --git a/lib/features/home/content/content.dart b/lib/features/home/content/content.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/features/home/content/home_content.dart b/lib/features/home/content/home_content.dart new file mode 100644 index 0000000..c798937 --- /dev/null +++ b/lib/features/home/content/home_content.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; +import 'package:restaurant_tour/features/favorites/screen/favorites_screen.dart'; +import 'package:restaurant_tour/features/restaurants/restaurant_list/screen/restaurant_list_screen.dart'; + +class HomeContent extends StatelessWidget { + const HomeContent({super.key}); + + @override + Widget build(BuildContext context) { + return DefaultTabController( + length: 2, + child: Scaffold( + appBar: AppBar( + title: const Text('RestauranTour'), + bottom: const TabBar( + tabs: [ + Tab(text: 'All Restaurants'), + Tab(text: 'My Favorites'), + ], + ), + ), + body: const TabBarView( + children: [RestaurantListScreen(), FavoritesScreen()], + ), + ), + ); + } +} diff --git a/lib/features/home/cubit/cubit.dart b/lib/features/home/cubit/cubit.dart new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/lib/features/home/cubit/cubit.dart @@ -0,0 +1 @@ + diff --git a/lib/features/home/home.dart b/lib/features/home/home.dart new file mode 100644 index 0000000..369197f --- /dev/null +++ b/lib/features/home/home.dart @@ -0,0 +1,3 @@ +export 'content/content.dart'; +export 'cubit/cubit.dart'; +export 'screen/screen.dart'; diff --git a/lib/features/home/screen/screen.dart b/lib/features/home/screen/screen.dart new file mode 100644 index 0000000..7261137 --- /dev/null +++ b/lib/features/home/screen/screen.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:restaurant_tour/app/app.dart'; +import 'package:restaurant_tour/features/home/content/home_content.dart'; +import 'package:restaurant_tour/features/restaurants/restaurant_list/restaurant_list.dart'; + +class HomeScreen extends StatelessWidget { + const HomeScreen({super.key}); + + static const String routePath = '/'; + + @override + Widget build(BuildContext context) { + return MultiBlocProvider( + providers: [ + BlocProvider( + create: (context) => RestaurantListCubit( + restaurantRepository: context.read(), + )..getRestaurants(), + ), + ], + child: const HomeContent(), + ); + } +} diff --git a/lib/features/restaurants/restaurant_detail/content/restaurant_detail_content.dart b/lib/features/restaurants/restaurant_detail/content/restaurant_detail_content.dart new file mode 100644 index 0000000..501bcbc --- /dev/null +++ b/lib/features/restaurants/restaurant_detail/content/restaurant_detail_content.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:restaurant_tour/app/app.dart'; +import 'package:restaurant_tour/features/restaurants/restaurant_detail/restaurant_detail.dart'; + +class RestaurantDetailContent extends StatelessWidget { + const RestaurantDetailContent({required this.restaurant, super.key}); + + final Restaurant restaurant; + + @override + Widget build(BuildContext context) { + return Scaffold( + body: CustomScrollView( + slivers: [ + SliverAppBar( + title: Text(restaurant.name ?? ''), + backgroundColor: Colors.white, + floating: false, + pinned: true, + leading: IconButton( + icon: const Icon(Icons.arrow_back, color: Colors.black), + onPressed: () { + context.pop(); + }, + ), + actions: [ + FavoriteButtonWidget(restaurant: restaurant), + const SizedBox( + width: 14, + ), + ], + ), + RestaurantInformationWidget(restaurant: restaurant), + ReviewListWidget( + restaurant: restaurant, + ), + SliverPadding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).padding.bottom + 24, + ), + sliver: const SliverToBoxAdapter( + child: SizedBox.shrink(), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/restaurants/restaurant_detail/restaurant_detail.dart b/lib/features/restaurants/restaurant_detail/restaurant_detail.dart new file mode 100644 index 0000000..e691bba --- /dev/null +++ b/lib/features/restaurants/restaurant_detail/restaurant_detail.dart @@ -0,0 +1 @@ +export 'widgets/widgets.dart'; diff --git a/lib/features/restaurants/restaurant_detail/screen/restaurant_detail_screen.dart b/lib/features/restaurants/restaurant_detail/screen/restaurant_detail_screen.dart new file mode 100644 index 0000000..b3f1143 --- /dev/null +++ b/lib/features/restaurants/restaurant_detail/screen/restaurant_detail_screen.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:restaurant_tour/app/app.dart'; +import 'package:restaurant_tour/features/restaurants/restaurant_detail/content/restaurant_detail_content.dart'; + +class RestaurantDetailScreen extends StatelessWidget { + const RestaurantDetailScreen({required this.restaurant, super.key}); + + final Restaurant restaurant; + + static const String routeName = 'restaurantDetailScreen'; + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: context.read(), + child: RestaurantDetailContent(restaurant: restaurant), + ); + } +} diff --git a/lib/features/restaurants/restaurant_detail/widgets/detail_divider.dart b/lib/features/restaurants/restaurant_detail/widgets/detail_divider.dart new file mode 100644 index 0000000..0f55d7a --- /dev/null +++ b/lib/features/restaurants/restaurant_detail/widgets/detail_divider.dart @@ -0,0 +1,16 @@ +import 'package:flutter/material.dart'; + +class DetailDivider extends StatelessWidget { + const DetailDivider({super.key}); + + @override + Widget build(BuildContext context) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 24.0), + child: Divider( + thickness: 1.0, + color: Color(0xffEEEEEE), + ), + ); + } +} diff --git a/lib/features/restaurants/restaurant_detail/widgets/favorite_button_widget.dart b/lib/features/restaurants/restaurant_detail/widgets/favorite_button_widget.dart new file mode 100644 index 0000000..6124f90 --- /dev/null +++ b/lib/features/restaurants/restaurant_detail/widgets/favorite_button_widget.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:restaurant_tour/app/app.dart'; + +class FavoriteButtonWidget extends StatelessWidget { + const FavoriteButtonWidget({required this.restaurant, super.key}); + + final Restaurant restaurant; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + final isFavoriteRestaurant = + context.read().isFavorite(restaurant); + return InkWell( + onTap: () { + context.read().toggleFavorite(restaurant); + }, + child: isFavoriteRestaurant + ? const Icon(Icons.favorite) + : const Icon(Icons.favorite_border), + ); + }, + ); + } +} diff --git a/lib/features/restaurants/restaurant_detail/widgets/restaurant_information_widget.dart b/lib/features/restaurants/restaurant_detail/widgets/restaurant_information_widget.dart new file mode 100644 index 0000000..7761bf9 --- /dev/null +++ b/lib/features/restaurants/restaurant_detail/widgets/restaurant_information_widget.dart @@ -0,0 +1,109 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:restaurant_tour/app/app.dart'; +import 'package:restaurant_tour/features/restaurants/restaurant_detail/restaurant_detail.dart'; +import 'package:restaurant_tour/features/restaurants/restaurant_list/restaurant_list.dart'; + +class RestaurantInformationWidget extends StatelessWidget { + const RestaurantInformationWidget({required this.restaurant, super.key}); + + final Restaurant restaurant; + + @override + Widget build(BuildContext context) { + return SliverList( + delegate: SliverChildListDelegate( + [ + Hero( + tag: restaurant.id!, + child: CachedNetworkImage( + imageUrl: restaurant.heroImage, + height: 361, + fit: BoxFit.cover, + ), + ), + Padding( + padding: const EdgeInsets.only(left: 24, right: 24, top: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + height: 20, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text.rich( + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyLarge, + TextSpan( + children: [ + TextSpan( + text: restaurant.price ?? '\$', + ), + TextSpan( + text: ' ${restaurant.displayCategory}', + ), + ], + ), + ), + RestaurantAvailableStatusWidget( + isOpen: restaurant.isOpen, + ), + ], + ), + ), + const DetailDivider(), + Text( + 'Address', + style: Theme.of(context).textTheme.bodyLarge, + ), + const SizedBox( + height: 24, + ), + SizedBox( + width: 121, + child: Text( + restaurant.location?.formattedAddress ?? '', + style: Theme.of(context).textTheme.titleMedium, + ), + ), + const DetailDivider(), + Text( + 'Overall Rating', + style: Theme.of(context).textTheme.bodyLarge, + ), + const SizedBox( + height: 16, + ), + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + (restaurant.rating ?? 0).toString(), + style: Theme.of(context) + .textTheme + .displayLarge! + .copyWith(fontSize: 28), + ), + const SizedBox( + width: 2, + ), + const StartRatingWidget(rating: 1), + ], + ), + const DetailDivider(), + Text( + '${restaurant.reviews?.length ?? 0} Reviews', + style: Theme.of(context).textTheme.bodyLarge, + ), + const SizedBox( + height: 24, + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/restaurants/restaurant_detail/widgets/reviews_list.dart b/lib/features/restaurants/restaurant_detail/widgets/reviews_list.dart new file mode 100644 index 0000000..f05a1dc --- /dev/null +++ b/lib/features/restaurants/restaurant_detail/widgets/reviews_list.dart @@ -0,0 +1,74 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:restaurant_tour/app/app.dart'; +import 'package:restaurant_tour/features/restaurants/restaurant_detail/restaurant_detail.dart'; +import 'package:restaurant_tour/features/restaurants/restaurant_list/restaurant_list.dart'; + +class ReviewListWidget extends StatelessWidget { + const ReviewListWidget({required this.restaurant, super.key}); + + final Restaurant restaurant; + + @override + Widget build(BuildContext context) { + return SliverList( + delegate: SliverChildBuilderDelegate( + (BuildContext context, int index) { + final review = restaurant.reviews![index]; + return Padding( + padding: const EdgeInsets.only( + left: 24, + right: 24, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + StartRatingWidget(rating: review.rating ?? 0), + const SizedBox( + height: 8, + ), + Text( + review.text ?? 'No description', + maxLines: 4, + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox( + height: 8, + ), + Row( + children: [ + CircleAvatar( + radius: 24, + backgroundColor: Colors.grey[200], + child: CachedNetworkImage( + imageUrl: review.user?.imageUrl ?? '', + imageBuilder: (context, imageProvider) => CircleAvatar( + radius: 24, + backgroundImage: imageProvider, + ), + placeholder: (context, url) => + const CircularProgressIndicator(), + errorWidget: (context, url, error) => + const Icon(Icons.error), + ), + ), + const SizedBox( + width: 8, + ), + Text( + review.user?.name ?? 'No user name', + style: Theme.of(context).textTheme.bodyLarge, + ), + ], + ), + if (index < (restaurant.reviews?.length ?? 0) - 1) + const DetailDivider(), + ], + ), + ); + }, + childCount: restaurant.reviews?.length ?? 0, + ), + ); + } +} diff --git a/lib/features/restaurants/restaurant_detail/widgets/widgets.dart b/lib/features/restaurants/restaurant_detail/widgets/widgets.dart new file mode 100644 index 0000000..5b748da --- /dev/null +++ b/lib/features/restaurants/restaurant_detail/widgets/widgets.dart @@ -0,0 +1,4 @@ +export 'detail_divider.dart'; +export 'favorite_button_widget.dart'; +export 'reviews_list.dart'; +export 'restaurant_information_widget.dart'; diff --git a/lib/features/restaurants/restaurant_list/content/content.dart b/lib/features/restaurants/restaurant_list/content/content.dart new file mode 100644 index 0000000..e040afa --- /dev/null +++ b/lib/features/restaurants/restaurant_list/content/content.dart @@ -0,0 +1,4 @@ +export 'restaurants_loading_list_content.dart'; +export 'restaurant_list_error_content.dart'; +export 'restaurant_list.dart'; +export 'restaurant_list_content.dart'; diff --git a/lib/features/restaurants/restaurant_list/content/restaurant_list.dart b/lib/features/restaurants/restaurant_list/content/restaurant_list.dart new file mode 100644 index 0000000..ddbb7be --- /dev/null +++ b/lib/features/restaurants/restaurant_list/content/restaurant_list.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:restaurant_tour/features/restaurants/restaurant_list/restaurant_list.dart'; + +class RestaurantsList extends StatelessWidget { + const RestaurantsList({ + required this.restaurantsStateLoaded, + super.key, + }); + + final RestaurantListLoaded restaurantsStateLoaded; + + @override + Widget build(BuildContext context) { + return RefreshIndicator( + onRefresh: () async { + context.read().getRestaurants(); + }, + child: ListView.builder( + padding: const EdgeInsets.all(12.0), + itemCount: restaurantsStateLoaded.restaurants.length, + itemBuilder: (context, index) { + final restaurant = restaurantsStateLoaded.restaurants[index]; + return RestaurantListItemWidget(restaurant: restaurant); + }, + ), + ); + } +} diff --git a/lib/features/restaurants/restaurant_list/content/restaurant_list_content.dart b/lib/features/restaurants/restaurant_list/content/restaurant_list_content.dart new file mode 100644 index 0000000..8f56661 --- /dev/null +++ b/lib/features/restaurants/restaurant_list/content/restaurant_list_content.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:restaurant_tour/features/restaurants/restaurant_list/restaurant_list.dart'; + +class RestaurantListContent extends StatelessWidget { + const RestaurantListContent({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return switch (state) { + RestaurantListInitial() || + RestaurantListLoading() => + const RestaurantsLoadingListContent(), + RestaurantListError() => RestaurantListErrorContent( + errorMesssage: state.errorMessage, + ), + RestaurantListLoaded() => state.restaurants.isNotEmpty + ? RestaurantsList(restaurantsStateLoaded: state) + : const Center( + child: RestaurantListErrorContent( + errorMesssage: 'No restaurants available', + ), + ), + }; + }, + ); + } +} diff --git a/lib/features/restaurants/restaurant_list/content/restaurant_list_error_content.dart b/lib/features/restaurants/restaurant_list/content/restaurant_list_error_content.dart new file mode 100644 index 0000000..780d4c9 --- /dev/null +++ b/lib/features/restaurants/restaurant_list/content/restaurant_list_error_content.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:restaurant_tour/features/restaurants/restaurant_list/cubit/cubit.dart'; + +class RestaurantListErrorContent extends StatelessWidget { + const RestaurantListErrorContent({ + required this.errorMesssage, + super.key, + }); + + final String errorMesssage; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + errorMesssage, + textAlign: TextAlign.center, + ), + const SizedBox( + height: 12.0, + ), + ElevatedButton( + onPressed: () { + context.read().getRestaurants(); + }, + child: const Text('Try again'), + ), + ], + ); + } +} diff --git a/lib/features/restaurants/restaurant_list/content/restaurants_loading_list_content.dart b/lib/features/restaurants/restaurant_list/content/restaurants_loading_list_content.dart new file mode 100644 index 0000000..1b96853 --- /dev/null +++ b/lib/features/restaurants/restaurant_list/content/restaurants_loading_list_content.dart @@ -0,0 +1,17 @@ +import 'package:flutter/material.dart'; +import 'package:restaurant_tour/features/restaurants/restaurant_list/restaurant_list.dart'; + +class RestaurantsLoadingListContent extends StatelessWidget { + const RestaurantsLoadingListContent({super.key}); + + @override + Widget build(BuildContext context) { + return ListView.builder( + padding: const EdgeInsets.all(12.0), + itemCount: 6, + itemBuilder: (context, index) { + return const ShimmerRestaurantListItemWidget(); + }, + ); + } +} diff --git a/lib/features/restaurants/restaurant_list/cubit/cubit.dart b/lib/features/restaurants/restaurant_list/cubit/cubit.dart new file mode 100644 index 0000000..8440f53 --- /dev/null +++ b/lib/features/restaurants/restaurant_list/cubit/cubit.dart @@ -0,0 +1 @@ +export 'restaurant_list_cubit.dart'; diff --git a/lib/features/restaurants/restaurant_list/cubit/restaurant_list_cubit.dart b/lib/features/restaurants/restaurant_list/cubit/restaurant_list_cubit.dart new file mode 100644 index 0000000..17b9180 --- /dev/null +++ b/lib/features/restaurants/restaurant_list/cubit/restaurant_list_cubit.dart @@ -0,0 +1,60 @@ +import 'package:bloc/bloc.dart'; +import 'package:flutter/material.dart'; +import 'package:restaurant_tour/app/app.dart'; + +part 'restaurant_list_state.dart'; + +/// [RestaurantListCubit] is responsible for managing the state of the restaurant list. +/// +/// It communicates with the [RestaurantRepository] to fetch restaurant data and +/// emits different states depending on the process of fetching the restaurants. +/// +/// ### States: +/// - [RestaurantListInitial]: The initial state when no data has been fetched yet. +/// - [RestaurantListLoading]: Emitted when the restaurants are being fetched from the repository. +/// - [RestaurantListLoaded]: Emitted when the list of restaurants is successfully fetched and available. +/// - [RestaurantListError]: Emitted when an error occurs during the fetching process. +/// +/// ### Usage: +/// - To fetch restaurants, the [getRestaurants] method is called. +/// - The cubit emits different states based on the success or failure of the data fetching. +class RestaurantListCubit extends Cubit { + /// The repository responsible for fetching restaurant data. + final RestaurantRepository restaurantRepository; + + /// Constructor for [RestaurantListCubit]. + /// + /// Requires a [RestaurantRepository] to interact with the restaurant data source. + /// The initial state is [RestaurantListInitial]. + RestaurantListCubit({required this.restaurantRepository}) + : super(RestaurantListInitial()); + + /// Fetches a list of restaurants from the repository. + /// + /// This method starts by emitting the [RestaurantListLoading] state. + /// Then it calls [restaurantRepository.fetchRestaurants] to get the list of restaurants. + /// + /// If successful, it emits [RestaurantListLoaded] with the fetched list. + /// If an error occurs, it emits [RestaurantListError] with an error message. + /// + /// [offset] is an optional parameter for pagination, defaulting to 0. + Future getRestaurants({int offset = 0}) async { + try { + // Emit loading state while fetching restaurants + emit(RestaurantListLoading()); + + // Fetch restaurants from repository + final restaurants = + await restaurantRepository.fetchRestaurants(offset: offset); + + // Emit loaded state with fetched restaurants + emit(RestaurantListLoaded(restaurants: restaurants)); + } on RestaurantCustomException catch (exception) { + // Emit error state with custom exception message + emit(RestaurantListError(errorMessage: exception.message)); + } on Exception catch (exception) { + // Emit error state with generic exception message + emit(RestaurantListError(errorMessage: exception.toString())); + } + } +} diff --git a/lib/features/restaurants/restaurant_list/cubit/restaurant_list_state.dart b/lib/features/restaurants/restaurant_list/cubit/restaurant_list_state.dart new file mode 100644 index 0000000..d2e9e16 --- /dev/null +++ b/lib/features/restaurants/restaurant_list/cubit/restaurant_list_state.dart @@ -0,0 +1,18 @@ +part of 'restaurant_list_cubit.dart'; + +@immutable +sealed class RestaurantListState {} + +final class RestaurantListInitial extends RestaurantListState {} + +final class RestaurantListLoading extends RestaurantListState {} + +final class RestaurantListError extends RestaurantListState { + RestaurantListError({required this.errorMessage}); + final String errorMessage; +} + +final class RestaurantListLoaded extends RestaurantListState { + RestaurantListLoaded({required this.restaurants}); + final List restaurants; +} diff --git a/lib/features/restaurants/restaurant_list/restaurant_list.dart b/lib/features/restaurants/restaurant_list/restaurant_list.dart new file mode 100644 index 0000000..d2a36bb --- /dev/null +++ b/lib/features/restaurants/restaurant_list/restaurant_list.dart @@ -0,0 +1,3 @@ +export 'widgets/widgets.dart'; +export 'content/content.dart'; +export 'cubit/cubit.dart'; diff --git a/lib/features/restaurants/restaurant_list/screen/restaurant_list_screen.dart b/lib/features/restaurants/restaurant_list/screen/restaurant_list_screen.dart new file mode 100644 index 0000000..6c3b301 --- /dev/null +++ b/lib/features/restaurants/restaurant_list/screen/restaurant_list_screen.dart @@ -0,0 +1,11 @@ +import 'package:flutter/material.dart'; +import 'package:restaurant_tour/features/restaurants/restaurant_list/restaurant_list.dart'; + +class RestaurantListScreen extends StatelessWidget { + const RestaurantListScreen({super.key}); + + @override + Widget build(BuildContext context) { + return const RestaurantListContent(); + } +} diff --git a/lib/features/restaurants/restaurant_list/widgets/restaurant_available_status_widget.dart b/lib/features/restaurants/restaurant_list/widgets/restaurant_available_status_widget.dart new file mode 100644 index 0000000..1893435 --- /dev/null +++ b/lib/features/restaurants/restaurant_list/widgets/restaurant_available_status_widget.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; + +class RestaurantAvailableStatusWidget extends StatelessWidget { + const RestaurantAvailableStatusWidget({required this.isOpen, super.key}); + + final bool isOpen; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Text( + isOpen ? 'Open Now' : 'Closed', + ), + const SizedBox( + width: 8, + ), + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: Color(isOpen ? 0xff5CD313 : 0xffEA5E5E), + borderRadius: BorderRadius.circular(100), + ), + ), + ], + ); + } +} diff --git a/lib/features/restaurants/restaurant_list/widgets/restaurant_list_item_widget.dart b/lib/features/restaurants/restaurant_list/widgets/restaurant_list_item_widget.dart new file mode 100644 index 0000000..9b4f30e --- /dev/null +++ b/lib/features/restaurants/restaurant_list/widgets/restaurant_list_item_widget.dart @@ -0,0 +1,101 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:restaurant_tour/app/app.dart'; +import 'package:restaurant_tour/features/restaurants/restaurant_detail/screen/restaurant_detail_screen.dart'; +import 'package:restaurant_tour/features/restaurants/restaurant_list/widgets/restaurant_available_status_widget.dart'; +import 'package:restaurant_tour/features/restaurants/restaurant_list/widgets/start_rating_widget.dart'; + +class RestaurantListItemWidget extends StatelessWidget { + const RestaurantListItemWidget({required this.restaurant, super.key}); + + final Restaurant restaurant; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () { + context.pushNamed(RestaurantDetailScreen.routeName, extra: restaurant); + }, + child: Container( + padding: const EdgeInsets.all(8.0), + margin: const EdgeInsets.only(bottom: 12.0), + decoration: BoxDecoration( + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.12), + spreadRadius: 1, + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + color: Colors.white, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8.0), + child: Hero( + tag: restaurant.id!, + child: CachedNetworkImage( + height: 88, + width: 88, + fit: BoxFit.cover, + imageUrl: restaurant.heroImage, + ), + ), + ), + const SizedBox(width: 12.0), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + height: 48.0, + child: Text( + restaurant.name ?? '', + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.displayLarge, + ), + ), + const SizedBox(height: 4), + SizedBox( + height: 20, + child: Text.rich( + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyLarge, + TextSpan( + children: [ + TextSpan( + text: restaurant.price ?? '\$', + ), + TextSpan( + text: ' ${restaurant.displayCategory}', + ), + ], + ), + ), + ), + const SizedBox(height: 4), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + StartRatingWidget( + rating: restaurant.rating?.toInt() ?? 0, + ), + RestaurantAvailableStatusWidget( + isOpen: restaurant.isOpen, + ), + ], + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/restaurants/restaurant_list/widgets/shimmer_restaurant_list_item_widget.dart b/lib/features/restaurants/restaurant_list/widgets/shimmer_restaurant_list_item_widget.dart new file mode 100644 index 0000000..85fecf2 --- /dev/null +++ b/lib/features/restaurants/restaurant_list/widgets/shimmer_restaurant_list_item_widget.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; +import 'package:shimmer/shimmer.dart'; + +class ShimmerRestaurantListItemWidget extends StatelessWidget { + const ShimmerRestaurantListItemWidget({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(8.0), + margin: const EdgeInsets.only(bottom: 12.0), + decoration: BoxDecoration( + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.12), + spreadRadius: 1, + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + color: Colors.white, + borderRadius: BorderRadius.circular(8), + ), + child: Shimmer.fromColors( + baseColor: Colors.grey.shade300, + highlightColor: Colors.grey.shade100, + child: Row( + children: [ + Container( + decoration: BoxDecoration( + color: Colors.grey, + borderRadius: BorderRadius.circular(8.0), + ), + width: 88.0, + height: 88.0, + ), + const SizedBox(width: 12.0), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + decoration: BoxDecoration( + color: Colors.grey, + borderRadius: BorderRadius.circular(8.0), + ), + height: 22.0, + ), + const SizedBox(height: 4), + Container( + height: 22, + decoration: BoxDecoration( + color: Colors.grey, + borderRadius: BorderRadius.circular(8.0), + ), + ), + const SizedBox(height: 4), + Container( + width: 28, + height: 20, + decoration: BoxDecoration( + color: Colors.grey, + borderRadius: BorderRadius.circular(8.0), + ), + ), + const SizedBox(height: 4), + Container( + width: 60, + height: 12, + decoration: BoxDecoration( + color: Colors.grey, + borderRadius: BorderRadius.circular(8.0), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/restaurants/restaurant_list/widgets/start_rating_widget.dart b/lib/features/restaurants/restaurant_list/widgets/start_rating_widget.dart new file mode 100644 index 0000000..17ecfc6 --- /dev/null +++ b/lib/features/restaurants/restaurant_list/widgets/start_rating_widget.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; + +class StartRatingWidget extends StatelessWidget { + const StartRatingWidget({required this.rating, super.key}); + + final int rating; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: List.generate(rating, (index) { + return const Icon( + Icons.star, + color: Color(0xFFFFB800), + size: 12, + ); + }), + ); + } +} diff --git a/lib/features/restaurants/restaurant_list/widgets/widgets.dart b/lib/features/restaurants/restaurant_list/widgets/widgets.dart new file mode 100644 index 0000000..6a9bfe3 --- /dev/null +++ b/lib/features/restaurants/restaurant_list/widgets/widgets.dart @@ -0,0 +1,4 @@ +export 'shimmer_restaurant_list_item_widget.dart'; +export 'restaurant_list_item_widget.dart'; +export 'restaurant_available_status_widget.dart'; +export 'start_rating_widget.dart'; diff --git a/lib/features/restaurants/restaurants.dart b/lib/features/restaurants/restaurants.dart new file mode 100644 index 0000000..370cade --- /dev/null +++ b/lib/features/restaurants/restaurants.dart @@ -0,0 +1,2 @@ +export 'restaurant_list/restaurant_list.dart'; +export 'restaurant_detail/restaurant_detail.dart'; From 823c4e6b45369112bb4d8b9d66d86ea6f2f31c14 Mon Sep 17 00:00:00 2001 From: Luis Carlos Date: Fri, 13 Sep 2024 15:12:45 -0500 Subject: [PATCH 4/7] chore: remove unused models, queries, and test files - Deleted obsolete restaurant models and related generated files. - Removed old query and typography files no longer in use. - Cleaned up redundant test files for cubits, views, and widgets. --- lib/models/restaurant.dart | 157 ----------------------------------- lib/models/restaurant.g.dart | 109 ------------------------ lib/query.dart | 34 -------- lib/typography.dart | 49 ----------- test/widget_test.dart | 19 ----- 5 files changed, 368 deletions(-) delete mode 100644 lib/models/restaurant.dart delete mode 100644 lib/models/restaurant.g.dart delete mode 100644 lib/query.dart delete mode 100644 lib/typography.dart delete mode 100644 test/widget_test.dart diff --git a/lib/models/restaurant.dart b/lib/models/restaurant.dart deleted file mode 100644 index 1c7ad2f..0000000 --- a/lib/models/restaurant.dart +++ /dev/null @@ -1,157 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; - -part 'restaurant.g.dart'; - -@JsonSerializable() -class Category { - final String? alias; - final String? title; - - Category({ - this.alias, - this.title, - }); - - factory Category.fromJson(Map json) => - _$CategoryFromJson(json); - - Map toJson() => _$CategoryToJson(this); -} - -@JsonSerializable() -class Hours { - @JsonKey(name: 'is_open_now') - final bool? isOpenNow; - - const Hours({ - this.isOpenNow, - }); - - factory Hours.fromJson(Map json) => _$HoursFromJson(json); - - Map toJson() => _$HoursToJson(this); -} - -@JsonSerializable() -class User { - final String? id; - @JsonKey(name: 'image_url') - final String? imageUrl; - final String? name; - - const User({ - this.id, - this.imageUrl, - this.name, - }); - - factory User.fromJson(Map json) => _$UserFromJson(json); - - Map toJson() => _$UserToJson(this); -} - -@JsonSerializable() -class Review { - final String? id; - final int? rating; - final String? text; - final User? user; - - const Review({ - this.id, - this.rating, - this.user, - this.text, - }); - - factory Review.fromJson(Map json) => _$ReviewFromJson(json); - - Map toJson() => _$ReviewToJson(this); -} - -@JsonSerializable() -class Location { - @JsonKey(name: 'formatted_address') - final String? formattedAddress; - - Location({ - this.formattedAddress, - }); - - factory Location.fromJson(Map json) => - _$LocationFromJson(json); - - Map toJson() => _$LocationToJson(this); -} - -@JsonSerializable() -class Restaurant { - final String? id; - final String? name; - final String? price; - final double? rating; - final List? photos; - final List? categories; - final List? hours; - final List? reviews; - final Location? location; - - const Restaurant({ - this.id, - this.name, - this.price, - this.rating, - this.photos, - this.categories, - this.hours, - this.reviews, - this.location, - }); - - factory Restaurant.fromJson(Map json) => - _$RestaurantFromJson(json); - - Map toJson() => _$RestaurantToJson(this); - - /// Use the first category for the category shown to the user - String get displayCategory { - if (categories != null && categories!.isNotEmpty) { - return categories!.first.title ?? ''; - } - return ''; - } - - /// Use the first image as the image shown to the user - String get heroImage { - if (photos != null && photos!.isNotEmpty) { - return photos!.first; - } - return ''; - } - - /// This logic is probably not correct in all cases but it is ok - /// for this application - bool get isOpen { - if (hours != null && hours!.isNotEmpty) { - return hours!.first.isOpenNow ?? false; - } - return false; - } -} - -@JsonSerializable() -class RestaurantQueryResult { - final int? total; - @JsonKey(name: 'business') - final List? restaurants; - - const RestaurantQueryResult({ - this.total, - this.restaurants, - }); - - factory RestaurantQueryResult.fromJson(Map json) => - _$RestaurantQueryResultFromJson(json); - - Map toJson() => _$RestaurantQueryResultToJson(this); -} diff --git a/lib/models/restaurant.g.dart b/lib/models/restaurant.g.dart deleted file mode 100644 index 3ed33f9..0000000 --- a/lib/models/restaurant.g.dart +++ /dev/null @@ -1,109 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'restaurant.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -Category _$CategoryFromJson(Map json) => Category( - alias: json['alias'] as String?, - title: json['title'] as String?, - ); - -Map _$CategoryToJson(Category instance) => { - 'alias': instance.alias, - 'title': instance.title, - }; - -Hours _$HoursFromJson(Map json) => Hours( - isOpenNow: json['is_open_now'] as bool?, - ); - -Map _$HoursToJson(Hours instance) => { - 'is_open_now': instance.isOpenNow, - }; - -User _$UserFromJson(Map json) => User( - id: json['id'] as String?, - imageUrl: json['image_url'] as String?, - name: json['name'] as String?, - ); - -Map _$UserToJson(User instance) => { - 'id': instance.id, - 'image_url': instance.imageUrl, - 'name': instance.name, - }; - -Review _$ReviewFromJson(Map json) => Review( - id: json['id'] as String?, - rating: json['rating'] as int?, - user: json['user'] == null - ? null - : User.fromJson(json['user'] as Map), - ); - -Map _$ReviewToJson(Review instance) => { - 'id': instance.id, - 'rating': instance.rating, - 'user': instance.user, - }; - -Location _$LocationFromJson(Map json) => Location( - formattedAddress: json['formatted_address'] as String?, - ); - -Map _$LocationToJson(Location instance) => { - 'formatted_address': instance.formattedAddress, - }; - -Restaurant _$RestaurantFromJson(Map json) => Restaurant( - id: json['id'] as String?, - name: json['name'] as String?, - price: json['price'] as String?, - rating: (json['rating'] as num?)?.toDouble(), - photos: - (json['photos'] as List?)?.map((e) => e as String).toList(), - categories: (json['categories'] as List?) - ?.map((e) => Category.fromJson(e as Map)) - .toList(), - hours: (json['hours'] as List?) - ?.map((e) => Hours.fromJson(e as Map)) - .toList(), - reviews: (json['reviews'] as List?) - ?.map((e) => Review.fromJson(e as Map)) - .toList(), - location: json['location'] == null - ? null - : Location.fromJson(json['location'] as Map), - ); - -Map _$RestaurantToJson(Restaurant instance) => - { - 'id': instance.id, - 'name': instance.name, - 'price': instance.price, - 'rating': instance.rating, - 'photos': instance.photos, - 'categories': instance.categories, - 'hours': instance.hours, - 'reviews': instance.reviews, - 'location': instance.location, - }; - -RestaurantQueryResult _$RestaurantQueryResultFromJson( - Map json) => - RestaurantQueryResult( - total: json['total'] as int?, - restaurants: (json['business'] as List?) - ?.map((e) => Restaurant.fromJson(e as Map)) - .toList(), - ); - -Map _$RestaurantQueryResultToJson( - RestaurantQueryResult instance) => - { - 'total': instance.total, - 'business': instance.restaurants, - }; diff --git a/lib/query.dart b/lib/query.dart deleted file mode 100644 index 7a8993b..0000000 --- a/lib/query.dart +++ /dev/null @@ -1,34 +0,0 @@ -String query(int offset) => ''' - query getRestaurants { - search(location: "Las Vegas", limit: 20, offset: $offset) { - total - business { - id - name - price - rating - photos - reviews { - id - rating - text - user { - id - image_url - name - } - } - categories { - title - alias - } - hours { - is_open_now - } - location { - formatted_address - } - } - } - } - '''; 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/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); - }); -} From 67ac775f0c7d0b6ec0b28ca04149dd5fc971762b Mon Sep 17 00:00:00 2001 From: Luis Carlos Date: Fri, 13 Sep 2024 15:13:51 -0500 Subject: [PATCH 5/7] chore(pubspec): update dependencies --- pubspec.lock | 489 +++++++++++++++++++++++++++++++++++++++++++-------- pubspec.yaml | 16 +- 2 files changed, 424 insertions(+), 81 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index f95a63e..4b6061b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,26 +5,26 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: ae92f5d747aee634b87f89d9946000c2de774be1d6ac3e58268224348cd0101a + sha256: "0b2f2bd91ba804e53a61d757b986f89f1f9eaed5b11e4b2f5a2468d86d6c9fc7" url: "https://pub.dev" source: hosted - version: "61.0.0" + version: "67.0.0" analyzer: dependency: transitive description: name: analyzer - sha256: ea3d8652bda62982addfd92fdc2d0214e5f82e43325104990d4f4c4a2a313562 + sha256: "37577842a27e4338429a1cbc32679d508836510b056f1eedf0c8d20e39c1383d" url: "https://pub.dev" source: hosted - version: "5.13.0" + version: "6.4.1" args: dependency: transitive description: name: args - sha256: "0bd9a99b6eb96f07af141f0eb53eace8983e8e5aa5de59777aca31684680ef22" + sha256: "7cf60b9f0cc88203c5a190b4cd62a99feea42759a7fa695010eb5de1c0b2252a" url: "https://pub.dev" source: hosted - version: "2.3.0" + version: "2.5.0" async: dependency: transitive description: @@ -33,6 +33,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.11.0" + bloc: + dependency: "direct main" + description: + name: bloc + sha256: "106842ad6569f0b60297619e9e0b1885c2fb9bf84812935490e6c5275777804e" + url: "https://pub.dev" + source: hosted + version: "8.1.4" + bloc_test: + dependency: "direct main" + description: + name: bloc_test + sha256: "165a6ec950d9252ebe36dc5335f2e6eb13055f33d56db0eeb7642768849b43d2" + url: "https://pub.dev" + source: hosted + version: "9.1.7" boolean_selector: dependency: transitive description: @@ -45,10 +61,10 @@ packages: dependency: transitive description: name: build - sha256: "3fbda25365741f8251b39f3917fb3c8e286a96fd068a5a242e11c2012d495777" + sha256: "80184af8b6cb3e5c1c4ec6d8544d27711700bc3e6d2efad04238c7b5290889f0" url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.4.1" build_config: dependency: transitive description: @@ -61,18 +77,18 @@ packages: dependency: transitive description: name: build_daemon - sha256: "5f02d73eb2ba16483e693f80bee4f088563a820e47d1027d4cdfe62b5bb43e65" + sha256: "79b2aef6ac2ed00046867ed354c88778c9c0f029df8a20fe10b5436826721ef9" url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "4.0.2" build_resolvers: dependency: transitive description: name: build_resolvers - sha256: "6c4dd11d05d056e76320b828a1db0fc01ccd376922526f8e9d6c796a5adbac20" + sha256: "339086358431fa15d7eca8b6a36e5d783728cf025e559b834f4609a1fcfb7b0a" url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "2.4.2" build_runner: dependency: "direct dev" description: @@ -85,10 +101,10 @@ packages: dependency: transitive description: name: build_runner_core - sha256: f4d6244cc071ba842c296cb1c4ee1b31596b9f924300647ac7a1445493471a3f + sha256: e3c79f69a64bdfcd8a776a3c28db4eb6e3fb5356d013ae5eb2e52007706d5dbe url: "https://pub.dev" source: hosted - version: "7.2.3" + version: "7.3.1" built_collection: dependency: transitive description: @@ -101,34 +117,50 @@ packages: dependency: transitive description: name: built_value - sha256: b6c9911b2d670376918d5b8779bc27e0e612a94ec3ff0343689e991d8d0a3b8a + sha256: c7913a9737ee4007efedaffc968c049fd0f3d0e49109e778edc10de9426005cb url: "https://pub.dev" source: hosted - version: "8.1.4" - characters: + version: "8.9.2" + cached_network_image: + dependency: "direct main" + description: + name: cached_network_image + sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + cached_network_image_platform_interface: dependency: transitive description: - name: characters - sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" + name: cached_network_image_platform_interface + sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829" url: "https://pub.dev" source: hosted - version: "1.3.0" - charcode: + version: "4.1.1" + cached_network_image_web: dependency: transitive description: - name: charcode - sha256: fb98c0f6d12c920a02ee2d998da788bca066ca5f148492b7085ee23372b12306 + name: cached_network_image_web + sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062" url: "https://pub.dev" source: hosted version: "1.3.1" + characters: + dependency: transitive + description: + name: characters + sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" + url: "https://pub.dev" + source: hosted + version: "1.3.0" checked_yaml: dependency: transitive description: name: checked_yaml - sha256: dd007e4fb8270916820a0d66e24f619266b60773cddd082c6439341645af2659 + sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "2.0.3" clock: dependency: transitive description: @@ -157,26 +189,42 @@ packages: dependency: transitive description: name: convert - sha256: f08428ad63615f96a27e34221c65e1a451439b5f26030f78d790f461c686d65d + sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.1.1" + coverage: + dependency: transitive + description: + name: coverage + sha256: c1fb2dce3c0085f39dc72668e85f8e0210ec7de05345821ff58530567df345a5 + url: "https://pub.dev" + source: hosted + version: "1.9.2" crypto: dependency: transitive description: name: crypto - sha256: cf75650c66c0316274e21d7c43d3dea246273af5955bd94e8184837cd577575c + sha256: ec30d999af904f33454ba22ed9a86162b35e52b44ac4807d1d93c288041d7d27 url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.5" dart_style: dependency: transitive description: name: dart_style - sha256: "1efa911ca7086affd35f463ca2fc1799584fb6aa89883cf0af8e3664d6a02d55" + sha256: "99e066ce75c89d6b29903d788a7bb9369cf754f7b24bf70bf4b6d6d6b26853b9" url: "https://pub.dev" source: hosted - version: "2.3.2" + version: "2.3.6" + diff_match_patch: + dependency: transitive + description: + name: diff_match_patch + sha256: "2efc9e6e8f449d0abe15be240e2c2a3bcd977c8d126cfd70598aee60af35c0a4" + url: "https://pub.dev" + source: hosted + version: "0.4.1" fake_async: dependency: transitive description: @@ -185,27 +233,59 @@ 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: name: file - sha256: b69516f2c26a5bcac4eee2e32512e1a5205ab312b3536c1c1227b2b942b5f9ad + sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c" url: "https://pub.dev" source: hosted - version: "6.1.2" + version: "7.0.0" fixnum: dependency: transitive description: name: fixnum - sha256: "6a2ef17156f4dc49684f9d99aaf4a93aba8ac49f5eac861755f5730ddf6e2e4e" + sha256: "25517a4deb0c03aa0f32fd12db525856438902d9c16536311e76cdc57b31d7d1" url: "https://pub.dev" source: hosted - version: "1.0.0" + version: "1.1.0" flutter: dependency: "direct main" 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_cache_manager: + dependency: transitive + description: + name: flutter_cache_manager + sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + 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: @@ -219,30 +299,51 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" frontend_server_client: dependency: transitive description: name: frontend_server_client - sha256: "408e3ca148b31c20282ad6f37ebfa6f4bdc8fede5b74bc2f08d9d92b55db3612" + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 url: "https://pub.dev" source: hosted - version: "3.2.0" + version: "4.0.0" glob: dependency: transitive description: name: glob - sha256: "8321dd2c0ab0683a91a51307fa844c6db4aa8e3981219b78961672aaab434658" + sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63" url: "https://pub.dev" source: hosted - version: "2.0.2" + version: "2.1.2" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: "2ddb88e9ad56ae15ee144ed10e33886777eb5ca2509a914850a5faa7b52ff459" + url: "https://pub.dev" + source: hosted + version: "14.2.7" graphs: dependency: transitive description: name: graphs - sha256: aedc5a15e78fc65a6e23bcd927f24c64dd995062bcd1ca6eda65a3cff92a4d19 + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.3.2" + hive: + dependency: transitive + description: + name: hive + sha256: "8dcf6db979d7933da8217edcec84e9df1bdb4e4edc7fc77dbd5aa74356d6d941" + url: "https://pub.dev" + source: hosted + version: "2.2.3" http: dependency: "direct main" description: @@ -255,34 +356,42 @@ packages: dependency: transitive description: name: http_multi_server - sha256: bfb651625e251a88804ad6d596af01ea903544757906addcb2dcdf088b5ea185 + sha256: "97486f20f9c2f7be8f514851703d0119c3596d14ea63227af6f7a481ef2b2f8b" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.2.1" http_parser: dependency: transitive description: name: http_parser - sha256: e362d639ba3bc07d5a71faebb98cde68c05bfbcfbbb444b60b6f60bb67719185 + sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "4.0.2" + hydrated_bloc: + dependency: "direct main" + description: + name: hydrated_bloc + sha256: af35b357739fe41728df10bec03aad422cdc725a1e702e03af9d2a41ea05160c + url: "https://pub.dev" + source: hosted + version: "9.1.5" io: dependency: transitive description: name: io - sha256: "0d4c73c3653ab85bf696d51a9657604c900a370549196a91f33e4c39af760852" + sha256: "2ec25704aba361659e10e3e5f5d672068d332fc8ac516421d483a11e5cbd061e" url: "https://pub.dev" source: hosted - version: "1.0.3" + version: "1.0.4" js: 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: @@ -335,10 +444,10 @@ packages: dependency: transitive description: name: logging - sha256: "293ae2d49fd79d4c04944c3a26dfd313382d5f52e821ec57119230ae16031ad4" + sha256: "623a88c9594aa774443aa3eb2d41807a48486b5613e67599fb4c41c0ad47c340" url: "https://pub.dev" source: hosted - version: "1.0.2" + version: "1.2.0" matcher: dependency: transitive description: @@ -367,18 +476,50 @@ packages: dependency: transitive description: name: mime - sha256: fd5f81041e6a9fc9b9d7fa2cb8a01123f9f5d5d49136e06cb9dc7d33689529f4 + sha256: "801fd0b26f14a4a58ccb09d5892c3fbdeff209594300a542492cf13fba9d247a" url: "https://pub.dev" source: hosted - version: "1.0.1" + version: "1.0.6" + mocktail: + dependency: "direct main" + 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" + octo_image: + dependency: transitive + description: + name: octo_image + sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" package_config: dependency: transitive description: name: package_config - sha256: a4d5ede5ca9c3d88a2fef1147a078570c861714c806485c596b109819135bc12 + sha256: "1c5b77ccc91e4823a5af61ee74e6b972db1ef98c2ff5a18d3161c982a55448bd" url: "https://pub.dev" source: hosted - version: "2.0.2" + version: "2.1.0" path: dependency: transitive description: @@ -387,46 +528,150 @@ packages: 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: name: pool - sha256: "05955e3de2683e1746222efd14b775df7131139e07695dc8e24650f6b4204504" + sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" url: "https://pub.dev" source: hosted - version: "1.5.0" + version: "1.5.1" + provider: + dependency: transitive + description: + name: provider + sha256: c8a055ee5ce3fd98d6fc872478b03823ffdb448699c6ebdbbc71d59b596fd48c + url: "https://pub.dev" + source: hosted + version: "6.1.2" pub_semver: dependency: transitive description: name: pub_semver - sha256: b5a5fcc6425ea43704852ba4453ba94b08c2226c63418a260240c3a054579014 + sha256: "40d3ab1bbd474c4c2328c91e3a7df8c6dd629b79ece4c4bd04bee496a224fb0c" url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.1.4" pubspec_parse: dependency: transitive description: name: pubspec_parse - sha256: "3686efe4a4613a4449b1a4ae08670aadbd3376f2e78d93e3f8f0919db02a7256" + sha256: c799b721d79eb6ee6fa56f00c04b472dcd44a30d258fac2174a6ec57302678f8 url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.3.0" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" shelf: dependency: transitive description: name: shelf - sha256: c240984c924796e055e831a0a36db23be8cb04f170b26df572931ab36418421d + sha256: ad29c505aee705f41a4d8963641f91ac4cee3c8fad5947e033390a7bd8180fa4 url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.4.1" + 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: name: shelf_web_socket - sha256: fd84910bf7d58db109082edf7326b75322b8f186162028482f53dc892f00332d + sha256: "9ca081be41c60190ebcb4766b2486a7d50261db7bd0f5d9615f2d653637a84c1" url: "https://pub.dev" source: hosted - version: "1.0.1" + version: "1.0.4" + 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 +693,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: @@ -456,6 +717,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.0" + sprintf: + dependency: transitive + description: + name: sprintf + sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" + url: "https://pub.dev" + source: hosted + version: "7.0.0" + sqflite: + dependency: transitive + description: + name: sqflite + sha256: a43e5a27235518c03ca238e7b4732cf35eabe863a369ceba6cbefa537a66f16d + url: "https://pub.dev" + source: hosted + version: "2.3.3+1" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "3da423ce7baf868be70e2c0976c28a1bb2f73644268b7ffa7d2e08eab71f16a4" + url: "https://pub.dev" + source: hosted + version: "2.5.4" stack_trace: dependency: transitive description: @@ -476,10 +761,10 @@ packages: dependency: transitive description: name: stream_transform - sha256: ed464977cb26a1f41537e177e190c67223dbd9f4f683489b6ab2e5d211ec564e + sha256: "14a00e794c7c11aa145a170587321aedce29769c08d7f58b1d141da75e3b1c6f" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.1.0" string_scanner: dependency: transitive description: @@ -488,6 +773,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 +789,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,22 +805,38 @@ 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: name: timing - sha256: c386d07d7f5efc613479a7c4d9d64b03710b03cfaa7e8ad5f2bfb295a1f0dfad + sha256: "70a3b636575d4163c477e6de42f247a23b315ae20e86442bebe32d3cabf61c32" url: "https://pub.dev" source: hosted - version: "1.0.0" + version: "1.0.1" typed_data: dependency: transitive description: name: typed_data - sha256: "53bdf7e979cfbf3e28987552fd72f637e63f3c8724c9e56d9246942dc2fa36ee" + sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.3.2" + uuid: + dependency: transitive + description: + name: uuid + sha256: f33d6bb662f0e4f79dcd7ada2e6170f3b3a2530c28fc41f49a411ddedd576a77 + url: "https://pub.dev" + source: hosted + version: "4.5.0" vector_math: dependency: transitive description: @@ -540,10 +857,10 @@ packages: dependency: transitive description: name: watcher - sha256: e42dfcc48f67618344da967b10f62de57e04bae01d9d3af4c2596f3712a88c99 + sha256: "3d2ad6751b3c16cf07c7fca317a1413b3f26530319181b37e3b9039b84fc01d8" url: "https://pub.dev" source: hosted - version: "1.0.1" + version: "1.1.0" web: dependency: transitive description: @@ -556,18 +873,34 @@ packages: dependency: transitive description: name: web_socket_channel - sha256: "0c2ada1b1aeb2ad031ca81872add6be049b8cb479262c6ad3c4b0f9c24eaab2f" + sha256: d88238e5eac9a42bb43ca4e721edba3c08c6354d4a53063afaa568516217621b url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.4.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: name: yaml - sha256: "3cee79b1715110341012d27756d9bae38e650588acd38d3f3c610822e1337ace" + sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" url: "https://pub.dev" source: hosted - version: "3.1.0" + version: "3.1.2" 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..84e91ed 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,20 +1,29 @@ name: restaurant_tour description: Flutter developer coding challenge starter project. -publish_to: 'none' +publish_to: "none" version: 1.0.0+1 - environment: sdk: ">=3.1.0 <4.0.0" flutter: ">=3.19.6" dependencies: + bloc: ^8.1.4 + bloc_test: ^9.1.7 + cached_network_image: ^3.4.1 flutter: sdk: flutter + flutter_bloc: ^8.1.6 + flutter_dotenv: ^5.1.0 + go_router: ^14.2.7 http: ^1.2.2 + hydrated_bloc: ^9.1.5 json_annotation: ^4.9.0 + mocktail: ^1.0.4 + path_provider: ^2.1.4 + shimmer: ^3.0.0 dev_dependencies: flutter_test: @@ -45,4 +54,5 @@ flutter: style: italic - asset: assets/fonts/OpenSans/OpenSans-SemiBold.ttf weight: 600 - + assets: + - .env From 66d81a8463e30ac4a20488214a0e0ee2e29193e9 Mon Sep 17 00:00:00 2001 From: Luis Carlos Date: Fri, 13 Sep 2024 15:14:55 -0500 Subject: [PATCH 6/7] test(app): add unit and widget tests for favorite and app features --- test/app/cubits/favorite_cubit_test.dart | 119 ++++++++++++++++ test/app/view/app_test.dart | 31 +++++ .../content/favorites_content_test.dart | 130 ++++++++++++++++++ .../screen/favorites_screen_test.dart | 44 ++++++ 4 files changed, 324 insertions(+) create mode 100644 test/app/cubits/favorite_cubit_test.dart create mode 100644 test/app/view/app_test.dart create mode 100644 test/features/favorites/content/favorites_content_test.dart create mode 100644 test/features/favorites/screen/favorites_screen_test.dart diff --git a/test/app/cubits/favorite_cubit_test.dart b/test/app/cubits/favorite_cubit_test.dart new file mode 100644 index 0000000..ddbf182 --- /dev/null +++ b/test/app/cubits/favorite_cubit_test.dart @@ -0,0 +1,119 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:bloc_test/bloc_test.dart'; +import 'package:hydrated_bloc/hydrated_bloc.dart'; +import 'package:mocktail/mocktail.dart'; + +import 'package:restaurant_tour/app/app.dart'; + +class MockStorage extends Mock implements Storage {} + +void main() { + late FavoriteCubit favoriteCubit; + late Storage hydratedStorage; + + setUp(() async { + TestWidgetsFlutterBinding.ensureInitialized(); + hydratedStorage = MockStorage(); + when(() => hydratedStorage.write(any(), any())) + .thenAnswer((_) async {}); + + when(() => hydratedStorage.read(any())).thenReturn({ + 'restaurants': [], + }); + + HydratedBloc.storage = hydratedStorage; + favoriteCubit = FavoriteCubit(); + }); + + tearDown(() { + favoriteCubit.close(); + }); + + blocTest( + 'Emits [FavoriteLoaded] when restoring favorites from storage', + build: () => favoriteCubit, + act: (bloc) => bloc.emit(favoriteCubit.fromJson({'restaurants': []})), + expect: () => [isA()], + ); + + blocTest( + 'Emits [FavoriteError] when an exception occurs while restoring favorites from storage.', + setUp: () => when(() => hydratedStorage.read(any())).thenReturn({ + 'error': {'error'}, + }), + build: () => favoriteCubit, + act: (bloc) => + bloc.emit(favoriteCubit.fromJson(hydratedStorage.read('key'))), + expect: () => [isA()], + ); + + blocTest( + 'Emits true when the restaurant is in favorites', + build: () => favoriteCubit, + seed: () => FavoriteLoaded( + restaurants: [const Restaurant(id: '1', name: 'Test Restaurant')], + ), + act: (bloc) { + final isFav = + bloc.isFavorite(const Restaurant(id: '1', name: 'Test Restaurant')); + expect(isFav, isTrue); + }, + ); + + blocTest( + 'Emits false when the restaurant is not in favorites', + build: () => favoriteCubit, + seed: () => FavoriteLoaded( + restaurants: [const Restaurant(id: '2', name: 'Another Restaurant')], + ), + act: (bloc) { + final isFav = + bloc.isFavorite(const Restaurant(id: '1', name: 'Test Restaurant')); + expect(isFav, isFalse); + }, + ); + + blocTest( + 'Adds a restaurant to favorites when it is not already in the list', + build: () => favoriteCubit, + seed: () => FavoriteLoaded(restaurants: []), + act: (bloc) => bloc.toggleFavorite( + const Restaurant(id: '1', name: 'Test Restaurant'), + ), + expect: () => [ + isA().having( + (state) => state.restaurants.length, + 'favorites count', + 1, + ), + ], + ); + + blocTest( + 'Removes a restaurant from favorites when it is already in the list', + build: () => favoriteCubit, + seed: () => FavoriteLoaded( + restaurants: [const Restaurant(id: '1', name: 'Test Restaurant')], + ), + act: (bloc) => bloc.toggleFavorite( + const Restaurant(id: '1', name: 'Test Restaurant'), + ), + expect: () => [ + isA().having( + (state) => state.restaurants.length, + 'favorites count', + 0, + ), + ], + ); + + blocTest( + 'Emits FavoriteError when the state is not FavoriteLoaded', + build: () => favoriteCubit, + seed: () => FavoriteError(), + act: (bloc) => bloc.toggleFavorite( + const Restaurant(id: '1', name: 'Test Restaurant'), + ), + expect: () => [isA()], + ); +} diff --git a/test/app/view/app_test.dart b/test/app/view/app_test.dart new file mode 100644 index 0000000..b9702b1 --- /dev/null +++ b/test/app/view/app_test.dart @@ -0,0 +1,31 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:restaurant_tour/app/app.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:restaurant_tour/features/home/screen/screen.dart'; + +class _MockRestaurantRepository extends Mock implements RestaurantRepository {} + +void main() { + late RestaurantRepository restaurantRepository; + + setUp(() { + restaurantRepository = _MockRestaurantRepository(); + }); + + group('app', () { + testWidgets('Should Render Home Screen', (tester) async { + when(() => restaurantRepository.fetchRestaurants()).thenAnswer( + (_) async => + RestaurantQueryResult.fromJson(fakeRestaurants).restaurants ?? [], + ); + await tester.pumpWidget( + RepositoryProvider( + create: (context) => restaurantRepository, + child: const App(), + ), + ); + expect(find.byType(HomeScreen), findsOneWidget); + }); + }); +} diff --git a/test/features/favorites/content/favorites_content_test.dart b/test/features/favorites/content/favorites_content_test.dart new file mode 100644 index 0000000..685b8a5 --- /dev/null +++ b/test/features/favorites/content/favorites_content_test.dart @@ -0,0 +1,130 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hydrated_bloc/hydrated_bloc.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:restaurant_tour/app/app.dart'; +import 'package:restaurant_tour/features/favorites/content/favorites_content.dart'; + +class MockStorage extends Mock implements Storage {} + +class MockFavoriteCubit extends MockCubit + implements FavoriteCubit {} + +void main() { + late FavoriteCubit favoriteCubit; + late Storage hydratedStorage; + setUp(() { + TestWidgetsFlutterBinding.ensureInitialized(); + hydratedStorage = MockStorage(); + when(() => hydratedStorage.write(any(), any())) + .thenAnswer((_) async {}); + + when(() => hydratedStorage.read(any())).thenReturn({ + 'restaurants': [], + }); + + HydratedBloc.storage = hydratedStorage; + favoriteCubit = MockFavoriteCubit(); + }); + + tearDown(() { + favoriteCubit.close(); + }); + + group('favorites', () { + testWidgets('Should Render Favorites Content', (tester) async { + when(() => favoriteCubit.state) + .thenReturn(FavoriteLoaded(restaurants: [])); + + await tester.pumpWidget( + MaterialApp( + home: BlocProvider( + create: (context) => favoriteCubit, + child: const FavoritesContent(), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.byType(FavoritesContent), findsOneWidget); + }); + }); + + testWidgets('Shows a list of favorites when favorites are loaded', + (tester) async { + when(() => favoriteCubit.state).thenReturn( + FavoriteLoaded( + restaurants: [const Restaurant(id: '1', name: 'Test Restaurant')], + ), + ); + + await tester.pumpWidget( + MaterialApp( + home: BlocProvider.value( + value: favoriteCubit, + child: const FavoritesContent(), + ), + ), + ); + + expect(find.byType(ListView), findsOneWidget); + expect(find.text('Test Restaurant'), findsOneWidget); + }); + + group('FavoritesContent Tests', () { + testWidgets('Shows "No favorites" when there are no favorites', + (tester) async { + when(() => favoriteCubit.state) + .thenReturn(FavoriteLoaded(restaurants: [])); + + await tester.pumpWidget( + MaterialApp( + home: BlocProvider.value( + value: favoriteCubit, + child: const FavoritesContent(), + ), + ), + ); + + expect(find.text('No favorites'), findsOneWidget); + }); + + testWidgets('Shows a list of favorites when favorites are loaded', + (tester) async { + when(() => favoriteCubit.state).thenReturn( + FavoriteLoaded( + restaurants: [const Restaurant(id: '1', name: 'Test Restaurant')], + ), + ); + + await tester.pumpWidget( + MaterialApp( + home: BlocProvider.value( + value: favoriteCubit, + child: const FavoritesContent(), + ), + ), + ); + + expect(find.byType(ListView), findsOneWidget); + expect(find.text('Test Restaurant'), findsOneWidget); + }); + + testWidgets('Shows "Failed to load favorites" on error state', + (tester) async { + when(() => favoriteCubit.state).thenReturn(FavoriteError()); + + await tester.pumpWidget( + MaterialApp( + home: BlocProvider.value( + value: favoriteCubit, + child: const FavoritesContent(), + ), + ), + ); + + expect(find.text('Failed to load favorites'), findsOneWidget); + }); + }); +} diff --git a/test/features/favorites/screen/favorites_screen_test.dart b/test/features/favorites/screen/favorites_screen_test.dart new file mode 100644 index 0000000..b185498 --- /dev/null +++ b/test/features/favorites/screen/favorites_screen_test.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hydrated_bloc/hydrated_bloc.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:restaurant_tour/app/app.dart'; +import 'package:restaurant_tour/features/favorites/screen/favorites_screen.dart'; + +class MockStorage extends Mock implements Storage {} + +void main() { + late FavoriteCubit favoriteCubit; + late Storage hydratedStorage; + setUp(() { + TestWidgetsFlutterBinding.ensureInitialized(); + hydratedStorage = MockStorage(); + when(() => hydratedStorage.write(any(), any())) + .thenAnswer((_) async {}); + + when(() => hydratedStorage.read(any())).thenReturn({ + 'restaurants': [], + }); + HydratedBloc.storage = hydratedStorage; + favoriteCubit = FavoriteCubit(); + }); + + tearDown(() { + favoriteCubit.close(); + }); + + group('favorites', () { + testWidgets('Should Render Favorites Screen', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: BlocProvider( + create: (context) => FavoriteCubit(), + child: const FavoritesScreen(), + ), + ), + ); + expect(find.byType(FavoritesScreen), findsOneWidget); + }); + }); +} From 49d800bb662150e054dd222674e061e2ae909414 Mon Sep 17 00:00:00 2001 From: Luis Carlos Date: Fri, 13 Sep 2024 15:39:17 -0500 Subject: [PATCH 7/7] docs(readme): update README with project setup and LCOV coverage report instructions --- README.md | 212 +++++++++++++----------------------------------------- 1 file changed, 48 insertions(+), 164 deletions(-) diff --git a/README.md b/README.md index 412d444..50cd622 100644 --- a/README.md +++ b/README.md @@ -1,202 +1,86 @@ # Restaurant Tour -Welcome to Superformula's Coding challenge, we are excited to see what you can build! +This Flutter project is created as a response to **Superformula's** coding challenge. Below you’ll find the instructions on how to configure and run the project, as well as a brief overview of the technologies used. -This take home test aims to evaluate your skills in building a Flutter application. We are looking for a well-structured and well-tested application that demonstrates your knowledge of Flutter and the Dart language. +## Project Setup -We are not looking for pixel perfect designs, but we are looking for a well-structured application that demonstrates your skills and best practices developing a flutter application. We know there are many ways to solve a problem, and we are interested in seeing how you approach this one. If you have any questions, please don't hesitate to ask. +### Prerequisites -Things we'll be looking on your submission: -- App structure for scalability -- Error and optional (?) handling -- Widget tree optimization -- State management -- Test coverage +Install dependencies using `fvm`: This project uses [fvm](https://fvm.app/) to manage the Flutter version. -Think of the app you'll be building as the final product, do not over engineer it for possible future features, but do not under engineer it either. We are looking for a balance. We want that the functionalities that you implement are well thought out and implemented. +- **Install `fvm`** if you don’t have it installed: -As an example, for the favorites feature you can simply use SharedPreferences, you don't need to use a complex database solution, but we're looking for a solid shared preferences implementation. + ```sh + dart pub global activate fvm + ``` + Make sure you add `~/.pub-cache/bin` to your PATH if necessary. +- **Use the Flutter version specified in the project**: -Be sure to read **all** of this document carefully, and follow the guidelines within. + ```sh + fvm use + ``` -## Vendorized Flutter +- **Install all the project dependencies**: -3. We use [fvm](https://fvm.app/) for managing the flutter version within the project. Using terminal, while being on the test repository, install the tools dependencies by running the following commands: + ```sh + fvm flutter pub get + ``` - ```sh - dart pub global activate fvm - ``` +### Environment Configuration - The output of the command will ask to add the folder `./pub-cache/bin` to your PATH variables, if you didn't already. If that is the case, add it to your environment variables, and restart the terminal. +Create a `.env` file in the root of the project with the following variables: - ```sh - export PATH="$PATH":"$HOME/.pub-cache/bin" # Add this to your environment variables - ``` - -4. Install the project's flutter version using `fvm`. - - ```sh - fvm use - ``` - -5. From now on, you will run all the flutter commands with the `fvm` prefix. Get all the projects dependencies. - - ```sh - fvm flutter pub get - ``` - -More information on the approach can be found here: - -> hhttps://fvm.app/docs/getting_started/installation - -From the root directory: - - -### IDE Setup - -
-Use with VSCode -

- -If you're a VScode user link the new Flutter SDK path in your settings -`$projectRoot/.vscode/settings.json` (create if it doesn't exist yet) - -```json -{ - "dart.flutterSdkPath": ".fvm/flutter_sdk" -} +```env +YELP_API_KEY=your_yelp_api_key +USE_FAKE_DATA=false ``` +If you don’t want to use the Yelp API, you can set `USE_FAKE_DATA=true` to use mocked restaurant data instead of fetching from Yelp. -

-
- -
-Use with IntelliJ / Android Studio -

- -Go to `Preferences > Languages & Frameworks > Flutter` and set the Flutter SDK path to `$projectRoot/.fvm/flutter_sdk` - -IntelliJ Settings - -

-
- -## Requirements - -### App Structure - -#### Restaurant List Page - -- Tab Bar - - List of favorites (stored client side) - - List of businesses - - Hero image - - Name - - Price - - Category - - Rating (rounded to the nearest value) - - Open/Closed +### Running the App -#### Restaurant Detail View +After configuring the environment, run the app with the following command: -- Ability to favorite a business -- Name -- Hero image -- Price and category -- Address -- Rating -- Total reviews -- List of reviews - - User name - - Rating - - User image - - Review Text (These are just snippets of the full review, usually like 3-4 lines long) - -#### Misc. - -- Clear documentation on the structure and architecture of your application. -- Clear and logical commit messages. - - We suggest following [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) - -## Test Coverage - -To demonstrate your experience writing different types of tests in Flutter please do the following: - -- We are looking to see how you write tests in Flutter. We are not looking for 100% coverage but we are looking for a good mix of unit and widget tests. -- We are specially looking for you to cover at least one file for each domain layer (interface, application, repositories, etc). - -Feel free to add more tests as you see fit but the above is the minimum requirement. - -## Design - -- See this [Figma File](https://www.figma.com/file/KsEhQUp66m9yeVkvQ0hSZm/Flutter-Test?node-id=0%3A1) for design information related to the overall look and feel of the application. We do not expect pixel-perfection but would like the application to visually be close to what is specified in the Figma file. - -![List View](screenshots/listview.png) -![Detail View](screenshots/detailview.png) - -## API - -The [Yelp GraphQL API](https://www.yelp.com/developers/graphql/guides/intro) is used as the API for this Application. We have provided the boilerplate of the API requests and backing data models to save you some time. To successfully make a request to the Yelp GraphQL API, please follow these steps: - -1. Please go to https://www.yelp.com/signup and sign up for a developer account. -1. Once signed up, navigate to https://www.yelp.com/developers/v3/manage_app. -1. Create a new app by filling out the required information. -1. Once your app is created, scroll down and join the `Developer Beta`. This allows you to use the GraphQL API. -1. Copy your API Key from your app page and paste it on `line 5` [yelp_repository.dart](app/lib/yelp_repository.dart) replacing the `` with your key. -1. Run the app and tap the `Fetch Restaurants` button. If you see a log like `Fetched x restaurants` you are all set! +```sh +fvm flutter run +``` -## Technical Requirements +## Overview of the Project ### State Management -Please restrict your usage of state management or dependency injection to the following options: - -1. [provider](https://pub.dev/packages/provider) -2. [Riverpod](https://pub.dev/packages/riverpod) -3. [bloc](https://pub.dev/packages/bloc) -4. [get_it](https://pub.dev/packages/get_it)/[get_it_mixins](https://pub.dev/packages/get_it_mixin) -5. [Mobx](https://pub.dev/packages/mobx) - -We ask this because this challenge values consistency and efficiency over ingenuity. Using commonly used libraries ensures that we can review your code in a timely manner and allows us to provide better feedback. - -## Coding Values - -At **Superformula** we strive to build applications that have - -- Consistent architecture -- Extensible, clean code -- Solid testing -- Good security & performance best practices - -### Clear, consistent architecture +The project utilizes **Cubits** (from the `flutter_bloc` package) for state management, offering a clear separation of concerns. The user’s favorite restaurant data is persisted locally using **HydratedBloc**, which stores the data across app sessions. -Approach your submission as if it were a real world app. This includes Use any libraries that you would normally choose. +### Local Data Persistence -_Please note: we're interested in your code & the way you solve the problem, not how well you can use a particular library or feature._ +- **Favorites**: The app uses `hydrated_bloc` to store the user's favorite restaurants locally. This ensures that even when the app is restarted, the user’s favorite restaurants are preserved. -### Easy to understand +### Tests -Writing boring code that is easy to follow is essential at **Superformula**. +The project includes a number of tests to demonstrate knowledge of unit testing and widget testing. While not every single part of the app has full test coverage, the main parts are well tested to show how to approach Flutter testing with tools such as `bloc_test` and `mocktail`. -We're interested in your method and how you approach the problem just as much as we're interested in the end result. +## Test Coverage Reports -### Solid testing approach +To generate the test coverage reports for this project, **LCOV** was used. -While the purpose of this challenge is not to gauge whether you can achieve 100% test coverage, we do seek to evaluate whether you know how & what to test. +### Generating a Coverage Report -## Q&A +To generate the LCOV report: -> Where should I send back the result when I'm done? +1. Run the following command to collect coverage information: -Please fork this repo and then send us a pull request to our repo when you think you are done. There is no deadline for this task unless otherwise noted to you directly. + ```sh + fvm flutter test --coverage + ``` -> What if I have a question? +2. Generate the LCOV report: -Just create a new issue in this repo and we will respond and get back to you quickly. + ```sh + genhtml coverage/lcov.info -o coverage/ + ``` -## Review +3. Open the `index.html` file from the generated `coverage/` folder to view the detailed coverage report. -The coding challenge is a take-home test upon which we'll be conducting a thorough code review once complete. The review will consist of meeting some more of our mobile engineers and giving a review of the solution you have designed. Please be prepared to share your screen and run/demo the application to the group. During this process, the engineers will be asking questions. +The report provides a breakdown of the test coverage across all directories and files in the project, ensuring that key areas are well tested.