From a4cb96968653cc3eaaaf0d506c66a5ca5986f17d Mon Sep 17 00:00:00 2001 From: Edgardo Zuniga Date: Fri, 12 Apr 2024 16:03:52 -0600 Subject: [PATCH 1/6] feat(setup): install FVM, fetch dependencies, secure API keys with dotenv, and set up main UI screens --- .fvm/fvm_config.json | 3 +- .fvmrc | 4 + .gitignore | 7 +- .vscode/settings.json | 8 +- android/build.gradle | 2 +- ios/Flutter/Debug.xcconfig | 1 + ios/Flutter/Release.xcconfig | 1 + ios/Podfile | 44 +++++ ios/Podfile.lock | 23 +++ ios/Runner.xcodeproj/project.pbxproj | 68 +++++++ .../contents.xcworkspacedata | 3 + ios/Runner/Base.lproj/Main.storyboard | 13 +- lib/contants/text_style_constants.dart | 35 ++++ lib/main.dart | 52 +---- lib/repositories/yelp_repository.dart | 6 +- lib/screens/restaurant_detail_screen.dart | 184 ++++++++++++++++++ lib/screens/restaurants_screen.dart | 56 ++++++ lib/screens/views/all_restaurants_view.dart | 20 ++ .../views/favorite_restaurants_view.dart | 21 ++ lib/theme/main_theme.dart | 43 ++++ lib/widgets/restaurant_card_widget.dart | 103 ++++++++++ lib/widgets/restaurant_item_widget.dart | 27 +++ lib/widgets/review_card_widget.dart | 47 +++++ pubspec.lock | 156 +++++++++++++-- pubspec.yaml | 11 +- 25 files changed, 856 insertions(+), 82 deletions(-) create mode 100644 .fvmrc create mode 100644 ios/Podfile create mode 100644 ios/Podfile.lock create mode 100644 lib/contants/text_style_constants.dart create mode 100644 lib/screens/restaurant_detail_screen.dart create mode 100644 lib/screens/restaurants_screen.dart create mode 100644 lib/screens/views/all_restaurants_view.dart create mode 100644 lib/screens/views/favorite_restaurants_view.dart create mode 100644 lib/theme/main_theme.dart create mode 100644 lib/widgets/restaurant_card_widget.dart create mode 100644 lib/widgets/restaurant_item_widget.dart create mode 100644 lib/widgets/review_card_widget.dart diff --git a/.fvm/fvm_config.json b/.fvm/fvm_config.json index d8abe1b9..1726d93d 100644 --- a/.fvm/fvm_config.json +++ b/.fvm/fvm_config.json @@ -1,4 +1,3 @@ { - "flutterSdkVersion": "3.13.9", - "flavors": {} + "flutterSdkVersion": "3.13.9" } \ No newline at end of file diff --git a/.fvmrc b/.fvmrc new file mode 100644 index 00000000..6108f14a --- /dev/null +++ b/.fvmrc @@ -0,0 +1,4 @@ +{ + "flutter": "3.13.9", + "flavors": {} +} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 1be2d875..69968622 100644 --- a/.gitignore +++ b/.gitignore @@ -46,4 +46,9 @@ app.*.map.json /android/app/release # fvm -.fvm/flutter_sdk \ No newline at end of file + +# FVM Version Cache +.fvm/ + +# Hide .env file with sensitive data +.env \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index f285aa4a..30ecbd75 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,9 +1,3 @@ { - "dart.flutterSdkPath": ".fvm/flutter_sdk", - "search.exclude": { - "**/.fvm": true - }, - "files.watcherExclude": { - "**/.fvm": true - } + "dart.flutterSdkPath": ".fvm/flutter_sdk" } \ No newline at end of file diff --git a/android/build.gradle b/android/build.gradle index 24047dce..7af76c2a 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -26,6 +26,6 @@ subprojects { project.evaluationDependsOn(':app') } -task clean(type: Delete) { +tasks.register("clean", Delete) { delete rootProject.buildDir } diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig index 592ceee8..ec97fc6f 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 592ceee8..c4855bfe 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 00000000..3c6dbb6d --- /dev/null +++ b/ios/Podfile @@ -0,0 +1,44 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '11.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 00000000..97899b13 --- /dev/null +++ b/ios/Podfile.lock @@ -0,0 +1,23 @@ +PODS: + - Flutter (1.0.0) + - path_provider_foundation (0.0.1): + - Flutter + - FlutterMacOS + +DEPENDENCIES: + - Flutter (from `Flutter`) + - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) + +EXTERNAL SOURCES: + Flutter: + :path: Flutter + path_provider_foundation: + :path: ".symlinks/plugins/path_provider_foundation/darwin" + +SPEC CHECKSUMS: + Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854 + path_provider_foundation: 3784922295ac71e43754bd15e0653ccfd36a147c + +PODFILE CHECKSUM: aa3b1d9cb94e8055dc6468141196cf9e4c8e33df + +COCOAPODS: 1.15.2 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 73cf3f6d..41ff9fdf 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 8C6D6A104316E422A501D715 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 162C7F7578E5A65825E052A8 /* Pods_Runner.framework */; }; 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 */; }; @@ -31,7 +32,9 @@ /* Begin PBXFileReference section */ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 162C7F7578E5A65825E052A8 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 4C16E82A7CA9A912FAFCE439 /* 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 = ""; }; 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 = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; @@ -42,6 +45,8 @@ 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 = ""; }; + A136471F75D3CE0B14C99D01 /* 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 = ""; }; + BB48F4533F02225DA383EAD3 /* 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 = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -49,12 +54,21 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 8C6D6A104316E422A501D715 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 44BC54F666F081B02849A7BA /* Frameworks */ = { + isa = PBXGroup; + children = ( + 162C7F7578E5A65825E052A8 /* Pods_Runner.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( @@ -72,6 +86,8 @@ 9740EEB11CF90186004384FC /* Flutter */, 97C146F01CF9000F007C117D /* Runner */, 97C146EF1CF9000F007C117D /* Products */, + BAD0884EF99AD91421FC0403 /* Pods */, + 44BC54F666F081B02849A7BA /* Frameworks */, ); sourceTree = ""; }; @@ -98,6 +114,17 @@ path = Runner; sourceTree = ""; }; + BAD0884EF99AD91421FC0403 /* Pods */ = { + isa = PBXGroup; + children = ( + 4C16E82A7CA9A912FAFCE439 /* Pods-Runner.debug.xcconfig */, + A136471F75D3CE0B14C99D01 /* Pods-Runner.release.xcconfig */, + BB48F4533F02225DA383EAD3 /* Pods-Runner.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -105,12 +132,14 @@ isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( + 295363677098BBFDE82EEA4C /* [CP] Check Pods Manifest.lock */, 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + 47CEE88DEFDE431696C4B91F /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -169,6 +198,28 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + 295363677098BBFDE82EEA4C /* [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; + }; 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -185,6 +236,23 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; }; + 47CEE88DEFDE431696C4B91F /* [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; diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata index 1d526a16..21a3cc14 100644 --- a/ios/Runner.xcworkspace/contents.xcworkspacedata +++ b/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -4,4 +4,7 @@ + + diff --git a/ios/Runner/Base.lproj/Main.storyboard b/ios/Runner/Base.lproj/Main.storyboard index f3c28516..228e24f9 100644 --- a/ios/Runner/Base.lproj/Main.storyboard +++ b/ios/Runner/Base.lproj/Main.storyboard @@ -1,8 +1,10 @@ - - + + + - + + @@ -14,13 +16,14 @@ - + - + + diff --git a/lib/contants/text_style_constants.dart b/lib/contants/text_style_constants.dart new file mode 100644 index 00000000..9339e80a --- /dev/null +++ b/lib/contants/text_style_constants.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +class TextStylesClass { + //Lora's font common text styles + //basic text style for titles + static final TextStyle titleTextStyle = + GoogleFonts.lora(fontSize: 18, fontWeight: FontWeight.bold); + + //basic text style for titles + static final TextStyle restaurantCardTitleTextStyle = + GoogleFonts.lora(fontSize: 16, fontWeight: FontWeight.w500); + + //Open Sans' font common text styles + //basic text style for titles + static final TextStyle subtitlesTextStyle = + GoogleFonts.openSans(fontSize: 16, fontWeight: FontWeight.w600); + + static final TextStyle priceCategoryTextStyle = + GoogleFonts.openSans(fontSize: 12); + + static final TextStyle usernameTextStyle = + GoogleFonts.openSans(fontSize: 12, fontWeight: FontWeight.w400); + + static final TextStyle openCloseRestaurantTextStyle = GoogleFonts.openSans( + fontStyle: FontStyle.italic, + fontWeight: FontWeight.w400, + fontSize: 12, + ); + + static final TextStyle reviewRestaurantTextStyle = GoogleFonts.openSans( + fontSize: 16, + fontWeight: FontWeight.w400, + ); +} diff --git a/lib/main.dart b/lib/main.dart index c6ce7473..5e025c06 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,57 +1,25 @@ import 'package:flutter/material.dart'; -import 'package:restaurantour/repositories/yelp_repository.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; -void main() { +import 'package:restaurantour/screens/restaurants_screen.dart'; +import 'package:restaurantour/theme/main_theme.dart'; + +void main() async { + WidgetsFlutterBinding + .ensureInitialized(); //Ensure flutter before loading .env file + await dotenv.load(); //Load the sensitive data with .env runApp(const Restaurantour()); } class Restaurantour extends StatelessWidget { - // This widget is the root of your application. const Restaurantour({Key? key}) : super(key: key); @override Widget build(BuildContext context) { return MaterialApp( title: 'RestauranTour', - theme: ThemeData( - visualDensity: VisualDensity.adaptivePlatformDensity, - ), - home: const HomePage(), - ); - } -} - -class HomePage extends StatelessWidget { - const HomePage({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - return Scaffold( - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text('Restaurantour'), - ElevatedButton( - child: const Text('Fetch Restaurants'), - onPressed: () async { - final yelpRepo = YelpRepository(); - - try { - final result = await yelpRepo.getRestaurants(); - if (result != null) { - print('Fetched ${result.restaurants!.length} restaurants'); - } else { - print('No restaurants fetched'); - } - } catch (e) { - print('Failed to fetch restaurants: $e'); - } - }, - ), - ], - ), - ), + home: const RestaurantListScreen(), + theme: MainTheme.mainTheme, ); } } diff --git a/lib/repositories/yelp_repository.dart b/lib/repositories/yelp_repository.dart index f251d7b4..bfe78108 100644 --- a/lib/repositories/yelp_repository.dart +++ b/lib/repositories/yelp_repository.dart @@ -1,8 +1,10 @@ import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; + import 'package:restaurantour/models/restaurant.dart'; -const _apiKey = ''; +//const _apiKey = ''; class YelpRepository { late Dio dio; @@ -14,7 +16,7 @@ class YelpRepository { BaseOptions( baseUrl: 'https://api.yelp.com', headers: { - 'Authorization': 'Bearer $_apiKey', + 'Authorization': 'Bearer ${dotenv.env['API_KEY']}', 'Content-Type': 'application/graphql', }, ), diff --git a/lib/screens/restaurant_detail_screen.dart b/lib/screens/restaurant_detail_screen.dart new file mode 100644 index 00000000..2617dd2d --- /dev/null +++ b/lib/screens/restaurant_detail_screen.dart @@ -0,0 +1,184 @@ +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:restaurantour/widgets/review_card_widget.dart'; + +class RestaurantDetailScreen extends StatefulWidget { + const RestaurantDetailScreen({Key? key, required this.restaurantId}) + : super(key: key); + + final String restaurantId; + + @override + State createState() => _RestaurantDetailScreenState(); +} + +class _RestaurantDetailScreenState extends State { + late List reviews; + + @override + void initState() { + super.initState(); + //Populate the reviews list + reviews = []; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + leading: IconButton( + onPressed: () { + Navigator.pop(context); + }, + icon: const Icon( + Icons.arrow_back, + color: Colors.black, + ), + ), + title: const Text( + 'Restaurants Name Goes Here sdasfs', + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + actions: [ + IconButton( + onPressed: () {}, + icon: const Icon( + Icons.favorite_border_outlined, + color: Colors.black, + ), + ), + ], + ), + body: ListView( + children: [ + Container( + width: double.infinity, + height: 361, + decoration: const BoxDecoration( + image: DecorationImage( + image: NetworkImage( + 'https://images.pexels.com/photos/262978/pexels-photo-262978.jpeg', + ), + fit: BoxFit.cover, + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'price and category', + style: GoogleFonts.openSans( + fontSize: 12, + ), + ), + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + 'Open Now', + style: GoogleFonts.openSans( + fontStyle: FontStyle.italic, + fontWeight: FontWeight.w400, + fontSize: 12, + ), + ), + const Gap(6), + Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: Colors.green, + ), + ), + ], + ), + ], + ), + const Gap(24), + const Divider(), + const Gap(24), + Text( + 'Address', + style: GoogleFonts.openSans( + fontWeight: FontWeight.w400, + fontSize: 12, + ), + ), + const Gap(24), + Text( + '102 Lakeside Ave', + style: GoogleFonts.openSans( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + Text( + 'Seattle, WA 98122', + style: GoogleFonts.openSans( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + const Gap(24), + const Divider(), + const Gap(24), + Text( + 'Overall Rating', + style: GoogleFonts.openSans( + fontWeight: FontWeight.w400, + fontSize: 12, + ), + ), + const Gap(24), + Row( + children: [ + Text( + '4.6', + style: GoogleFonts.lora( + fontWeight: FontWeight.w700, + fontSize: 28, + ), + ), + const Icon( + Icons.star, + color: Colors.amber, + ), + ], + ), + const Gap(24), + const Divider(), + const Gap(24), + Text( + '42 Reviews', + style: GoogleFonts.openSans( + fontSize: 12, + fontWeight: FontWeight.w400, + ), + ), + const Gap(24), + //show/loop through reviews - except the last one + for (int i = 0; i < reviews.length - 1; i++) ...[ + reviews[i], + const Divider(), + ], + + //show the last review (without the divider at the end) + reviews.isNotEmpty ? reviews.last : Container(), + const Gap(100), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/restaurants_screen.dart b/lib/screens/restaurants_screen.dart new file mode 100644 index 00000000..cf65a649 --- /dev/null +++ b/lib/screens/restaurants_screen.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:restaurantour/contants/text_style_constants.dart'; +import 'package:restaurantour/screens/views/all_restaurants_view.dart'; +import 'package:restaurantour/screens/views/favorite_restaurants_view.dart'; + +class RestaurantListScreen extends StatelessWidget { + const RestaurantListScreen({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return DefaultTabController( + initialIndex: 0, + length: 2, + child: Scaffold( + appBar: AppBar( + backgroundColor: Colors.white, + title: Text( + 'Restaurantour', + style: TextStylesClass.titleTextStyle, + ), + bottom: TabBar( + onTap: (value) { + //TODO: Login for the state provider + }, + tabAlignment: TabAlignment.center, + indicatorColor: Colors.black, + tabs: [ + Tab( + child: Text( + 'All Restaurants', + style: TextStylesClass.subtitlesTextStyle, + ), + ), + Tab( + child: Text( + 'My Favorites', + style: TextStylesClass.subtitlesTextStyle, + ), + ), + ], + ), + ), + body: const TabBarView( + children: [ + Center( + child: AllRestaurantsView(), + ), + Center( + child: FavoriteRestaurantsView(), + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/views/all_restaurants_view.dart b/lib/screens/views/all_restaurants_view.dart new file mode 100644 index 00000000..1233b37c --- /dev/null +++ b/lib/screens/views/all_restaurants_view.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; +import 'package:restaurantour/widgets/restaurant_item_widget.dart'; + +class AllRestaurantsView extends StatefulWidget { + const AllRestaurantsView({Key? key}) : super(key: key); + + @override + State createState() => _AllRestaurantsViewState(); +} + +class _AllRestaurantsViewState extends State { + @override + Widget build(BuildContext context) { + return ListView.builder( + padding: const EdgeInsets.only(bottom: 100, top: 12), + itemCount: 10, + itemBuilder: (context, index) => const BuildRestaurantItem(), + ); + } +} diff --git a/lib/screens/views/favorite_restaurants_view.dart b/lib/screens/views/favorite_restaurants_view.dart new file mode 100644 index 00000000..d86c3f72 --- /dev/null +++ b/lib/screens/views/favorite_restaurants_view.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; +import 'package:restaurantour/widgets/restaurant_item_widget.dart'; + +class FavoriteRestaurantsView extends StatefulWidget { + const FavoriteRestaurantsView({Key? key}) : super(key: key); + + @override + State createState() => + _FavoriteRestaurantsViewState(); +} + +class _FavoriteRestaurantsViewState extends State { + @override + Widget build(BuildContext context) { + return ListView.builder( + padding: const EdgeInsets.only(bottom: 100, top: 12), + itemCount: 3, + itemBuilder: (context, index) => const BuildRestaurantItem(), + ); + } +} diff --git a/lib/theme/main_theme.dart b/lib/theme/main_theme.dart new file mode 100644 index 00000000..26d53680 --- /dev/null +++ b/lib/theme/main_theme.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +class MainTheme { + static ThemeData get mainTheme { + const Color primaryColor = Color(0xFFFB8C00); + const Color secondaryColor = Color(0xFF4CAF50); + const Color errorColor = Colors.red; + const Color primaryTextColor = Colors.black; + + return ThemeData.from( + colorScheme: const ColorScheme( + primary: primaryColor, + secondary: secondaryColor, + surface: Colors.white, + background: Colors.white, + error: errorColor, + onPrimary: Colors.white, + onSecondary: Colors.white, + onSurface: primaryTextColor, + onBackground: primaryTextColor, + onError: Colors.white, + brightness: Brightness.light, + ), + ).copyWith( + appBarTheme: AppBarTheme( + color: Colors.white, + titleTextStyle: GoogleFonts.lora( + color: Colors.black, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + iconTheme: const IconThemeData( + color: primaryTextColor, + ), + buttonTheme: const ButtonThemeData( + buttonColor: primaryColor, + textTheme: ButtonTextTheme.primary, + ), + ); + } +} diff --git a/lib/widgets/restaurant_card_widget.dart b/lib/widgets/restaurant_card_widget.dart new file mode 100644 index 00000000..1fba299f --- /dev/null +++ b/lib/widgets/restaurant_card_widget.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:restaurantour/contants/text_style_constants.dart'; + +class RestaurantCardWidget extends StatelessWidget { + const RestaurantCardWidget({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: SizedBox( + width: double.infinity, + child: Container( + height: 104, + padding: const EdgeInsets.all(8.0), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.5), + blurRadius: 2, + offset: const Offset(0, 1), + ), + ], + ), + child: Row( + children: [ + Container( + width: 88, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + image: const DecorationImage( + image: NetworkImage( + 'https://images.pexels.com/photos/262978/pexels-photo-262978.jpeg', + ), + fit: BoxFit.cover, + ), + ), + ), + const Gap(10), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Restaurant Name that wraps in 2 lines sdfgsdfgsdfgdsfg ', + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStylesClass.restaurantCardTitleTextStyle, + ), + const Gap(8), + Text( + 'price and category', + style: TextStylesClass.priceCategoryTextStyle, + ), + const Gap(6), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: List.generate( + 5, + (index) => const Icon( + Icons.star, + color: Colors.amber, + size: 14, + ), + ), + ), + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + 'Open Now', + style: + TextStylesClass.openCloseRestaurantTextStyle, + ), + const Gap(6), + Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: Colors.green, + ), + ), + ], + ), + ], + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/restaurant_item_widget.dart b/lib/widgets/restaurant_item_widget.dart new file mode 100644 index 00000000..a33aef27 --- /dev/null +++ b/lib/widgets/restaurant_item_widget.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; +import 'package:restaurantour/screens/restaurant_detail_screen.dart'; +import 'package:restaurantour/widgets/restaurant_card_widget.dart'; + +class BuildRestaurantItem extends StatelessWidget { + const BuildRestaurantItem({ + Key? key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const RestaurantDetailScreen( + //TODO: send restaurant id + restaurantId: 'dummy_id', + ), + ), + ); + }, + child: const RestaurantCardWidget(), + ); + } +} diff --git a/lib/widgets/review_card_widget.dart b/lib/widgets/review_card_widget.dart new file mode 100644 index 00000000..9a2862ff --- /dev/null +++ b/lib/widgets/review_card_widget.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:restaurantour/contants/text_style_constants.dart'; + +class ReviewCardWidget extends StatelessWidget { + const ReviewCardWidget({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + const Gap(8), + Row( + children: List.generate( + 5, + (index) => const Icon( + Icons.star, + color: Colors.amber, + size: 14, + ), + ), + ), + const Gap(12), + Text( + 'Review text goes here. Review text goes here. Review text goes here. Review text goes here.', + style: TextStylesClass.reviewRestaurantTextStyle, + ), + const Gap(12), + Row( + children: [ + CircleAvatar( + child: Image.network( + 'https://gopostr.s3.amazonaws.com/favicon_url/CMXfauwVNmmVLyKpV0Qkg582dzzQWcp0Eje9gMiQ.png', + ), + ), + const Gap(8), + Text( + 'User Name', + style: TextStylesClass.usernameTextStyle, + ), + ], + ), + const Gap(16), + ], + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 0b052c68..aa5eca7c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -149,10 +149,10 @@ packages: dependency: transitive description: name: collection - sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a + sha256: f092b211a4319e98e5ff58223576de6c2803db36221657b46c82574721240687 url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.17.2" convert: dependency: transitive description: @@ -201,6 +201,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.1" + ffi: + dependency: transitive + description: + name: ffi + sha256: "7bf0adc28a23d395f19f3f1eb21dd7cfd1dd9f8e1c50051c069122e6853bc878" + url: "https://pub.dev" + source: hosted + version: "2.1.0" file: dependency: transitive description: @@ -222,6 +230,14 @@ packages: description: flutter source: sdk version: "0.0.0" + 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: @@ -251,6 +267,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.2.0" + gap: + dependency: "direct main" + description: + name: gap + sha256: f19387d4e32f849394758b91377f9153a1b41d79513ef7668c088c77dbc6955d + url: "https://pub.dev" + source: hosted + version: "3.0.1" glob: dependency: transitive description: @@ -259,6 +283,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.2" + google_fonts: + dependency: "direct main" + description: + name: google_fonts + sha256: f0b8d115a13ecf827013ec9fc883390ccc0e87a96ed5347a3114cac177ef18e8 + url: "https://pub.dev" + source: hosted + version: "6.1.0" graphs: dependency: transitive description: @@ -267,6 +299,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.1" + http: + dependency: transitive + description: + name: http + sha256: "759d1a329847dd0f39226c688d3e06a6b8679668e350e2891a6474f8b4bb8525" + url: "https://pub.dev" + source: hosted + version: "1.1.0" http_multi_server: dependency: transitive description: @@ -351,10 +391,10 @@ packages: dependency: transitive description: name: meta - sha256: a6e590c838b18133bb482a2745ad77c5bb7715fb0451209e1a7567d416678b8e + sha256: "3c74dbf8763d36539f114c799d8a2d87343b5067e9d796ca22b5eb8437090ee3" url: "https://pub.dev" source: hosted - version: "1.10.0" + version: "1.9.1" mime: dependency: transitive description: @@ -387,14 +427,78 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: c9e7d3a4cd1410877472158bee69963a4579f78b68c65a2b7d40d1a7a88bb161 + url: "https://pub.dev" + source: hosted + version: "2.1.3" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "51f0d2c554cfbc9d6a312ab35152fc77e2f0b758ce9f1a444a3a1e5b8f3c6b7f" + url: "https://pub.dev" + source: hosted + version: "2.2.3" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "5a7999be66e000916500be4f15a3633ebceb8302719b47b9cc49ce924125350f" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + 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: "8bc9f22eee8690981c22aa7fc602f5c85b497a6fb2ceb35ee5a5e5ed85ad8170" + url: "https://pub.dev" + source: hosted + version: "2.2.1" petitparser: dependency: transitive description: name: petitparser - sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27 + sha256: cb3798bef7fc021ac45b308f4b51208a152792445cce0448c9a4ba5879dd8750 + url: "https://pub.dev" + source: hosted + version: "5.4.0" + platform: + dependency: transitive + description: + name: platform + sha256: "12220bb4b65720483f8fa9450b4332347737cf8213dd2840d8b2c823e47243ec" url: "https://pub.dev" source: hosted - version: "6.0.2" + version: "3.1.4" + 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: @@ -468,18 +572,18 @@ packages: dependency: transitive description: name: stack_trace - sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" + sha256: c3c7d8edb15bee7f0f74debd4b9c5f3c2ea86766fe4178eb2a18eb30a0bdaed5 url: "https://pub.dev" source: hosted - version: "1.11.1" + version: "1.11.0" stream_channel: dependency: transitive description: name: stream_channel - sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + sha256: "83615bee9045c1d322bbbd1ba209b7a749c2cbcdcb3fdd1df8eb488b3279c1c8" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.1" stream_transform: dependency: transitive description: @@ -508,10 +612,10 @@ packages: dependency: transitive description: name: test_api - sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b" + sha256: "75760ffd7786fffdfb9597c35c5b27eaeec82be8edfb6d71d32651128ed7aab8" url: "https://pub.dev" source: hosted - version: "0.6.1" + version: "0.6.0" timing: dependency: transitive description: @@ -572,10 +676,10 @@ packages: dependency: transitive description: name: web - sha256: afe077240a270dcfd2aafe77602b4113645af95d0ad31128cc02bce5ac5d5152 + sha256: dc8ccd225a2005c1be616fe02951e2e342092edf968cf0844220383757ef8f10 url: "https://pub.dev" source: hosted - version: "0.3.0" + version: "0.1.4-beta" web_socket_channel: dependency: transitive description: @@ -584,14 +688,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" + win32: + dependency: transitive + description: + name: win32 + sha256: b0f37db61ba2f2e9b7a78a1caece0052564d1bc70668156cf3a29d676fe4e574 + url: "https://pub.dev" + source: hosted + version: "5.1.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: faea9dee56b520b55a566385b84f2e8de55e7496104adada9962e0bd11bcff1d + url: "https://pub.dev" + source: hosted + version: "1.0.4" xml: dependency: transitive description: name: xml - sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 + sha256: "5bc72e1e45e941d825fd7468b9b4cc3b9327942649aeb6fc5cdbf135f0a86e84" url: "https://pub.dev" source: hosted - version: "6.5.0" + version: "6.3.0" yaml: dependency: transitive description: @@ -601,5 +721,5 @@ packages: source: hosted version: "3.1.0" sdks: - dart: ">=3.2.0 <4.0.0" - flutter: ">=3.7.0-0" + dart: ">=3.1.0 <4.0.0" + flutter: ">=3.13.0" diff --git a/pubspec.yaml b/pubspec.yaml index be3055e0..2e803c28 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,11 +1,10 @@ name: restaurantour description: Flutter developer coding challenge starter project. -publish_to: 'none' +publish_to: "none" version: 1.0.0+1 - environment: sdk: ">=2.12.0 <3.0.0" @@ -16,6 +15,9 @@ dependencies: dio: ^5.4.0 json_annotation: ^4.8.1 flutter_svg: ^2.0.9 + flutter_dotenv: ^5.1.0 + google_fonts: 6.1.0 + gap: ^3.0.1 dev_dependencies: flutter_test: @@ -26,5 +28,6 @@ dev_dependencies: flutter: uses-material-design: true -# assets: -# - assets/svg/ \ No newline at end of file + assets: + - .env + # - assets/svg/ From 58983707de3d99ba7796a182eec080d759cc19b0 Mon Sep 17 00:00:00 2001 From: Edgardo Zuniga Date: Fri, 12 Apr 2024 16:13:10 -0600 Subject: [PATCH 2/6] feat(setup): install FVM, fetch dependencies, secure API keys with dotenv, and set up main UI screens --- android/app/build.gradle | 2 +- android/build.gradle | 4 +- .../gradle/wrapper/gradle-wrapper.properties | 2 +- lib/contants/text_style_constants.dart | 7 ++- lib/screens/restaurant_detail_screen.dart | 44 ++++++------------- lib/widgets/review_card_widget.dart | 2 +- 6 files changed, 25 insertions(+), 36 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index e47cb81d..48e4ccd6 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -26,7 +26,7 @@ apply plugin: 'kotlin-android' apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" android { - compileSdkVersion flutter.compileSdkVersion + compileSdkVersion 34 compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 diff --git a/android/build.gradle b/android/build.gradle index 7af76c2a..f7eb7f63 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,12 +1,12 @@ buildscript { - ext.kotlin_version = '1.3.50' + ext.kotlin_version = '1.7.10' repositories { google() mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:4.1.0' + classpath 'com.android.tools.build:gradle:7.3.0' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index bc6a58af..6b665338 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.7-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-all.zip diff --git a/lib/contants/text_style_constants.dart b/lib/contants/text_style_constants.dart index 9339e80a..6dfb0626 100644 --- a/lib/contants/text_style_constants.dart +++ b/lib/contants/text_style_constants.dart @@ -19,7 +19,7 @@ class TextStylesClass { static final TextStyle priceCategoryTextStyle = GoogleFonts.openSans(fontSize: 12); - static final TextStyle usernameTextStyle = + static final TextStyle captionTextStyle = GoogleFonts.openSans(fontSize: 12, fontWeight: FontWeight.w400); static final TextStyle openCloseRestaurantTextStyle = GoogleFonts.openSans( @@ -32,4 +32,9 @@ class TextStylesClass { fontSize: 16, fontWeight: FontWeight.w400, ); + + static final TextStyle restaurantAddressTextStyle = GoogleFonts.openSans( + fontSize: 14, + fontWeight: FontWeight.w600, + ); } diff --git a/lib/screens/restaurant_detail_screen.dart b/lib/screens/restaurant_detail_screen.dart index 2617dd2d..86f91a28 100644 --- a/lib/screens/restaurant_detail_screen.dart +++ b/lib/screens/restaurant_detail_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; import 'package:google_fonts/google_fonts.dart'; +import 'package:restaurantour/contants/text_style_constants.dart'; import 'package:restaurantour/widgets/review_card_widget.dart'; class RestaurantDetailScreen extends StatefulWidget { @@ -20,7 +21,11 @@ class _RestaurantDetailScreenState extends State { void initState() { super.initState(); //Populate the reviews list - reviews = []; + reviews = const [ + ReviewCardWidget(), + ReviewCardWidget(), + ReviewCardWidget(), + ]; } @override @@ -75,20 +80,14 @@ class _RestaurantDetailScreenState extends State { children: [ Text( 'price and category', - style: GoogleFonts.openSans( - fontSize: 12, - ), + style: TextStylesClass.priceCategoryTextStyle, ), Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ Text( 'Open Now', - style: GoogleFonts.openSans( - fontStyle: FontStyle.italic, - fontWeight: FontWeight.w400, - fontSize: 12, - ), + style: TextStylesClass.openCloseRestaurantTextStyle, ), const Gap(6), Container( @@ -108,35 +107,23 @@ class _RestaurantDetailScreenState extends State { const Gap(24), Text( 'Address', - style: GoogleFonts.openSans( - fontWeight: FontWeight.w400, - fontSize: 12, - ), + style: TextStylesClass.captionTextStyle, ), const Gap(24), Text( '102 Lakeside Ave', - style: GoogleFonts.openSans( - fontSize: 14, - fontWeight: FontWeight.w600, - ), + style: TextStylesClass.restaurantAddressTextStyle, ), Text( 'Seattle, WA 98122', - style: GoogleFonts.openSans( - fontSize: 14, - fontWeight: FontWeight.w600, - ), + style: TextStylesClass.restaurantAddressTextStyle, ), const Gap(24), const Divider(), const Gap(24), Text( 'Overall Rating', - style: GoogleFonts.openSans( - fontWeight: FontWeight.w400, - fontSize: 12, - ), + style: TextStylesClass.captionTextStyle, ), const Gap(24), Row( @@ -158,11 +145,8 @@ class _RestaurantDetailScreenState extends State { const Divider(), const Gap(24), Text( - '42 Reviews', - style: GoogleFonts.openSans( - fontSize: 12, - fontWeight: FontWeight.w400, - ), + '${reviews.length} Reviews', + style: TextStylesClass.captionTextStyle, ), const Gap(24), //show/loop through reviews - except the last one diff --git a/lib/widgets/review_card_widget.dart b/lib/widgets/review_card_widget.dart index 9a2862ff..80dcb9a4 100644 --- a/lib/widgets/review_card_widget.dart +++ b/lib/widgets/review_card_widget.dart @@ -36,7 +36,7 @@ class ReviewCardWidget extends StatelessWidget { const Gap(8), Text( 'User Name', - style: TextStylesClass.usernameTextStyle, + style: TextStylesClass.captionTextStyle, ), ], ), From d19bc9dda1ced27ebd0e7b1287c5058979e6b932 Mon Sep 17 00:00:00 2001 From: Edgardo Zuniga Date: Sat, 13 Apr 2024 15:16:11 -0600 Subject: [PATCH 3/6] feat(state management): implement Riverpod for global state handling - Created RestaurantsNotifier and FavoritesNotifier for managing state of restaurant. - Updated UI components to reflect state changes dynamically. - This commit introduces Riverpod as the state management solution, enhancing the application's ability to handle state more efficiently and making the UI more responsive. --- lib/main.dart | 3 +- .../favorite_restaurants_provider.dart | 21 +++ lib/providers/fetch_restaurants_provider.dart | 48 +++++++ lib/screens/restaurant_detail_screen.dart | 125 +++++++++++++---- lib/screens/restaurants_screen.dart | 10 +- lib/screens/views/all_restaurants_view.dart | 32 +++-- .../views/favorite_restaurants_view.dart | 18 +-- lib/widgets/restaurant_card_widget.dart | 19 ++- lib/widgets/restaurant_item_widget.dart | 12 +- lib/widgets/review_card_widget.dart | 18 ++- pubspec.lock | 132 +++++++++++++++--- pubspec.yaml | 4 +- 12 files changed, 357 insertions(+), 85 deletions(-) create mode 100644 lib/providers/favorite_restaurants_provider.dart create mode 100644 lib/providers/fetch_restaurants_provider.dart diff --git a/lib/main.dart b/lib/main.dart index 5e025c06..6acd684a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:restaurantour/screens/restaurants_screen.dart'; import 'package:restaurantour/theme/main_theme.dart'; @@ -8,7 +9,7 @@ void main() async { WidgetsFlutterBinding .ensureInitialized(); //Ensure flutter before loading .env file await dotenv.load(); //Load the sensitive data with .env - runApp(const Restaurantour()); + runApp(const ProviderScope(child: Restaurantour())); } class Restaurantour extends StatelessWidget { diff --git a/lib/providers/favorite_restaurants_provider.dart b/lib/providers/favorite_restaurants_provider.dart new file mode 100644 index 00000000..0afa6bfd --- /dev/null +++ b/lib/providers/favorite_restaurants_provider.dart @@ -0,0 +1,21 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:restaurantour/models/restaurant.dart'; + +class FavoritesNotifier extends StateNotifier> { + FavoritesNotifier() : super([]); + + void addFavorite(Restaurant restaurant) { + if (!state.contains(restaurant)) { + state = [...state, restaurant]; + } + } + + void removeFavorite(Restaurant restaurant) { + state = state.where((r) => r.id != restaurant.id).toList(); + } +} + +final favoritesProvider = + StateNotifierProvider>((ref) { + return FavoritesNotifier(); +}); diff --git a/lib/providers/fetch_restaurants_provider.dart b/lib/providers/fetch_restaurants_provider.dart new file mode 100644 index 00000000..ff260e65 --- /dev/null +++ b/lib/providers/fetch_restaurants_provider.dart @@ -0,0 +1,48 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:restaurantour/models/restaurant.dart'; +import 'package:restaurantour/repositories/yelp_repository.dart'; + +// YelpRepository provider +final yelpRepositoryProvider = Provider((ref) { + return YelpRepository(); +}); + +//Restuaurants Provider +class RestaurantsNotifier extends StateNotifier>> { + final YelpRepository repository; + + RestaurantsNotifier(this.repository) : super(const AsyncValue.loading()) { + fetchRestaurantsIfNeeded(); + } + + Future fetchRestaurantsIfNeeded() async { + if (state is! AsyncData) { + fetchRestaurants(); + } + } + + Future fetchRestaurants() async { + try { + final result = await repository.getRestaurants(); + if (result != null && result.restaurants != null) { + state = AsyncValue.data(result.restaurants!); + } else { + state = AsyncValue.error( + Exception("No restaurants found"), + StackTrace.current, + ); + } + } catch (e) { + state = AsyncValue.error( + Exception('Failed to fetch restaurants: $e'), + StackTrace.current, + ); + } + } +} + +final restaurantsNotifierProvider = + StateNotifierProvider>>( + (ref) { + return RestaurantsNotifier(ref.watch(yelpRepositoryProvider)); +}); diff --git a/lib/screens/restaurant_detail_screen.dart b/lib/screens/restaurant_detail_screen.dart index 86f91a28..4c86d084 100644 --- a/lib/screens/restaurant_detail_screen.dart +++ b/lib/screens/restaurant_detail_screen.dart @@ -1,35 +1,111 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:gap/gap.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:restaurantour/contants/text_style_constants.dart'; +import 'package:restaurantour/models/restaurant.dart'; +import 'package:restaurantour/providers/favorite_restaurants_provider.dart'; +import 'package:restaurantour/providers/fetch_restaurants_provider.dart'; import 'package:restaurantour/widgets/review_card_widget.dart'; -class RestaurantDetailScreen extends StatefulWidget { +class RestaurantDetailScreen extends ConsumerStatefulWidget { const RestaurantDetailScreen({Key? key, required this.restaurantId}) : super(key: key); final String restaurantId; @override - State createState() => _RestaurantDetailScreenState(); + _RestaurantDetailScreenState createState() => _RestaurantDetailScreenState(); } -class _RestaurantDetailScreenState extends State { +class _RestaurantDetailScreenState + extends ConsumerState { late List reviews; + late bool isFavorite = false; @override void initState() { super.initState(); //Populate the reviews list - reviews = const [ - ReviewCardWidget(), - ReviewCardWidget(), - ReviewCardWidget(), - ]; + reviews = []; + final restaurants = ref.read(restaurantsNotifierProvider).asData?.value; + Restaurant? restaurant; + if (restaurants != null) { + final restaurant = restaurants.firstWhere( + (rest) => rest.id == widget.restaurantId, + orElse: () => throw Exception( + "Restaurant with ID ${widget.restaurantId} not found"), + ); + if (restaurant.reviews != null) { + populateReviewsList(restaurant.reviews!); + } + } + + if (restaurant != null) { + isFavorite = ref.read(favoritesProvider).contains(restaurant); + if (restaurant.reviews != null) { + populateReviewsList(restaurant.reviews!); + } + } + } + +//Add or Remove to/from favorites + void toggleFavorite(Restaurant restaurant) { + final favoritesNotifier = ref.read(favoritesProvider.notifier); + if (isFavorite) { + favoritesNotifier.removeFavorite(restaurant); + } else { + favoritesNotifier.addFavorite(restaurant); + } + setState(() { + isFavorite = !isFavorite; // Toggle the favorite status in the UI + }); + } + + //Populate Reviews List + void populateReviewsList(List fetchedReviews) { + for (Review review in fetchedReviews) { + reviews.add( + ReviewCardWidget( + rating: review.rating, + username: review.user?.name, + userImgUrl: review.user?.imageUrl, + ), + ); + } } @override Widget build(BuildContext context) { + final restaurantState = ref.watch(restaurantsNotifierProvider); + + return restaurantState.when( + loading: () => const CircularProgressIndicator( + color: Colors.black, + ), + error: (error, _) => Text('Error: $error'), + data: (restaurants) { + final restaurant = restaurants.firstWhere( + (rest) => rest.id == widget.restaurantId, + orElse: () => throw Exception('Restaurant not found'), + ); + return buildRestaurantDetails(restaurant); + }, + ); + } + + Widget buildRestaurantDetails(Restaurant restaurant) { + //Get overall rating + double overallRating = 0; + if (restaurant.reviews != null && restaurant.reviews!.isNotEmpty) { + overallRating = restaurant.reviews! + .map( + (review) => review.rating!.toDouble(), + ) + .reduce((a, b) => a + b) / + restaurant.reviews!.length; + } + return Scaffold( appBar: AppBar( leading: IconButton( @@ -41,17 +117,19 @@ class _RestaurantDetailScreenState extends State { color: Colors.black, ), ), - title: const Text( - 'Restaurants Name Goes Here sdasfs', + title: Text( + restaurant.name ?? 'No restaurant name found', maxLines: 1, overflow: TextOverflow.ellipsis, ), actions: [ IconButton( - onPressed: () {}, - icon: const Icon( - Icons.favorite_border_outlined, - color: Colors.black, + onPressed: () { + toggleFavorite(restaurant); + }, + icon: Icon( + isFavorite ? Icons.favorite : Icons.favorite_border_outlined, + color: Colors.red, ), ), ], @@ -61,10 +139,10 @@ class _RestaurantDetailScreenState extends State { Container( width: double.infinity, height: 361, - decoration: const BoxDecoration( + decoration: BoxDecoration( image: DecorationImage( image: NetworkImage( - 'https://images.pexels.com/photos/262978/pexels-photo-262978.jpeg', + restaurant.heroImage, ), fit: BoxFit.cover, ), @@ -79,7 +157,7 @@ class _RestaurantDetailScreenState extends State { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - 'price and category', + '${restaurant.price} ${restaurant.categories}', style: TextStylesClass.priceCategoryTextStyle, ), Row( @@ -93,9 +171,10 @@ class _RestaurantDetailScreenState extends State { Container( width: 8, height: 8, - decoration: const BoxDecoration( + decoration: BoxDecoration( shape: BoxShape.circle, - color: Colors.green, + color: + restaurant.isOpen ? Colors.green : Colors.red, ), ), ], @@ -111,11 +190,7 @@ class _RestaurantDetailScreenState extends State { ), const Gap(24), Text( - '102 Lakeside Ave', - style: TextStylesClass.restaurantAddressTextStyle, - ), - Text( - 'Seattle, WA 98122', + restaurant.location?.formattedAddress ?? '', style: TextStylesClass.restaurantAddressTextStyle, ), const Gap(24), @@ -129,7 +204,7 @@ class _RestaurantDetailScreenState extends State { Row( children: [ Text( - '4.6', + overallRating != 0 ? '$overallRating' : 'No Reviews', style: GoogleFonts.lora( fontWeight: FontWeight.w700, fontSize: 28, diff --git a/lib/screens/restaurants_screen.dart b/lib/screens/restaurants_screen.dart index cf65a649..ecdd12e1 100644 --- a/lib/screens/restaurants_screen.dart +++ b/lib/screens/restaurants_screen.dart @@ -1,15 +1,16 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:restaurantour/contants/text_style_constants.dart'; import 'package:restaurantour/screens/views/all_restaurants_view.dart'; import 'package:restaurantour/screens/views/favorite_restaurants_view.dart'; -class RestaurantListScreen extends StatelessWidget { +class RestaurantListScreen extends ConsumerWidget { const RestaurantListScreen({Key? key}) : super(key: key); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { return DefaultTabController( - initialIndex: 0, + initialIndex: 1, length: 2, child: Scaffold( appBar: AppBar( @@ -19,9 +20,6 @@ class RestaurantListScreen extends StatelessWidget { style: TextStylesClass.titleTextStyle, ), bottom: TabBar( - onTap: (value) { - //TODO: Login for the state provider - }, tabAlignment: TabAlignment.center, indicatorColor: Colors.black, tabs: [ diff --git a/lib/screens/views/all_restaurants_view.dart b/lib/screens/views/all_restaurants_view.dart index 1233b37c..346903fc 100644 --- a/lib/screens/views/all_restaurants_view.dart +++ b/lib/screens/views/all_restaurants_view.dart @@ -1,20 +1,32 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:restaurantour/providers/fetch_restaurants_provider.dart'; import 'package:restaurantour/widgets/restaurant_item_widget.dart'; -class AllRestaurantsView extends StatefulWidget { +class AllRestaurantsView extends ConsumerWidget { const AllRestaurantsView({Key? key}) : super(key: key); @override - State createState() => _AllRestaurantsViewState(); -} + Widget build(BuildContext context, WidgetRef ref) { + final restaurantsState = ref.watch(restaurantsNotifierProvider); -class _AllRestaurantsViewState extends State { - @override - Widget build(BuildContext context) { - return ListView.builder( - padding: const EdgeInsets.only(bottom: 100, top: 12), - itemCount: 10, - itemBuilder: (context, index) => const BuildRestaurantItem(), + return restaurantsState.when( + loading: () => const Center( + child: CircularProgressIndicator( + color: Colors.black, + ), + ), + error: (error, stack) => + const Center(child: Text('Error: No restaurants found')), + data: (restaurants) { + return ListView.builder( + padding: const EdgeInsets.only(bottom: 100, top: 12), + itemCount: restaurants.length, + itemBuilder: (context, index) => BuildRestaurantItem( + restaurantData: restaurants[index], + ), + ); + }, ); } } diff --git a/lib/screens/views/favorite_restaurants_view.dart b/lib/screens/views/favorite_restaurants_view.dart index d86c3f72..216b2ba8 100644 --- a/lib/screens/views/favorite_restaurants_view.dart +++ b/lib/screens/views/favorite_restaurants_view.dart @@ -1,21 +1,21 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:restaurantour/providers/favorite_restaurants_provider.dart'; import 'package:restaurantour/widgets/restaurant_item_widget.dart'; -class FavoriteRestaurantsView extends StatefulWidget { +class FavoriteRestaurantsView extends ConsumerWidget { const FavoriteRestaurantsView({Key? key}) : super(key: key); @override - State createState() => - _FavoriteRestaurantsViewState(); -} + Widget build(BuildContext context, WidgetRef ref) { + final favoriteRestaurantsList = ref.watch(favoritesProvider); -class _FavoriteRestaurantsViewState extends State { - @override - Widget build(BuildContext context) { return ListView.builder( padding: const EdgeInsets.only(bottom: 100, top: 12), - itemCount: 3, - itemBuilder: (context, index) => const BuildRestaurantItem(), + itemCount: favoriteRestaurantsList.length, + itemBuilder: (context, index) => BuildRestaurantItem( + restaurantData: favoriteRestaurantsList[index], + ), ); } } diff --git a/lib/widgets/restaurant_card_widget.dart b/lib/widgets/restaurant_card_widget.dart index 1fba299f..ef0bc1de 100644 --- a/lib/widgets/restaurant_card_widget.dart +++ b/lib/widgets/restaurant_card_widget.dart @@ -1,9 +1,12 @@ import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; import 'package:restaurantour/contants/text_style_constants.dart'; +import 'package:restaurantour/models/restaurant.dart'; class RestaurantCardWidget extends StatelessWidget { - const RestaurantCardWidget({Key? key}) : super(key: key); + const RestaurantCardWidget({Key? key, required this.restaurantData}) + : super(key: key); + final Restaurant restaurantData; @override Widget build(BuildContext context) { @@ -31,9 +34,9 @@ class RestaurantCardWidget extends StatelessWidget { width: 88, decoration: BoxDecoration( borderRadius: BorderRadius.circular(8), - image: const DecorationImage( + image: DecorationImage( image: NetworkImage( - 'https://images.pexels.com/photos/262978/pexels-photo-262978.jpeg', + restaurantData.heroImage, ), fit: BoxFit.cover, ), @@ -46,14 +49,14 @@ class RestaurantCardWidget extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Restaurant Name that wraps in 2 lines sdfgsdfgsdfgdsfg ', + restaurantData.name ?? 'No restaurant name found', maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStylesClass.restaurantCardTitleTextStyle, ), const Gap(8), Text( - 'price and category', + '${restaurantData.price} ${restaurantData.categories}', style: TextStylesClass.priceCategoryTextStyle, ), const Gap(6), @@ -82,9 +85,11 @@ class RestaurantCardWidget extends StatelessWidget { Container( width: 8, height: 8, - decoration: const BoxDecoration( + decoration: BoxDecoration( shape: BoxShape.circle, - color: Colors.green, + color: restaurantData.isOpen + ? Colors.green + : Colors.red, ), ), ], diff --git a/lib/widgets/restaurant_item_widget.dart b/lib/widgets/restaurant_item_widget.dart index a33aef27..79bc0092 100644 --- a/lib/widgets/restaurant_item_widget.dart +++ b/lib/widgets/restaurant_item_widget.dart @@ -1,11 +1,14 @@ import 'package:flutter/material.dart'; +import 'package:restaurantour/models/restaurant.dart'; import 'package:restaurantour/screens/restaurant_detail_screen.dart'; import 'package:restaurantour/widgets/restaurant_card_widget.dart'; class BuildRestaurantItem extends StatelessWidget { const BuildRestaurantItem({ Key? key, + required this.restaurantData, }) : super(key: key); + final Restaurant restaurantData; @override Widget build(BuildContext context) { @@ -14,14 +17,15 @@ class BuildRestaurantItem extends StatelessWidget { Navigator.push( context, MaterialPageRoute( - builder: (context) => const RestaurantDetailScreen( - //TODO: send restaurant id - restaurantId: 'dummy_id', + builder: (context) => RestaurantDetailScreen( + restaurantId: restaurantData.id!, ), ), ); }, - child: const RestaurantCardWidget(), + child: RestaurantCardWidget( + restaurantData: restaurantData, + ), ); } } diff --git a/lib/widgets/review_card_widget.dart b/lib/widgets/review_card_widget.dart index 80dcb9a4..610909b8 100644 --- a/lib/widgets/review_card_widget.dart +++ b/lib/widgets/review_card_widget.dart @@ -3,16 +3,25 @@ import 'package:gap/gap.dart'; import 'package:restaurantour/contants/text_style_constants.dart'; class ReviewCardWidget extends StatelessWidget { - const ReviewCardWidget({Key? key}) : super(key: key); + const ReviewCardWidget({ + Key? key, + this.rating = 0, + this.username = "User name", + this.userImgUrl, + }) : super(key: key); + final int? rating; + final String? username; + final String? userImgUrl; @override Widget build(BuildContext context) { + int roundedRating = rating!.round(); return Column( children: [ const Gap(8), Row( children: List.generate( - 5, + roundedRating, (index) => const Icon( Icons.star, color: Colors.amber, @@ -30,12 +39,13 @@ class ReviewCardWidget extends StatelessWidget { children: [ CircleAvatar( child: Image.network( - 'https://gopostr.s3.amazonaws.com/favicon_url/CMXfauwVNmmVLyKpV0Qkg582dzzQWcp0Eje9gMiQ.png', + userImgUrl ?? + 'https://gopostr.s3.amazonaws.com/favicon_url/CMXfauwVNmmVLyKpV0Qkg582dzzQWcp0Eje9gMiQ.png', ), ), const Gap(8), Text( - 'User Name', + username!, style: TextStylesClass.captionTextStyle, ), ], diff --git a/pubspec.lock b/pubspec.lock index aa5eca7c..1cd6efd8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -17,14 +17,22 @@ packages: url: "https://pub.dev" source: hosted version: "5.13.0" + analyzer_plugin: + dependency: transitive + description: + name: analyzer_plugin + sha256: c1d5f167683de03d5ab6c3b53fc9aeefc5d59476e7810ba7bbddff50c6f4392d + url: "https://pub.dev" + source: hosted + version: "0.11.2" 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: @@ -77,10 +85,10 @@ packages: dependency: "direct dev" description: name: build_runner - sha256: "581bacf68f89ec8792f5e5a0b2c4decd1c948e97ce659dc783688c8a88fbec21" + sha256: "3ac61a79bfb6f6cc11f693591063a7f19a7af628dc52f141743edac5c16e8c22" url: "https://pub.dev" source: hosted - version: "2.4.8" + version: "2.4.9" build_runner_core: dependency: transitive description: @@ -129,6 +137,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.1" + ci: + dependency: transitive + description: + name: ci + sha256: "145d095ce05cddac4d797a158bc4cf3b6016d1fe63d8c3d2fbd7212590adca13" + url: "https://pub.dev" + source: hosted + version: "0.1.0" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: c05b7406fdabc7a49a3929d4af76bcaccbbffcbcdcf185b082e1ae07da323d19 + url: "https://pub.dev" + source: hosted + version: "0.4.1" clock: dependency: transitive description: @@ -165,10 +189,10 @@ packages: dependency: transitive description: name: crypto - sha256: cf75650c66c0316274e21d7c43d3dea246273af5955bd94e8184837cd577575c + sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.3" cupertino_icons: dependency: "direct main" description: @@ -177,6 +201,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.6" + custom_lint: + dependency: "direct dev" + description: + name: custom_lint + sha256: "7c0aec12df22f9082146c354692056677f1e70bc43471644d1fdb36c6fdda799" + url: "https://pub.dev" + source: hosted + version: "0.6.4" + custom_lint_core: + dependency: transitive + description: + name: custom_lint_core + sha256: a85e8f78f4c52f6c63cdaf8c872eb573db0231dcdf3c3a5906d493c1f8bc20e6 + url: "https://pub.dev" + source: hosted + version: "0.6.3" dart_style: dependency: transitive description: @@ -213,10 +253,10 @@ packages: 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: @@ -246,6 +286,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.4" + flutter_riverpod: + dependency: "direct main" + description: + name: flutter_riverpod + sha256: "4bce556b7ecbfea26109638d5237684538d4abc509d253e6c5c4c5733b360098" + url: "https://pub.dev" + source: hosted + version: "2.4.10" flutter_svg: dependency: "direct main" description: @@ -259,6 +307,14 @@ packages: description: flutter source: sdk version: "0.0.0" + freezed_annotation: + dependency: transitive + description: + name: freezed_annotation + sha256: c3fd9336eb55a38cc1bbd79ab17573113a8deccd0ecbbf926cca3c62803b5c2d + url: "https://pub.dev" + source: hosted + version: "2.4.1" frontend_server_client: dependency: transitive description: @@ -279,10 +335,10 @@ packages: dependency: transitive description: name: glob - sha256: "8321dd2c0ab0683a91a51307fa844c6db4aa8e3981219b78961672aaab434658" + sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63" url: "https://pub.dev" source: hosted - version: "2.0.2" + version: "2.1.2" google_fonts: dependency: "direct main" description: @@ -407,10 +463,10 @@ packages: 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: @@ -511,18 +567,34 @@ packages: 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: c63b2876e58e194e4b0828fcb080ad0e06d051cb607a6be51a9e084f47cb9367 url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.2.3" + riverpod: + dependency: transitive + description: + name: riverpod + sha256: "548e2192eb7aeb826eb89387f814edb76594f3363e2c0bb99dd733d795ba3589" + url: "https://pub.dev" + source: hosted + version: "2.5.0" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "0c7c0cedd93788d996e33041ffecda924cc54389199cde4e6a34b440f50044cb" + url: "https://pub.dev" + source: hosted + version: "0.27.7" shelf: dependency: transitive description: @@ -568,6 +640,14 @@ 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" stack_trace: dependency: transitive description: @@ -576,6 +656,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.11.0" + state_notifier: + dependency: transitive + description: + name: state_notifier + sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb + url: "https://pub.dev" + source: hosted + version: "1.0.0" stream_channel: dependency: transitive description: @@ -632,6 +720,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" + uuid: + dependency: transitive + description: + name: uuid + sha256: "22c94e5ad1e75f9934b766b53c742572ee2677c56bc871d850a57dad0f82127f" + url: "https://pub.dev" + source: hosted + version: "4.2.2" vector_graphics: dependency: transitive description: @@ -716,10 +812,10 @@ packages: 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.1.0 <4.0.0" flutter: ">=3.13.0" diff --git a/pubspec.yaml b/pubspec.yaml index 2e803c28..449a5747 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -18,13 +18,15 @@ dependencies: flutter_dotenv: ^5.1.0 google_fonts: 6.1.0 gap: ^3.0.1 + flutter_riverpod: ^2.4.10 dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^1.0.2 - build_runner: ^2.4.8 + build_runner: ^2.4.9 json_serializable: ^6.7.1 + custom_lint: ^0.6.4 flutter: uses-material-design: true From e9f2f849d68d7db3b58d35e5e4a81d01bfbdf90e Mon Sep 17 00:00:00 2001 From: Edgardo Zuniga Date: Sat, 13 Apr 2024 16:32:51 -0600 Subject: [PATCH 4/6] fix(ui): adjust UI elements, refactor code, and fix minor bugs - Adjusted various UI elements for better alignment and consistency. - Refactored components to improve code readability and maintainability. - Fixed minor bugs affecting the responsiveness and display of UI elements. --- lib/screens/restaurant_detail_screen.dart | 60 ++++++++++------------- lib/screens/restaurants_screen.dart | 2 +- lib/utils/rating_calculator.dart | 15 ++++++ lib/widgets/restaurant_card_widget.dart | 12 +++-- lib/widgets/review_card_widget.dart | 2 +- pubspec.lock | 2 +- pubspec.yaml | 1 + 7 files changed, 54 insertions(+), 40 deletions(-) create mode 100644 lib/utils/rating_calculator.dart diff --git a/lib/screens/restaurant_detail_screen.dart b/lib/screens/restaurant_detail_screen.dart index 4c86d084..448608f0 100644 --- a/lib/screens/restaurant_detail_screen.dart +++ b/lib/screens/restaurant_detail_screen.dart @@ -1,11 +1,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:collection/collection.dart'; import 'package:gap/gap.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:restaurantour/contants/text_style_constants.dart'; import 'package:restaurantour/models/restaurant.dart'; import 'package:restaurantour/providers/favorite_restaurants_provider.dart'; import 'package:restaurantour/providers/fetch_restaurants_provider.dart'; +import 'package:restaurantour/utils/rating_calculator.dart'; import 'package:restaurantour/widgets/review_card_widget.dart'; class RestaurantDetailScreen extends ConsumerStatefulWidget { @@ -26,25 +28,23 @@ class _RestaurantDetailScreenState @override void initState() { super.initState(); - //Populate the reviews list + initRestaurantDetails(); + } + + //Initialization for restaurant details + void initRestaurantDetails() async { reviews = []; final restaurants = ref.read(restaurantsNotifierProvider).asData?.value; - Restaurant? restaurant; if (restaurants != null) { - final restaurant = restaurants.firstWhere( - (rest) => rest.id == widget.restaurantId, - orElse: () => throw Exception( - "Restaurant with ID ${widget.restaurantId} not found"), - ); - if (restaurant.reviews != null) { - populateReviewsList(restaurant.reviews!); - } - } - - if (restaurant != null) { - isFavorite = ref.read(favoritesProvider).contains(restaurant); - if (restaurant.reviews != null) { - populateReviewsList(restaurant.reviews!); + final restaurant = restaurants + .firstWhereOrNull((rest) => rest.id == widget.restaurantId); + if (restaurant != null) { + isFavorite = ref.read(favoritesProvider).contains(restaurant); + if (restaurant.reviews != null) { + populateReviewsList(restaurant.reviews!); + } + } else { + throw Exception("Restaurant with ID ${widget.restaurantId} not found"); } } } @@ -85,26 +85,19 @@ class _RestaurantDetailScreenState ), error: (error, _) => Text('Error: $error'), data: (restaurants) { - final restaurant = restaurants.firstWhere( - (rest) => rest.id == widget.restaurantId, - orElse: () => throw Exception('Restaurant not found'), - ); + final restaurant = restaurants + .firstWhereOrNull((rest) => rest.id == widget.restaurantId); + if (restaurant == null) { + return const Text("Restaurant not found"); + } return buildRestaurantDetails(restaurant); }, ); } Widget buildRestaurantDetails(Restaurant restaurant) { - //Get overall rating - double overallRating = 0; - if (restaurant.reviews != null && restaurant.reviews!.isNotEmpty) { - overallRating = restaurant.reviews! - .map( - (review) => review.rating!.toDouble(), - ) - .reduce((a, b) => a + b) / - restaurant.reviews!.length; - } + double overallRating = + RatingCalculator.calculateAverageRating(restaurant.reviews!); return Scaffold( appBar: AppBar( @@ -117,6 +110,7 @@ class _RestaurantDetailScreenState color: Colors.black, ), ), + centerTitle: false, title: Text( restaurant.name ?? 'No restaurant name found', maxLines: 1, @@ -124,9 +118,7 @@ class _RestaurantDetailScreenState ), actions: [ IconButton( - onPressed: () { - toggleFavorite(restaurant); - }, + onPressed: () => toggleFavorite(restaurant), icon: Icon( isFavorite ? Icons.favorite : Icons.favorite_border_outlined, color: Colors.red, @@ -157,7 +149,7 @@ class _RestaurantDetailScreenState mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - '${restaurant.price} ${restaurant.categories}', + '${restaurant.price} ${restaurant.categories?.first.title}', style: TextStylesClass.priceCategoryTextStyle, ), Row( diff --git a/lib/screens/restaurants_screen.dart b/lib/screens/restaurants_screen.dart index ecdd12e1..59545b22 100644 --- a/lib/screens/restaurants_screen.dart +++ b/lib/screens/restaurants_screen.dart @@ -10,7 +10,7 @@ class RestaurantListScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { return DefaultTabController( - initialIndex: 1, + initialIndex: 0, length: 2, child: Scaffold( appBar: AppBar( diff --git a/lib/utils/rating_calculator.dart b/lib/utils/rating_calculator.dart new file mode 100644 index 00000000..ca5c85bf --- /dev/null +++ b/lib/utils/rating_calculator.dart @@ -0,0 +1,15 @@ +import 'package:restaurantour/models/restaurant.dart'; + +class RatingCalculator { + static double calculateAverageRating(List reviews) { + if (reviews.isNotEmpty) { + double average = reviews + .map((review) => review.rating?.toDouble() ?? 0.0) + .reduce((a, b) => a + b) / + reviews.length; + + return double.parse(average.toStringAsFixed(2)); + } + return 0.0; // Return 0 if there are no reviews or reviews are null + } +} diff --git a/lib/widgets/restaurant_card_widget.dart b/lib/widgets/restaurant_card_widget.dart index ef0bc1de..42652bea 100644 --- a/lib/widgets/restaurant_card_widget.dart +++ b/lib/widgets/restaurant_card_widget.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; import 'package:restaurantour/contants/text_style_constants.dart'; import 'package:restaurantour/models/restaurant.dart'; +import 'package:restaurantour/utils/rating_calculator.dart'; class RestaurantCardWidget extends StatelessWidget { const RestaurantCardWidget({Key? key, required this.restaurantData}) @@ -10,6 +11,11 @@ class RestaurantCardWidget extends StatelessWidget { @override Widget build(BuildContext context) { + //Get overall rating + int overallRating = + RatingCalculator.calculateAverageRating(restaurantData.reviews!) + .round(); + return Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), child: SizedBox( @@ -56,7 +62,7 @@ class RestaurantCardWidget extends StatelessWidget { ), const Gap(8), Text( - '${restaurantData.price} ${restaurantData.categories}', + '${restaurantData.price} ${restaurantData.categories?.first.title}', style: TextStylesClass.priceCategoryTextStyle, ), const Gap(6), @@ -65,7 +71,7 @@ class RestaurantCardWidget extends StatelessWidget { children: [ Row( children: List.generate( - 5, + overallRating, (index) => const Icon( Icons.star, color: Colors.amber, @@ -77,7 +83,7 @@ class RestaurantCardWidget extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.center, children: [ Text( - 'Open Now', + restaurantData.isOpen ? 'Open Now' : 'Closed', style: TextStylesClass.openCloseRestaurantTextStyle, ), diff --git a/lib/widgets/review_card_widget.dart b/lib/widgets/review_card_widget.dart index 610909b8..29b7c11e 100644 --- a/lib/widgets/review_card_widget.dart +++ b/lib/widgets/review_card_widget.dart @@ -38,7 +38,7 @@ class ReviewCardWidget extends StatelessWidget { Row( children: [ CircleAvatar( - child: Image.network( + backgroundImage: NetworkImage( userImgUrl ?? 'https://gopostr.s3.amazonaws.com/favicon_url/CMXfauwVNmmVLyKpV0Qkg582dzzQWcp0Eje9gMiQ.png', ), diff --git a/pubspec.lock b/pubspec.lock index 1cd6efd8..594d8619 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -170,7 +170,7 @@ packages: source: hosted version: "4.10.0" collection: - dependency: transitive + dependency: "direct main" description: name: collection sha256: f092b211a4319e98e5ff58223576de6c2803db36221657b46c82574721240687 diff --git a/pubspec.yaml b/pubspec.yaml index 449a5747..4b832a4b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -11,6 +11,7 @@ environment: dependencies: flutter: sdk: flutter + collection: ^1.15.0 cupertino_icons: ^1.0.6 dio: ^5.4.0 json_annotation: ^4.8.1 From 41dcf0e7c3a6d0bdf872e888a45bdb69e7b6eb2d Mon Sep 17 00:00:00 2001 From: Edgardo Zuniga Date: Sat, 13 Apr 2024 20:03:11 -0600 Subject: [PATCH 5/6] feat: add unit tests for state management This commit introduces unit tests for state management functionalities. Adiitionally, it includes a widget test for the BuildRestaurantItem() widget. Minor general fixes are also included. --- assets/img/no_image_available.png | Bin 0 -> 38192 bytes lib/screens/restaurant_detail_screen.dart | 21 +++++--- lib/widgets/restaurant_card_widget.dart | 20 +++++--- lib/widgets/restaurant_item_widget.dart | 2 +- pubspec.lock | 8 +++ pubspec.yaml | 3 +- test/provider/favorite_restaurants_test.dart | 51 +++++++++++++++++++ test/provider/widget_test.dart | 32 ++++++++++++ test/widget_test.dart | 20 -------- 9 files changed, 120 insertions(+), 37 deletions(-) create mode 100644 assets/img/no_image_available.png create mode 100644 test/provider/favorite_restaurants_test.dart create mode 100644 test/provider/widget_test.dart delete mode 100644 test/widget_test.dart diff --git a/assets/img/no_image_available.png b/assets/img/no_image_available.png new file mode 100644 index 0000000000000000000000000000000000000000..35d466067879f26e0bf22c06b2d2fe4f97decf8c GIT binary patch literal 38192 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 V*`!K;`49M~@=#NuK+Yua{{wo`O^yHn literal 0 HcmV?d00001 diff --git a/lib/screens/restaurant_detail_screen.dart b/lib/screens/restaurant_detail_screen.dart index 448608f0..582ef3c7 100644 --- a/lib/screens/restaurant_detail_screen.dart +++ b/lib/screens/restaurant_detail_screen.dart @@ -133,9 +133,12 @@ class _RestaurantDetailScreenState height: 361, decoration: BoxDecoration( image: DecorationImage( - image: NetworkImage( - restaurant.heroImage, - ), + image: restaurant.heroImage.isNotEmpty + ? NetworkImage( + restaurant.heroImage, + ) + : const AssetImage('assets/img/no_image_available.png') + as ImageProvider, fit: BoxFit.cover, ), ), @@ -149,14 +152,14 @@ class _RestaurantDetailScreenState mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - '${restaurant.price} ${restaurant.categories?.first.title}', + '${restaurant.price ?? 'N/A'} ${restaurant.categories?.isNotEmpty == true ? restaurant.categories!.first.title : 'N/A'}', style: TextStylesClass.priceCategoryTextStyle, ), Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ Text( - 'Open Now', + restaurant.isOpen == true ? 'Open Now' : 'Closed', style: TextStylesClass.openCloseRestaurantTextStyle, ), const Gap(6), @@ -182,7 +185,8 @@ class _RestaurantDetailScreenState ), const Gap(24), Text( - restaurant.location?.formattedAddress ?? '', + restaurant.location?.formattedAddress ?? + 'No addressed provided', style: TextStylesClass.restaurantAddressTextStyle, ), const Gap(24), @@ -196,7 +200,10 @@ class _RestaurantDetailScreenState Row( children: [ Text( - overallRating != 0 ? '$overallRating' : 'No Reviews', + //Check if there are any reviews + (overallRating != 0.0 && restaurant.reviews!.isNotEmpty) + ? '$overallRating' + : 'No Reviews', style: GoogleFonts.lora( fontWeight: FontWeight.w700, fontSize: 28, diff --git a/lib/widgets/restaurant_card_widget.dart b/lib/widgets/restaurant_card_widget.dart index 42652bea..6a4ffdc5 100644 --- a/lib/widgets/restaurant_card_widget.dart +++ b/lib/widgets/restaurant_card_widget.dart @@ -12,9 +12,10 @@ class RestaurantCardWidget extends StatelessWidget { @override Widget build(BuildContext context) { //Get overall rating - int overallRating = - RatingCalculator.calculateAverageRating(restaurantData.reviews!) - .round(); + final int overallRating = restaurantData.reviews?.isNotEmpty ?? false + ? RatingCalculator.calculateAverageRating(restaurantData.reviews!) + .round() + : 0; return Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), @@ -41,9 +42,10 @@ class RestaurantCardWidget extends StatelessWidget { decoration: BoxDecoration( borderRadius: BorderRadius.circular(8), image: DecorationImage( - image: NetworkImage( - restaurantData.heroImage, - ), + image: restaurantData.heroImage.isNotEmpty + ? NetworkImage(restaurantData.heroImage) + : const AssetImage('assets/img/no_image_available.png') + as ImageProvider, fit: BoxFit.cover, ), ), @@ -62,7 +64,7 @@ class RestaurantCardWidget extends StatelessWidget { ), const Gap(8), Text( - '${restaurantData.price} ${restaurantData.categories?.first.title}', + '${restaurantData.price ?? 'N/A'} ${restaurantData.categories?.isNotEmpty == true ? restaurantData.categories!.first.title : "N/A"}', style: TextStylesClass.priceCategoryTextStyle, ), const Gap(6), @@ -83,7 +85,9 @@ class RestaurantCardWidget extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.center, children: [ Text( - restaurantData.isOpen ? 'Open Now' : 'Closed', + restaurantData.isOpen == true + ? 'Open Now' + : 'Closed', style: TextStylesClass.openCloseRestaurantTextStyle, ), diff --git a/lib/widgets/restaurant_item_widget.dart b/lib/widgets/restaurant_item_widget.dart index 79bc0092..2abd7f81 100644 --- a/lib/widgets/restaurant_item_widget.dart +++ b/lib/widgets/restaurant_item_widget.dart @@ -18,7 +18,7 @@ class BuildRestaurantItem extends StatelessWidget { context, MaterialPageRoute( builder: (context) => RestaurantDetailScreen( - restaurantId: restaurantData.id!, + restaurantId: restaurantData.id ?? 'noId', ), ), ); diff --git a/pubspec.lock b/pubspec.lock index 594d8619..a63f35b7 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -459,6 +459,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.1" + mockito: + dependency: "direct main" + description: + name: mockito + sha256: "6841eed20a7befac0ce07df8116c8b8233ed1f4486a7647c7fc5a02ae6163917" + url: "https://pub.dev" + source: hosted + version: "5.4.4" package_config: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 4b832a4b..cddd44f1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -20,6 +20,7 @@ dependencies: google_fonts: 6.1.0 gap: ^3.0.1 flutter_riverpod: ^2.4.10 + mockito: ^5.4.4 dev_dependencies: flutter_test: @@ -33,4 +34,4 @@ flutter: uses-material-design: true assets: - .env - # - assets/svg/ + - assets/img/ diff --git a/test/provider/favorite_restaurants_test.dart b/test/provider/favorite_restaurants_test.dart new file mode 100644 index 00000000..ebd20763 --- /dev/null +++ b/test/provider/favorite_restaurants_test.dart @@ -0,0 +1,51 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:restaurantour/models/restaurant.dart'; +import 'package:restaurantour/providers/favorite_restaurants_provider.dart'; + +void main() { + group('FavoritesNotifier', () { + test('should add a restaurant to favorites when not already added', () { + final container = ProviderContainer(); + final notifier = container.read(favoritesProvider.notifier); + const testRestaurant = Restaurant(id: '1', name: 'Test Restaurant'); + + notifier.addFavorite(testRestaurant); + + expect( + container.read(favoritesProvider), + contains(testRestaurant), + ); + }); + + test('should not add a duplicate restaurant to favorites', () { + final container = ProviderContainer(); + final notifier = container.read(favoritesProvider.notifier); + const testRestaurant = Restaurant(id: '1', name: 'Test Restaurant'); + + notifier.addFavorite(testRestaurant); + notifier.addFavorite(testRestaurant); // Attempt to add it again + + expect( + container.read(favoritesProvider).length, + 1, + ); + }); + + test('should remove a restaurant from favorites', () { + final container = ProviderContainer(); + final notifier = container.read(favoritesProvider.notifier); + const testRestaurant = Restaurant(id: '1', name: 'Test Restaurant'); + + notifier.addFavorite(testRestaurant); + notifier.removeFavorite(testRestaurant); + + expect( + container.read(favoritesProvider), + isNot( + contains(testRestaurant), + ), + ); + }); + }); +} diff --git a/test/provider/widget_test.dart b/test/provider/widget_test.dart new file mode 100644 index 00000000..f3ec364a --- /dev/null +++ b/test/provider/widget_test.dart @@ -0,0 +1,32 @@ +// ignore_for_file: prefer_const_constructors +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:restaurantour/models/restaurant.dart'; + +import 'package:restaurantour/widgets/restaurant_card_widget.dart'; +import 'package:restaurantour/widgets/restaurant_item_widget.dart'; + +class MockNavigatorObserver extends Mock implements NavigatorObserver {} + +void main() { + testWidgets('BuildRestaurantItem displays restaurant name', + (WidgetTester tester) async { + // Mock restaurant data + final mockRestaurant = Restaurant(id: '1', name: 'Test Restaurant'); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: BuildRestaurantItem( + restaurantData: mockRestaurant, + ), + ), + ), + ); + + // Verify that the RestaurantCardWidget is displaying the correct name + expect(find.byType(RestaurantCardWidget), findsOneWidget); + expect(find.text('Test Restaurant'), findsOneWidget); + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart deleted file mode 100644 index 83fbeae4..00000000 --- a/test/widget_test.dart +++ /dev/null @@ -1,20 +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:restaurantour/main.dart'; - -void main() { - testWidgets('Page loads', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const Restaurantour()); - - // Verify that tests will run - expect(find.text('Fetch Restaurants'), findsOneWidget); - }); -} From ca574dea9e638ffaa84af2d4cf0e621bafa364ac Mon Sep 17 00:00:00 2001 From: Edgardo Zuniga Date: Sat, 13 Apr 2024 20:54:47 -0600 Subject: [PATCH 6/6] feat:minor UI improvements and code comments - Minor UI improvements. - adding comments to improve code understandability for future reference and collaboration. --- .../text_style_constants.dart | 0 lib/screens/restaurant_detail_screen.dart | 25 +++++++++++++------ lib/screens/restaurants_screen.dart | 16 ++++++------ lib/screens/views/all_restaurants_view.dart | 6 +++++ .../views/favorite_restaurants_view.dart | 10 ++++++++ lib/theme/main_theme.dart | 1 + lib/utils/rating_calculator.dart | 1 + lib/widgets/restaurant_card_widget.dart | 5 +++- lib/widgets/restaurant_item_widget.dart | 3 +++ lib/widgets/review_card_widget.dart | 9 +++++-- pubspec.lock | 16 ++++++------ pubspec.yaml | 2 +- test/provider/widget_test.dart | 3 --- 13 files changed, 67 insertions(+), 30 deletions(-) rename lib/{contants => constants}/text_style_constants.dart (100%) diff --git a/lib/contants/text_style_constants.dart b/lib/constants/text_style_constants.dart similarity index 100% rename from lib/contants/text_style_constants.dart rename to lib/constants/text_style_constants.dart diff --git a/lib/screens/restaurant_detail_screen.dart b/lib/screens/restaurant_detail_screen.dart index 582ef3c7..f80a40f1 100644 --- a/lib/screens/restaurant_detail_screen.dart +++ b/lib/screens/restaurant_detail_screen.dart @@ -3,7 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:collection/collection.dart'; import 'package:gap/gap.dart'; import 'package:google_fonts/google_fonts.dart'; -import 'package:restaurantour/contants/text_style_constants.dart'; +import 'package:restaurantour/constants/text_style_constants.dart'; import 'package:restaurantour/models/restaurant.dart'; import 'package:restaurantour/providers/favorite_restaurants_provider.dart'; import 'package:restaurantour/providers/fetch_restaurants_provider.dart'; @@ -22,26 +22,30 @@ class RestaurantDetailScreen extends ConsumerStatefulWidget { class _RestaurantDetailScreenState extends ConsumerState { + // State variables late List reviews; late bool isFavorite = false; @override void initState() { super.initState(); - initRestaurantDetails(); + initRestaurantDetails(); // Fetch and process restaurant details } - //Initialization for restaurant details + // Fetches and processes restaurant details void initRestaurantDetails() async { - reviews = []; + reviews = []; //variable where the reviews will be managed final restaurants = ref.read(restaurantsNotifierProvider).asData?.value; + // Check if restaurants data is available if (restaurants != null) { + // Find restaurant by ID final restaurant = restaurants .firstWhereOrNull((rest) => rest.id == widget.restaurantId); if (restaurant != null) { + // Check favorite status isFavorite = ref.read(favoritesProvider).contains(restaurant); if (restaurant.reviews != null) { - populateReviewsList(restaurant.reviews!); + populateReviewsList(restaurant.reviews!); // Populate reviews list } } else { throw Exception("Restaurant with ID ${widget.restaurantId} not found"); @@ -49,7 +53,7 @@ class _RestaurantDetailScreenState } } -//Add or Remove to/from favorites + // Toggles restaurant as favorites void toggleFavorite(Restaurant restaurant) { final favoritesNotifier = ref.read(favoritesProvider.notifier); if (isFavorite) { @@ -58,11 +62,11 @@ class _RestaurantDetailScreenState favoritesNotifier.addFavorite(restaurant); } setState(() { - isFavorite = !isFavorite; // Toggle the favorite status in the UI + isFavorite = !isFavorite; }); } - //Populate Reviews List + // Populates the reviews list with ReviewCardWidget widgets void populateReviewsList(List fetchedReviews) { for (Review review in fetchedReviews) { reviews.add( @@ -80,10 +84,13 @@ class _RestaurantDetailScreenState final restaurantState = ref.watch(restaurantsNotifierProvider); return restaurantState.when( + // Handle loading state loading: () => const CircularProgressIndicator( color: Colors.black, ), + // Handle error state error: (error, _) => Text('Error: $error'), + // Handle data state data: (restaurants) { final restaurant = restaurants .firstWhereOrNull((rest) => rest.id == widget.restaurantId); @@ -95,7 +102,9 @@ class _RestaurantDetailScreenState ); } + // Builds the UI for the restaurant details screen Widget buildRestaurantDetails(Restaurant restaurant) { + //Obtain the overall rating for the current restaurant double overallRating = RatingCalculator.calculateAverageRating(restaurant.reviews!); diff --git a/lib/screens/restaurants_screen.dart b/lib/screens/restaurants_screen.dart index 59545b22..59bf6670 100644 --- a/lib/screens/restaurants_screen.dart +++ b/lib/screens/restaurants_screen.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:restaurantour/contants/text_style_constants.dart'; +import 'package:restaurantour/constants/text_style_constants.dart'; import 'package:restaurantour/screens/views/all_restaurants_view.dart'; import 'package:restaurantour/screens/views/favorite_restaurants_view.dart'; @@ -10,16 +10,20 @@ class RestaurantListScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { return DefaultTabController( + // This controls the tab behavior (initial index, number of tabs) initialIndex: 0, length: 2, child: Scaffold( appBar: AppBar( + // Configure app bar appearance backgroundColor: Colors.white, + centerTitle: true, title: Text( 'Restaurantour', style: TextStylesClass.titleTextStyle, ), bottom: TabBar( + // Configure tab layout and appearance tabAlignment: TabAlignment.center, indicatorColor: Colors.black, tabs: [ @@ -40,12 +44,10 @@ class RestaurantListScreen extends ConsumerWidget { ), body: const TabBarView( children: [ - Center( - child: AllRestaurantsView(), - ), - Center( - child: FavoriteRestaurantsView(), - ), + //All restaurants list view + AllRestaurantsView(), + //Favorite restaurants list view + FavoriteRestaurantsView(), ], ), ), diff --git a/lib/screens/views/all_restaurants_view.dart b/lib/screens/views/all_restaurants_view.dart index 346903fc..b90592e6 100644 --- a/lib/screens/views/all_restaurants_view.dart +++ b/lib/screens/views/all_restaurants_view.dart @@ -3,25 +3,31 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:restaurantour/providers/fetch_restaurants_provider.dart'; import 'package:restaurantour/widgets/restaurant_item_widget.dart'; +// This widget displays a list of all restaurants class AllRestaurantsView extends ConsumerWidget { const AllRestaurantsView({Key? key}) : super(key: key); @override Widget build(BuildContext context, WidgetRef ref) { + // Access the current state of the restaurantsNotifierProvider final restaurantsState = ref.watch(restaurantsNotifierProvider); return restaurantsState.when( + // Handle loading state loading: () => const Center( child: CircularProgressIndicator( color: Colors.black, ), ), + // Handle error state error: (error, stack) => const Center(child: Text('Error: No restaurants found')), + // Handle data state and build the list data: (restaurants) { return ListView.builder( padding: const EdgeInsets.only(bottom: 100, top: 12), itemCount: restaurants.length, + // Build each restaurant item using BuildRestaurantItem widget itemBuilder: (context, index) => BuildRestaurantItem( restaurantData: restaurants[index], ), diff --git a/lib/screens/views/favorite_restaurants_view.dart b/lib/screens/views/favorite_restaurants_view.dart index 216b2ba8..30a03837 100644 --- a/lib/screens/views/favorite_restaurants_view.dart +++ b/lib/screens/views/favorite_restaurants_view.dart @@ -3,16 +3,26 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:restaurantour/providers/favorite_restaurants_provider.dart'; import 'package:restaurantour/widgets/restaurant_item_widget.dart'; +// This widget displays a list of favorite restaurants class FavoriteRestaurantsView extends ConsumerWidget { const FavoriteRestaurantsView({Key? key}) : super(key: key); @override Widget build(BuildContext context, WidgetRef ref) { + // Access the current list of favorite restaurants from the provider final favoriteRestaurantsList = ref.watch(favoritesProvider); + // Display a message if there are no favorite restaurants + if (favoriteRestaurantsList.isEmpty) { + return const Center( + child: Text('No favorite restaurants yet'), + ); + } + return ListView.builder( padding: const EdgeInsets.only(bottom: 100, top: 12), itemCount: favoriteRestaurantsList.length, + // Build each restaurant item using BuildRestaurantItem widget itemBuilder: (context, index) => BuildRestaurantItem( restaurantData: favoriteRestaurantsList[index], ), diff --git a/lib/theme/main_theme.dart b/lib/theme/main_theme.dart index 26d53680..6580ea24 100644 --- a/lib/theme/main_theme.dart +++ b/lib/theme/main_theme.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; +//Basic color theme class MainTheme { static ThemeData get mainTheme { const Color primaryColor = Color(0xFFFB8C00); diff --git a/lib/utils/rating_calculator.dart b/lib/utils/rating_calculator.dart index ca5c85bf..5ebe30ae 100644 --- a/lib/utils/rating_calculator.dart +++ b/lib/utils/rating_calculator.dart @@ -1,5 +1,6 @@ import 'package:restaurantour/models/restaurant.dart'; +//Get overall rating for a restaurant / based on reviews class RatingCalculator { static double calculateAverageRating(List reviews) { if (reviews.isNotEmpty) { diff --git a/lib/widgets/restaurant_card_widget.dart b/lib/widgets/restaurant_card_widget.dart index 6a4ffdc5..b6c02511 100644 --- a/lib/widgets/restaurant_card_widget.dart +++ b/lib/widgets/restaurant_card_widget.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; -import 'package:restaurantour/contants/text_style_constants.dart'; +import 'package:restaurantour/constants/text_style_constants.dart'; import 'package:restaurantour/models/restaurant.dart'; import 'package:restaurantour/utils/rating_calculator.dart'; @@ -42,6 +42,7 @@ class RestaurantCardWidget extends StatelessWidget { decoration: BoxDecoration( borderRadius: BorderRadius.circular(8), image: DecorationImage( + //Display a dummy image for restaurants with no image image: restaurantData.heroImage.isNotEmpty ? NetworkImage(restaurantData.heroImage) : const AssetImage('assets/img/no_image_available.png') @@ -64,6 +65,7 @@ class RestaurantCardWidget extends StatelessWidget { ), const Gap(8), Text( + //general null checks '${restaurantData.price ?? 'N/A'} ${restaurantData.categories?.isNotEmpty == true ? restaurantData.categories!.first.title : "N/A"}', style: TextStylesClass.priceCategoryTextStyle, ), @@ -72,6 +74,7 @@ class RestaurantCardWidget extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Row( + //generate the stars depending on the overall rating children: List.generate( overallRating, (index) => const Icon( diff --git a/lib/widgets/restaurant_item_widget.dart b/lib/widgets/restaurant_item_widget.dart index 2abd7f81..d53fdf88 100644 --- a/lib/widgets/restaurant_item_widget.dart +++ b/lib/widgets/restaurant_item_widget.dart @@ -14,15 +14,18 @@ class BuildRestaurantItem extends StatelessWidget { Widget build(BuildContext context) { return GestureDetector( onTap: () { + //Navigate to the restaurant's details screen on tap Navigator.push( context, MaterialPageRoute( builder: (context) => RestaurantDetailScreen( + //In case no id is given (might never happen) restaurantId: restaurantData.id ?? 'noId', ), ), ); }, + //Widget used to build UI child: RestaurantCardWidget( restaurantData: restaurantData, ), diff --git a/lib/widgets/review_card_widget.dart b/lib/widgets/review_card_widget.dart index 29b7c11e..a217f57f 100644 --- a/lib/widgets/review_card_widget.dart +++ b/lib/widgets/review_card_widget.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:flutter_lorem/flutter_lorem.dart'; import 'package:gap/gap.dart'; -import 'package:restaurantour/contants/text_style_constants.dart'; +import 'package:restaurantour/constants/text_style_constants.dart'; class ReviewCardWidget extends StatelessWidget { const ReviewCardWidget({ @@ -15,6 +16,9 @@ class ReviewCardWidget extends StatelessWidget { @override Widget build(BuildContext context) { + //Generate lorem ipsum text for reviews + String dummyReviewText = lorem(paragraphs: 1, words: 15); + //Round the overall rating (for stars generation) int roundedRating = rating!.round(); return Column( children: [ @@ -31,13 +35,14 @@ class ReviewCardWidget extends StatelessWidget { ), const Gap(12), Text( - 'Review text goes here. Review text goes here. Review text goes here. Review text goes here.', + dummyReviewText, style: TextStylesClass.reviewRestaurantTextStyle, ), const Gap(12), Row( children: [ CircleAvatar( + //Use dummy user image if userImgUrl is null backgroundImage: NetworkImage( userImgUrl ?? 'https://gopostr.s3.amazonaws.com/favicon_url/CMXfauwVNmmVLyKpV0Qkg582dzzQWcp0Eje9gMiQ.png', diff --git a/pubspec.lock b/pubspec.lock index a63f35b7..dd9883a7 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -286,6 +286,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.4" + flutter_lorem: + dependency: "direct main" + description: + name: flutter_lorem + sha256: "004e1af5b030b632628d8cc1eb2f90451f48b6c0080f43069f69baa4959e9fe5" + url: "https://pub.dev" + source: hosted + version: "2.0.0" flutter_riverpod: dependency: "direct main" description: @@ -459,14 +467,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.1" - mockito: - dependency: "direct main" - description: - name: mockito - sha256: "6841eed20a7befac0ce07df8116c8b8233ed1f4486a7647c7fc5a02ae6163917" - url: "https://pub.dev" - source: hosted - version: "5.4.4" package_config: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index cddd44f1..84eefb0e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -20,7 +20,7 @@ dependencies: google_fonts: 6.1.0 gap: ^3.0.1 flutter_riverpod: ^2.4.10 - mockito: ^5.4.4 + flutter_lorem: ^2.0.0 dev_dependencies: flutter_test: diff --git a/test/provider/widget_test.dart b/test/provider/widget_test.dart index f3ec364a..b2da90cd 100644 --- a/test/provider/widget_test.dart +++ b/test/provider/widget_test.dart @@ -1,14 +1,11 @@ // ignore_for_file: prefer_const_constructors import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:mockito/mockito.dart'; import 'package:restaurantour/models/restaurant.dart'; import 'package:restaurantour/widgets/restaurant_card_widget.dart'; import 'package:restaurantour/widgets/restaurant_item_widget.dart'; -class MockNavigatorObserver extends Mock implements NavigatorObserver {} - void main() { testWidgets('BuildRestaurantItem displays restaurant name', (WidgetTester tester) async {