feat: add flutter_product_page package

This commit is contained in:
Freek van de Ven 2024-06-26 15:24:28 +02:00
parent 67dbce85aa
commit 5082dab216
34 changed files with 2138 additions and 0 deletions

View file

@ -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/

View file

@ -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
<!-- omit in toc -->
**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<TestObject> objects = [];
/// for (int i = 0; i < 10; i++) {
/// objects.add(TestObject(name: "name", id: i));
/// }
///
/// sort<TestObject>(
/// 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<T>(
/// Determines the sorting direction, can be either Ascending or Descending
SortDirection sortDirection,
/// Incoming list, which gets sorted
List<T> toSort, [
/// Optional comparable, which is only necessary for complex types
SortFieldGetter<T>? sortValueCallback,
]) {
if (toSort.length < 2) return;
assert(
toSort.whereType<Comparable>().isNotEmpty || sortValueCallback != null);
BidirectionalSorter<T>(
sortInstructions: <SortInstruction<T>>[
SortInstruction(
sortValueCallback ?? (t) => t as Comparable, sortDirection),
],
).sort(toSort);
}
/// same functionality as [sort] but with the added functionality
/// of sorting multiple values
void sortMulti<T>(
/// Incoming list, which gets sorted
List<T> toSort,
/// list of comparables to sort multiple values at once,
/// priority based on index
List<SortInstruction<T>> sortValueCallbacks,
) {
if (toSort.length < 2) return;
assert(sortValueCallbacks.isNotEmpty);
BidirectionalSorter<T>(
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.

View file

@ -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.

View file

@ -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 <support@iconica.nl>

View file

@ -0,0 +1,9 @@
include: package:flutter_iconica_analysis/components_options.yaml
# Possible to overwrite the rules from the package
analyzer:
exclude:
linter:
rules:

View file

@ -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/

View file

@ -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.

View file

@ -0,0 +1,9 @@
include: package:flutter_iconica_analysis/analysis_options.yaml
# Possible to overwrite the rules from the package
analyzer:
exclude:
linter:
rules:

View file

@ -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<MyShop> shops,
required List<MyProduct> 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>(
() => 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",
),
),
);

View file

@ -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,
),
),
);
}
}

View file

@ -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;
}

View file

@ -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,
});
}

View file

@ -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),
),
),
);

View file

@ -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

View file

@ -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";

View file

@ -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;
}

View file

@ -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<List<ProductPageShop>> shops;
/// A function that returns all the products that belong to a certain shop.
/// The function must return a [ProductPageContent] object.
final Future<ProductPageContent> 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;
}

View file

@ -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<ProductPageProduct> products;
/// Optional highlighted discounted product to display.
final ProductPageProduct? discountedProduct;
}

View file

@ -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;
}

View file

@ -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,
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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<ProductPageProduct> products,
) {
var categorizedProducts = <String, List<ProductPageProduct>>{};
for (var product in products) {
if (!categorizedProducts.containsKey(product.category)) {
categorizedProducts[product.category] = [];
}
categorizedProducts[product.category]?.add(product);
}
// Create Category instances
var categories = <Category>[];
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,
);
}

View file

@ -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;
}

View file

@ -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();
}
}

View file

@ -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),
),
),
),
),
),
);
}
}

View file

@ -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<ProductPageShop> 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,
);
}
}

View file

@ -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,
],
),
),
),
);
}
}

View file

@ -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<ProductPageShop>? 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<ProductPageShop> 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,
],
);
},
),
);
}

View file

@ -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,
);
}

View file

@ -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<ProductPageShop> 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(),
),
);
}
}

View file

@ -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,
],
),
),
);
}
}

View file

@ -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<ProductPageShop> 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<int> currentRow,
double availableRowLength,
) {
var theme = Theme.of(context);
var row = <Widget>[];
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<Row> _buildButtonRows(BuildContext context) {
var theme = Theme.of(context);
var rows = <Row>[];
var currentRow = <int>[];
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 = <int>[];
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,
),
);
}

View file

@ -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