diff --git a/.specstory/.what-is-this.md b/.specstory/.what-is-this.md new file mode 100644 index 00000000..a0e0cb86 --- /dev/null +++ b/.specstory/.what-is-this.md @@ -0,0 +1,69 @@ +# SpecStory Artifacts Directory + +This directory is automatically created and maintained by the SpecStory extension to preserve your AI chat history. + +## What's Here? + +- `.specstory/history`: Contains auto-saved markdown files of your AI coding sessions + - Each file represents a separate AI chat session + - If you enable auto-save, files are automatically updated as you work + - You can enable/disable the auto-save feature in the SpecStory settings, it is disabled by default +- `.specstory/.project.json`: Contains the persistent project identity for the current workspace + - This file is only present if you enable AI rules derivation + - This is used to provide consistent project identity of your project, even as the workspace is moved or renamed +- `.specstory/ai_rules_backups`: Contains backups of the `.cursor/rules/derived-cursor-rules.mdc` or the `.github/copilot-instructions.md` file + - Backups are automatically created each time the `.cursor/rules/derived-cursor-rules.mdc` or the `.github/copilot-instructions.md` file is updated + - You can enable/disable the AI Rules derivation feature in the SpecStory settings, it is disabled by default +- `.specstory/.gitignore`: Contains directives to exclude non-essential contents of the `.specstory` directory from version control + - Add `/history` to exclude the auto-saved chat history from version control + +## Valuable Uses + +- Capture: Keep your context window up-to-date when starting new Chat/Composer sessions via @ references +- Search: For previous prompts and code snippets +- Learn: Meta-analyze your patterns and learn from your past experiences +- Derive: Keep the AI on course with your past decisions by automatically deriving rules from your AI interactions + +## Version Control + +We recommend keeping this directory under version control to maintain a history of your AI interactions. However, if you prefer not to version these files, you can exclude them by adding this to your `.gitignore`: + +``` +.specstory/** +``` + +We recommend __not__ keeping the `.specstory/ai_rules_backups` directory under version control if you are already using git to version your AI rules, and committing regularly. You can exclude it by adding this to your `.gitignore`: + +``` +.specstory/ai_rules_backups +``` + +## Searching Your Codebase + +When searching your codebase, search results may include your previous AI coding interactions. To focus solely on your actual code files, you can exclude the AI interaction history from search results. + +To exclude AI interaction history: + +1. Open the "Find in Files" search in Cursor or VSCode (Cmd/Ctrl + Shift + F) +2. Navigate to the "files to exclude" section +3. Add the following pattern: + +``` +.specstory/* +``` + +This will ensure your searches only return results from your working codebase files. + +## Notes + +- Auto-save only works when Cursor or VSCode flushes sqlite database data to disk. This results in a small delay after the AI response is complete before SpecStory can save the history. + +## Settings + +You can control auto-saving behavior in Cursor or VSCode: + +1. Open Cursor/Code → Settings → VS Code Settings (Cmd/Ctrl + ,) +2. Search for "SpecStory" +3. Find "Auto Save" setting to enable/disable + +Auto-save occurs when changes are detected in the sqlite database, or every 2 minutes as a safety net. \ No newline at end of file diff --git a/.specstory/history/2025-07-08_16-11Z-how-to-create-new-branch-in-github-in-terminal.md b/.specstory/history/2025-07-08_16-11Z-how-to-create-new-branch-in-github-in-terminal.md new file mode 100644 index 00000000..bc8d5b82 --- /dev/null +++ b/.specstory/history/2025-07-08_16-11Z-how-to-create-new-branch-in-github-in-terminal.md @@ -0,0 +1,266 @@ + + +# how to create new branch in github in terminal (2025-07-08 16:11Z) + +_**User**_ + +how to create new branch in github in terminal + +--- + +_**Assistant**_ + +To create a new branch in GitHub using the terminal, follow these steps: + +1. Open your terminal and navigate to your project directory. +2. Run the following command to create and switch to a new branch (replace `your-branch-name` with your desired branch name): + +```sh +git checkout -b your-branch-name +``` + +3. Push the new branch to GitHub: + +```sh +git push -u origin your-branch-name +``` + +Your new branch will now be available on GitHub. + +--- + +_**User**_ + +practice name it + +--- + +_**Assistant**_ + +A good practice for naming a new branch is to use a clear, descriptive, and concise name that reflects the purpose of the branch. Common conventions include: + +- Use lowercase letters and hyphens to separate words. +- Prefix the branch name with the type of work, such as `feature/`, `bugfix/`, or `hotfix/`. +- Include a short description or ticket number if relevant. + +Examples: +- feature/responsive-layout +- bugfix/fix-login-error +- hotfix/update-dependencies + +For your file, a branch name like feature/responsive-two-column-layout would be appropriate. + +--- + +_**User**_ + +nabirahmani@Muhammads-MacBook-Pro Flutter-Architect-Pro % git push practice +fatal: 'practice' does not appear to be a git repository +fatal: Could not read from remote repository. + +Please make sure you have the correct access rights +and the repository exists. +nabirahmani@Muhammads-MacBook-Pro Flutter-Architect-Pro % + +--- + +_**Assistant**_ + +The error occurs because practice is not a valid remote name. To push a branch, you should use the correct remote name, usually origin. Here’s how to do it: + +1. Check your remotes: +```sh +git remote -v +``` + +2. Push your branch to the correct remote (replace your-branch-name with your actual branch name): +```sh +git push -u origin your-branch-name +``` + +If you want to add a new remote called practice, you can do so with: +```sh +git remote add practice +``` +But for most cases, use origin as the remote name. + +--- + +_**User**_ + +practice + +--- + +_**Assistant**_ + +It looks like you are trying to use practice as a remote name or branch. If you want to create and push a branch called practice, use these commands: + +```sh +git checkout -b practice +git push -u origin practice +``` + +If you meant something else by "practice," please clarify! + +--- + +_**User**_ + +Launching lib/main.dart on iPhone 16 Pro Max in debug mode... +Xcode build done. 1.4s +Failed to build iOS app +Error (Xcode): The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation. + +Could not build the application for the simulator. +Error launching application on iPhone 16 Pro Max. + +Exited (1). ??? + +--- + +_**Assistant**_ + +The error message means your CocoaPods dependencies are out of sync. To fix this, run the following command in your iOS project directory: + +```sh +cd ios +pod install +``` + +If you still get errors, try updating CocoaPods: + +```sh +sudo gem install cocoapods +pod repo update +pod install +``` + +After that, try running your Flutter app again: + +```sh +cd .. +flutter run +``` + +This should resolve the issue and allow your app to launch on the simulator. + +--- + +_**User**_ + +A new release of RubyGems is available: 3.4.10 → 3.6.9! +Run `gem update --system 3.6.9` to update your installation. + +Updating spec repo `trunk` +[!] No `Podfile' found in the project directory. +nabirahmani@Muhammads-MacBook-Pro Flutter-Architect-Pro % cd .. +flutter run +Error: No pubspec.yaml file found. +This command should be run from the root of your Flutter project. +nabirahmani@Muhammads-MacBook-Pro Flutter_Apps % + +--- + +_**Assistant**_ + +Here’s how to resolve your issues: + +1. No `Podfile` found: +You need to run `pod install` inside the `ios` directory of your Flutter project, not the project root. +Example: +```sh +cd /Users/nabirahmani/Desktop/Flutter_Apps/Flutter-Architect-Pro/ecommerce_app/ios +pod install +``` + +2. No `pubspec.yaml` file found: +You must run `flutter run` from the root of your Flutter project, where `pubspec.yaml` exists. +Example: +```sh +cd /Users/nabirahmani/Desktop/Flutter_Apps/Flutter-Architect-Pro/ecommerce_app +flutter run +``` + +Summary: +- Always run `pod install` inside the `ios` folder. +- Always run `flutter run` inside the folder containing `pubspec.yaml` (your Flutter app root). + +Let me know if you need more help! + +--- + +_**User**_ + + key: state.pageKey, is would problem in runtime or anytime in MaterialPage + +--- + +_**Assistant**_ + +Using key: state.pageKey in MaterialPage is correct and recommended when working with GoRouter. It helps Flutter manage page identity and navigation stack, especially for deep linking and web navigation. + +You should not encounter runtime problems as long as you use state.pageKey for each MaterialPage in your GoRouter configuration. This ensures proper page transitions and state management. + +If you see any specific errors related to pageKey, please share the error message for more targeted help. Otherwise, your usage is appropriate. + +--- + +_**User**_ + +what is the advantage of using it + +--- + +_**Assistant**_ + +Using MaterialPage in GoRouter provides several advantages: + +1. **Custom Page Transitions**: MaterialPage allows you to customize transitions, dialogs, and animations, giving you more control over navigation behavior. + +2. **Unique Page Keys**: By using key: state.pageKey, Flutter can uniquely identify each page, which helps with state restoration, deep linking, and web navigation. + +3. **Fullscreen Dialogs**: The fullscreenDialog property lets you present pages as modal dialogs, matching native iOS/Android behavior. + +4. **Navigator 2.0 Compatibility**: MaterialPage is required for advanced navigation scenarios with Navigator 2.0, such as nested navigation and complex routing. + +In summary, MaterialPage improves navigation flexibility, reliability, and user experience in Flutter apps using GoRouter. + +--- + +_**User**_ + +no key: state.pageKey, this one + +--- + +_**Assistant**_ + +Using key: state.pageKey in MaterialPage is important because it gives each page a unique identity in the navigation stack. This helps Flutter: + +- Correctly manage page transitions and animations. +- Restore navigation state after hot reloads or app restarts. +- Avoid duplicate pages when navigating with deep links or web URLs. + +Without a unique key, you may encounter navigation bugs, unexpected page behavior, or issues with state restoration. So, using key: state.pageKey is a best practice with GoRouter and MaterialPage. + +--- + +_**User**_ + +is shows in product id or count base on select in we url , it does actally key: state.pageKey, + +--- + +_**Assistant**_ + +Yes, key: state.pageKey helps Flutter uniquely identify each page instance, especially when navigating to different product IDs via web URLs or deep links. + +When you select or navigate to a different product (e.g., /product/1, /product/2), state.pageKey ensures that each ProductScreen is treated as a separate page in the navigation stack. This prevents issues like page duplication, incorrect state, or improper back navigation. + +In summary: +- It works for product ID or count-based navigation. +- It ensures correct page behavior when switching between products via URL or app navigation. + +--- + diff --git a/ecommerce_app/.metadata b/ecommerce_app/.metadata index 7539b439..a4934ec1 100644 --- a/ecommerce_app/.metadata +++ b/ecommerce_app/.metadata @@ -4,7 +4,27 @@ # This file should be version controlled and should not be manually edited. version: - revision: 5f105a6ca7a5ac7b8bc9b241f4c2d86f4188cf5c - channel: stable + revision: "6fba2447e95c451518584c35e25f5433f14d888c" + channel: "stable" project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 6fba2447e95c451518584c35e25f5433f14d888c + base_revision: 6fba2447e95c451518584c35e25f5433f14d888c + - platform: web + create_revision: 6fba2447e95c451518584c35e25f5433f14d888c + base_revision: 6fba2447e95c451518584c35e25f5433f14d888c + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/ecommerce_app/ios/Flutter/AppFrameworkInfo.plist b/ecommerce_app/ios/Flutter/AppFrameworkInfo.plist index 8d4492f9..7c569640 100644 --- a/ecommerce_app/ios/Flutter/AppFrameworkInfo.plist +++ b/ecommerce_app/ios/Flutter/AppFrameworkInfo.plist @@ -21,6 +21,6 @@ CFBundleVersion 1.0 MinimumOSVersion - 9.0 + 12.0 diff --git a/ecommerce_app/ios/Podfile.lock b/ecommerce_app/ios/Podfile.lock index 42460734..cef6aa64 100644 --- a/ecommerce_app/ios/Podfile.lock +++ b/ecommerce_app/ios/Podfile.lock @@ -9,8 +9,8 @@ EXTERNAL SOURCES: :path: Flutter SPEC CHECKSUMS: - Flutter: 50d75fe2f02b26cc09d224853bb45737f8b3214a + Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 PODFILE CHECKSUM: 4e8f8b2be68aeea4c0d5beb6ff1e79fface1d048 -COCOAPODS: 1.11.2 +COCOAPODS: 1.16.2 diff --git a/ecommerce_app/ios/Runner.xcodeproj/project.pbxproj b/ecommerce_app/ios/Runner.xcodeproj/project.pbxproj index d92feeaa..a10f9ab2 100644 --- a/ecommerce_app/ios/Runner.xcodeproj/project.pbxproj +++ b/ecommerce_app/ios/Runner.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 51; + objectVersion = 54; objects = { /* Begin PBXBuildFile section */ @@ -154,7 +154,7 @@ 97C146E61CF9000F007C117D /* Project object */ = { isa = PBXProject; attributes = { - LastUpgradeCheck = 1300; + LastUpgradeCheck = 1510; ORGANIZATIONNAME = ""; TargetAttributes = { 97C146ED1CF9000F007C117D = { @@ -198,10 +198,12 @@ /* Begin PBXShellScriptBuildPhase section */ 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", ); name = "Thin Binary"; outputPaths = ( @@ -212,6 +214,7 @@ }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); @@ -321,7 +324,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 9.0; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -399,7 +402,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 9.0; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -448,7 +451,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 9.0; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; diff --git a/ecommerce_app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ecommerce_app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index c87d15a3..9c12df59 100644 --- a/ecommerce_app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/ecommerce_app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -1,6 +1,6 @@ diff --git a/ecommerce_app/ios/Runner/AppDelegate.swift b/ecommerce_app/ios/Runner/AppDelegate.swift index 70693e4a..b6363034 100644 --- a/ecommerce_app/ios/Runner/AppDelegate.swift +++ b/ecommerce_app/ios/Runner/AppDelegate.swift @@ -1,7 +1,7 @@ import UIKit import Flutter -@UIApplicationMain +@main @objc class AppDelegate: FlutterAppDelegate { override func application( _ application: UIApplication, diff --git a/ecommerce_app/ios/Runner/Info.plist b/ecommerce_app/ios/Runner/Info.plist index e19836d5..42fd8ff4 100644 --- a/ecommerce_app/ios/Runner/Info.plist +++ b/ecommerce_app/ios/Runner/Info.plist @@ -43,5 +43,9 @@ UIViewControllerBasedStatusBarAppearance + CADisableMinimumFrameDurationOnPhone + + UIApplicationSupportsIndirectInputEvents + diff --git a/ecommerce_app/lib/main.dart b/ecommerce_app/lib/main.dart index 3f83f8ce..fcec988f 100644 --- a/ecommerce_app/lib/main.dart +++ b/ecommerce_app/lib/main.dart @@ -1,27 +1,38 @@ -import 'dart:async'; - import 'package:ecommerce_app/src/app.dart'; +import 'package:ecommerce_app/src/localization/string_hardcoded.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_web_plugins/flutter_web_plugins.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); - // https://docs.flutter.dev/testing/errors - await runZonedGuarded(() async { - runApp(const MyApp()); + usePathUrlStrategy(); + // * Register error handlers. For more info, see: + // * https://docs.flutter.dev/testing/errors + registerErrorHandlers(); + // * Entry point of the app + runApp(const MyApp()); +} - FlutterError.onError = (FlutterErrorDetails details) { - FlutterError.presentError(details); - }; - ErrorWidget.builder = (FlutterErrorDetails details) { - return Scaffold( - appBar: AppBar( - backgroundColor: Colors.red, - title: const Text('An error occurred'), - ), - body: Center(child: Text(details.toString())), - ); - }; - }, (Object error, StackTrace stack) { +void registerErrorHandlers() { + // * Show some error UI if any uncaught exception happens + FlutterError.onError = (FlutterErrorDetails details) { + FlutterError.presentError(details); + debugPrint(details.toString()); + }; + // * Handle errors from the underlying platform/OS + PlatformDispatcher.instance.onError = (Object error, StackTrace stack) { debugPrint(error.toString()); - }); + return true; + }; + // * Show some error UI when any widget in the app fails to build + ErrorWidget.builder = (FlutterErrorDetails details) { + return Scaffold( + appBar: AppBar( + backgroundColor: Colors.red, + title: Text('An error occurred'.hardcoded), + ), + body: Center(child: Text(details.toString())), + ); + }; } diff --git a/ecommerce_app/lib/src/app.dart b/ecommerce_app/lib/src/app.dart index 112baf62..7865845f 100644 --- a/ecommerce_app/lib/src/app.dart +++ b/ecommerce_app/lib/src/app.dart @@ -1,5 +1,5 @@ -import 'package:ecommerce_app/src/features/products_list/products_list_screen.dart'; import 'package:ecommerce_app/src/localization/string_hardcoded.dart'; +import 'package:ecommerce_app/src/routing/app_router.dart'; import 'package:flutter/material.dart'; class MyApp extends StatelessWidget { @@ -7,13 +7,15 @@ class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { - return MaterialApp( + return MaterialApp.router( debugShowCheckedModeBanner: false, restorationScopeId: 'app', - home: const ProductsListScreen(), + // * The home page of the app + routerConfig: router, onGenerateTitle: (BuildContext context) => 'My Shop'.hardcoded, theme: ThemeData( - visualDensity: const VisualDensity(), + // * Use this to toggle Material 3 (defaults to true since Flutter 3.16) + useMaterial3: true, primarySwatch: Colors.grey, appBarTheme: const AppBarTheme( backgroundColor: Colors.black87, @@ -22,8 +24,8 @@ class MyApp extends StatelessWidget { ), elevatedButtonTheme: ElevatedButtonThemeData( style: ElevatedButton.styleFrom( - primary: Colors.black, // background (button) color - onPrimary: Colors.white, // foreground (text) color + backgroundColor: Colors.black, // background (button) color + foregroundColor: Colors.white, // foreground (text) color ), ), ), diff --git a/ecommerce_app/lib/src/common_widgets/action_text_button.dart b/ecommerce_app/lib/src/common_widgets/action_text_button.dart index 04d70b14..6209dbf8 100644 --- a/ecommerce_app/lib/src/common_widgets/action_text_button.dart +++ b/ecommerce_app/lib/src/common_widgets/action_text_button.dart @@ -12,12 +12,12 @@ class ActionTextButton extends StatelessWidget { return Padding( padding: const EdgeInsets.symmetric(horizontal: Sizes.p16), child: TextButton( + onPressed: onPressed, child: Text(text, style: Theme.of(context) .textTheme - .headline6! + .titleLarge! .copyWith(color: Colors.white)), - onPressed: onPressed, ), ); } diff --git a/ecommerce_app/lib/src/common_widgets/alert_dialogs.dart b/ecommerce_app/lib/src/common_widgets/alert_dialogs.dart index 82ce0ea8..8a2cbe48 100644 --- a/ecommerce_app/lib/src/common_widgets/alert_dialogs.dart +++ b/ecommerce_app/lib/src/common_widgets/alert_dialogs.dart @@ -1,52 +1,52 @@ import 'package:ecommerce_app/src/localization/string_hardcoded.dart'; import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:ecommerce_app/src/platform/platform_is.dart'; +import 'package:go_router/go_router.dart'; /// Generic function to show a platform-aware Material or Cupertino dialog Future showAlertDialog({ required BuildContext context, required String title, - required String content, + String? content, String? cancelActionText, - required String defaultActionText, + String defaultActionText = 'OK', }) async { - if (!PlatformIs.iOS) { - return showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text(title), - content: Text(content), - actions: [ - if (cancelActionText != null) - TextButton( - child: Text(cancelActionText), - onPressed: () => Navigator.of(context).pop(false), - ), - TextButton( - child: Text(defaultActionText), - onPressed: () => Navigator.of(context).pop(true), - ), - ], - ), - ); - } - return showCupertinoDialog( + return showDialog( context: context, - builder: (context) => CupertinoAlertDialog( + // * Only make the dialog dismissible if there is a cancel button + barrierDismissible: cancelActionText != null, + // * AlertDialog.adaptive was added in Flutter 3.13 + builder: (context) => AlertDialog.adaptive( title: Text(title), - content: Text(content), - actions: [ - if (cancelActionText != null) - CupertinoDialogAction( - child: Text(cancelActionText), - onPressed: () => Navigator.of(context).pop(false), - ), - CupertinoDialogAction( - child: Text(defaultActionText), - onPressed: () => Navigator.of(context).pop(true), - ), - ], + content: content != null ? Text(content) : null, + // * Use [TextButton] or [CupertinoDialogAction] depending on the platform + // https://codewithandrea.com/tips/default-target-platform/ + actions: kIsWeb || + !(defaultTargetPlatform == TargetPlatform.iOS || + defaultTargetPlatform == TargetPlatform.macOS) + ? [ + if (cancelActionText != null) + TextButton( + child: Text(cancelActionText), + onPressed: () => context.pop(false), + ), + TextButton( + child: Text(defaultActionText), + onPressed: () => context.pop(true), + ), + ] + : [ + if (cancelActionText != null) + CupertinoDialogAction( + child: Text(cancelActionText), + onPressed: () => context.pop(false), + ), + CupertinoDialogAction( + child: Text(defaultActionText), + onPressed: () => context.pop(true), + ), + ], ), ); } @@ -63,3 +63,9 @@ Future showExceptionAlertDialog({ content: exception.toString(), defaultActionText: 'OK'.hardcoded, ); + +Future showNotImplementedAlertDialog({required BuildContext context}) => + showAlertDialog( + context: context, + title: 'Not implemented'.hardcoded, + ); diff --git a/ecommerce_app/lib/src/common_widgets/cart_total_text.dart b/ecommerce_app/lib/src/common_widgets/cart_total_text.dart index ee67d689..d44514a2 100644 --- a/ecommerce_app/lib/src/common_widgets/cart_total_text.dart +++ b/ecommerce_app/lib/src/common_widgets/cart_total_text.dart @@ -13,7 +13,7 @@ class CartTotalText extends StatelessWidget { final totalFormatted = kCurrencyFormatter.format(cartTotal); return Text( 'Total: $totalFormatted', - style: Theme.of(context).textTheme.headline5, + style: Theme.of(context).textTheme.headlineSmall, textAlign: TextAlign.center, ); } diff --git a/ecommerce_app/lib/src/common_widgets/custom_text_button.dart b/ecommerce_app/lib/src/common_widgets/custom_text_button.dart index 3ec1e57c..1bc1bd9b 100644 --- a/ecommerce_app/lib/src/common_widgets/custom_text_button.dart +++ b/ecommerce_app/lib/src/common_widgets/custom_text_button.dart @@ -15,12 +15,13 @@ class CustomTextButton extends StatelessWidget { return SizedBox( height: Sizes.p48, child: TextButton( + onPressed: onPressed, child: Text( text, style: style, textAlign: TextAlign.center, + overflow: TextOverflow.visible, ), - onPressed: onPressed, ), ); } diff --git a/ecommerce_app/lib/src/common_widgets/error_message_widget.dart b/ecommerce_app/lib/src/common_widgets/error_message_widget.dart index 22fa92ce..c626b396 100644 --- a/ecommerce_app/lib/src/common_widgets/error_message_widget.dart +++ b/ecommerce_app/lib/src/common_widgets/error_message_widget.dart @@ -7,7 +7,8 @@ class ErrorMessageWidget extends StatelessWidget { Widget build(BuildContext context) { return Text( errorMessage, - style: Theme.of(context).textTheme.headline6!.copyWith(color: Colors.red), + style: + Theme.of(context).textTheme.titleLarge!.copyWith(color: Colors.red), ); } } diff --git a/ecommerce_app/lib/src/common_widgets/item_quantity_selector.dart b/ecommerce_app/lib/src/common_widgets/item_quantity_selector.dart index 8e999ba2..7a047822 100644 --- a/ecommerce_app/lib/src/common_widgets/item_quantity_selector.dart +++ b/ecommerce_app/lib/src/common_widgets/item_quantity_selector.dart @@ -15,7 +15,7 @@ class ItemQuantitySelector extends StatelessWidget { final int? itemIndex; final ValueChanged? onChanged; - // * Some keys that will be used in the widget tests + // * Keys for testing using find.byKey() static Key decrementKey([int? index]) => index != null ? Key('decrement-$index') : const Key('decrement'); static Key quantityKey([int? index]) => @@ -50,7 +50,7 @@ class ItemQuantitySelector extends StatelessWidget { '$quantity', key: quantityKey(itemIndex), textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyText1, + style: Theme.of(context).textTheme.bodyLarge, ), ), IconButton( diff --git a/ecommerce_app/lib/src/common_widgets/primary_button.dart b/ecommerce_app/lib/src/common_widgets/primary_button.dart index 5358c9c0..06007af6 100644 --- a/ecommerce_app/lib/src/common_widgets/primary_button.dart +++ b/ecommerce_app/lib/src/common_widgets/primary_button.dart @@ -27,7 +27,7 @@ class PrimaryButton extends StatelessWidget { textAlign: TextAlign.center, style: Theme.of(context) .textTheme - .headline6! + .titleLarge! .copyWith(color: Colors.white), ), ), diff --git a/ecommerce_app/lib/src/common_widgets/responsive_center.dart b/ecommerce_app/lib/src/common_widgets/responsive_center.dart index 0dcc6398..24457202 100644 --- a/ecommerce_app/lib/src/common_widgets/responsive_center.dart +++ b/ecommerce_app/lib/src/common_widgets/responsive_center.dart @@ -1,5 +1,5 @@ +import 'package:ecommerce_app/src/constants/breakpoints.dart'; import 'package:flutter/material.dart'; -import 'package:ecommerce_app/src/constants/app_sizes.dart'; /// Reusable widget for showing a child with a maximum content width constraint. /// If available width is larger than the maximum width, the child will be diff --git a/ecommerce_app/lib/src/common_widgets/responsive_scrollable_card.dart b/ecommerce_app/lib/src/common_widgets/responsive_scrollable_card.dart index f77ad390..e6415e5d 100644 --- a/ecommerce_app/lib/src/common_widgets/responsive_scrollable_card.dart +++ b/ecommerce_app/lib/src/common_widgets/responsive_scrollable_card.dart @@ -1,3 +1,4 @@ +import 'package:ecommerce_app/src/constants/breakpoints.dart'; import 'package:flutter/material.dart'; import 'package:ecommerce_app/src/common_widgets/responsive_center.dart'; import 'package:ecommerce_app/src/constants/app_sizes.dart'; diff --git a/ecommerce_app/lib/src/common_widgets/responsive_two_column_layout.dart b/ecommerce_app/lib/src/common_widgets/responsive_two_column_layout.dart index 3d5d6c2c..1d945851 100644 --- a/ecommerce_app/lib/src/common_widgets/responsive_two_column_layout.dart +++ b/ecommerce_app/lib/src/common_widgets/responsive_two_column_layout.dart @@ -1,5 +1,5 @@ +import 'package:ecommerce_app/src/constants/breakpoints.dart'; import 'package:flutter/material.dart'; -import 'package:ecommerce_app/src/constants/app_sizes.dart'; /// Responsive layout that shows two child widgets side by side if there is /// enough space, or vertically stacked if there is not enough space. diff --git a/ecommerce_app/lib/src/constants/app_sizes.dart b/ecommerce_app/lib/src/constants/app_sizes.dart index 7a63de38..cf240a26 100644 --- a/ecommerce_app/lib/src/constants/app_sizes.dart +++ b/ecommerce_app/lib/src/constants/app_sizes.dart @@ -13,12 +13,6 @@ class Sizes { static const p64 = 64.0; } -/// Layout breakpoints used in the app. -class Breakpoint { - static const double desktop = 900; - static const double tablet = 600; -} - /// Constant gap widths const gapW4 = SizedBox(width: Sizes.p4); const gapW8 = SizedBox(width: Sizes.p8); diff --git a/ecommerce_app/lib/src/constants/breakpoints.dart b/ecommerce_app/lib/src/constants/breakpoints.dart new file mode 100644 index 00000000..34ee032c --- /dev/null +++ b/ecommerce_app/lib/src/constants/breakpoints.dart @@ -0,0 +1,5 @@ +/// Layout breakpoints used in the app. +class Breakpoint { + static const double desktop = 900; + static const double tablet = 600; +} diff --git a/ecommerce_app/lib/src/constants/test_products.dart b/ecommerce_app/lib/src/constants/test_products.dart index 8b750586..de6e2344 100644 --- a/ecommerce_app/lib/src/constants/test_products.dart +++ b/ecommerce_app/lib/src/constants/test_products.dart @@ -9,6 +9,8 @@ const kTestProducts = [ description: 'Lorem ipsum', price: 15, availableQuantity: 5, + avgRating: 4.5, + numRatings: 2, ), Product( id: '2', @@ -17,6 +19,8 @@ const kTestProducts = [ description: 'Lorem ipsum', price: 13, availableQuantity: 5, + avgRating: 4, + numRatings: 2, ), Product( id: '3', @@ -25,6 +29,8 @@ const kTestProducts = [ description: 'Lorem ipsum', price: 17, availableQuantity: 5, + avgRating: 5, + numRatings: 2, ), Product( id: '4', diff --git a/ecommerce_app/lib/src/features/account/account_screen.dart b/ecommerce_app/lib/src/features/account/account_screen.dart index 6ddb3ef6..3513ab08 100644 --- a/ecommerce_app/lib/src/features/account/account_screen.dart +++ b/ecommerce_app/lib/src/features/account/account_screen.dart @@ -1,9 +1,11 @@ +import 'package:ecommerce_app/src/common_widgets/alert_dialogs.dart'; import 'package:ecommerce_app/src/localization/string_hardcoded.dart'; import 'package:ecommerce_app/src/models/app_user.dart'; import 'package:flutter/material.dart'; import 'package:ecommerce_app/src/common_widgets/action_text_button.dart'; import 'package:ecommerce_app/src/common_widgets/responsive_center.dart'; import 'package:ecommerce_app/src/constants/app_sizes.dart'; +import 'package:go_router/go_router.dart'; /// Simple account screen showing some user info and a logout button. class AccountScreen extends StatelessWidget { @@ -18,8 +20,20 @@ class AccountScreen extends StatelessWidget { ActionTextButton( text: 'Logout'.hardcoded, onPressed: () async { - // TODO: Sign out - Navigator.of(context).pop(); + // * Get the navigator beforehand to prevent this warning: + // * Don't use 'BuildContext's across async gaps. + // * More info here: https://youtu.be/bzWaMpD1LHY + final navigator = GoRouter.of(context); + final logout = await showAlertDialog( + context: context, + title: 'Are you sure?'.hardcoded, + cancelActionText: 'Cancel'.hardcoded, + defaultActionText: 'Logout'.hardcoded, + ); + if (logout == true) { + // TODO: Sign out the user. + navigator.pop(); + } }, ), ], @@ -38,7 +52,7 @@ class UserDataTable extends StatelessWidget { @override Widget build(BuildContext context) { - final style = Theme.of(context).textTheme.subtitle2!; + final style = Theme.of(context).textTheme.titleSmall!; // TODO: get user from auth repository const user = AppUser(uid: '123', email: 'test@test.com'); return DataTable( diff --git a/ecommerce_app/lib/src/features/address/address_screen.dart b/ecommerce_app/lib/src/features/address/address_screen.dart new file mode 100644 index 00000000..0c0fdcc6 --- /dev/null +++ b/ecommerce_app/lib/src/features/address/address_screen.dart @@ -0,0 +1,163 @@ +import 'package:ecommerce_app/src/localization/string_hardcoded.dart'; +import 'package:flutter/material.dart'; +import 'package:ecommerce_app/src/common_widgets/primary_button.dart'; +import 'package:ecommerce_app/src/common_widgets/responsive_scrollable_card.dart'; +import 'package:ecommerce_app/src/constants/app_sizes.dart'; + +/// A page where the user can enter and submit all the address details. +/// * This page is not yet in use. We will add it to the checkout flows later. +class AddressScreen extends StatefulWidget { + const AddressScreen({Key? key, this.onSubmit}) : super(key: key); + final VoidCallback? onSubmit; + + // * Keys for testing using find.byKey() + static const addressKey = Key('address'); + static const townCityKey = Key('townCity'); + static const stateKey = Key('state'); + static const postalCodeKey = Key('postalCode'); + static const countryKey = Key('country'); + static const scrollableKey = Key('addressPageScrollable'); + + @override + State createState() => _AddressPageState(); +} + +class _AddressPageState extends State { + final _formKey = GlobalKey(); + + final _addressController = TextEditingController(); + final _cityController = TextEditingController(); + final _stateController = TextEditingController(); + final _postalCodeController = TextEditingController(); + final _countryController = TextEditingController(); + + final _isLoading = false; + + @override + void dispose() { + // * TextEditingControllers should be always disposed + _addressController.dispose(); + _cityController.dispose(); + _stateController.dispose(); + _postalCodeController.dispose(); + _countryController.dispose(); + super.dispose(); + } + + Future _submit() async { + if (_formKey.currentState!.validate()) { + FocusScope.of(context).unfocus(); + // TODO: uncomment and submit the address + // final address = Address( + // address: _addressController.value.text, + // city: _cityController.value.text, + // state: _stateController.value.text, + // postalCode: _postalCodeController.value.text, + // country: _countryController.value.text, + // ); + // TODO: Only fire callback if submission is successful + widget.onSubmit?.call(); + } + } + + @override + Widget build(BuildContext context) { + return ResponsiveScrollableCard( + key: AddressScreen.scrollableKey, + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + AddressFormField( + formFieldKey: AddressScreen.addressKey, + controller: _addressController, + labelText: 'Address'.hardcoded, + keyboardType: TextInputType.streetAddress, + enabled: !_isLoading, + ), + gapH8, + AddressFormField( + formFieldKey: AddressScreen.townCityKey, + controller: _cityController, + labelText: 'Town/City'.hardcoded, + keyboardType: TextInputType.streetAddress, + enabled: !_isLoading, + ), + gapH8, + AddressFormField( + formFieldKey: AddressScreen.stateKey, + controller: _stateController, + labelText: 'State'.hardcoded, + keyboardType: TextInputType.streetAddress, + enabled: !_isLoading, + ), + gapH8, + AddressFormField( + formFieldKey: AddressScreen.postalCodeKey, + controller: _postalCodeController, + labelText: 'Postal Code'.hardcoded, + keyboardType: TextInputType.streetAddress, + enabled: !_isLoading, + ), + gapH8, + AddressFormField( + formFieldKey: AddressScreen.countryKey, + controller: _countryController, + labelText: 'Country'.hardcoded, + keyboardType: TextInputType.streetAddress, + enabled: !_isLoading, + ), + gapH8, + PrimaryButton( + text: 'Submit'.hardcoded, + onPressed: _submit, + isLoading: _isLoading, + ) + ], + ), + ), + ); + } +} + +// Reusable address form field +class AddressFormField extends StatelessWidget { + const AddressFormField({ + Key? key, + required this.controller, + required this.labelText, + this.keyboardType, + this.enabled = true, + this.formFieldKey, + }) : super(key: key); + + /// Controller used to read out the value in the parent widget + final TextEditingController controller; + final String labelText; + final TextInputType? keyboardType; + + /// Whether the text field is enabled or not + final bool enabled; + + /// Key used in the widget tests + final Key? formFieldKey; + + @override + Widget build(BuildContext context) { + return TextFormField( + key: formFieldKey, + controller: controller, + decoration: InputDecoration( + labelText: labelText, + enabled: enabled, + ), + autocorrect: true, + textInputAction: TextInputAction.next, + keyboardType: keyboardType, + keyboardAppearance: Brightness.light, + validator: (value) => + value?.isNotEmpty == true ? null : 'Can\'t be empty'.hardcoded, + ); + } +} diff --git a/ecommerce_app/lib/src/features/checkout/checkout_screen.dart b/ecommerce_app/lib/src/features/checkout/checkout_screen.dart index 4393eb47..8414a6ab 100644 --- a/ecommerce_app/lib/src/features/checkout/checkout_screen.dart +++ b/ecommerce_app/lib/src/features/checkout/checkout_screen.dart @@ -5,6 +5,7 @@ import 'package:ecommerce_app/src/features/checkout/payment/payment_page.dart'; import 'package:ecommerce_app/src/features/sign_in/email_password_sign_in_screen.dart'; /// The two sub-routes that are presented as part of the checkout flow. +/// TODO: add the address page as well (see [AddressScreen]). enum CheckoutSubRoute { register, payment } /// This is the root widget of the checkout flow, which is composed of 2 pages: @@ -19,7 +20,7 @@ class CheckoutScreen extends StatefulWidget { const CheckoutScreen({Key? key}) : super(key: key); @override - _CheckoutScreenState createState() => _CheckoutScreenState(); + State createState() => _CheckoutScreenState(); } class _CheckoutScreenState extends State { @@ -27,6 +28,12 @@ class _CheckoutScreenState extends State { var _subRoute = CheckoutSubRoute.register; + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + void _onSignedIn() { setState(() => _subRoute = CheckoutSubRoute.payment); // perform a nice scroll animation to reveal the next page diff --git a/ecommerce_app/lib/src/features/checkout/payment/payment_button.dart b/ecommerce_app/lib/src/features/checkout/payment/payment_button.dart index d5c57025..f99aefc1 100644 --- a/ecommerce_app/lib/src/features/checkout/payment/payment_button.dart +++ b/ecommerce_app/lib/src/features/checkout/payment/payment_button.dart @@ -1,3 +1,4 @@ +import 'package:ecommerce_app/src/common_widgets/alert_dialogs.dart'; import 'package:ecommerce_app/src/localization/string_hardcoded.dart'; import 'package:flutter/material.dart'; import 'package:ecommerce_app/src/common_widgets/primary_button.dart'; @@ -8,9 +9,7 @@ class PaymentButton extends StatelessWidget { Future _pay(BuildContext context) async { // TODO: Implement - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Not implemented')), - ); + showNotImplementedAlertDialog(context: context); } @override diff --git a/ecommerce_app/lib/src/features/home_app_bar/home_app_bar.dart b/ecommerce_app/lib/src/features/home_app_bar/home_app_bar.dart index 3bf37e76..7ca89512 100644 --- a/ecommerce_app/lib/src/features/home_app_bar/home_app_bar.dart +++ b/ecommerce_app/lib/src/features/home_app_bar/home_app_bar.dart @@ -1,14 +1,16 @@ +import 'package:ecommerce_app/src/constants/breakpoints.dart'; import 'package:ecommerce_app/src/features/account/account_screen.dart'; import 'package:ecommerce_app/src/features/orders_list/orders_list_screen.dart'; import 'package:ecommerce_app/src/features/sign_in/email_password_sign_in_screen.dart'; import 'package:ecommerce_app/src/features/sign_in/email_password_sign_in_state.dart'; import 'package:ecommerce_app/src/localization/string_hardcoded.dart'; import 'package:ecommerce_app/src/models/app_user.dart'; +import 'package:ecommerce_app/src/routing/app_router.dart'; import 'package:flutter/material.dart'; import 'package:ecommerce_app/src/common_widgets/action_text_button.dart'; -import 'package:ecommerce_app/src/constants/app_sizes.dart'; import 'package:ecommerce_app/src/features/home_app_bar/more_menu_button.dart'; import 'package:ecommerce_app/src/features/home_app_bar/shopping_cart_icon.dart'; +import 'package:go_router/go_router.dart'; /// Custom [AppBar] widget that is reused by the [ProductsListScreen] and /// [ProductScreen]. @@ -16,7 +18,7 @@ import 'package:ecommerce_app/src/features/home_app_bar/shopping_cart_icon.dart' /// - [ShoppingCartIcon] /// - Orders button /// - Account or Sign-in button -class HomeAppBar extends StatelessWidget with PreferredSizeWidget { +class HomeAppBar extends StatelessWidget implements PreferredSizeWidget { const HomeAppBar({Key? key}) : super(key: key); @override @@ -44,39 +46,22 @@ class HomeAppBar extends StatelessWidget with PreferredSizeWidget { title: Text('My Shop'.hardcoded), actions: [ const ShoppingCartIcon(), + // ignore: unnecessary_null_comparison if (user != null) ...[ ActionTextButton( - key: MoreMenuButton.ordersKey, - text: 'Orders'.hardcoded, - onPressed: () => Navigator.of(context).push( - MaterialPageRoute( - fullscreenDialog: true, - builder: (_) => const OrdersListScreen(), - ), - ), - ), + key: MoreMenuButton.ordersKey, + text: 'Orders'.hardcoded, + onPressed: () => context.goNamed(AppRoute.orders.name)), ActionTextButton( key: MoreMenuButton.accountKey, text: 'Account'.hardcoded, - onPressed: () => Navigator.of(context).push( - MaterialPageRoute( - fullscreenDialog: true, - builder: (_) => const AccountScreen(), - ), - ), + onPressed: () => context.goNamed(AppRoute.accounts.name), ), ] else ActionTextButton( key: MoreMenuButton.signInKey, text: 'Sign In'.hardcoded, - onPressed: () => Navigator.of(context).push( - MaterialPageRoute( - fullscreenDialog: true, - builder: (_) => const EmailPasswordSignInScreen( - formType: EmailPasswordSignInFormType.signIn, - ), - ), - ), + onPressed: () => context.goNamed(AppRoute.signin.name), ) ], ); diff --git a/ecommerce_app/lib/src/features/home_app_bar/more_menu_button.dart b/ecommerce_app/lib/src/features/home_app_bar/more_menu_button.dart index 88ea908e..6cbd90af 100644 --- a/ecommerce_app/lib/src/features/home_app_bar/more_menu_button.dart +++ b/ecommerce_app/lib/src/features/home_app_bar/more_menu_button.dart @@ -3,8 +3,10 @@ import 'package:ecommerce_app/src/features/orders_list/orders_list_screen.dart'; import 'package:ecommerce_app/src/features/sign_in/email_password_sign_in_screen.dart'; import 'package:ecommerce_app/src/features/sign_in/email_password_sign_in_state.dart'; import 'package:ecommerce_app/src/localization/string_hardcoded.dart'; +import 'package:ecommerce_app/src/routing/app_router.dart'; import 'package:flutter/material.dart'; import 'package:ecommerce_app/src/models/app_user.dart'; +import 'package:go_router/go_router.dart'; enum PopupMenuOption { signIn, @@ -16,6 +18,7 @@ class MoreMenuButton extends StatelessWidget { const MoreMenuButton({Key? key, this.user}) : super(key: key); final AppUser? user; + // * Keys for testing using find.byKey() static const signInKey = Key('menuSignIn'); static const ordersKey = Key('menuOrders'); static const accountKey = Key('menuAccount'); @@ -31,20 +34,20 @@ class MoreMenuButton extends StatelessWidget { ? >[ PopupMenuItem( key: ordersKey, - child: Text('Orders'.hardcoded), value: PopupMenuOption.orders, + child: Text('Orders'.hardcoded), ), PopupMenuItem( key: accountKey, - child: Text('Account'.hardcoded), value: PopupMenuOption.account, + child: Text('Account'.hardcoded), ), ] : >[ PopupMenuItem( key: signInKey, - child: Text('Sign In'.hardcoded), value: PopupMenuOption.signIn, + child: Text('Sign In'.hardcoded), ), ]; }, @@ -52,30 +55,15 @@ class MoreMenuButton extends StatelessWidget { // push to different routes based on selected option switch (option) { case PopupMenuOption.signIn: - Navigator.of(context).push( - MaterialPageRoute( - fullscreenDialog: true, - builder: (_) => const EmailPasswordSignInScreen( - formType: EmailPasswordSignInFormType.signIn, - ), - ), - ); + context.goNamed(AppRoute.signin.name); break; case PopupMenuOption.orders: - Navigator.of(context).push( - MaterialPageRoute( - fullscreenDialog: true, - builder: (_) => const OrdersListScreen(), - ), + context.goNamed( + AppRoute.orders.name, ); break; case PopupMenuOption.account: - Navigator.of(context).push( - MaterialPageRoute( - fullscreenDialog: true, - builder: (_) => const AccountScreen(), - ), - ); + context.goNamed(AppRoute.accounts.name); break; } }, diff --git a/ecommerce_app/lib/src/features/home_app_bar/shopping_cart_icon.dart b/ecommerce_app/lib/src/features/home_app_bar/shopping_cart_icon.dart index 7d8b22ce..0495bcbc 100644 --- a/ecommerce_app/lib/src/features/home_app_bar/shopping_cart_icon.dart +++ b/ecommerce_app/lib/src/features/home_app_bar/shopping_cart_icon.dart @@ -1,11 +1,13 @@ -import 'package:ecommerce_app/src/features/shopping_cart/shopping_cart_screen.dart'; +import 'package:ecommerce_app/src/routing/app_router.dart'; import 'package:flutter/material.dart'; import 'package:ecommerce_app/src/constants/app_sizes.dart'; +import 'package:go_router/go_router.dart'; /// Shopping cart icon with items count badge class ShoppingCartIcon extends StatelessWidget { const ShoppingCartIcon({Key? key}) : super(key: key); + // * Keys for testing using find.byKey() static const shoppingCartIconKey = Key('shopping-cart'); @override @@ -18,12 +20,7 @@ class ShoppingCartIcon extends StatelessWidget { child: IconButton( key: shoppingCartIconKey, icon: const Icon(Icons.shopping_cart), - onPressed: () => Navigator.of(context).push( - MaterialPageRoute( - fullscreenDialog: true, - builder: (_) => const ShoppingCartScreen(), - ), - ), + onPressed: () => context.goNamed(AppRoute.cart.name), ), ), if (cartItemsCount > 0) @@ -56,9 +53,13 @@ class ShoppingCartIconBadge extends StatelessWidget { child: Text( '$itemsCount', textAlign: TextAlign.center, + // * Force text scale factor to 1.0 irrespective of the device's + // * textScaleFactor. This is to prevent the text from growing bigger + // * than the available space. + textScaler: const TextScaler.linear(1.0), style: Theme.of(context) .textTheme - .caption! + .bodySmall! .copyWith(color: Colors.white), ), ), diff --git a/ecommerce_app/lib/src/features/leave_review_page/leave_review_screen.dart b/ecommerce_app/lib/src/features/leave_review_page/leave_review_screen.dart index db2cea39..10df17cf 100644 --- a/ecommerce_app/lib/src/features/leave_review_page/leave_review_screen.dart +++ b/ecommerce_app/lib/src/features/leave_review_page/leave_review_screen.dart @@ -1,3 +1,5 @@ +import 'package:ecommerce_app/src/common_widgets/alert_dialogs.dart'; +import 'package:ecommerce_app/src/constants/breakpoints.dart'; import 'package:ecommerce_app/src/localization/string_hardcoded.dart'; import 'package:flutter/material.dart'; import 'package:ecommerce_app/src/common_widgets/responsive_center.dart'; @@ -34,6 +36,7 @@ class LeaveReviewForm extends StatefulWidget { final String productId; final Review? review; + // * Keys for testing using find.byKey() static const reviewCommentKey = Key('reviewComment'); @override @@ -48,24 +51,30 @@ class _LeaveReviewFormState extends State { @override void initState() { super.initState(); - if (widget.review != null) { - _controller.text = widget.review!.comment; - _rating = widget.review!.score; + final review = widget.review; + if (review != null) { + _controller.text = review.comment; + _rating = review.score; } } + @override + void dispose() { + // * TextEditingControllers should be always disposed + _controller.dispose(); + super.dispose(); + } + Future _submitReview() async { + await showNotImplementedAlertDialog(context: context); // only submit if new rating or different from before - final previousReview = widget.review; - if (previousReview == null || - _rating != previousReview.score || - _controller.text != previousReview.comment) { - // TODO: Submit review - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Not implemented')), - ); - } - Navigator.of(context).pop(); + // final previousReview = widget.review; + // if (previousReview == null || + // _rating != previousReview.score || + // _controller.text != previousReview.comment) { + // // TODO: Submit review + // } + // Navigator.of(context).pop(); } @override @@ -75,8 +84,7 @@ class _LeaveReviewFormState extends State { children: [ if (widget.review != null) ...[ Text( - 'You reviewed this product before. You can edit your review.' - .hardcoded, + 'You reviewed this product before. Submit again to edit.'.hardcoded, textAlign: TextAlign.center, ), gapH24, diff --git a/ecommerce_app/lib/src/features/not_found/empty_placeholder_widget.dart b/ecommerce_app/lib/src/features/not_found/empty_placeholder_widget.dart index 9fe4462f..336ccb94 100644 --- a/ecommerce_app/lib/src/features/not_found/empty_placeholder_widget.dart +++ b/ecommerce_app/lib/src/features/not_found/empty_placeholder_widget.dart @@ -1,7 +1,9 @@ import 'package:ecommerce_app/src/localization/string_hardcoded.dart'; +import 'package:ecommerce_app/src/routing/app_router.dart'; import 'package:flutter/material.dart'; import 'package:ecommerce_app/src/common_widgets/primary_button.dart'; import 'package:ecommerce_app/src/constants/app_sizes.dart'; +import 'package:go_router/go_router.dart'; /// Placeholder widget showing a message and CTA to go back to the home screen. class EmptyPlaceholderWidget extends StatelessWidget { @@ -20,14 +22,13 @@ class EmptyPlaceholderWidget extends StatelessWidget { children: [ Text( message, - style: Theme.of(context).textTheme.headline4, + style: Theme.of(context).textTheme.headlineMedium, textAlign: TextAlign.center, ), gapH32, PrimaryButton( - onPressed: () => - Navigator.of(context).popUntil((route) => route.isFirst), - text: 'Go Home'.hardcoded, + onPressed: () => context.goNamed(AppRoute.home.name), + text: 'Go to home'.hardcoded, ) ], ), diff --git a/ecommerce_app/lib/src/features/orders_list/order_card.dart b/ecommerce_app/lib/src/features/orders_list/order_card.dart index a3f5c446..cb114d78 100644 --- a/ecommerce_app/lib/src/features/orders_list/order_card.dart +++ b/ecommerce_app/lib/src/features/orders_list/order_card.dart @@ -58,7 +58,7 @@ class OrderHeader extends StatelessWidget { children: [ Text( 'Order placed'.hardcoded.toUpperCase(), - style: Theme.of(context).textTheme.caption, + style: Theme.of(context).textTheme.bodySmall, ), gapH4, Text(dateFormatted), @@ -70,7 +70,7 @@ class OrderHeader extends StatelessWidget { Text( 'Total'.hardcoded.toUpperCase(), textAlign: TextAlign.end, - style: Theme.of(context).textTheme.caption, + style: Theme.of(context).textTheme.bodySmall, ), gapH4, Text(totalFormatted), diff --git a/ecommerce_app/lib/src/features/orders_list/order_item_list_tile.dart b/ecommerce_app/lib/src/features/orders_list/order_item_list_tile.dart index c214586a..500ce7c0 100644 --- a/ecommerce_app/lib/src/features/orders_list/order_item_list_tile.dart +++ b/ecommerce_app/lib/src/features/orders_list/order_item_list_tile.dart @@ -33,7 +33,7 @@ class OrderItemListTile extends StatelessWidget { gapH12, Text( 'Quantity: ${item.quantity}'.hardcoded, - style: Theme.of(context).textTheme.caption, + style: Theme.of(context).textTheme.bodySmall, ), ], ), diff --git a/ecommerce_app/lib/src/features/orders_list/order_status_label.dart b/ecommerce_app/lib/src/features/orders_list/order_status_label.dart index c166cfd2..9ee9c21e 100644 --- a/ecommerce_app/lib/src/features/orders_list/order_status_label.dart +++ b/ecommerce_app/lib/src/features/orders_list/order_status_label.dart @@ -9,7 +9,7 @@ class OrderStatusLabel extends StatelessWidget { @override Widget build(BuildContext context) { - final textStyle = Theme.of(context).textTheme.bodyText1!; + final textStyle = Theme.of(context).textTheme.bodyLarge!; switch (order.orderStatus) { case OrderStatus.confirmed: return Text( diff --git a/ecommerce_app/lib/src/features/orders_list/orders_list_screen.dart b/ecommerce_app/lib/src/features/orders_list/orders_list_screen.dart index bca92ee0..bd4a08dc 100644 --- a/ecommerce_app/lib/src/features/orders_list/orders_list_screen.dart +++ b/ecommerce_app/lib/src/features/orders_list/orders_list_screen.dart @@ -34,7 +34,7 @@ class OrdersListScreen extends StatelessWidget { ? Center( child: Text( 'No previous orders'.hardcoded, - style: Theme.of(context).textTheme.headline3, + style: Theme.of(context).textTheme.displaySmall, textAlign: TextAlign.center, ), ) diff --git a/ecommerce_app/lib/src/features/product_page/add_to_cart/add_to_cart_widget.dart b/ecommerce_app/lib/src/features/product_page/add_to_cart/add_to_cart_widget.dart index 4d960255..02de9351 100644 --- a/ecommerce_app/lib/src/features/product_page/add_to_cart/add_to_cart_widget.dart +++ b/ecommerce_app/lib/src/features/product_page/add_to_cart/add_to_cart_widget.dart @@ -1,5 +1,6 @@ import 'dart:math'; +import 'package:ecommerce_app/src/common_widgets/alert_dialogs.dart'; import 'package:ecommerce_app/src/localization/string_hardcoded.dart'; import 'package:flutter/material.dart'; import 'package:ecommerce_app/src/common_widgets/item_quantity_selector.dart'; @@ -33,9 +34,7 @@ class AddToCartWidget extends StatelessWidget { maxQuantity: min(availableQuantity, 10), // TODO: Implement onChanged onChanged: (value) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Not implemented')), - ); + showNotImplementedAlertDialog(context: context); }, ), ], @@ -48,9 +47,7 @@ class AddToCartWidget extends StatelessWidget { isLoading: false, // TODO: Implement onPressed onPressed: () { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Not implemented')), - ); + showNotImplementedAlertDialog(context: context); }, text: availableQuantity > 0 ? 'Add to Cart'.hardcoded @@ -60,7 +57,7 @@ class AddToCartWidget extends StatelessWidget { gapH8, Text( 'Already added to cart'.hardcoded, - style: Theme.of(context).textTheme.caption, + style: Theme.of(context).textTheme.bodySmall, textAlign: TextAlign.center, ), ] diff --git a/ecommerce_app/lib/src/features/product_page/leave_review_action.dart b/ecommerce_app/lib/src/features/product_page/leave_review_action.dart index 432ab0a1..b62b4d94 100644 --- a/ecommerce_app/lib/src/features/product_page/leave_review_action.dart +++ b/ecommerce_app/lib/src/features/product_page/leave_review_action.dart @@ -1,11 +1,13 @@ import 'package:ecommerce_app/src/features/leave_review_page/leave_review_screen.dart'; import 'package:ecommerce_app/src/localization/string_hardcoded.dart'; import 'package:ecommerce_app/src/models/purchase.dart'; +import 'package:ecommerce_app/src/routing/app_router.dart'; import 'package:ecommerce_app/src/utils/date_formatter.dart'; import 'package:flutter/material.dart'; import 'package:ecommerce_app/src/common_widgets/custom_text_button.dart'; import 'package:ecommerce_app/src/common_widgets/responsive_two_column_layout.dart'; import 'package:ecommerce_app/src/constants/app_sizes.dart'; +import 'package:go_router/go_router.dart'; /// Simple widget to show the product purchase date along with a button to /// leave a review. @@ -18,6 +20,7 @@ class LeaveReviewAction extends StatelessWidget { Widget build(BuildContext context) { // TODO: Read from data source final purchase = Purchase(orderId: 'abc', orderDate: DateTime.now()); + // ignore: unnecessary_null_comparison if (purchase != null) { // TODO: Inject date formatter final dateFormatted = kDateFormatter.format(purchase.orderDate); @@ -38,13 +41,11 @@ class LeaveReviewAction extends StatelessWidget { text: 'Leave a review'.hardcoded, style: Theme.of(context) .textTheme - .bodyText1! + .bodyLarge! .copyWith(color: Colors.green[700]), - onPressed: () => Navigator.of(context).push( - MaterialPageRoute( - fullscreenDialog: true, - builder: (_) => LeaveReviewScreen(productId: productId), - ), + onPressed: () => context.goNamed( + AppRoute.leaveReview.name, + pathParameters: {'id': productId}, ), ), ), diff --git a/ecommerce_app/lib/src/features/product_page/product_average_rating.dart b/ecommerce_app/lib/src/features/product_page/product_average_rating.dart index a6d615ef..e0a1f404 100644 --- a/ecommerce_app/lib/src/features/product_page/product_average_rating.dart +++ b/ecommerce_app/lib/src/features/product_page/product_average_rating.dart @@ -19,14 +19,14 @@ class ProductAverageRating extends StatelessWidget { gapW8, Text( product.avgRating.toStringAsFixed(1), - style: Theme.of(context).textTheme.bodyText1, + style: Theme.of(context).textTheme.bodyLarge, ), gapW8, Text( product.numRatings == 1 ? '1 rating' : '${product.numRatings} ratings', - style: Theme.of(context).textTheme.bodyText2, + style: Theme.of(context).textTheme.bodyMedium, ), ], ); diff --git a/ecommerce_app/lib/src/features/product_page/product_reviews/product_rating_bar.dart b/ecommerce_app/lib/src/features/product_page/product_reviews/product_rating_bar.dart index 30e8f433..a903d19d 100644 --- a/ecommerce_app/lib/src/features/product_page/product_reviews/product_rating_bar.dart +++ b/ecommerce_app/lib/src/features/product_page/product_reviews/product_rating_bar.dart @@ -23,6 +23,9 @@ class ProductRatingBar extends StatelessWidget { /// callback called when the rating changes final ValueChanged onRatingUpdate; + // * Keys for testing using find.byKey() + static Key starRatingKey(int index) => Key('stars-$index'); + @override Widget build(BuildContext context) { return RatingBar.builder( @@ -33,7 +36,8 @@ class ProductRatingBar extends StatelessWidget { itemSize: itemSize, itemBuilder: (context, index) => Icon( Icons.star, - key: Key('stars-$index'), + // * Keys for testing using find.byKey() + key: starRatingKey(index), color: Colors.amber, ), onRatingUpdate: onRatingUpdate, diff --git a/ecommerce_app/lib/src/features/product_page/product_reviews/product_review_card.dart b/ecommerce_app/lib/src/features/product_page/product_reviews/product_review_card.dart index cd55eed6..743fe691 100644 --- a/ecommerce_app/lib/src/features/product_page/product_reviews/product_review_card.dart +++ b/ecommerce_app/lib/src/features/product_page/product_reviews/product_review_card.dart @@ -1,3 +1,4 @@ +import 'package:ecommerce_app/src/common_widgets/alert_dialogs.dart'; import 'package:flutter/material.dart'; import 'package:ecommerce_app/src/constants/app_sizes.dart'; import 'package:ecommerce_app/src/features/product_page/product_reviews/product_rating_bar.dart'; @@ -27,19 +28,18 @@ class ProductReviewCard extends StatelessWidget { itemSize: 20, // TODO: Implement onRatingUpdate onRatingUpdate: (value) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Not implemented')), - ); + showNotImplementedAlertDialog(context: context); }, ), - Text(dateFormatted, style: Theme.of(context).textTheme.caption), + Text(dateFormatted, + style: Theme.of(context).textTheme.bodySmall), ], ), if (review.comment.isNotEmpty) ...[ gapH16, Text( review.comment, - style: Theme.of(context).textTheme.caption, + style: Theme.of(context).textTheme.bodySmall, ) ], ], diff --git a/ecommerce_app/lib/src/features/product_page/product_reviews/product_reviews_list.dart b/ecommerce_app/lib/src/features/product_page/product_reviews/product_reviews_list.dart index 89b8e434..1d1df90c 100644 --- a/ecommerce_app/lib/src/features/product_page/product_reviews/product_reviews_list.dart +++ b/ecommerce_app/lib/src/features/product_page/product_reviews/product_reviews_list.dart @@ -1,3 +1,4 @@ +import 'package:ecommerce_app/src/constants/breakpoints.dart'; import 'package:flutter/material.dart'; import 'package:ecommerce_app/src/common_widgets/responsive_center.dart'; import 'package:ecommerce_app/src/constants/app_sizes.dart'; @@ -12,7 +13,18 @@ class ProductReviewsList extends StatelessWidget { @override Widget build(BuildContext context) { // TODO: Read from data source - final reviews = []; + final reviews = [ + Review( + date: DateTime(2022, 2, 12), + score: 4.5, + comment: 'Great product, would buy again!', + ), + Review( + date: DateTime(2022, 2, 10), + score: 4.0, + comment: 'Looks great but the packaging was damaged.', + ), + ]; return SliverList( delegate: SliverChildBuilderDelegate( (BuildContext context, int index) => ResponsiveCenter( diff --git a/ecommerce_app/lib/src/features/product_page/product_screen.dart b/ecommerce_app/lib/src/features/product_page/product_screen.dart index 3375c9f2..4c2b1edf 100644 --- a/ecommerce_app/lib/src/features/product_page/product_screen.dart +++ b/ecommerce_app/lib/src/features/product_page/product_screen.dart @@ -26,6 +26,7 @@ class ProductScreen extends StatelessWidget { kTestProducts.firstWhere((product) => product.id == productId); return Scaffold( appBar: const HomeAppBar(), + // ignore: unnecessary_null_comparison body: product == null ? EmptyPlaceholderWidget( message: 'Product not found'.hardcoded, @@ -67,7 +68,8 @@ class ProductDetails extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text(product.title, style: Theme.of(context).textTheme.headline6), + Text(product.title, + style: Theme.of(context).textTheme.titleLarge), gapH8, Text(product.description), // Only show average if there is at least one rating @@ -79,7 +81,7 @@ class ProductDetails extends StatelessWidget { const Divider(), gapH8, Text(priceFormatted, - style: Theme.of(context).textTheme.headline5), + style: Theme.of(context).textTheme.headlineSmall), gapH8, LeaveReviewAction(productId: product.id), const Divider(), diff --git a/ecommerce_app/lib/src/features/products_list/product_card.dart b/ecommerce_app/lib/src/features/products_list/product_card.dart index 92e7ce14..13e2ac1c 100644 --- a/ecommerce_app/lib/src/features/products_list/product_card.dart +++ b/ecommerce_app/lib/src/features/products_list/product_card.dart @@ -13,6 +13,7 @@ class ProductCard extends StatelessWidget { final Product product; final VoidCallback? onPressed; + // * Keys for testing using find.byKey() static const productCardKey = Key('product-card'); @override @@ -32,20 +33,21 @@ class ProductCard extends StatelessWidget { gapH8, const Divider(), gapH8, - Text(product.title, style: Theme.of(context).textTheme.headline6), + Text(product.title, + style: Theme.of(context).textTheme.titleLarge), if (product.numRatings >= 1) ...[ gapH8, ProductAverageRating(product: product), ], gapH24, Text(priceFormatted, - style: Theme.of(context).textTheme.headline5), + style: Theme.of(context).textTheme.headlineSmall), gapH4, Text( product.availableQuantity <= 0 ? 'Out of Stock'.hardcoded : 'Quantity: ${product.availableQuantity}'.hardcoded, - style: Theme.of(context).textTheme.caption, + style: Theme.of(context).textTheme.bodySmall, ) ], ), diff --git a/ecommerce_app/lib/src/features/products_list/products_grid.dart b/ecommerce_app/lib/src/features/products_list/products_grid.dart index 787b3a60..481b6de1 100644 --- a/ecommerce_app/lib/src/features/products_list/products_grid.dart +++ b/ecommerce_app/lib/src/features/products_list/products_grid.dart @@ -3,10 +3,12 @@ import 'dart:math'; import 'package:ecommerce_app/src/constants/test_products.dart'; import 'package:ecommerce_app/src/features/product_page/product_screen.dart'; import 'package:ecommerce_app/src/localization/string_hardcoded.dart'; +import 'package:ecommerce_app/src/routing/app_router.dart'; import 'package:flutter/material.dart'; import 'package:flutter_layout_grid/flutter_layout_grid.dart'; import 'package:ecommerce_app/src/constants/app_sizes.dart'; import 'package:ecommerce_app/src/features/products_list/product_card.dart'; +import 'package:go_router/go_router.dart'; /// A widget that displays the list of products that match the search query. class ProductsGrid extends StatelessWidget { @@ -20,7 +22,7 @@ class ProductsGrid extends StatelessWidget { ? Center( child: Text( 'No products found'.hardcoded, - style: Theme.of(context).textTheme.headline4, + style: Theme.of(context).textTheme.headlineMedium, ), ) : ProductsLayoutGrid( @@ -29,11 +31,11 @@ class ProductsGrid extends StatelessWidget { final product = products[index]; return ProductCard( product: product, - onPressed: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => ProductScreen(productId: product.id), - ), - ), + onPressed: () => + context.goNamed(AppRoute.product.name, pathParameters: { + 'id': product.id, + }), + // onPressed: () => context.push('/product/${product.id}'), // IGNORE ); }, ); diff --git a/ecommerce_app/lib/src/features/products_list/products_list_screen.dart b/ecommerce_app/lib/src/features/products_list/products_list_screen.dart index a4f2dd39..e89f0dc9 100644 --- a/ecommerce_app/lib/src/features/products_list/products_list_screen.dart +++ b/ecommerce_app/lib/src/features/products_list/products_list_screen.dart @@ -50,10 +50,6 @@ class _ProductsListScreenState extends State { ResponsiveSliverCenter( padding: EdgeInsets.all(Sizes.p16), child: ProductsSearchTextField(), - // child: Text( - // 'Latest Products'.hardcoded, - // style: Theme.of(context).textTheme.headline4, - // ), ), ResponsiveSliverCenter( padding: EdgeInsets.all(Sizes.p16), diff --git a/ecommerce_app/lib/src/features/products_list/products_search_text_field.dart b/ecommerce_app/lib/src/features/products_list/products_search_text_field.dart index 309186e9..bb6766b1 100644 --- a/ecommerce_app/lib/src/features/products_list/products_search_text_field.dart +++ b/ecommerce_app/lib/src/features/products_list/products_search_text_field.dart @@ -13,6 +13,13 @@ class ProductsSearchTextField extends StatefulWidget { class _ProductsSearchTextFieldState extends State { final _controller = TextEditingController(); + @override + void dispose() { + // * TextEditingControllers should be always disposed + _controller.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { // See this article for more info about how to use [ValueListenableBuilder] @@ -24,7 +31,7 @@ class _ProductsSearchTextFieldState extends State { return TextField( controller: _controller, autofocus: false, - style: Theme.of(context).textTheme.headline6, + style: Theme.of(context).textTheme.titleLarge, decoration: InputDecoration( hintText: 'Search products'.hardcoded, icon: const Icon(Icons.search), diff --git a/ecommerce_app/lib/src/features/shopping_cart/shopping_cart_item.dart b/ecommerce_app/lib/src/features/shopping_cart/shopping_cart_item.dart index a877457a..165f1e2f 100644 --- a/ecommerce_app/lib/src/features/shopping_cart/shopping_cart_item.dart +++ b/ecommerce_app/lib/src/features/shopping_cart/shopping_cart_item.dart @@ -1,5 +1,6 @@ import 'dart:math'; +import 'package:ecommerce_app/src/common_widgets/alert_dialogs.dart'; import 'package:ecommerce_app/src/constants/test_products.dart'; import 'package:ecommerce_app/src/localization/string_hardcoded.dart'; import 'package:flutter/material.dart'; @@ -63,6 +64,7 @@ class ShoppingCartItemContents extends StatelessWidget { final int itemIndex; final bool isEditable; + // * Keys for testing using find.byKey() static Key deleteKey(int index) => Key('delete-$index'); @override @@ -79,9 +81,10 @@ class ShoppingCartItemContents extends StatelessWidget { endContent: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text(product.title, style: Theme.of(context).textTheme.headline5), + Text(product.title, style: Theme.of(context).textTheme.headlineSmall), gapH24, - Text(priceFormatted, style: Theme.of(context).textTheme.headline5), + Text(priceFormatted, + style: Theme.of(context).textTheme.headlineSmall), gapH24, isEditable // show the quantity selector and a delete button @@ -94,9 +97,7 @@ class ShoppingCartItemContents extends StatelessWidget { itemIndex: itemIndex, // TODO: Implement onChanged onChanged: (value) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Not implemented')), - ); + showNotImplementedAlertDialog(context: context); }, ), IconButton( @@ -104,9 +105,7 @@ class ShoppingCartItemContents extends StatelessWidget { icon: Icon(Icons.delete, color: Colors.red[700]), // TODO: Implement onPressed onPressed: () { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Not implemented')), - ); + showNotImplementedAlertDialog(context: context); }, ), const Spacer(), diff --git a/ecommerce_app/lib/src/features/shopping_cart/shopping_cart_items_builder.dart b/ecommerce_app/lib/src/features/shopping_cart/shopping_cart_items_builder.dart index 7be7766a..212429ba 100644 --- a/ecommerce_app/lib/src/features/shopping_cart/shopping_cart_items_builder.dart +++ b/ecommerce_app/lib/src/features/shopping_cart/shopping_cart_items_builder.dart @@ -1,3 +1,4 @@ +import 'package:ecommerce_app/src/constants/breakpoints.dart'; import 'package:ecommerce_app/src/localization/string_hardcoded.dart'; import 'package:flutter/material.dart'; import 'package:ecommerce_app/src/common_widgets/cart_total_with_cta.dart'; diff --git a/ecommerce_app/lib/src/features/shopping_cart/shopping_cart_screen.dart b/ecommerce_app/lib/src/features/shopping_cart/shopping_cart_screen.dart index 2c6e944a..48da0048 100644 --- a/ecommerce_app/lib/src/features/shopping_cart/shopping_cart_screen.dart +++ b/ecommerce_app/lib/src/features/shopping_cart/shopping_cart_screen.dart @@ -1,10 +1,12 @@ import 'package:ecommerce_app/src/features/checkout/checkout_screen.dart'; import 'package:ecommerce_app/src/localization/string_hardcoded.dart'; import 'package:ecommerce_app/src/models/item.dart'; +import 'package:ecommerce_app/src/routing/app_router.dart'; import 'package:flutter/material.dart'; import 'package:ecommerce_app/src/common_widgets/primary_button.dart'; import 'package:ecommerce_app/src/features/shopping_cart/shopping_cart_item.dart'; import 'package:ecommerce_app/src/features/shopping_cart/shopping_cart_items_builder.dart'; +import 'package:go_router/go_router.dart'; /// Shopping cart screen showing the items in the cart (with editable /// quantities) and a button to checkout. @@ -40,12 +42,7 @@ class ShoppingCartScreen extends StatelessWidget { ), ctaBuilder: (_) => PrimaryButton( text: 'Checkout'.hardcoded, - onPressed: () => Navigator.of(context).push( - MaterialPageRoute( - fullscreenDialog: true, - builder: (_) => const CheckoutScreen(), - ), - ), + onPressed: () => context.goNamed(AppRoute.checkout.name), ), ), ); diff --git a/ecommerce_app/lib/src/features/sign_in/email_password_sign_in_screen.dart b/ecommerce_app/lib/src/features/sign_in/email_password_sign_in_screen.dart index eabd7d59..7c532388 100644 --- a/ecommerce_app/lib/src/features/sign_in/email_password_sign_in_screen.dart +++ b/ecommerce_app/lib/src/features/sign_in/email_password_sign_in_screen.dart @@ -16,6 +16,7 @@ class EmailPasswordSignInScreen extends StatelessWidget { : super(key: key); final EmailPasswordSignInFormType formType; + // * Keys for testing using find.byKey() static const emailKey = Key('email'); static const passwordKey = Key('password'); @@ -70,6 +71,7 @@ class _EmailPasswordSignInContentsState @override void dispose() { + // * TextEditingControllers should be always disposed _node.dispose(); _emailController.dispose(); _passwordController.dispose(); diff --git a/ecommerce_app/lib/src/localization/app_localizations.dart b/ecommerce_app/lib/src/localization/app_localizations.dart new file mode 100644 index 00000000..800c1008 --- /dev/null +++ b/ecommerce_app/lib/src/localization/app_localizations.dart @@ -0,0 +1,817 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:intl/intl.dart' as intl; + +import 'app_localizations_en.dart'; + +// ignore_for_file: type=lint + +/// Callers can lookup localized strings with an instance of AppLocalizations +/// returned by `AppLocalizations.of(context)`. +/// +/// Applications need to include `AppLocalizations.delegate()` in their app's +/// `localizationDelegates` list, and the locales they support in the app's +/// `supportedLocales` list. For example: +/// +/// ```dart +/// import 'localization/app_localizations.dart'; +/// +/// return MaterialApp( +/// localizationsDelegates: AppLocalizations.localizationsDelegates, +/// supportedLocales: AppLocalizations.supportedLocales, +/// home: MyApplicationHome(), +/// ); +/// ``` +/// +/// ## Update pubspec.yaml +/// +/// Please make sure to update your pubspec.yaml to include the following +/// packages: +/// +/// ```yaml +/// dependencies: +/// # Internationalization support. +/// flutter_localizations: +/// sdk: flutter +/// intl: any # Use the pinned version from flutter_localizations +/// +/// # Rest of dependencies +/// ``` +/// +/// ## iOS Applications +/// +/// iOS applications define key application metadata, including supported +/// locales, in an Info.plist file that is built into the application bundle. +/// To configure the locales supported by your app, you’ll need to edit this +/// file. +/// +/// First, open your project’s ios/Runner.xcworkspace Xcode workspace file. +/// Then, in the Project Navigator, open the Info.plist file under the Runner +/// project’s Runner folder. +/// +/// Next, select the Information Property List item, select Add Item from the +/// Editor menu, then select Localizations from the pop-up menu. +/// +/// Select and expand the newly-created Localizations item then, for each +/// locale your application supports, add a new item and select the locale +/// you wish to add from the pop-up menu in the Value field. This list should +/// be consistent with the languages listed in the AppLocalizations.supportedLocales +/// property. +abstract class AppLocalizations { + AppLocalizations(String locale) + : localeName = intl.Intl.canonicalizedLocale(locale.toString()); + + final String localeName; + + static AppLocalizations? of(BuildContext context) { + return Localizations.of(context, AppLocalizations); + } + + static const LocalizationsDelegate delegate = + _AppLocalizationsDelegate(); + + /// A list of this localizations delegate along with the default localizations + /// delegates. + /// + /// Returns a list of localizations delegates containing this delegate along with + /// GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate, + /// and GlobalWidgetsLocalizations.delegate. + /// + /// Additional delegates can be added by appending to this list in + /// MaterialApp. This list does not have to be used at all if a custom list + /// of delegates is preferred or required. + static const List> localizationsDelegates = + >[ + delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ]; + + /// A list of this localizations delegate's supported locales. + static const List supportedLocales = [Locale('en')]; + + /// No description provided for @appTitle. + /// + /// In en, this message translates to: + /// **'My Shop'** + String get appTitle; + + /// No description provided for @previewNotice. + /// + /// In en, this message translates to: + /// **'This is a preview of the final app that you\'ll build in the course. More features will be added before the course is complete.'** + String get previewNotice; + + /// The title to be shown in the menu bar + /// + /// In en, this message translates to: + /// **'My Shop'** + String get appBarTitle; + + /// No description provided for @ok. + /// + /// In en, this message translates to: + /// **'OK'** + String get ok; + + /// No description provided for @anErrorOccurred. + /// + /// In en, this message translates to: + /// **'An error occurred'** + String get anErrorOccurred; + + /// No description provided for @orders. + /// + /// In en, this message translates to: + /// **'Orders'** + String get orders; + + /// No description provided for @account. + /// + /// In en, this message translates to: + /// **'Account'** + String get account; + + /// No description provided for @admin. + /// + /// In en, this message translates to: + /// **'Admin'** + String get admin; + + /// Title of the sign in page + /// + /// In en, this message translates to: + /// **'Sign In'** + String get signIn; + + /// No description provided for @register. + /// + /// In en, this message translates to: + /// **'Register'** + String get register; + + /// No description provided for @forgotPassword. + /// + /// In en, this message translates to: + /// **'Forgot password'** + String get forgotPassword; + + /// No description provided for @forgotPasswordQuestion. + /// + /// In en, this message translates to: + /// **'Forgot password?'** + String get forgotPasswordQuestion; + + /// No description provided for @createAnAccount. + /// + /// In en, this message translates to: + /// **'Create an account'** + String get createAnAccount; + + /// No description provided for @needAnAccount. + /// + /// In en, this message translates to: + /// **'Need an account? Register'** + String get needAnAccount; + + /// No description provided for @haveAnAccount. + /// + /// In en, this message translates to: + /// **'Have an account? Sign in'** + String get haveAnAccount; + + /// No description provided for @signInFailed. + /// + /// In en, this message translates to: + /// **'Sign in failed'** + String get signInFailed; + + /// No description provided for @registrationFailed. + /// + /// In en, this message translates to: + /// **'Registration failed'** + String get registrationFailed; + + /// No description provided for @passwordResetFailed. + /// + /// In en, this message translates to: + /// **'Password reset failed'** + String get passwordResetFailed; + + /// No description provided for @sendResetLink. + /// + /// In en, this message translates to: + /// **'Send reset link'** + String get sendResetLink; + + /// No description provided for @backToSignIn. + /// + /// In en, this message translates to: + /// **'Back to sign in'** + String get backToSignIn; + + /// No description provided for @resetLinkSentTitle. + /// + /// In en, this message translates to: + /// **'Reset link sent'** + String get resetLinkSentTitle; + + /// No description provided for @resetLinkSentMessage. + /// + /// In en, this message translates to: + /// **'Check your email to reset your password'** + String get resetLinkSentMessage; + + /// No description provided for @emailLabel. + /// + /// In en, this message translates to: + /// **'Email'** + String get emailLabel; + + /// No description provided for @emailHint. + /// + /// In en, this message translates to: + /// **'test@test.com'** + String get emailHint; + + /// No description provided for @password8CharactersLabel. + /// + /// In en, this message translates to: + /// **'Password (8+ characters)'** + String get password8CharactersLabel; + + /// No description provided for @passwordLabel. + /// + /// In en, this message translates to: + /// **'Password'** + String get passwordLabel; + + /// No description provided for @invalidEmailEmpty. + /// + /// In en, this message translates to: + /// **'Email can\'t be empty'** + String get invalidEmailEmpty; + + /// No description provided for @invalidPasswordTooShort. + /// + /// In en, this message translates to: + /// **'Password is too short'** + String get invalidPasswordTooShort; + + /// No description provided for @invalidPasswordEmpty. + /// + /// In en, this message translates to: + /// **'Password can\'t be empty'** + String get invalidPasswordEmpty; + + /// No description provided for @logout. + /// + /// In en, this message translates to: + /// **'Logout'** + String get logout; + + /// No description provided for @submit. + /// + /// In en, this message translates to: + /// **'Submit'** + String get submit; + + /// No description provided for @invalidEmail. + /// + /// In en, this message translates to: + /// **'Invalid email'** + String get invalidEmail; + + /// No description provided for @emailAlreadyInUse. + /// + /// In en, this message translates to: + /// **'Email already in use'** + String get emailAlreadyInUse; + + /// No description provided for @weakPassword. + /// + /// In en, this message translates to: + /// **'Password is too weak'** + String get weakPassword; + + /// No description provided for @operationNotAllowed. + /// + /// In en, this message translates to: + /// **'Operation not allowed'** + String get operationNotAllowed; + + /// No description provided for @wrongPassword. + /// + /// In en, this message translates to: + /// **'Wrong password'** + String get wrongPassword; + + /// No description provided for @userNotFound. + /// + /// In en, this message translates to: + /// **'User not found'** + String get userNotFound; + + /// No description provided for @userDisabled. + /// + /// In en, this message translates to: + /// **'User disabled'** + String get userDisabled; + + /// No description provided for @tooManyAuthRequests. + /// + /// In en, this message translates to: + /// **'This account has been locked due to too many failed login attempts. Please try again later.'** + String get tooManyAuthRequests; + + /// No description provided for @permissionDenied. + /// + /// In en, this message translates to: + /// **'Permission denied'** + String get permissionDenied; + + /// When the product was purchased + /// + /// In en, this message translates to: + /// **'Purchased on {date}'** + String purchasedOnDate(String date); + + /// No description provided for @leaveReview. + /// + /// In en, this message translates to: + /// **'Leave a review'** + String get leaveReview; + + /// No description provided for @yourReviewHint. + /// + /// In en, this message translates to: + /// **'Your review (optional)'** + String get yourReviewHint; + + /// No description provided for @previouslyReviewedHint. + /// + /// In en, this message translates to: + /// **'You reviewed this product before. You can edit your review.'** + String get previouslyReviewedHint; + + /// The number of ratings + /// + /// In en, this message translates to: + /// **'({count, plural, =1{1 rating} other{{count} ratings}})'** + String numRatings(int count); + + /// Title of the address page + /// + /// In en, this message translates to: + /// **'Address'** + String get address; + + /// No description provided for @townCity. + /// + /// In en, this message translates to: + /// **'Town/City'** + String get townCity; + + /// No description provided for @state. + /// + /// In en, this message translates to: + /// **'State'** + String get state; + + /// No description provided for @postalCode. + /// + /// In en, this message translates to: + /// **'Postal Code'** + String get postalCode; + + /// No description provided for @country. + /// + /// In en, this message translates to: + /// **'Country'** + String get country; + + /// Message for product fields that can't be empty + /// + /// In en, this message translates to: + /// **'Can\'t be empty'** + String get cantBeEmpty; + + /// No description provided for @invalidUrl. + /// + /// In en, this message translates to: + /// **'Not a valid URL'** + String get invalidUrl; + + /// No description provided for @minimumLength20Chars. + /// + /// In en, this message translates to: + /// **'Minimum length: 20 characters'** + String get minimumLength20Chars; + + /// No description provided for @invalidNumber. + /// + /// In en, this message translates to: + /// **'Not a valid number'** + String get invalidNumber; + + /// No description provided for @priceGreaterThanZero. + /// + /// In en, this message translates to: + /// **'Price must be greater than zero'** + String get priceGreaterThanZero; + + /// No description provided for @priceLessThanMax. + /// + /// In en, this message translates to: + /// **'The maximum price must be less than \$100,000'** + String get priceLessThanMax; + + /// No description provided for @quantityGreaterThanZero. + /// + /// In en, this message translates to: + /// **'Quantity must be zero or more'** + String get quantityGreaterThanZero; + + /// No description provided for @quantityLessThanMax. + /// + /// In en, this message translates to: + /// **'The maximum quantity must be less than 1,000'** + String get quantityLessThanMax; + + /// Field label used in account page + /// + /// In en, this message translates to: + /// **'Field'** + String get field; + + /// No description provided for @value. + /// + /// In en, this message translates to: + /// **'Value'** + String get value; + + /// No description provided for @uidLowercase. + /// + /// In en, this message translates to: + /// **'uid'** + String get uidLowercase; + + /// No description provided for @emailLowercase. + /// + /// In en, this message translates to: + /// **'email'** + String get emailLowercase; + + /// No description provided for @isAdminLowercase. + /// + /// In en, this message translates to: + /// **'isAdmin'** + String get isAdminLowercase; + + /// Admin Dashboard page title + /// + /// In en, this message translates to: + /// **'Admin Dashboard'** + String get adminDashboard; + + /// No description provided for @manageOrders. + /// + /// In en, this message translates to: + /// **'Manage Orders'** + String get manageOrders; + + /// No description provided for @userId. + /// + /// In en, this message translates to: + /// **'User ID'** + String get userId; + + /// No description provided for @userEmail. + /// + /// In en, this message translates to: + /// **'User Email'** + String get userEmail; + + /// No description provided for @newProduct. + /// + /// In en, this message translates to: + /// **'New Product'** + String get newProduct; + + /// No description provided for @editProduct. + /// + /// In en, this message translates to: + /// **'Edit Product'** + String get editProduct; + + /// No description provided for @productUpdated. + /// + /// In en, this message translates to: + /// **'Product updated'** + String get productUpdated; + + /// No description provided for @couldNotSaveProduct. + /// + /// In en, this message translates to: + /// **'Could not save product data'** + String get couldNotSaveProduct; + + /// No description provided for @couldNotUpdateOrderStatus. + /// + /// In en, this message translates to: + /// **'Could not update order status'** + String get couldNotUpdateOrderStatus; + + /// No description provided for @imageUrl. + /// + /// In en, this message translates to: + /// **'Image URL'** + String get imageUrl; + + /// No description provided for @title. + /// + /// In en, this message translates to: + /// **'Title'** + String get title; + + /// No description provided for @description. + /// + /// In en, this message translates to: + /// **'Description'** + String get description; + + /// No description provided for @price. + /// + /// In en, this message translates to: + /// **'Price'** + String get price; + + /// No description provided for @availableQuantity. + /// + /// In en, this message translates to: + /// **'Available Quantity'** + String get availableQuantity; + + /// No description provided for @manageProducts. + /// + /// In en, this message translates to: + /// **'Manage Products'** + String get manageProducts; + + /// Payment page title + /// + /// In en, this message translates to: + /// **'Payment'** + String get payment; + + /// No description provided for @cardPayment. + /// + /// In en, this message translates to: + /// **'Card Payment'** + String get cardPayment; + + /// No description provided for @saveCardDuringPayment. + /// + /// In en, this message translates to: + /// **'Save card during payment'** + String get saveCardDuringPayment; + + /// No description provided for @devBuildTestingNotice. + /// + /// In en, this message translates to: + /// **'[DEV build] Use 4242 4242 4242 4242 for testing'** + String get devBuildTestingNotice; + + /// No description provided for @missingAddress. + /// + /// In en, this message translates to: + /// **'The operation can\'t be completed (missing address)'** + String get missingAddress; + + /// No description provided for @userNotSignedIn. + /// + /// In en, this message translates to: + /// **'The operation can\'t be completed (not signed in)'** + String get userNotSignedIn; + + /// No description provided for @orderPlaced. + /// + /// In en, this message translates to: + /// **'Order placed'** + String get orderPlaced; + + /// No description provided for @total. + /// + /// In en, this message translates to: + /// **'Total'** + String get total; + + /// The total value of the shopping cart + /// + /// In en, this message translates to: + /// **'Total: {value}'** + String totalValue(String value); + + /// Title of orders page + /// + /// In en, this message translates to: + /// **'Your Orders'** + String get yourOrders; + + /// No description provided for @noPreviousOrders. + /// + /// In en, this message translates to: + /// **'No previous orders'** + String get noPreviousOrders; + + /// No description provided for @couldNotPlaceOrder. + /// + /// In en, this message translates to: + /// **'Could not place order'** + String get couldNotPlaceOrder; + + /// No description provided for @status. + /// + /// In en, this message translates to: + /// **'Status:'** + String get status; + + /// No description provided for @confirmed. + /// + /// In en, this message translates to: + /// **'Confirmed'** + String get confirmed; + + /// No description provided for @confirmedPreparingDelivery. + /// + /// In en, this message translates to: + /// **'Confirmed - preparing for delivery'** + String get confirmedPreparingDelivery; + + /// No description provided for @shipped. + /// + /// In en, this message translates to: + /// **'Shipped'** + String get shipped; + + /// No description provided for @delivered. + /// + /// In en, this message translates to: + /// **'Delivered'** + String get delivered; + + /// Heading for product listings + /// + /// In en, this message translates to: + /// **'Latest Products'** + String get latestProducts; + + /// No description provided for @searchProducts. + /// + /// In en, this message translates to: + /// **'Search products'** + String get searchProducts; + + /// No description provided for @noProductsFound. + /// + /// In en, this message translates to: + /// **'No products found'** + String get noProductsFound; + + /// No description provided for @productNotFound. + /// + /// In en, this message translates to: + /// **'Product not found'** + String get productNotFound; + + /// No description provided for @quantity. + /// + /// In en, this message translates to: + /// **'Quantity:'** + String get quantity; + + /// The quantity of the product to be added to the cart + /// + /// In en, this message translates to: + /// **'Quantity: {quantity}'** + String quantityValue(int quantity); + + /// The quantity of the product to be added to the cart + /// + /// In en, this message translates to: + /// **'{quantity} available'** + String quantityAvailable(int quantity); + + /// Title of shopping cart page + /// + /// In en, this message translates to: + /// **'Shopping Cart'** + String get shoppingCart; + + /// No description provided for @addToCart. + /// + /// In en, this message translates to: + /// **'Add to Cart'** + String get addToCart; + + /// No description provided for @outOfStock. + /// + /// In en, this message translates to: + /// **'Out of Stock'** + String get outOfStock; + + /// No description provided for @cantAddItemToCart. + /// + /// In en, this message translates to: + /// **'Can\'t add item to cart'** + String get cantAddItemToCart; + + /// No description provided for @cantUpdateQuantity. + /// + /// In en, this message translates to: + /// **'Could not update quantity'** + String get cantUpdateQuantity; + + /// No description provided for @cantDeleteItem. + /// + /// In en, this message translates to: + /// **'Could not delete item'** + String get cantDeleteItem; + + /// No description provided for @alreadyAddedToCart. + /// + /// In en, this message translates to: + /// **'Already added to cart'** + String get alreadyAddedToCart; + + /// No description provided for @shoppingCartEmpty. + /// + /// In en, this message translates to: + /// **'Your shopping cart is empty'** + String get shoppingCartEmpty; + + /// No description provided for @goBack. + /// + /// In en, this message translates to: + /// **'Go Back'** + String get goBack; + + /// No description provided for @checkout. + /// + /// In en, this message translates to: + /// **'Checkout'** + String get checkout; + + /// No description provided for @pay. + /// + /// In en, this message translates to: + /// **'Pay'** + String get pay; + + /// No description provided for @notFound404. + /// + /// In en, this message translates to: + /// **'404 - Page not found!'** + String get notFound404; + + /// No description provided for @goHome. + /// + /// In en, this message translates to: + /// **'Go Home'** + String get goHome; +} + +class _AppLocalizationsDelegate + extends LocalizationsDelegate { + const _AppLocalizationsDelegate(); + + @override + Future load(Locale locale) { + return SynchronousFuture(lookupAppLocalizations(locale)); + } + + @override + bool isSupported(Locale locale) => + ['en'].contains(locale.languageCode); + + @override + bool shouldReload(_AppLocalizationsDelegate old) => false; +} + +AppLocalizations lookupAppLocalizations(Locale locale) { + // Lookup logic when only language code is specified. + switch (locale.languageCode) { + case 'en': + return AppLocalizationsEn(); + } + + throw FlutterError( + 'AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely ' + 'an issue with the localizations generation tool. Please file an issue ' + 'on GitHub with a reproducible sample app and the gen-l10n configuration ' + 'that was used.'); +} diff --git a/ecommerce_app/lib/src/localization/app_localizations_en.dart b/ecommerce_app/lib/src/localization/app_localizations_en.dart new file mode 100644 index 00000000..b387bb1e --- /dev/null +++ b/ecommerce_app/lib/src/localization/app_localizations_en.dart @@ -0,0 +1,379 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for English (`en`). +class AppLocalizationsEn extends AppLocalizations { + AppLocalizationsEn([String locale = 'en']) : super(locale); + + @override + String get appTitle => 'My Shop'; + + @override + String get previewNotice => + 'This is a preview of the final app that you\'ll build in the course. More features will be added before the course is complete.'; + + @override + String get appBarTitle => 'My Shop'; + + @override + String get ok => 'OK'; + + @override + String get anErrorOccurred => 'An error occurred'; + + @override + String get orders => 'Orders'; + + @override + String get account => 'Account'; + + @override + String get admin => 'Admin'; + + @override + String get signIn => 'Sign In'; + + @override + String get register => 'Register'; + + @override + String get forgotPassword => 'Forgot password'; + + @override + String get forgotPasswordQuestion => 'Forgot password?'; + + @override + String get createAnAccount => 'Create an account'; + + @override + String get needAnAccount => 'Need an account? Register'; + + @override + String get haveAnAccount => 'Have an account? Sign in'; + + @override + String get signInFailed => 'Sign in failed'; + + @override + String get registrationFailed => 'Registration failed'; + + @override + String get passwordResetFailed => 'Password reset failed'; + + @override + String get sendResetLink => 'Send reset link'; + + @override + String get backToSignIn => 'Back to sign in'; + + @override + String get resetLinkSentTitle => 'Reset link sent'; + + @override + String get resetLinkSentMessage => 'Check your email to reset your password'; + + @override + String get emailLabel => 'Email'; + + @override + String get emailHint => 'test@test.com'; + + @override + String get password8CharactersLabel => 'Password (8+ characters)'; + + @override + String get passwordLabel => 'Password'; + + @override + String get invalidEmailEmpty => 'Email can\'t be empty'; + + @override + String get invalidPasswordTooShort => 'Password is too short'; + + @override + String get invalidPasswordEmpty => 'Password can\'t be empty'; + + @override + String get logout => 'Logout'; + + @override + String get submit => 'Submit'; + + @override + String get invalidEmail => 'Invalid email'; + + @override + String get emailAlreadyInUse => 'Email already in use'; + + @override + String get weakPassword => 'Password is too weak'; + + @override + String get operationNotAllowed => 'Operation not allowed'; + + @override + String get wrongPassword => 'Wrong password'; + + @override + String get userNotFound => 'User not found'; + + @override + String get userDisabled => 'User disabled'; + + @override + String get tooManyAuthRequests => + 'This account has been locked due to too many failed login attempts. Please try again later.'; + + @override + String get permissionDenied => 'Permission denied'; + + @override + String purchasedOnDate(String date) { + return 'Purchased on $date'; + } + + @override + String get leaveReview => 'Leave a review'; + + @override + String get yourReviewHint => 'Your review (optional)'; + + @override + String get previouslyReviewedHint => + 'You reviewed this product before. You can edit your review.'; + + @override + String numRatings(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count ratings', + one: '1 rating', + ); + return '($_temp0)'; + } + + @override + String get address => 'Address'; + + @override + String get townCity => 'Town/City'; + + @override + String get state => 'State'; + + @override + String get postalCode => 'Postal Code'; + + @override + String get country => 'Country'; + + @override + String get cantBeEmpty => 'Can\'t be empty'; + + @override + String get invalidUrl => 'Not a valid URL'; + + @override + String get minimumLength20Chars => 'Minimum length: 20 characters'; + + @override + String get invalidNumber => 'Not a valid number'; + + @override + String get priceGreaterThanZero => 'Price must be greater than zero'; + + @override + String get priceLessThanMax => + 'The maximum price must be less than \$100,000'; + + @override + String get quantityGreaterThanZero => 'Quantity must be zero or more'; + + @override + String get quantityLessThanMax => + 'The maximum quantity must be less than 1,000'; + + @override + String get field => 'Field'; + + @override + String get value => 'Value'; + + @override + String get uidLowercase => 'uid'; + + @override + String get emailLowercase => 'email'; + + @override + String get isAdminLowercase => 'isAdmin'; + + @override + String get adminDashboard => 'Admin Dashboard'; + + @override + String get manageOrders => 'Manage Orders'; + + @override + String get userId => 'User ID'; + + @override + String get userEmail => 'User Email'; + + @override + String get newProduct => 'New Product'; + + @override + String get editProduct => 'Edit Product'; + + @override + String get productUpdated => 'Product updated'; + + @override + String get couldNotSaveProduct => 'Could not save product data'; + + @override + String get couldNotUpdateOrderStatus => 'Could not update order status'; + + @override + String get imageUrl => 'Image URL'; + + @override + String get title => 'Title'; + + @override + String get description => 'Description'; + + @override + String get price => 'Price'; + + @override + String get availableQuantity => 'Available Quantity'; + + @override + String get manageProducts => 'Manage Products'; + + @override + String get payment => 'Payment'; + + @override + String get cardPayment => 'Card Payment'; + + @override + String get saveCardDuringPayment => 'Save card during payment'; + + @override + String get devBuildTestingNotice => + '[DEV build] Use 4242 4242 4242 4242 for testing'; + + @override + String get missingAddress => + 'The operation can\'t be completed (missing address)'; + + @override + String get userNotSignedIn => + 'The operation can\'t be completed (not signed in)'; + + @override + String get orderPlaced => 'Order placed'; + + @override + String get total => 'Total'; + + @override + String totalValue(String value) { + return 'Total: $value'; + } + + @override + String get yourOrders => 'Your Orders'; + + @override + String get noPreviousOrders => 'No previous orders'; + + @override + String get couldNotPlaceOrder => 'Could not place order'; + + @override + String get status => 'Status:'; + + @override + String get confirmed => 'Confirmed'; + + @override + String get confirmedPreparingDelivery => 'Confirmed - preparing for delivery'; + + @override + String get shipped => 'Shipped'; + + @override + String get delivered => 'Delivered'; + + @override + String get latestProducts => 'Latest Products'; + + @override + String get searchProducts => 'Search products'; + + @override + String get noProductsFound => 'No products found'; + + @override + String get productNotFound => 'Product not found'; + + @override + String get quantity => 'Quantity:'; + + @override + String quantityValue(int quantity) { + return 'Quantity: $quantity'; + } + + @override + String quantityAvailable(int quantity) { + return '$quantity available'; + } + + @override + String get shoppingCart => 'Shopping Cart'; + + @override + String get addToCart => 'Add to Cart'; + + @override + String get outOfStock => 'Out of Stock'; + + @override + String get cantAddItemToCart => 'Can\'t add item to cart'; + + @override + String get cantUpdateQuantity => 'Could not update quantity'; + + @override + String get cantDeleteItem => 'Could not delete item'; + + @override + String get alreadyAddedToCart => 'Already added to cart'; + + @override + String get shoppingCartEmpty => 'Your shopping cart is empty'; + + @override + String get goBack => 'Go Back'; + + @override + String get checkout => 'Checkout'; + + @override + String get pay => 'Pay'; + + @override + String get notFound404 => '404 - Page not found!'; + + @override + String get goHome => 'Go Home'; +} diff --git a/ecommerce_app/lib/src/models/address.dart b/ecommerce_app/lib/src/models/address.dart new file mode 100644 index 00000000..3d754a18 --- /dev/null +++ b/ecommerce_app/lib/src/models/address.dart @@ -0,0 +1,16 @@ +/// Address information used for shipping. +class Address { + const Address({ + required this.address, + required this.city, + required this.state, + required this.postalCode, + required this.country, + }); + + final String address; + final String city; + final String state; + final String postalCode; + final String country; +} diff --git a/ecommerce_app/lib/src/models/purchase.dart b/ecommerce_app/lib/src/models/purchase.dart index 3b9ed13b..df364060 100644 --- a/ecommerce_app/lib/src/models/purchase.dart +++ b/ecommerce_app/lib/src/models/purchase.dart @@ -1,3 +1,5 @@ +import 'package:ecommerce_app/src/models/order.dart'; + /// Model class containing order details that need to be shown in the product /// page if the product was purchased by the current user. class Purchase { @@ -5,6 +7,6 @@ class Purchase { required this.orderId, required this.orderDate, }); - final String orderId; + final OrderID orderId; final DateTime orderDate; } diff --git a/ecommerce_app/lib/src/platform/platform_is.dart b/ecommerce_app/lib/src/platform/platform_is.dart deleted file mode 100644 index 4a8a6ddc..00000000 --- a/ecommerce_app/lib/src/platform/platform_is.dart +++ /dev/null @@ -1,52 +0,0 @@ -import 'universal_platform_web.dart' - if (dart.library.io) 'universal_platform_vm.dart'; - -/// A universal platform checker. -/// -/// Can be used to check active physical Flutter platform on all platforms. -/// -/// To check what host platform the app is running on use: -/// -/// * PlatformIs.android -/// * PlatformIs.iOS -/// * PlatformIs.macOS -/// * PlatformIs.windows -/// * PlatformIs.linux -/// * PlatformIs.fuchsia -/// -/// To check the device type use: -/// -/// * PlatformIs.mobile (Android or iOS) -/// * PlatformIs.desktop (Windows, macOS or Linux) -/// -/// Currently Fuchsia is not considered mobile nor desktop, even if it -/// might be so in the future. -/// -/// To check if the Flutter application is running on Web you can use: -/// -/// * PlatformIs.web -/// -/// Alternatively the Flutter foundation compile time constant kIsWeb also -/// works well for that. -/// -/// The platform checks are supported independently on web. You can use -/// PlatformIs windows, iOS, macOS, Android and Linux to check what the host -/// platform is when you are running a Flutter Web application. -/// -/// Checking if we are running on a Fuchsia host in a Web browser, is not yet fully -/// supported. If running in a Web browser on Fuchsia, PlatformIs.web will be true, but -/// PlatformIs.fuchsia will be false. Future versions, when Fuchsia is released, -/// may fix this. -class PlatformIs { - PlatformIs._(); - static bool get web => UniversalPlatform.web; - static bool get macOS => UniversalPlatform.macOS; - static bool get windows => UniversalPlatform.windows; - static bool get linux => UniversalPlatform.linux; - static bool get android => UniversalPlatform.android; - static bool get iOS => UniversalPlatform.iOS; - static bool get fuchsia => UniversalPlatform.fuchsia; - static bool get mobile => PlatformIs.iOS || PlatformIs.android; - static bool get desktop => - PlatformIs.macOS || PlatformIs.windows || PlatformIs.linux; -} diff --git a/ecommerce_app/lib/src/platform/universal_platform_vm.dart b/ecommerce_app/lib/src/platform/universal_platform_vm.dart deleted file mode 100644 index 8f93f668..00000000 --- a/ecommerce_app/lib/src/platform/universal_platform_vm.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'dart:io'; -// NOTE: -// Never import this library directly in the application. The PlatformIs -// class and library uses conditional imports to only import this file on -// VM platform builds. - -// UniversalPlatform for Flutter VM builds. -// -// We are using Dart VM builds, so we use dart:io Platform to -// get the current platform. -class UniversalPlatform { - UniversalPlatform._(); - static bool get web => false; - static bool get macOS => Platform.isMacOS; - static bool get windows => Platform.isWindows; - static bool get linux => Platform.isLinux; - static bool get android => Platform.isAndroid; - static bool get iOS => Platform.isIOS; - static bool get fuchsia => Platform.isFuchsia; -} diff --git a/ecommerce_app/lib/src/platform/universal_platform_web.dart b/ecommerce_app/lib/src/platform/universal_platform_web.dart deleted file mode 100644 index f26332dc..00000000 --- a/ecommerce_app/lib/src/platform/universal_platform_web.dart +++ /dev/null @@ -1,43 +0,0 @@ -// ignore: avoid_web_libraries_in_flutter -import 'dart:html' as html; - -// NOTE: -// Never import this library directly in the application. The PlatformIs -// class and library uses conditional imports to only import this file on -// Web platform builds. - -final html.Navigator _nav = html.window.navigator; - -// UniversalPlatform for Flutter WEB build. -// -// We can use dart:html Navigator to get the current platform. -// -// This function is borrowed, with minor modifications, from GetX utils library with MIT license. -// Credits for it belong to its author Jonny Borges https://github.com/jonataslaw -// https://github.com/jonataslaw/getx/blob/master/lib/get_utils/src/platform/platform_web.dart -// -class UniversalPlatform { - UniversalPlatform._(); - static bool get web => true; - static bool get macOS => _nav.appVersion.contains('Mac OS') && !iOS; - static bool get windows => _nav.appVersion.contains('Win'); - static bool get linux => - (_nav.appVersion.contains('Linux') || _nav.appVersion.contains('x11')) && - !android; - // Source: https://developer.chrome.com/multidevice/user-agent - static bool get android => _nav.appVersion.contains('Android '); - static bool get iOS { - // maxTouchPoints is needed to separate iPad iOS13 vs new MacOS - return _hasMatch(_nav.platform, '/iPad|iPhone|iPod/') || - (_nav.platform == 'MacIntel' && _nav.maxTouchPoints! > 1); - } - - // Theoretically we could be in a Web browser on Fuchsia too, but - // we have no info on how to get that info yet, so we return false. - static bool get fuchsia => false; -} - -bool _hasMatch(String? value, String pattern) { - // ignore: avoid_bool_literals_in_conditional_expressions - return (value == null) ? false : RegExp(pattern).hasMatch(value); -} diff --git a/ecommerce_app/lib/src/routing/app_router.dart b/ecommerce_app/lib/src/routing/app_router.dart new file mode 100644 index 00000000..9762c9db --- /dev/null +++ b/ecommerce_app/lib/src/routing/app_router.dart @@ -0,0 +1,108 @@ +import 'package:ecommerce_app/src/features/account/account_screen.dart'; +import 'package:ecommerce_app/src/features/checkout/checkout_screen.dart'; +import 'package:ecommerce_app/src/features/leave_review_page/leave_review_screen.dart'; +import 'package:ecommerce_app/src/features/not_found/not_found_screen.dart'; +import 'package:ecommerce_app/src/features/orders_list/orders_list_screen.dart'; +import 'package:ecommerce_app/src/features/product_page/product_screen.dart'; +import 'package:ecommerce_app/src/features/products_list/products_list_screen.dart'; +import 'package:ecommerce_app/src/features/shopping_cart/shopping_cart_screen.dart'; +import 'package:ecommerce_app/src/features/sign_in/email_password_sign_in_screen.dart'; +import 'package:ecommerce_app/src/features/sign_in/email_password_sign_in_state.dart'; + +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +enum AppRoute { + home, + cart, + orders, + accounts, + signin, + product, + leaveReview, + checkout, +} + +final router = GoRouter( + initialLocation: '/', + debugLogDiagnostics: true, + routes: [ + GoRoute( + path: '/', + name: AppRoute.home.name, + builder: (context, state) => const ProductsListScreen(), + routes: [ + GoRoute( + path: 'product/:id', + name: AppRoute.product.name, + builder: (context, state) { + final productId = state.pathParameters['id']!; + + return ProductScreen( + productId: productId, + ); + }, + routes: [ + GoRoute( + path: 'leaveReview', + name: AppRoute.leaveReview.name, + pageBuilder: (context, state) => MaterialPage( + key: state.pageKey, + fullscreenDialog: true, + child: LeaveReviewScreen( + productId: state.pathParameters['id']!, + ), + ), + ), + ]), + GoRoute( + path: 'cart', + name: AppRoute.cart.name, + pageBuilder: (context, state) => MaterialPage( + key: state.pageKey, + fullscreenDialog: true, + child: const ShoppingCartScreen()), + routes: [ + GoRoute( + path: 'checkout', + name: AppRoute.checkout.name, + pageBuilder: (context, state) => MaterialPage( + key: state.pageKey, + fullscreenDialog: true, + child: const CheckoutScreen(), + ), + ), + ], + ), + GoRoute( + path: 'orders', + name: AppRoute.orders.name, + pageBuilder: (context, state) => MaterialPage( + key: state.pageKey, + fullscreenDialog: true, + child: const OrdersListScreen()), + ), + GoRoute( + path: 'accounts', + name: AppRoute.accounts.name, + pageBuilder: (context, state) => MaterialPage( + key: state.pageKey, + fullscreenDialog: true, + child: const AccountScreen()), + ), + GoRoute( + path: 'signin', + name: AppRoute.signin.name, + pageBuilder: (context, state) => MaterialPage( + key: state.pageKey, + fullscreenDialog: true, + child: const EmailPasswordSignInScreen( + formType: EmailPasswordSignInFormType.signIn, + )), + ), + ], + ), + ], + errorBuilder: (context, state) { + return const NotFoundScreen(); + }); diff --git a/ecommerce_app/pubspec.lock b/ecommerce_app/pubspec.lock index f9c2fcef..f8e6491c 100644 --- a/ecommerce_app/pubspec.lock +++ b/ecommerce_app/pubspec.lock @@ -5,51 +5,50 @@ packages: dependency: transitive description: name: async - url: "https://pub.dartlang.org" + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" source: hosted - version: "2.8.2" + version: "2.13.0" boolean_selector: dependency: transitive description: name: boolean_selector - url: "https://pub.dartlang.org" + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.1.2" characters: dependency: transitive description: name: characters - url: "https://pub.dartlang.org" + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + url: "https://pub.dev" source: hosted - version: "1.2.0" - charcode: - dependency: transitive - description: - name: charcode - url: "https://pub.dartlang.org" - source: hosted - version: "1.3.1" + version: "1.4.0" clock: dependency: transitive description: name: clock - url: "https://pub.dartlang.org" + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.1.2" collection: dependency: transitive description: name: collection - url: "https://pub.dartlang.org" + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" source: hosted - version: "1.15.0" + version: "1.19.1" fake_async: dependency: transitive description: name: fake_async - url: "https://pub.dartlang.org" + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.3.3" flutter: dependency: "direct main" description: flutter @@ -59,14 +58,16 @@ packages: dependency: "direct main" description: name: flutter_layout_grid - url: "https://pub.dartlang.org" + sha256: "739e568db97af031d528dfd8a80d333df0e5a310a126e087690fa42cd61dfb5f" + url: "https://pub.dev" source: hosted - version: "1.0.3" + version: "2.0.8" flutter_lints: dependency: "direct main" description: name: flutter_lints - url: "https://pub.dartlang.org" + sha256: b543301ad291598523947dc534aaddc5aaad597b709d2426d3a0e0d44c5cb493 + url: "https://pub.dev" source: hosted version: "1.0.4" flutter_localizations: @@ -78,124 +79,185 @@ packages: dependency: "direct main" description: name: flutter_rating_bar - url: "https://pub.dartlang.org" + sha256: d2af03469eac832c591a1eba47c91ecc871fe5708e69967073c043b2d775ed93 + url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "4.0.1" flutter_test: dependency: "direct dev" description: flutter source: sdk version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: c489908a54ce2131f1d1b7cc631af9c1a06fac5ca7c449e959192089f9489431 + url: "https://pub.dev" + source: hosted + version: "16.0.0" intl: dependency: "direct main" description: name: intl - url: "https://pub.dartlang.org" + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" source: hosted - version: "0.17.0" + version: "0.20.2" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" + url: "https://pub.dev" + source: hosted + version: "10.0.9" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 + url: "https://pub.dev" + source: hosted + version: "3.0.9" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + url: "https://pub.dev" + source: hosted + version: "3.0.1" lints: dependency: transitive description: name: lints - url: "https://pub.dartlang.org" + sha256: a2c3d198cb5ea2e179926622d433331d8b58374ab8f29cdda6e863bd62fd369c + url: "https://pub.dev" source: hosted version: "1.0.1" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" matcher: dependency: transitive description: name: matcher - url: "https://pub.dartlang.org" + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://pub.dev" source: hosted - version: "0.12.11" + version: "0.12.17" material_color_utilities: dependency: transitive description: name: material_color_utilities - url: "https://pub.dartlang.org" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" source: hosted - version: "0.1.3" + version: "0.11.1" meta: dependency: transitive description: name: meta - url: "https://pub.dartlang.org" + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + url: "https://pub.dev" source: hosted - version: "1.7.0" + version: "1.16.0" path: dependency: transitive description: name: path - url: "https://pub.dartlang.org" + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" source: hosted - version: "1.8.0" + version: "1.9.1" quiver: dependency: transitive description: name: quiver - url: "https://pub.dartlang.org" + sha256: "616b691d1c8f5c53b7b39ce3542f6a25308d7900bf689d0210e72a644a10387e" + url: "https://pub.dev" source: hosted version: "3.0.1+1" sky_engine: dependency: transitive description: flutter source: sdk - version: "0.0.99" + version: "0.0.0" source_span: dependency: transitive description: name: source_span - url: "https://pub.dartlang.org" + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + url: "https://pub.dev" source: hosted - version: "1.8.1" + version: "1.10.1" stack_trace: dependency: transitive description: name: stack_trace - url: "https://pub.dartlang.org" + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" source: hosted - version: "1.10.0" + version: "1.12.1" stream_channel: dependency: transitive description: name: stream_channel - url: "https://pub.dartlang.org" + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.1.4" string_scanner: dependency: transitive description: name: string_scanner - url: "https://pub.dartlang.org" + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.4.1" term_glyph: dependency: transitive description: name: term_glyph - url: "https://pub.dartlang.org" + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.2.2" test_api: dependency: transitive description: name: test_api - url: "https://pub.dartlang.org" + sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd + url: "https://pub.dev" source: hosted - version: "0.4.8" - typed_data: + version: "0.7.4" + vector_math: dependency: transitive description: - name: typed_data - url: "https://pub.dartlang.org" + name: vector_math + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + url: "https://pub.dev" source: hosted - version: "1.3.0" - vector_math: + version: "2.1.4" + vm_service: dependency: transitive description: - name: vector_math - url: "https://pub.dartlang.org" + name: vm_service + sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02 + url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "15.0.0" sdks: - dart: ">=2.16.0 <3.0.0" - flutter: ">=1.25.0" + dart: ">=3.7.0-0 <4.0.0" + flutter: ">=3.27.0" diff --git a/ecommerce_app/pubspec.yaml b/ecommerce_app/pubspec.yaml index 39f1c2f9..d979637b 100644 --- a/ecommerce_app/pubspec.yaml +++ b/ecommerce_app/pubspec.yaml @@ -15,9 +15,10 @@ dependencies: flutter_lints: 1.0.4 flutter_localizations: sdk: flutter - intl: 0.17.0 # Used for date and currency formatting - flutter_layout_grid: 1.0.3 # Used for content-sized items in the products grid layout - flutter_rating_bar: 4.0.0 # Used for the "leave a rating" feature + intl: ^0.20.2 # Used for date and currency formatting + flutter_layout_grid: ^2.0.8 # Used for content-sized items in the products grid layout + flutter_rating_bar: ^4.0.1 # Used for the "leave a rating" feature + go_router: ^16.0.0 dev_dependencies: flutter_test: diff --git a/ecommerce_app/windows/flutter/generated_plugins.cmake b/ecommerce_app/windows/flutter/generated_plugins.cmake index 4d10c251..b93c4c30 100644 --- a/ecommerce_app/windows/flutter/generated_plugins.cmake +++ b/ecommerce_app/windows/flutter/generated_plugins.cmake @@ -5,6 +5,9 @@ list(APPEND FLUTTER_PLUGIN_LIST ) +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + set(PLUGIN_BUNDLED_LIBRARIES) foreach(plugin ${FLUTTER_PLUGIN_LIST}) @@ -13,3 +16,8 @@ foreach(plugin ${FLUTTER_PLUGIN_LIST}) list(APPEND PLUGIN_BUNDLED_LIBRARIES $) list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin)