diff --git a/packages/flutter_product_page/.gitignore b/packages/flutter_product_page/.gitignore new file mode 100644 index 0000000..a81f4bf --- /dev/null +++ b/packages/flutter_product_page/.gitignore @@ -0,0 +1,56 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ +.metadata + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +/build/ +pubspec.lock + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release + +# env +*dotenv + +android/ +ios/ +linux/ +macos/ +web/ +windows/ \ No newline at end of file diff --git a/packages/flutter_product_page/CONTRIBUTING.md b/packages/flutter_product_page/CONTRIBUTING.md new file mode 100644 index 0000000..b8bb72a --- /dev/null +++ b/packages/flutter_product_page/CONTRIBUTING.md @@ -0,0 +1,195 @@ +# Contributing +First off, thanks for taking the time to contribute! ❤️ + +All types of contributions are encouraged and valued. +See the [Table of Contents](#table-of-contents) for different ways to help and details about how we handle them. +Please make sure to read the relevant section before making your contribution. +It will make it a lot easier for us maintainers and smooth out the experience for all involved. +Iconica looks forward to your contributions. 🎉 + +## Table of contents +- [Contributing](#contributing) + - [Table of contents](#table-of-contents) + - [Code of conduct](#code-of-conduct) + - [Legal notice](#legal-notice) + - [I have a question](#i-have-a-question) + - [I want to contribute](#i-want-to-contribute) + - [Reporting bugs](#reporting-bugs) + - [Contributing code](#contributing-code) + +## Code of conduct + +### Legal notice +When contributing to this project, you must agree that you have authored 100% of the content, that you have the necessary rights to the content and that the content you contribute may be provided under the project license. +All accepted pull requests and other additions to this project will be considered intellectual property of Iconica. + +All repositories should be kept clean of jokes, easter eggs and other unnecessary additions. + +## I have a question + +If you want to ask a question, we assume that you have read the available documentation found within the code. +Before you ask a question, it is best to search for existing issues that might help you. +In case you have found a suitable issue but still need clarification, you can ask your question +It is also advisable to search the internet for answers first. + +If you then still feel the need to ask a question and need clarification, we recommend the following: + +- Open an issue. +- Provide as much context as you can about what you're running into. + +We will then take care of the issue as soon as possible. + +## I want to contribute + +### Reporting bugs + + +**Before submitting a bug report** + +A good bug report shouldn't leave others needing to chase you up for more information. +Therefore, we ask you to investigate carefully, collect information and describe the issue in detail in your report. +Please complete the following steps in advance to help us fix any potential bug as fast as possible. + +- Make sure that you are using the latest version. +- Determine if your bug is really a bug and not an error on your side e.g. using incompatible environment components/versions (If you are looking for support, you might want to check [this section](#i-have-a-question)). +- To see if other users have experienced (and potentially already solved) the same issue you are having, check if there is not already a bug report existing for your bug or error. +- Also make sure to search the internet (including Stack Overflow) to see if users outside of Iconica have discussed the issue. +- Collect information about the bug: +- Stack trace (Traceback) +- OS, Platform and Version (Windows, Linux, macOS, x86, ARM) +- Version of the interpreter, compiler, SDK, runtime environment, package manager, depending on what seems relevant. +- Time and date of occurrence +- Describe the expected result and actual result +- Can you reliably reproduce the issue? And can you also reproduce it with older versions? Describe all steps that lead to the bug. + +Once it's filed: + +- The project team will label the issue accordingly. +- A team member will try to reproduce the issue with your provided steps. + If there are no reproduction steps or no obvious way to reproduce the issue, the team will ask you for additional information. +- If the team is able to reproduce the issue, it will be moved into the backlog, as well as marked with a priority, and the issue will be left to be [implemented by someone](#contributing-code). + +### Contributing code + +When you start working on your contribution, make sure you are aware of the relevant documentation and the functionality of the component you are working on. + +When writing code, follow the style guidelines set by Dart: [Effective Dart](https://Dart.dev/guides/language/effective-Dart). This contains most information you will need to write clean Dart code that is well documented. + +**Documentation** + +As Effective Dart indicates, documenting your public methods with Dart doc comments is recommended. +Aside from Effective Dart, we require specific information in the documentation of a method: + +At the very least, your documentation should first name what the code does, then followed below by requirements for calling the method, the result of the method. +Any references to internal variables or other methods should be done through [var] to indicate a reference. + +If the method or class is complex enough (determined by the reviewers) an example is required. +If unsure, add an example in the docs using code blocks. + +For classes and methods, document the individual parameters with their implications. + +> Tip: Remember that the shortest documentation can be written by having good descriptive names in the first place. + +An example: +```Dart +library iconica_utilities.bidirectional_sorter; + +part 'sorter.Dart'; +part 'enum.Dart'; + +/// Generic sort method, allow sorting of list with primitives or complex types. +/// Uses [SortDirection] to determine the direction, either Ascending or Descending, +/// Gets called on [List] toSort of type [T] which cannot be shorter than 2. +/// Optionally for complex types a [Comparable] [Function] can be given to compare complex types. +/// ``` +/// List objects = []; +/// for (int i = 0; i < 10; i++) { +/// objects.add(TestObject(name: "name", id: i)); +/// } +/// +/// sort( +/// SortDirection.descending, objects, (object) => object.id); +/// +/// ``` +/// In the above example a list of TestObjects is created, and then sorted in descending order. +/// If the implementation of TestObject is as following: +/// ``` +/// class TestObject { +/// final String name; +/// final int id; +/// +/// TestObject({required this.name, required this.id}); +/// } +/// ``` +/// And the list is logged to the console, the following will appear: +/// ``` +/// [name9, name8, name7, name6, name5, name4, name3, name2, name1, name0] +/// ``` + +void sort( + /// Determines the sorting direction, can be either Ascending or Descending + SortDirection sortDirection, + + /// Incoming list, which gets sorted + List toSort, [ + + /// Optional comparable, which is only necessary for complex types + SortFieldGetter? sortValueCallback, +]) { + if (toSort.length < 2) return; + assert( + toSort.whereType().isNotEmpty || sortValueCallback != null); + BidirectionalSorter( + sortInstructions: >[ + SortInstruction( + sortValueCallback ?? (t) => t as Comparable, sortDirection), + ], + ).sort(toSort); +} + +/// same functionality as [sort] but with the added functionality +/// of sorting multiple values +void sortMulti( + /// Incoming list, which gets sorted + List toSort, + + /// list of comparables to sort multiple values at once, + /// priority based on index + List> sortValueCallbacks, +) { + if (toSort.length < 2) return; + assert(sortValueCallbacks.isNotEmpty); + BidirectionalSorter( + sortInstructions: sortValueCallbacks, + ).sort(toSort); +} + +``` + +**Tests** + +For each public method that was created, excluding widgets, which contains any form of logic (e.g. Calculations, predicates or major side-effects) tests are required. + +A set of tests is written for each method, covering at least each path within the method. For example: + +```Dart +void foo() { + try { + var bar = doSomething(); + if (bar) { + doSomethingElse(); + } else { + doSomethingCool(); + } + } catch (_) { + displayError(); + } +} +``` +The method above should result in 3 tests: + +1. A test for the path leading to displayError by the cause of an exception +2. A test for if bar is true, resulting in doSomethingElse() +3. A test for if bar is false, resulting in the doSomethingCool() method being called. + +This means that we require 100% coverage of each method you test. diff --git a/packages/flutter_product_page/LICENSE b/packages/flutter_product_page/LICENSE new file mode 100644 index 0000000..4c21bd7 --- /dev/null +++ b/packages/flutter_product_page/LICENSE @@ -0,0 +1,9 @@ +Copyright (c) 2024 Iconica, All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + + Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/packages/flutter_product_page/README.md b/packages/flutter_product_page/README.md new file mode 100644 index 0000000..fd0ed4d --- /dev/null +++ b/packages/flutter_product_page/README.md @@ -0,0 +1,47 @@ +# flutter_product_page + +This component allows you to easily create and manage the products for any shop. Easily highlight a specific product +and automatically see your products categorized. This package allows users to gather more information about a product, +add it to a custom implementable shopping cart and even navigate to your own shopping cart. + +This component is very customizable, it allows you to adjust basically everything while providing clean defaults. + +## Features + +* Easily navigate between different shops, +* Show users a highlighted product, +* Integrate with your own shopping cart, +* Automatically categorized products, powered by the `flutter_nested_categories` package that you have full control over, even in this component. + +## Usage + +First, you must implement your own `Shop` and `Product` classes. Your shop class must extend from the `ProductPageShop` class provided by this module. Your `Product` class should extend from the `Product` class provided by this module. + +Next, you can create a `ProductPage` or a `ProductPageScreen`. The choice for the former is when you do not want to create a new Scaffold and the latter for when you want to create a new Scaffold. + +To show the page, you must configure what you want to show. Both the `ProductPage` and the `ProductPageScreen` take a parameter that is a `ProductPageConfiguration`. This allows you for a lot of customizability, including what shops there are and what products to show. + +For a more detailed example you can see the [example](https://github.com/Iconica-Development/flutter_product_page/tree/main/example). + +Or, you could run the example yourself: +``` +git clone https://github.com/Iconica-Development/flutter_product_page.git + +cd flutter_product_page + +cd example + +flutter run +``` + +## Issues + +Please file any issues, bugs or feature request as an issue on our [GitHub](https://github.com/Iconica-Development/flutter_product_page) page. Commercial support is available if you need help with integration with your app or services. You can contact us at [support@iconica.nl](mailto:support@iconica.nl). + +## Want to contribute + +If you would like to contribute to the component (e.g. by improving the documentation, solving a bug or adding a cool new feature), please carefully review our [contribution guide](./CONTRIBUTING.md) and send us your [pull request](https://github.com/Iconica-Development/flutter_product_page/pulls). + +## Author + +This flutter_product_page for Flutter is developed by [Iconica](https://iconica.nl). You can contact us at diff --git a/packages/flutter_product_page/analysis_options.yaml b/packages/flutter_product_page/analysis_options.yaml new file mode 100644 index 0000000..0736605 --- /dev/null +++ b/packages/flutter_product_page/analysis_options.yaml @@ -0,0 +1,9 @@ +include: package:flutter_iconica_analysis/components_options.yaml + +# Possible to overwrite the rules from the package + +analyzer: + exclude: + +linter: + rules: diff --git a/packages/flutter_product_page/example/.gitignore b/packages/flutter_product_page/example/.gitignore new file mode 100644 index 0000000..8760a85 --- /dev/null +++ b/packages/flutter_product_page/example/.gitignore @@ -0,0 +1,53 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +.metadata +pubspec.lock + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release + +# Platforms +/android/ +/ios/ +/linux/ +/macos/ +/web/ +/windows/ diff --git a/packages/flutter_product_page/example/README.md b/packages/flutter_product_page/example/README.md new file mode 100644 index 0000000..2b3fce4 --- /dev/null +++ b/packages/flutter_product_page/example/README.md @@ -0,0 +1,16 @@ +# example + +A new Flutter project. + +## Getting Started + +This project is a starting point for a Flutter application. + +A few resources to get you started if this is your first Flutter project: + +- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) +- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev/), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/packages/flutter_product_page/example/analysis_options.yaml b/packages/flutter_product_page/example/analysis_options.yaml new file mode 100644 index 0000000..31b4b51 --- /dev/null +++ b/packages/flutter_product_page/example/analysis_options.yaml @@ -0,0 +1,9 @@ +include: package:flutter_iconica_analysis/analysis_options.yaml + +# Possible to overwrite the rules from the package + +analyzer: + exclude: + +linter: + rules: diff --git a/packages/flutter_product_page/example/lib/config/product_page_screen_configuration.dart b/packages/flutter_product_page/example/lib/config/product_page_screen_configuration.dart new file mode 100644 index 0000000..daf34de --- /dev/null +++ b/packages/flutter_product_page/example/lib/config/product_page_screen_configuration.dart @@ -0,0 +1,81 @@ +import "package:example/models/my_product.dart"; +import "package:example/models/my_shop.dart"; +import "package:flutter/material.dart"; +import "package:flutter_product_page/flutter_product_page.dart"; + +ProductPageConfiguration getProductPageScreenConfiguration({ + required List shops, + required List products, +}) => + ProductPageConfiguration( + // (REQUIRED) List of shops to display. + shops: Future.value(shops), + + // (REQUIRED) Function that returns the products for a shop. + getProducts: (ProductPageShop shop) => Future( + () => ProductPageContent( + products: products, + discountedProduct: products.firstWhere( + (MyProduct product) => product.hasDiscount, + ), + ), + ), + + // (REQUIRED) Function that handles the functionality behind adding + // a product to the cart. + // ignore: avoid_print + onAddToCart: (product) => print("Add to cart: ${product.name}"), + + // (REQUIRED) Function that handles the functionality behind navigating + // to the product detail page. + // ignore: avoid_print + onNavigateToShoppingCart: () => print("Navigate to shopping cart"), + + // (RECOMMENDED) The shop that is initially selected. + // Must be one of the shops in the [shops] list. + initialShopId: shops.first.id, + + // (RECOMMENDED) Function that returns the amount of products in the + // shopping cart. This currently is mocked and should be replaced with + // a real implementation. + // getProductsInShoppingCart: () => 0, + + // (RECOMMENDED) Function that is fired when the shop selection changes. + // You could use this to clear your shopping cart or to change the + // products so they belong to the correct shop again. + onShopSelectionChange: (ProductPageShop shop) => + // ignore: avoid_print + print("Shop selected: ${shop.id}"), + + // (RECOMMENDED) Localizations for the product page. + localizations: const ProductPageLocalization( + navigateToShoppingCart: "Naar Winkelmandje", + discountTitle: "Weekaanbieding", + failedToLoadImageExplenation: "Afbeelding laden mislukt.", + close: "Sluiten", + ), + + // (RECOMMENDED) Function that returns the description for a product + // that is on sale. + getDiscountDescription: (ProductPageProduct product) => + """Koop nu ${product.name} voor slechts €${product.discountPrice?.toStringAsFixed(2)}""", + + /* + Some recommended functions to implement for additional customizability: + + Widget Function(BuildContext, Product)? productBuilder, + Widget Function(BuildContext, Product)? productPopupBuilder, + Widget Function(BuildContext)? noContentBuilder, + Widget Function(BuildContext, Object?, StackTrace?)? errorBuilder, + */ + + // (OPTIONAL) Styling for the shop selector + shopSelectorStyle: ShopSelectorStyle.row, + + // (OPTIONAL) Builder for the product item. + appBar: AppBar( + title: const Text( + "Producten pagina", + ), + ), + ); diff --git a/packages/flutter_product_page/example/lib/main.dart b/packages/flutter_product_page/example/lib/main.dart new file mode 100644 index 0000000..9f52a42 --- /dev/null +++ b/packages/flutter_product_page/example/lib/main.dart @@ -0,0 +1,48 @@ +import "package:example/config/product_page_screen_configuration.dart"; +import "package:example/models/my_product.dart"; +import "package:example/models/my_shop.dart"; +import "package:example/utils/theme.dart"; +import "package:flutter/material.dart"; +import "package:flutter_product_page/flutter_product_page.dart"; + +void main() => runApp(const MyApp()); + +class MyApp extends StatelessWidget { + /// + const MyApp({super.key}); + + @override + Widget build(BuildContext context) { + var shops = List.generate( + 7, + (int index) => MyShop( + id: index.toString(), + name: "Shop ${(index.isEven ? index * 40 : index) + 1}", + ), + ); + + var products = List.generate( + 6, + (int index) => MyProduct( + id: index.toString(), + name: "Product ${index + 1}", + price: 100.0, + imageUrl: "https://via.placeholder.com/150", + category: index.isEven ? "Category 1" : "Category 2", + hasDiscount: index.isEven, + discountPrice: 50.0, + ), + ); + + return MaterialApp( + title: "Flutter Demo", + theme: getTheme(), + home: ProductPageScreen( + configuration: getProductPageScreenConfiguration( + shops: shops, + products: products, + ), + ), + ); + } +} diff --git a/packages/flutter_product_page/example/lib/models/my_product.dart b/packages/flutter_product_page/example/lib/models/my_product.dart new file mode 100644 index 0000000..d7f760e --- /dev/null +++ b/packages/flutter_product_page/example/lib/models/my_product.dart @@ -0,0 +1,34 @@ +import "package:flutter_product_page/flutter_product_page.dart"; + +class MyProduct with ProductPageProduct { + const MyProduct({ + required this.id, + required this.name, + required this.price, + required this.imageUrl, + required this.category, + required this.hasDiscount, + required this.discountPrice, + }); + + @override + final String id; + + @override + final String name; + + @override + final double price; + + @override + final String imageUrl; + + @override + final String category; + + @override + final bool hasDiscount; + + @override + final double? discountPrice; +} diff --git a/packages/flutter_product_page/example/lib/models/my_shop.dart b/packages/flutter_product_page/example/lib/models/my_shop.dart new file mode 100644 index 0000000..ddf05b9 --- /dev/null +++ b/packages/flutter_product_page/example/lib/models/my_shop.dart @@ -0,0 +1,8 @@ +import "package:flutter_product_page/flutter_product_page.dart"; + +class MyShop extends ProductPageShop { + const MyShop({ + required super.id, + required super.name, + }); +} diff --git a/packages/flutter_product_page/example/lib/utils/theme.dart b/packages/flutter_product_page/example/lib/utils/theme.dart new file mode 100644 index 0000000..7893dc1 --- /dev/null +++ b/packages/flutter_product_page/example/lib/utils/theme.dart @@ -0,0 +1,37 @@ +import "package:flutter/material.dart"; + +ThemeData getTheme() => ThemeData( + textTheme: const TextTheme( + labelMedium: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w400, + color: Color.fromRGBO(0, 0, 0, 1), + ), + titleMedium: TextStyle( + fontSize: 16, + color: Color.fromRGBO(60, 60, 59, 1), + fontWeight: FontWeight.w700, + ), + bodyMedium: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w400, + fontFamily: "Futura", + color: Color.fromRGBO(60, 60, 59, 1), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + fillColor: Color.fromRGBO(255, 255, 255, 1), + ), + colorScheme: const ColorScheme.light( + primary: Color.fromRGBO(64, 87, 122, 1), + secondary: Color.fromRGBO(255, 255, 255, 1), + surface: Color.fromRGBO(250, 249, 246, 1), + ), + appBarTheme: const AppBarTheme( + backgroundColor: Color.fromRGBO(64, 87, 122, 1), + titleTextStyle: TextStyle( + fontSize: 28, + color: Color.fromRGBO(255, 255, 255, 1), + ), + ), + ); diff --git a/packages/flutter_product_page/example/pubspec.yaml b/packages/flutter_product_page/example/pubspec.yaml new file mode 100644 index 0000000..f56a1b6 --- /dev/null +++ b/packages/flutter_product_page/example/pubspec.yaml @@ -0,0 +1,33 @@ +name: example +description: "Demonstrates how to use the flutter_product_page package." +publish_to: 'none' +version: 1.0.0+1 + +environment: + sdk: '>=3.3.0 <4.0.0' + +dependencies: + flutter: + sdk: flutter + flutter_product_page: + path: ../ + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_iconica_analysis: + git: + url: https://github.com/Iconica-Development/flutter_iconica_analysis + ref: 7.0.0 + +flutter: + uses-material-design: true + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic diff --git a/packages/flutter_product_page/lib/flutter_product_page.dart b/packages/flutter_product_page/lib/flutter_product_page.dart new file mode 100644 index 0000000..2814df5 --- /dev/null +++ b/packages/flutter_product_page/lib/flutter_product_page.dart @@ -0,0 +1,13 @@ +/// Module for creating a product page with a list of products and a +/// detailed view of each product. +library flutter_product_page; + +export "src/configuration/product_page_category_styling_configuration.dart"; +export "src/configuration/product_page_configuration.dart"; +export "src/configuration/product_page_content.dart"; +export "src/configuration/product_page_localization.dart"; +export "src/configuration/product_page_shop_selector_style.dart"; +export "src/models/product.dart"; +export "src/models/product_page_shop.dart"; +export "src/ui/product_page.dart"; +export "src/ui/product_page_screen.dart"; diff --git a/packages/flutter_product_page/lib/src/configuration/product_page_category_styling_configuration.dart b/packages/flutter_product_page/lib/src/configuration/product_page_category_styling_configuration.dart new file mode 100644 index 0000000..f554222 --- /dev/null +++ b/packages/flutter_product_page/lib/src/configuration/product_page_category_styling_configuration.dart @@ -0,0 +1,54 @@ +import "package:flutter/material.dart"; +import "package:flutter_nested_categories/flutter_nested_categories.dart" + show CategoryHeaderStyling; + +/// Configuration for the styling of the category list on the product page. +/// This configuration allows to customize the title, header styling and +/// the collapsible behavior of the categories. +class ProductPageCategoryStylingConfiguration { + /// Constructor to create a new instance of + /// [ProductPageCategoryStylingConfiguration]. + const ProductPageCategoryStylingConfiguration({ + this.headerStyling, + this.headerCentered = false, + this.customTitle, + this.title, + this.titleStyle, + this.titleCentered = false, + this.isCategoryCollapsible = true, + }); + + /// Optional title for the category list. This will be displayed at the + /// top of the list. + final String? title; + + /// Optional custom title widget for the category list. This will be + /// displayed at the top of the list. If set, the text title will be + /// ignored. + final Widget? customTitle; + + /// Optional title style for the title of the category list. This will + /// be applied to the title of the category list. If not set, the default + /// text style will be used. + final TextStyle? titleStyle; + + /// Configure if the title should be centered. + /// + /// Default is false. + final bool titleCentered; + + /// Optional header styling for the categories. This will be applied to + /// the name of the categories. If not set, the default text style will + /// be used. + final CategoryHeaderStyling? headerStyling; + + /// Configure if the category header should be centered. + /// + /// Default is false. + final bool headerCentered; + + /// Configure if the category should be collapsible. + /// + /// Default is true. + final bool isCategoryCollapsible; +} diff --git a/packages/flutter_product_page/lib/src/configuration/product_page_configuration.dart b/packages/flutter_product_page/lib/src/configuration/product_page_configuration.dart new file mode 100644 index 0000000..a08c3d0 --- /dev/null +++ b/packages/flutter_product_page/lib/src/configuration/product_page_configuration.dart @@ -0,0 +1,202 @@ +import "package:flutter/material.dart"; +import "package:flutter_product_page/flutter_product_page.dart"; +import "package:flutter_product_page/src/ui/widgets/product_item_popup.dart"; + +/// Configuration for the product page. +class ProductPageConfiguration { + /// Constructor for the product page configuration. + ProductPageConfiguration({ + required this.shops, + // + required this.getProducts, + // + required this.onAddToCart, + required this.onNavigateToShoppingCart, + this.navigateToShoppingCartBuilder, + // + this.initialShopId, + // + this.productBuilder, + // + this.onShopSelectionChange, + this.getProductsInShoppingCart, + // + this.localizations = const ProductPageLocalization(), + // + this.shopSelectorStyle = ShopSelectorStyle.spacedWrap, + this.categoryStylingConfiguration = + const ProductPageCategoryStylingConfiguration(), + // + this.pagePadding = const EdgeInsets.all(4), + // + this.appBar, + this.bottomNavigationBar, + // + Function( + BuildContext context, + ProductPageProduct product, + )? onProductDetail, + String Function( + ProductPageProduct product, + )? getDiscountDescription, + Widget Function( + BuildContext context, + ProductPageProduct product, + )? productPopupBuilder, + Widget Function( + BuildContext context, + )? noContentBuilder, + Widget Function( + BuildContext context, + Object? error, + StackTrace? stackTrace, + )? errorBuilder, + }) { + _productPopupBuilder = productPopupBuilder; + _productPopupBuilder ??= + (BuildContext context, ProductPageProduct product) => ProductItemPopup( + product: product, + configuration: this, + ); + + _onProductDetail = onProductDetail; + _onProductDetail ??= + (BuildContext context, ProductPageProduct product) async { + var theme = Theme.of(context); + + await showModalBottomSheet( + context: context, + backgroundColor: theme.colorScheme.surface, + builder: (context) => _productPopupBuilder!( + context, + product, + ), + ); + }; + + _noContentBuilder = noContentBuilder; + _noContentBuilder ??= (BuildContext context) { + var theme = Theme.of(context); + return Center( + child: Text( + "No content", + style: theme.textTheme.titleLarge, + ), + ); + }; + + _errorBuilder = errorBuilder; + _errorBuilder ??= + (BuildContext context, Object? error, StackTrace? stackTrace) { + var theme = Theme.of(context); + return Center( + child: Text( + "Error: $error", + style: theme.textTheme.titleLarge, + ), + ); + }; + + _getDiscountDescription = getDiscountDescription; + _getDiscountDescription ??= + (ProductPageProduct product) => "${product.name} is on sale!"; + } + + /// The shop that is initially selected. + final String? initialShopId; + + /// A list of all the shops that the user must be able to navigate from. + final Future> shops; + + /// A function that returns all the products that belong to a certain shop. + /// The function must return a [ProductPageContent] object. + final Future Function(ProductPageShop shop) getProducts; + + /// The localizations for the product page. + final ProductPageLocalization localizations; + + /// Builder for the product item. These items will be displayed in the list + /// for each product in their seperated category. This builder should only + /// build the widget for one specific product. This builder has a default + /// in-case the developer does not override it. + Widget Function(BuildContext context, ProductPageProduct product)? + productBuilder; + + late Widget Function(BuildContext context, ProductPageProduct product)? + _productPopupBuilder; + + /// The builder for the product popup. This popup will be displayed when the + /// user clicks on a product. This builder should only build the widget that + /// displays the content of one specific product. + /// This builder has a default in-case the developer + Widget Function(BuildContext context, ProductPageProduct product) + get productPopupBuilder => _productPopupBuilder!; + + late Function(BuildContext context, ProductPageProduct product)? + _onProductDetail; + + /// This function handles the creation of the product detail popup. This + /// function has a default in-case the developer does not override it. + /// The default intraction is a popup, but this can be overriden. + Function(BuildContext context, ProductPageProduct product) + get onProductDetail => _onProductDetail!; + + late Widget Function(BuildContext context)? _noContentBuilder; + + /// The no content builder is used when a shop has no products. This builder + /// has a default in-case the developer does not override it. + Function(BuildContext context)? get noContentBuilder => _noContentBuilder; + + /// The builder for the shopping cart. This builder should return a widget + /// that navigates to the shopping cart overview page. + Widget Function(BuildContext context)? navigateToShoppingCartBuilder; + + late Widget Function( + BuildContext context, + Object? error, + StackTrace? stackTrace, + )? _errorBuilder; + + /// The error builder is used when an error occurs. This builder has a default + /// in-case the developer does not override it. + Widget Function(BuildContext context, Object? error, StackTrace? stackTrace)? + get errorBuilder => _errorBuilder; + + late String Function(ProductPageProduct product)? _getDiscountDescription; + + /// The function that returns the description of the discount for a product. + /// This allows you to translate and give custom messages for each product. + String Function(ProductPageProduct product)? get getDiscountDescription => + _getDiscountDescription!; + + /// This function must be implemented by the developer and should handle the + /// adding of a product to the cart. + Function(ProductPageProduct product) onAddToCart; + + /// This function gets executed when the user changes the shop selection. + /// This function always fires upon first load with the initial shop as well. + final Function(ProductPageShop shop)? onShopSelectionChange; + + /// This function must be implemented by the developer and should handle the + /// navigation to the shopping cart overview page. + final int Function()? getProductsInShoppingCart; + + /// This function must be implemented by the developer and should handle the + /// navigation to the shopping cart overview page. + final Function() onNavigateToShoppingCart; + + /// The style of the shop selector. + final ShopSelectorStyle shopSelectorStyle; + + /// The styling configuration for the category list. + final ProductPageCategoryStylingConfiguration categoryStylingConfiguration; + + /// The padding for the page. + final EdgeInsets pagePadding; + + /// Optional app bar that you can pass to the product page screen. + final Widget? bottomNavigationBar; + + /// Optional app bar that you can pass to the order detail screen. + final PreferredSizeWidget? appBar; +} diff --git a/packages/flutter_product_page/lib/src/configuration/product_page_content.dart b/packages/flutter_product_page/lib/src/configuration/product_page_content.dart new file mode 100644 index 0000000..ec172a4 --- /dev/null +++ b/packages/flutter_product_page/lib/src/configuration/product_page_content.dart @@ -0,0 +1,16 @@ +import "package:flutter_product_page/flutter_product_page.dart"; + +/// Return type that contains the products and an optional discounted product. +class ProductPageContent { + /// Default constructor for this class. + const ProductPageContent({ + required this.products, + this.discountedProduct, + }); + + /// List of products that belong to the shop. + final List products; + + /// Optional highlighted discounted product to display. + final ProductPageProduct? discountedProduct; +} diff --git a/packages/flutter_product_page/lib/src/configuration/product_page_localization.dart b/packages/flutter_product_page/lib/src/configuration/product_page_localization.dart new file mode 100644 index 0000000..47990f6 --- /dev/null +++ b/packages/flutter_product_page/lib/src/configuration/product_page_localization.dart @@ -0,0 +1,22 @@ +/// Localization for the product page +class ProductPageLocalization { + /// Default constructor + const ProductPageLocalization({ + this.navigateToShoppingCart = "To shopping cart", + this.discountTitle = "Discount", + this.failedToLoadImageExplenation = "Failed to load image", + this.close = "Close", + }); + + /// Message to navigate to the shopping cart + final String navigateToShoppingCart; + + /// Title for the discount + final String discountTitle; + + /// Explenation when the image failed to load + final String failedToLoadImageExplenation; + + /// Close button for the product page + final String close; +} diff --git a/packages/flutter_product_page/lib/src/configuration/product_page_shop_selector_style.dart b/packages/flutter_product_page/lib/src/configuration/product_page_shop_selector_style.dart new file mode 100644 index 0000000..e37c07a --- /dev/null +++ b/packages/flutter_product_page/lib/src/configuration/product_page_shop_selector_style.dart @@ -0,0 +1,8 @@ +/// Style for the shop selector in the product page. +enum ShopSelectorStyle { + /// Shops are displayed in a row. + row, + + /// Shops are displayed in a wrap. + spacedWrap, +} diff --git a/packages/flutter_product_page/lib/src/models/product.dart b/packages/flutter_product_page/lib/src/models/product.dart new file mode 100644 index 0000000..4e09fdb --- /dev/null +++ b/packages/flutter_product_page/lib/src/models/product.dart @@ -0,0 +1,26 @@ +/// The product page shop class contains all the required information +/// +/// This is a mixin class because another package will implement it, and the +/// 'MyProduct' class might have to extend another class as well. +mixin ProductPageProduct { + /// The unique identifier for the product. + String get id; + + /// The name of the product. + String get name; + + /// The image URL of the product. + String get imageUrl; + + /// The category of the product. + String get category; + + /// The price of the product. + double get price; + + /// Whether the product has a discount or not. + bool get hasDiscount; + + /// The discounted price of the product. Only used if [hasDiscount] is true. + double? get discountPrice; +} diff --git a/packages/flutter_product_page/lib/src/models/product_page_shop.dart b/packages/flutter_product_page/lib/src/models/product_page_shop.dart new file mode 100644 index 0000000..a4d0aa9 --- /dev/null +++ b/packages/flutter_product_page/lib/src/models/product_page_shop.dart @@ -0,0 +1,18 @@ +/// The product page shop class contains all the required information +/// that needs to be known about a certain shop. +/// +/// In your own implemententation, you must extend from this class so you can +/// add more fields to this class to suit your needs. +class ProductPageShop { + /// The default constructor for this class. + const ProductPageShop({ + required this.id, + required this.name, + }); + + /// The unique identifier for the shop. + final String id; + + /// The name of the shop. + final String name; +} diff --git a/packages/flutter_product_page/lib/src/services/category_service.dart b/packages/flutter_product_page/lib/src/services/category_service.dart new file mode 100644 index 0000000..76541be --- /dev/null +++ b/packages/flutter_product_page/lib/src/services/category_service.dart @@ -0,0 +1,73 @@ +import "package:flutter/material.dart"; +import "package:flutter_nested_categories/flutter_nested_categories.dart"; +import "package:flutter_product_page/flutter_product_page.dart"; +import "package:flutter_product_page/src/services/shopping_cart_notifier.dart"; +import "package:flutter_product_page/src/ui/components/product_item.dart"; + +/// A function that is called when a product is added to the cart. +ProductPageProduct onAddToCartWrapper( + ProductPageConfiguration configuration, + ShoppingCartNotifier shoppingCartNotifier, + ProductPageProduct product, +) { + shoppingCartNotifier.productsChanged(); + + configuration.onAddToCart(product); + + return product; +} + +/// Generates a [CategoryList] from a list of [Product]s and a +/// [ProductPageConfiguration]. +CategoryList getCategoryList( + BuildContext context, + ProductPageConfiguration configuration, + ShoppingCartNotifier shoppingCartNotifier, + List products, +) { + var categorizedProducts = >{}; + for (var product in products) { + if (!categorizedProducts.containsKey(product.category)) { + categorizedProducts[product.category] = []; + } + categorizedProducts[product.category]?.add(product); + } + + // Create Category instances + var categories = []; + categorizedProducts.forEach((categoryName, productList) { + var productWidgets = productList + .map( + (product) => configuration.productBuilder != null + ? configuration.productBuilder!(context, product) + : ProductItem( + product: product, + onProductDetail: configuration.onProductDetail, + onAddToCart: (ProductPageProduct product) => + onAddToCartWrapper( + configuration, + shoppingCartNotifier, + product, + ), + localizations: configuration.localizations, + ), + ) + .toList(); + var category = Category( + name: categoryName, + content: productWidgets, + ); + categories.add(category); + }); + + return CategoryList( + title: configuration.categoryStylingConfiguration.title, + titleStyle: configuration.categoryStylingConfiguration.titleStyle, + customTitle: configuration.categoryStylingConfiguration.customTitle, + headerCentered: configuration.categoryStylingConfiguration.headerCentered, + headerStyling: configuration.categoryStylingConfiguration.headerStyling, + isCategoryCollapsible: + configuration.categoryStylingConfiguration.isCategoryCollapsible, + content: categories, + ); +} diff --git a/packages/flutter_product_page/lib/src/services/selected_shop_service.dart b/packages/flutter_product_page/lib/src/services/selected_shop_service.dart new file mode 100644 index 0000000..502eaa5 --- /dev/null +++ b/packages/flutter_product_page/lib/src/services/selected_shop_service.dart @@ -0,0 +1,21 @@ +import "package:flutter/material.dart"; +import "package:flutter_product_page/src/models/product_page_shop.dart"; + +/// A service that provides the currently selected shop. +class SelectedShopService extends ChangeNotifier { + /// Creates a [SelectedShopService]. + SelectedShopService(); + + ProductPageShop? _selectedShop; + + /// Updates the selected shop. + void selectShop(ProductPageShop shop) { + if (_selectedShop == shop) return; + + _selectedShop = shop; + notifyListeners(); + } + + /// The currently selected shop. + ProductPageShop? get selectedShop => _selectedShop; +} diff --git a/packages/flutter_product_page/lib/src/services/shopping_cart_notifier.dart b/packages/flutter_product_page/lib/src/services/shopping_cart_notifier.dart new file mode 100644 index 0000000..d02251f --- /dev/null +++ b/packages/flutter_product_page/lib/src/services/shopping_cart_notifier.dart @@ -0,0 +1,10 @@ +import "package:flutter/material.dart"; + +/// Class that notifies listeners when the products in the shopping cart have +/// changed. +class ShoppingCartNotifier extends ChangeNotifier { + /// Notifies listeners that the products in the shopping cart have changed. + void productsChanged() { + notifyListeners(); + } +} diff --git a/packages/flutter_product_page/lib/src/ui/components/product_item.dart b/packages/flutter_product_page/lib/src/ui/components/product_item.dart new file mode 100644 index 0000000..05ed65d --- /dev/null +++ b/packages/flutter_product_page/lib/src/ui/components/product_item.dart @@ -0,0 +1,195 @@ +import "package:cached_network_image/cached_network_image.dart"; +import "package:flutter/material.dart"; +import "package:flutter_product_page/flutter_product_page.dart"; +import "package:skeletonizer/skeletonizer.dart"; + +/// Product item widget. +class ProductItem extends StatelessWidget { + /// Constructor for the product item widget. + const ProductItem({ + required this.product, + required this.onProductDetail, + required this.onAddToCart, + required this.localizations, + super.key, + }); + + /// Product to display. + final ProductPageProduct product; + + /// Function to call when the product detail is requested. + final Function(BuildContext context, ProductPageProduct selectedProduct) + onProductDetail; + + /// Function to call when the product is added to the cart. + final Function(ProductPageProduct selectedProduct) onAddToCart; + + /// Localizations for the product page. + final ProductPageLocalization localizations; + + /// Size of the product image. + static const double imageSize = 44; + + @override + Widget build(BuildContext context) { + var theme = Theme.of(context); + + var loadingImageSkeleton = const Skeletonizer.zone( + child: SizedBox(width: imageSize, height: imageSize, child: Bone.icon()), + ); + + var productIcon = ClipRRect( + borderRadius: BorderRadius.circular(4), + child: CachedNetworkImage( + imageUrl: product.imageUrl, + width: imageSize, + height: imageSize, + fit: BoxFit.cover, + placeholder: (context, url) => loadingImageSkeleton, + errorWidget: (context, url, error) => Tooltip( + message: localizations.failedToLoadImageExplenation, + child: Container( + width: 48, + height: 48, + alignment: Alignment.center, + child: const Icon( + Icons.error_outline_sharp, + color: Colors.red, + ), + ), + ), + ), + ); + + var productName = Padding( + padding: const EdgeInsets.only(left: 8), + child: Container( + constraints: const BoxConstraints(maxWidth: 150), + child: Text( + product.name, + style: theme.textTheme.titleMedium, + ), + ), + ); + + var productInformationIcon = Padding( + padding: const EdgeInsets.only(left: 4), + child: IconButton( + onPressed: () => onProductDetail(context, product), + icon: const Icon(Icons.info_outline), + ), + ); + + var productInteraction = Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + _PriceLabel( + price: product.price, + discountPrice: (product.hasDiscount && product.discountPrice != null) + ? product.discountPrice + : null, + ), + _AddToCardButton( + product: product, + onAddToCart: onAddToCart, + ), + ], + ); + + return Padding( + padding: const EdgeInsets.only(top: 12, bottom: 8), + child: Row( + children: [ + productIcon, + productName, + productInformationIcon, + const Spacer(), + productInteraction, + ], + ), + ); + } +} + +class _PriceLabel extends StatelessWidget { + const _PriceLabel({ + required this.price, + required this.discountPrice, + }); + + final double price; + final double? discountPrice; + + @override + Widget build(BuildContext context) { + var theme = Theme.of(context); + + if (discountPrice == null) + return Text( + price.toStringAsFixed(2), + style: theme.textTheme.bodyMedium, + ); + else + return Row( + children: [ + Text( + price.toStringAsFixed(2), + style: theme.textTheme.bodySmall?.copyWith( + fontSize: 10, + color: theme.colorScheme.primary, + decoration: TextDecoration.lineThrough, + ), + ), + Padding( + padding: const EdgeInsets.only(left: 4.0), + child: Text( + discountPrice!.toStringAsFixed(2), + style: theme.textTheme.bodyMedium, + ), + ), + ], + ); + } +} + +class _AddToCardButton extends StatelessWidget { + const _AddToCardButton({ + required this.product, + required this.onAddToCart, + }); + + final ProductPageProduct product; + final Function(ProductPageProduct product) onAddToCart; + + static const double boxSize = 29; + + @override + Widget build(BuildContext context) { + var theme = Theme.of(context); + return SizedBox( + width: boxSize, + height: boxSize, + child: Center( + child: IconButton( + padding: EdgeInsets.zero, + icon: Icon( + Icons.add, + color: theme.primaryColor, + size: 20, + ), + onPressed: () => onAddToCart(product), + style: ButtonStyle( + backgroundColor: WidgetStateProperty.all( + theme.colorScheme.secondary, + ), + shape: WidgetStateProperty.all( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(4), + ), + ), + ), + ), + ), + ); + } +} diff --git a/packages/flutter_product_page/lib/src/ui/components/shop_selector.dart b/packages/flutter_product_page/lib/src/ui/components/shop_selector.dart new file mode 100644 index 0000000..c9b3b76 --- /dev/null +++ b/packages/flutter_product_page/lib/src/ui/components/shop_selector.dart @@ -0,0 +1,63 @@ +import "package:flutter/material.dart"; +import "package:flutter_product_page/flutter_product_page.dart"; +import "package:flutter_product_page/src/services/selected_shop_service.dart"; +import "package:flutter_product_page/src/ui/widgets/horizontal_list_items.dart"; +import "package:flutter_product_page/src/ui/widgets/spaced_wrap.dart"; + +/// Shop selector widget that displays a list to navigate between shops. +class ShopSelector extends StatelessWidget { + /// Constructor for the shop selector. + const ShopSelector({ + required this.configuration, + required this.selectedShopService, + required this.shops, + required this.onTap, + this.paddingBetweenButtons = 4, + this.paddingOnButtons = 8, + super.key, + }); + + /// Configuration for the product page. + final ProductPageConfiguration configuration; + + /// Service for the selected shop. + final SelectedShopService selectedShopService; + + /// List of shops. + final List shops; + + /// Callback when a shop is tapped. + final Function(ProductPageShop shop) onTap; + + /// Padding between the buttons. + final double paddingBetweenButtons; + + /// Padding on the buttons. + final double paddingOnButtons; + + @override + Widget build(BuildContext context) { + if (shops.length == 1) { + return const SizedBox.shrink(); + } + + if (configuration.shopSelectorStyle == ShopSelectorStyle.spacedWrap) { + return SpacedWrap( + shops: shops, + selectedItem: selectedShopService.selectedShop!.id, + onTap: onTap, + width: MediaQuery.of(context).size.width - (16 * 2), + paddingBetweenButtons: paddingBetweenButtons, + paddingOnButtons: paddingOnButtons, + ); + } + + return HorizontalListItems( + shops: shops, + selectedItem: selectedShopService.selectedShop!.id, + onTap: onTap, + paddingBetweenButtons: paddingBetweenButtons, + paddingOnButtons: paddingOnButtons, + ); + } +} diff --git a/packages/flutter_product_page/lib/src/ui/components/weekly_discount.dart b/packages/flutter_product_page/lib/src/ui/components/weekly_discount.dart new file mode 100644 index 0000000..80da812 --- /dev/null +++ b/packages/flutter_product_page/lib/src/ui/components/weekly_discount.dart @@ -0,0 +1,127 @@ +import "package:cached_network_image/cached_network_image.dart"; +import "package:flutter/material.dart"; +import "package:flutter_product_page/flutter_product_page.dart"; + +/// A widget that displays a weekly discount. +class WeeklyDiscount extends StatelessWidget { + /// Creates a weekly discount. + const WeeklyDiscount({ + required this.configuration, + required this.product, + super.key, + }); + + /// Configuration for the product page. + final ProductPageConfiguration configuration; + + /// The product for which the discount is displayed. + final ProductPageProduct product; + + /// The top padding of the widget. + static const double topPadding = 32.0; + + @override + Widget build(BuildContext context) { + var theme = Theme.of(context); + + var bottomText = Padding( + padding: const EdgeInsets.all(20.0), + child: Text( + configuration.getDiscountDescription!(product), + style: theme.textTheme.titleMedium?.copyWith( + color: theme.colorScheme.primary, + ), + textAlign: TextAlign.left, + ), + ); + + var loadingImage = const Padding( + padding: EdgeInsets.all(32.0), + child: Center( + child: CircularProgressIndicator.adaptive(), + ), + ); + + var errorImage = Padding( + padding: const EdgeInsets.all(32.0), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.error_outline_rounded, + color: Colors.red, + ), + Text(configuration.localizations.failedToLoadImageExplenation), + ], + ), + ), + ); + + var image = Padding( + padding: const EdgeInsets.symmetric(horizontal: 1.0), + child: AspectRatio( + aspectRatio: 1, + child: CachedNetworkImage( + imageUrl: product.imageUrl, + width: double.infinity, + fit: BoxFit.cover, + placeholder: (context, url) => loadingImage, + errorWidget: (context, url, error) => errorImage, + ), + ), + ); + + var topText = DecoratedBox( + decoration: BoxDecoration( + color: theme.primaryColor, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(4), + topRight: Radius.circular(4), + ), + ), + child: SizedBox( + width: double.infinity, + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 8, + horizontal: 16, + ), + child: Text( + configuration.localizations.discountTitle.toUpperCase(), + style: theme.textTheme.titleMedium?.copyWith( + color: theme.colorScheme.onPrimary, + ), + textAlign: TextAlign.left, + ), + ), + ), + ); + + var boxDecoration = BoxDecoration( + border: Border.all( + color: theme.primaryColor, + width: 1.0, + ), + borderRadius: BorderRadius.circular(4.0), + ); + + return Padding( + padding: const EdgeInsets.only(top: topPadding), + child: DecoratedBox( + decoration: boxDecoration, + child: SizedBox( + width: double.infinity, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + topText, + image, + bottomText, + ], + ), + ), + ), + ); + } +} diff --git a/packages/flutter_product_page/lib/src/ui/product_page.dart b/packages/flutter_product_page/lib/src/ui/product_page.dart new file mode 100644 index 0000000..5c99990 --- /dev/null +++ b/packages/flutter_product_page/lib/src/ui/product_page.dart @@ -0,0 +1,288 @@ +import "package:flutter/material.dart"; +import "package:flutter_product_page/flutter_product_page.dart"; +import "package:flutter_product_page/src/services/category_service.dart"; +import "package:flutter_product_page/src/services/selected_shop_service.dart"; +import "package:flutter_product_page/src/services/shopping_cart_notifier.dart"; +import "package:flutter_product_page/src/ui/components/shop_selector.dart"; +import "package:flutter_product_page/src/ui/components/weekly_discount.dart"; + +/// A page that displays products. +class ProductPage extends StatelessWidget { + /// Constructor for the product page. + ProductPage({ + required this.configuration, + this.initialBuildShopId, + super.key, + }); + + /// Configuration for the product page. + final ProductPageConfiguration configuration; + + /// An optional initial shop ID to select. This overrides the initialShopId + /// from the configuration. + final String? initialBuildShopId; + + late final SelectedShopService _selectedShopService = SelectedShopService(); + + late final ShoppingCartNotifier _shoppingCartNotifier = + ShoppingCartNotifier(); + + @override + Widget build(BuildContext context) => Padding( + padding: configuration.pagePadding, + child: FutureBuilder( + future: configuration.shops, + builder: (BuildContext context, AsyncSnapshot data) { + if (data.connectionState == ConnectionState.waiting) { + return const Align( + alignment: Alignment.center, + child: CircularProgressIndicator.adaptive(), + ); + } + + if (data.hasError) { + return configuration.errorBuilder!( + context, + data.error, + data.stackTrace, + ); + } + + List? shops = data.data; + + if (shops == null || shops.isEmpty) { + return configuration.errorBuilder!(context, null, null); + } + + if (initialBuildShopId != null) { + ProductPageShop? initialShop; + + for (var shop in shops) { + if (shop.id == initialBuildShopId) { + initialShop = shop; + break; + } + } + + _selectedShopService.selectShop(initialShop ?? shops.first); + } else if (configuration.initialShopId != null) { + ProductPageShop? initialShop; + + for (var shop in shops) { + if (shop.id == configuration.initialShopId) { + initialShop = shop; + break; + } + } + + _selectedShopService.selectShop(initialShop ?? shops.first); + } else { + _selectedShopService.selectShop(shops.first); + } + + return ListenableBuilder( + listenable: _selectedShopService, + builder: (BuildContext context, Widget? _) { + configuration.onShopSelectionChange?.call( + _selectedShopService.selectedShop!, + ); + return _ProductPage( + configuration: configuration, + selectedShopService: _selectedShopService, + shoppingCartNotifier: _shoppingCartNotifier, + shops: shops, + ); + }, + ); + }, + ), + ); +} + +class _ProductPage extends StatelessWidget { + const _ProductPage({ + required this.configuration, + required this.selectedShopService, + required this.shoppingCartNotifier, + required this.shops, + }); + + final ProductPageConfiguration configuration; + final SelectedShopService selectedShopService; + final ShoppingCartNotifier shoppingCartNotifier; + + final List shops; + + void _onTapChangeShop(ProductPageShop shop) { + selectedShopService.selectShop(shop); + } + + @override + Widget build(BuildContext context) { + var pageContent = SingleChildScrollView( + child: Column( + children: [ + ShopSelector( + configuration: configuration, + selectedShopService: selectedShopService, + shops: shops, + onTap: _onTapChangeShop, + ), + _ShopContents( + configuration: configuration, + selectedShopService: selectedShopService, + shoppingCartNotifier: shoppingCartNotifier, + ), + ], + ), + ); + + return Stack( + children: [ + pageContent, + Align( + alignment: Alignment.bottomCenter, + child: configuration.navigateToShoppingCartBuilder != null + ? configuration.navigateToShoppingCartBuilder!(context) + : _NavigateToShoppingCartButton( + configuration: configuration, + shoppingCartNotifier: shoppingCartNotifier, + ), + ), + ], + ); + } +} + +class _NavigateToShoppingCartButton extends StatelessWidget { + const _NavigateToShoppingCartButton({ + required this.configuration, + required this.shoppingCartNotifier, + }); + + final ProductPageConfiguration configuration; + final ShoppingCartNotifier shoppingCartNotifier; + + @override + Widget build(BuildContext context) { + var theme = Theme.of(context); + + String getProductsInShoppingCartLabel() { + var fun = configuration.getProductsInShoppingCart; + + if (fun == null) { + return ""; + } + + return "(${fun()})"; + } + + return FilledButton( + onPressed: configuration.onNavigateToShoppingCart, + style: theme.filledButtonTheme.style?.copyWith( + backgroundColor: WidgetStateProperty.all( + theme.colorScheme.primary, + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 8.0, + ), + child: ListenableBuilder( + listenable: shoppingCartNotifier, + builder: (BuildContext context, Widget? _) => Text( + """${configuration.localizations.navigateToShoppingCart.toUpperCase()} ${getProductsInShoppingCartLabel()}""", + style: theme.textTheme.titleMedium?.copyWith( + color: theme.colorScheme.onPrimary, + ), + ), + ), + ), + ); + } +} + +class _ShopContents extends StatelessWidget { + const _ShopContents({ + required this.configuration, + required this.selectedShopService, + required this.shoppingCartNotifier, + }); + + final ProductPageConfiguration configuration; + final SelectedShopService selectedShopService; + final ShoppingCartNotifier shoppingCartNotifier; + + @override + Widget build(BuildContext context) => Padding( + padding: EdgeInsets.symmetric( + horizontal: configuration.pagePadding.horizontal, + ), + child: FutureBuilder( + // ignore: discarded_futures + future: configuration.getProducts( + selectedShopService.selectedShop!, + ), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Align( + alignment: Alignment.center, + child: CircularProgressIndicator.adaptive(), + ); + } + + if (snapshot.hasError) { + return configuration.errorBuilder!( + context, + snapshot.error, + snapshot.stackTrace, + ); + } + + var productPageContent = snapshot.data; + + if (productPageContent == null || + productPageContent.products.isEmpty) { + return configuration.noContentBuilder!(context); + } + + var productList = Padding( + padding: const EdgeInsets.fromLTRB(16, 24, 16, 8), + child: Column( + children: [ + // Products + getCategoryList( + context, + configuration, + shoppingCartNotifier, + productPageContent.products, + ), + + // Bottom padding so the last product is not cut off + // by the to shopping cart button. + const SizedBox(height: 48), + ], + ), + ); + + return Column( + children: [ + // Discounted product + if (productPageContent.discountedProduct != null) ...[ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: WeeklyDiscount( + configuration: configuration, + product: productPageContent.discountedProduct!, + ), + ), + ], + + productList, + ], + ); + }, + ), + ); +} diff --git a/packages/flutter_product_page/lib/src/ui/product_page_screen.dart b/packages/flutter_product_page/lib/src/ui/product_page_screen.dart new file mode 100644 index 0000000..959626b --- /dev/null +++ b/packages/flutter_product_page/lib/src/ui/product_page_screen.dart @@ -0,0 +1,36 @@ +import "package:flutter/material.dart"; +import "package:flutter_product_page/src/configuration/product_page_configuration.dart"; +import "package:flutter_product_page/src/ui/product_page.dart"; + +/// A screen that displays a product page. This screen contains a Scaffold, +/// in which the body is a SafeArea that contains a ProductPage widget. +/// +/// If you do not wish to create a Scaffold you can use the +/// [ProductPage] widget directly. +class ProductPageScreen extends StatelessWidget { + /// Constructor for the product page screen. + const ProductPageScreen({ + required this.configuration, + this.initialBuildShopId, + super.key, + }); + + /// Configuration for the product page. + final ProductPageConfiguration configuration; + + /// An optional initial shop ID to select. This overrides the initialShopId + /// from the configuration. + final String? initialBuildShopId; + + @override + Widget build(BuildContext context) => Scaffold( + body: SafeArea( + child: ProductPage( + configuration: configuration, + initialBuildShopId: initialBuildShopId, + ), + ), + appBar: configuration.appBar, + bottomNavigationBar: configuration.bottomNavigationBar, + ); +} diff --git a/packages/flutter_product_page/lib/src/ui/widgets/horizontal_list_items.dart b/packages/flutter_product_page/lib/src/ui/widgets/horizontal_list_items.dart new file mode 100644 index 0000000..8079723 --- /dev/null +++ b/packages/flutter_product_page/lib/src/ui/widgets/horizontal_list_items.dart @@ -0,0 +1,73 @@ +import "package:flutter/material.dart"; +import "package:flutter_product_page/flutter_product_page.dart"; + +/// Horizontal list of items. +class HorizontalListItems extends StatelessWidget { + /// Constructor for the horizontal list of items. + const HorizontalListItems({ + required this.shops, + required this.selectedItem, + required this.onTap, + this.paddingBetweenButtons = 2.0, + this.paddingOnButtons = 4, + super.key, + }); + + /// List of items. + final List shops; + + /// Selected item. + final String selectedItem; + + /// Padding between the buttons. + final double paddingBetweenButtons; + + /// Padding on the buttons. + final double paddingOnButtons; + + /// Callback when an item is tapped. + final Function(ProductPageShop shop) onTap; + + @override + Widget build(BuildContext context) { + var theme = Theme.of(context); + + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: shops + .map( + (shop) => Padding( + padding: EdgeInsets.only(right: paddingBetweenButtons), + child: InkWell( + onTap: () => onTap(shop), + child: Container( + decoration: BoxDecoration( + color: shop.id == selectedItem + ? theme.colorScheme.primary + : theme.colorScheme.secondary, + borderRadius: BorderRadius.circular(4), + border: Border.all( + color: theme.colorScheme.primary, + width: 1, + ), + ), + padding: EdgeInsets.all(paddingOnButtons), + child: Text( + shop.name, + style: shop.id == selectedItem + ? theme.textTheme.bodyMedium?.copyWith( + color: Colors.white, + fontWeight: FontWeight.bold, + ) + : theme.textTheme.bodyMedium, + ), + ), + ), + ), + ) + .toList(), + ), + ); + } +} diff --git a/packages/flutter_product_page/lib/src/ui/widgets/product_item_popup.dart b/packages/flutter_product_page/lib/src/ui/widgets/product_item_popup.dart new file mode 100644 index 0000000..5186ef5 --- /dev/null +++ b/packages/flutter_product_page/lib/src/ui/widgets/product_item_popup.dart @@ -0,0 +1,69 @@ +import "package:flutter/material.dart"; +import "package:flutter_product_page/flutter_product_page.dart"; + +/// A popup that displays the product item. +class ProductItemPopup extends StatelessWidget { + /// Constructor for the product item popup. + const ProductItemPopup({ + required this.product, + required this.configuration, + super.key, + }); + + /// The product to display. + final ProductPageProduct product; + + /// Configuration for the product page. + final ProductPageConfiguration configuration; + + @override + Widget build(BuildContext context) { + var theme = Theme.of(context); + + var productDescription = Padding( + padding: const EdgeInsets.fromLTRB(44, 32, 44, 20), + child: Text( + product.name, + textAlign: TextAlign.center, + ), + ); + + var closeButton = Padding( + padding: const EdgeInsets.fromLTRB(80, 0, 80, 32), + child: SizedBox( + width: 254, + child: ElevatedButton( + style: theme.elevatedButtonTheme.style?.copyWith( + shape: WidgetStateProperty.all( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(6), + ), + ), + ), + onPressed: () => Navigator.of(context).pop(), + child: Padding( + padding: const EdgeInsets.all(14), + child: Text( + configuration.localizations.close, + style: theme.textTheme.titleMedium?.copyWith( + color: theme.colorScheme.onSurface, + ), + ), + ), + ), + ), + ); + + return SingleChildScrollView( + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + productDescription, + closeButton, + ], + ), + ), + ); + } +} diff --git a/packages/flutter_product_page/lib/src/ui/widgets/spaced_wrap.dart b/packages/flutter_product_page/lib/src/ui/widgets/spaced_wrap.dart new file mode 100644 index 0000000..9bfa8b4 --- /dev/null +++ b/packages/flutter_product_page/lib/src/ui/widgets/spaced_wrap.dart @@ -0,0 +1,150 @@ +import "package:flutter/material.dart"; +import "package:flutter_product_page/flutter_product_page.dart"; + +/// SpacedWrap is a widget that wraps a list of items that are spaced out and +/// fill the available width. +class SpacedWrap extends StatelessWidget { + /// Creates a [SpacedWrap]. + const SpacedWrap({ + required this.shops, + required this.onTap, + required this.width, + this.paddingBetweenButtons = 2.0, + this.paddingOnButtons = 4.0, + this.selectedItem = "", + super.key, + }); + + /// List of items. + final List shops; + + /// Selected item. + final String selectedItem; + + /// Width of the widget. + final double width; + + /// Padding between the buttons. + final double paddingBetweenButtons; + + /// Padding on the buttons. + final double paddingOnButtons; + + /// Callback when an item is tapped. + final Function(ProductPageShop shop) onTap; + + Row _buildRow( + BuildContext context, + List currentRow, + double availableRowLength, + ) { + var theme = Theme.of(context); + + var row = []; + var extraButtonPadding = availableRowLength / currentRow.length / 2; + + for (var i = 0, len = currentRow.length; i < len; i++) { + var shop = shops[currentRow[i]]; + row.add( + Padding( + padding: EdgeInsets.only(top: paddingBetweenButtons), + child: InkWell( + onTap: () => onTap(shop), + child: Container( + decoration: BoxDecoration( + color: shop.id == selectedItem + ? theme.colorScheme.primary + : theme.colorScheme.secondary, + borderRadius: BorderRadius.circular(4), + border: Border.all( + color: theme.colorScheme.primary, + width: 1, + ), + ), + padding: EdgeInsets.symmetric( + horizontal: paddingOnButtons + extraButtonPadding, + vertical: paddingOnButtons, + ), + child: Text( + shop.name, + style: shop.id == selectedItem + ? theme.textTheme.bodyMedium?.copyWith( + color: Colors.white, + fontWeight: FontWeight.bold, + ) + : theme.textTheme.bodyMedium, + ), + ), + ), + ), + ); + if (shops.last != shop) { + row.add(const Spacer()); + } + } + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: row, + ); + } + + List _buildButtonRows(BuildContext context) { + var theme = Theme.of(context); + var rows = []; + var currentRow = []; + var availableRowLength = width; + + for (var i = 0; i < shops.length; i++) { + var shop = shops[i]; + + var textPainter = TextPainter( + text: TextSpan( + text: shop.name, + style: shop.id == selectedItem + ? theme.textTheme.bodyMedium?.copyWith( + color: Colors.white, + fontWeight: FontWeight.bold, + ) + : theme.textTheme.bodyMedium, + ), + maxLines: 1, + textDirection: TextDirection.ltr, + )..layout(minWidth: 0, maxWidth: double.infinity); + + var buttonWidth = textPainter.width + paddingOnButtons * 2; + + if (availableRowLength - buttonWidth < 0) { + rows.add( + _buildRow( + context, + currentRow, + availableRowLength, + ), + ); + currentRow = []; + availableRowLength = width; + } + + currentRow.add(i); + + availableRowLength -= buttonWidth + paddingBetweenButtons; + } + if (currentRow.isNotEmpty) { + rows.add( + _buildRow( + context, + currentRow, + availableRowLength, + ), + ); + } + return rows; + } + + @override + Widget build(BuildContext context) => Column( + children: _buildButtonRows( + context, + ), + ); +} diff --git a/packages/flutter_product_page/pubspec.yaml b/packages/flutter_product_page/pubspec.yaml new file mode 100644 index 0000000..b345fe2 --- /dev/null +++ b/packages/flutter_product_page/pubspec.yaml @@ -0,0 +1,39 @@ +name: flutter_product_page +description: "A Flutter module for the product page" +publish_to: 'none' +version: 1.0.0+1 +homepage: + +environment: + sdk: '>=3.3.4 <4.0.0' + +dependencies: + flutter: + sdk: flutter + skeletonizer: ^1.1.1 + cached_network_image: ^3.3.1 + flutter_nested_categories: + git: + url: https://github.com/Iconica-Development/flutter_nested_categories + ref: 0.0.1 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_iconica_analysis: + git: + url: https://github.com/Iconica-Development/flutter_iconica_analysis + ref: 7.0.0 + +flutter: + uses-material-design: true + + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic