feat: add interface to flutter_product_page

This commit is contained in:
mike doornenbal 2024-07-04 15:01:28 +02:00
parent 2426416c42
commit ee22dc98e6
21 changed files with 550 additions and 740 deletions

View file

@ -2,11 +2,7 @@
/// 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_page_shop.dart";
export "src/ui/product_page.dart";
export "src/ui/product_page_screen.dart";
export "src/configuration/product_page_translations.dart";
export "src/product_page_screen.dart";

View file

@ -1,54 +0,0 @@
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

@ -1,109 +1,48 @@
import "package:flutter/material.dart";
import "package:flutter_product_page/src/services/shopping_cart_notifier.dart";
import "package:flutter_product_page/src/ui/widgets/product_item_popup.dart";
import "package:flutter_shopping/flutter_shopping.dart";
import "package:flutter_product_page/flutter_product_page.dart";
import "package:flutter_product_page/src/widgets/product_item_popup.dart";
import "package:flutter_shopping_interface/flutter_shopping_interface.dart";
/// Configuration for the product page.
class ProductPageConfiguration {
/// Constructor for the product page configuration.
ProductPageConfiguration({
required this.shoppingService,
required this.shops,
required this.getProducts,
required this.onAddToCart,
required this.onNavigateToShoppingCart,
this.navigateToShoppingCartBuilder = _defaultNavigateToShoppingCartBuilder,
required this.getProductsInShoppingCart,
this.shoppingCartButtonBuilder = _defaultShoppingCartButtonBuilder,
this.initialShopId,
this.productBuilder,
this.onShopSelectionChange,
this.getProductsInShoppingCart,
this.localizations = const ProductPageLocalization(),
this.translations = const ProductPageTranslations(),
this.shopSelectorStyle = ShopSelectorStyle.spacedWrap,
this.categoryStylingConfiguration =
const ProductPageCategoryStylingConfiguration(),
this.pagePadding = const EdgeInsets.all(4),
this.appBar = _defaultAppBar,
this.bottomNavigationBar,
Function(
BuildContext context,
Product product,
)? onProductDetail,
String Function(
Product product,
)? getDiscountDescription,
Widget Function(
BuildContext context,
Product product,
)? productPopupBuilder,
Widget Function(
BuildContext context,
)? noContentBuilder,
Widget Function(
BuildContext context,
Object? error,
StackTrace? stackTrace,
)? errorBuilder,
}) {
_productPopupBuilder = productPopupBuilder;
_productPopupBuilder ??=
(BuildContext context, Product product) => ProductItemPopup(
product: product,
configuration: this,
);
this.onProductDetail = _onProductDetail,
this.discountDescription = _defaultDiscountDescription,
this.noContentBuilder = _defaultNoContentBuilder,
this.errorBuilder = _defaultErrorBuilder,
});
_onProductDetail = onProductDetail;
_onProductDetail ??= (BuildContext context, Product 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 ??= (Product product) =>
"${product.name}, now for ${product.discountPrice} each";
}
/// The shopping service that is used
final ShoppingService shoppingService;
/// 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;
final Future<List<Shop>> Function() 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 function must return a [List<Product>].
final Future<List<Product>> Function(Shop shop) getProducts;
/// The localizations for the product page.
final ProductPageLocalization localizations;
final ProductPageTranslations translations;
/// Builder for the product item. These items will be displayed in the list
/// for each product in their seperated category. This builder should only
@ -111,55 +50,24 @@ class ProductPageConfiguration {
/// in-case the developer does not override it.
Widget Function(BuildContext context, Product product)? productBuilder;
late Widget Function(BuildContext context, Product 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, Product product)
get productPopupBuilder => _productPopupBuilder!;
late Function(BuildContext context, Product 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, Product 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 product popup. This builder should return a widget
Function(
BuildContext context,
Product product,
String closeText,
) onProductDetail;
/// The builder for the shopping cart. This builder should return a widget
/// that navigates to the shopping cart overview page.
Widget Function(
BuildContext context,
ProductPageConfiguration configuration,
ShoppingCartNotifier notifier,
) navigateToShoppingCartBuilder;
) shoppingCartButtonBuilder;
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(Product 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(Product product)? get getDiscountDescription =>
_getDiscountDescription!;
/// The function that returns the discount description for a product.
String Function(
Product product,
) discountDescription;
/// This function must be implemented by the developer and should handle the
/// adding of a product to the cart.
@ -167,11 +75,11 @@ class ProductPageConfiguration {
/// 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;
final Function(Shop 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;
final int Function() getProductsInShoppingCart;
/// This function must be implemented by the developer and should handle the
/// navigation to the shopping cart overview page.
@ -180,9 +88,6 @@ class ProductPageConfiguration {
/// 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;
@ -191,6 +96,20 @@ class ProductPageConfiguration {
/// Optional app bar that you can pass to the order detail screen.
final AppBar Function(BuildContext context)? appBar;
/// Builder for the no content widget. This builder is used when there is no
/// content to display.
final Widget Function(
BuildContext context,
)? noContentBuilder;
/// Builder for the error widget. This builder is used when there is an error
/// to display.
Widget Function(
BuildContext context,
Object? error,
StackTrace? stackTrace,
) errorBuilder;
}
AppBar _defaultAppBar(
@ -210,21 +129,21 @@ AppBar _defaultAppBar(
);
}
Widget _defaultNavigateToShoppingCartBuilder(
Widget _defaultShoppingCartButtonBuilder(
BuildContext context,
ProductPageConfiguration configuration,
ShoppingCartNotifier notifier,
) {
var theme = Theme.of(context);
return ListenableBuilder(
listenable: notifier,
listenable: configuration.shoppingService.shoppingCartService,
builder: (context, widget) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 60),
child: SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: configuration.getProductsInShoppingCart?.call() != 0
onPressed: configuration
.shoppingService.shoppingCartService.products.isNotEmpty
? configuration.onNavigateToShoppingCart
: null,
style: theme.filledButtonTheme.style?.copyWith(
@ -238,7 +157,7 @@ Widget _defaultNavigateToShoppingCartBuilder(
vertical: 12,
),
child: Text(
configuration.localizations.navigateToShoppingCart,
configuration.translations.navigateToShoppingCart,
style: theme.textTheme.displayLarge,
),
),
@ -247,3 +166,51 @@ Widget _defaultNavigateToShoppingCartBuilder(
),
);
}
Future<void> _onProductDetail(
BuildContext context,
Product product,
String closeText,
) async {
var theme = Theme.of(context);
await showModalBottomSheet(
context: context,
backgroundColor: theme.colorScheme.surface,
builder: (context) => ProductItemPopup(
product: product,
closeText: closeText,
),
);
}
String _defaultDiscountDescription(
Product product,
) =>
"${product.name}, now for ${product.discountPrice} each";
Widget _defaultNoContentBuilder(
BuildContext context,
) {
var theme = Theme.of(context);
return Center(
child: Text(
"No content",
style: theme.textTheme.titleLarge,
),
);
}
Widget _defaultErrorBuilder(
BuildContext context,
Object? error,
StackTrace? stackTrace,
) {
var theme = Theme.of(context);
return Center(
child: Text(
"Error: $error",
style: theme.textTheme.titleLarge,
),
);
}

View file

@ -1,16 +0,0 @@
import "package:flutter_shopping/flutter_shopping.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<Product> products;
/// Optional highlighted discounted product to display.
final Product? discountedProduct;
}

View file

@ -1,7 +1,7 @@
/// Localization for the product page
class ProductPageLocalization {
class ProductPageTranslations {
/// Default constructor
const ProductPageLocalization({
const ProductPageTranslations({
this.navigateToShoppingCart = "View shopping cart",
this.discountTitle = "Weekly offer",
this.failedToLoadImageExplenation = "Failed to load image",

View file

@ -1,18 +0,0 @@
/// 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,238 @@
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/widgets/shop_selector.dart";
import "package:flutter_product_page/src/widgets/weekly_discount.dart";
import "package:flutter_shopping_interface/flutter_shopping_interface.dart";
/// A page that displays products.
class ProductPageScreen extends StatelessWidget {
/// Constructor for the product page.
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(
appBar: configuration.appBar!.call(context),
bottomNavigationBar: configuration.bottomNavigationBar,
body: SafeArea(
child: Padding(
padding: configuration.pagePadding,
child: FutureBuilder(
// ignore: discarded_futures
future: configuration.shops(),
builder: (BuildContext context, AsyncSnapshot data) {
if (data.connectionState == ConnectionState.waiting) {
return const Column(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Center(child: CircularProgressIndicator.adaptive()),
],
);
}
if (data.hasError) {
return configuration.errorBuilder(
context,
data.error,
data.stackTrace,
);
}
List<Shop>? shops = data.data;
if (shops == null || shops.isEmpty) {
return configuration.errorBuilder(context, null, null);
}
if (initialBuildShopId != null) {
Shop? initialShop;
for (var shop in shops) {
if (shop.id == initialBuildShopId) {
initialShop = shop;
break;
}
}
configuration.shoppingService.shopService
.selectShop(initialShop ?? shops.first);
} else if (configuration.initialShopId != null) {
Shop? initialShop;
for (var shop in shops) {
if (shop.id == configuration.initialShopId) {
initialShop = shop;
break;
}
}
configuration.shoppingService.shopService
.selectShop(initialShop ?? shops.first);
} else {
configuration.shoppingService.shopService
.selectShop(shops.first);
}
return ListenableBuilder(
listenable: configuration.shoppingService.shopService,
builder: (BuildContext context, Widget? _) {
configuration.onShopSelectionChange?.call(
configuration.shoppingService.shopService.selectedShop!,
);
return _ProductPage(
configuration: configuration,
shops: shops,
);
},
);
},
),
),
),
);
}
class _ProductPage extends StatelessWidget {
const _ProductPage({
required this.configuration,
required this.shops,
});
final ProductPageConfiguration configuration;
final List<Shop> shops;
@override
Widget build(BuildContext context) {
var pageContent = SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ShopSelector(
configuration: configuration,
shops: shops,
onTap: configuration.shoppingService.shopService.selectShop,
),
_ShopContents(
configuration: configuration,
),
],
),
);
return Stack(
children: [
pageContent,
Align(
alignment: Alignment.bottomCenter,
child: configuration.shoppingCartButtonBuilder(
context,
configuration,
),
),
],
);
}
}
class _ShopContents extends StatelessWidget {
const _ShopContents({
required this.configuration,
});
final ProductPageConfiguration configuration;
@override
Widget build(BuildContext context) {
var theme = Theme.of(context);
return Padding(
padding: EdgeInsets.symmetric(
horizontal: configuration.pagePadding.horizontal,
),
child: FutureBuilder(
// ignore: discarded_futures
future: configuration.getProducts(
configuration.shoppingService.shopService.selectedShop!,
),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator.adaptive());
}
if (snapshot.hasError) {
return configuration.errorBuilder(
context,
snapshot.error,
snapshot.stackTrace,
);
}
var productPageContent = snapshot.data;
if (productPageContent == null || productPageContent.isEmpty) {
return configuration.noContentBuilder!(context);
}
var productList = Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
child: Column(
children: [
// Products
getCategoryList(
context,
configuration,
productPageContent,
),
// Bottom padding so the last product is not cut off
// by the to shopping cart button.
const SizedBox(height: 48),
],
),
);
var discountedproducts = productPageContent
.where((product) => product.hasDiscount)
.toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Discounted product
if (discountedproducts.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: WeeklyDiscount(
configuration: configuration,
product: discountedproducts.first,
),
),
],
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
child: Text(
"What would you like to order?",
style: theme.textTheme.titleLarge,
textAlign: TextAlign.start,
),
),
productList,
],
);
},
),
);
}
}

View file

@ -1,28 +1,14 @@
import "package:flutter/material.dart";
import "package:flutter_nested_categories/flutter_nested_categories.dart";
import "package:flutter_product_page/src/services/shopping_cart_notifier.dart";
import "package:flutter_product_page/src/ui/components/product_item.dart";
import "package:flutter_shopping/flutter_shopping.dart";
/// A function that is called when a product is added to the cart.
Product onAddToCartWrapper(
ProductPageConfiguration configuration,
ShoppingCartNotifier shoppingCartNotifier,
Product product,
) {
shoppingCartNotifier.productsChanged();
configuration.onAddToCart(product);
return product;
}
import "package:flutter_product_page/flutter_product_page.dart";
import "package:flutter_product_page/src/widgets/product_item.dart";
import "package:flutter_shopping_interface/flutter_shopping_interface.dart";
/// Generates a [CategoryList] from a list of [Product]s and a
/// [ProductPageConfiguration].
Widget getCategoryList(
BuildContext context,
ProductPageConfiguration configuration,
ShoppingCartNotifier shoppingCartNotifier,
List<Product> products,
) {
var theme = Theme.of(context);
@ -44,12 +30,9 @@ Widget getCategoryList(
: ProductItem(
product: product,
onProductDetail: configuration.onProductDetail,
onAddToCart: (Product product) => onAddToCartWrapper(
configuration,
shoppingCartNotifier,
product,
),
localizations: configuration.localizations,
onAddToCart: (Product product) =>
configuration.onAddToCart(product),
translations: configuration.translations,
),
)
.toList();

View file

@ -1,21 +0,0 @@
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

@ -1,10 +0,0 @@
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

@ -1,252 +0,0 @@
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(
crossAxisAlignment: CrossAxisAlignment.start,
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(
context,
configuration,
shoppingCartNotifier,
),
),
],
);
}
}
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) {
var theme = Theme.of(context);
return 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, 12, 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(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Discounted product
if (productPageContent.discountedProduct != null) ...[
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: WeeklyDiscount(
configuration: configuration,
product: productPageContent.discountedProduct!,
),
),
],
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
child: Text(
"What would you like to order?",
style: theme.textTheme.titleLarge,
textAlign: TextAlign.start,
),
),
productList,
],
);
},
),
);
}
}

View file

@ -1,36 +0,0 @@
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(
appBar: configuration.appBar!.call(context),
body: SafeArea(
child: ProductPage(
configuration: configuration,
initialBuildShopId: initialBuildShopId,
),
),
bottomNavigationBar: configuration.bottomNavigationBar,
);
}

View file

@ -1,5 +1,5 @@
import "package:flutter/material.dart";
import "package:flutter_product_page/flutter_product_page.dart";
import "package:flutter_shopping_interface/flutter_shopping_interface.dart";
/// Horizontal list of items.
class HorizontalListItems extends StatelessWidget {
@ -14,7 +14,7 @@ class HorizontalListItems extends StatelessWidget {
});
/// List of items.
final List<ProductPageShop> shops;
final List<Shop> shops;
/// Selected item.
final String selectedItem;
@ -26,7 +26,7 @@ class HorizontalListItems extends StatelessWidget {
final double paddingOnButtons;
/// Callback when an item is tapped.
final Function(ProductPageShop shop) onTap;
final Function(Shop shop) onTap;
@override
Widget build(BuildContext context) {

View file

@ -1,6 +1,7 @@
import "package:cached_network_image/cached_network_image.dart";
import "package:flutter/material.dart";
import "package:flutter_shopping/flutter_shopping.dart";
import "package:flutter_product_page/flutter_product_page.dart";
import "package:flutter_shopping_interface/flutter_shopping_interface.dart";
import "package:skeletonizer/skeletonizer.dart";
/// Product item widget.
@ -10,7 +11,7 @@ class ProductItem extends StatelessWidget {
required this.product,
required this.onProductDetail,
required this.onAddToCart,
required this.localizations,
required this.translations,
super.key,
});
@ -18,13 +19,17 @@ class ProductItem extends StatelessWidget {
final Product product;
/// Function to call when the product detail is requested.
final Function(BuildContext context, Product selectedProduct) onProductDetail;
final Function(
BuildContext context,
Product selectedProduct,
String closeText,
) onProductDetail;
/// Function to call when the product is added to the cart.
final Function(Product selectedProduct) onAddToCart;
/// Localizations for the product page.
final ProductPageLocalization localizations;
final ProductPageTranslations translations;
/// Size of the product image.
static const double imageSize = 44;
@ -46,7 +51,7 @@ class ProductItem extends StatelessWidget {
fit: BoxFit.cover,
placeholder: (context, url) => loadingImageSkeleton,
errorWidget: (context, url, error) => Tooltip(
message: localizations.failedToLoadImageExplenation,
message: translations.failedToLoadImageExplenation,
child: Container(
width: 48,
height: 48,
@ -74,7 +79,11 @@ class ProductItem extends StatelessWidget {
var productInformationIcon = Padding(
padding: const EdgeInsets.only(left: 4),
child: IconButton(
onPressed: () => onProductDetail(context, product),
onPressed: () => onProductDetail(
context,
product,
translations.close,
),
icon: Icon(
Icons.info_outline,
color: theme.colorScheme.primary,

View file

@ -1,12 +1,12 @@
import "package:flutter/material.dart";
import "package:flutter_shopping/flutter_shopping.dart";
import "package:flutter_shopping_interface/flutter_shopping_interface.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,
required this.closeText,
super.key,
});
@ -14,7 +14,7 @@ class ProductItemPopup extends StatelessWidget {
final Product product;
/// Configuration for the product page.
final ProductPageConfiguration configuration;
final String closeText;
@override
Widget build(BuildContext context) {
@ -49,7 +49,7 @@ class ProductItemPopup extends StatelessWidget {
vertical: 8.0,
),
child: Text(
configuration.localizations.close,
closeText,
style: theme.textTheme.displayLarge,
),
),

View file

@ -1,15 +1,14 @@
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";
import "package:flutter_product_page/src/widgets/horizontal_list_items.dart";
import "package:flutter_product_page/src/widgets/spaced_wrap.dart";
import "package:flutter_shopping_interface/flutter_shopping_interface.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,
@ -21,13 +20,12 @@ class ShopSelector extends StatelessWidget {
final ProductPageConfiguration configuration;
/// Service for the selected shop.
final SelectedShopService selectedShopService;
/// List of shops.
final List<ProductPageShop> shops;
final List<Shop> shops;
/// Callback when a shop is tapped.
final Function(ProductPageShop shop) onTap;
final Function(Shop shop) onTap;
/// Padding between the buttons.
final double paddingBetweenButtons;
@ -44,7 +42,8 @@ class ShopSelector extends StatelessWidget {
if (configuration.shopSelectorStyle == ShopSelectorStyle.spacedWrap) {
return SpacedWrap(
shops: shops,
selectedItem: selectedShopService.selectedShop!.id,
selectedItem:
configuration.shoppingService.shopService.selectedShop!.id,
onTap: onTap,
width: MediaQuery.of(context).size.width - (16 * 2),
paddingBetweenButtons: paddingBetweenButtons,
@ -54,7 +53,7 @@ class ShopSelector extends StatelessWidget {
return HorizontalListItems(
shops: shops,
selectedItem: selectedShopService.selectedShop!.id,
selectedItem: configuration.shoppingService.shopService.selectedShop!.id,
onTap: onTap,
paddingBetweenButtons: paddingBetweenButtons,
paddingOnButtons: paddingOnButtons,

View file

@ -1,5 +1,5 @@
import "package:flutter/material.dart";
import "package:flutter_product_page/flutter_product_page.dart";
import "package:flutter_shopping_interface/flutter_shopping_interface.dart";
/// SpacedWrap is a widget that wraps a list of items that are spaced out and
/// fill the available width.
@ -16,7 +16,7 @@ class SpacedWrap extends StatelessWidget {
});
/// List of items.
final List<ProductPageShop> shops;
final List<Shop> shops;
/// Selected item.
final String selectedItem;
@ -31,7 +31,7 @@ class SpacedWrap extends StatelessWidget {
final double paddingOnButtons;
/// Callback when an item is tapped.
final Function(ProductPageShop shop) onTap;
final Function(Shop shop) onTap;
@override
Widget build(BuildContext context) {

View file

@ -1,6 +1,7 @@
import "package:cached_network_image/cached_network_image.dart";
import "package:flutter/material.dart";
import "package:flutter_shopping/flutter_shopping.dart";
import "package:flutter_product_page/flutter_product_page.dart";
import "package:flutter_shopping_interface/flutter_shopping_interface.dart";
/// A widget that displays a weekly discount.
class WeeklyDiscount extends StatelessWidget {
@ -27,7 +28,7 @@ class WeeklyDiscount extends StatelessWidget {
var bottomText = Padding(
padding: const EdgeInsets.all(20.0),
child: Text(
configuration.getDiscountDescription!(product),
configuration.discountDescription(product),
style: theme.textTheme.bodyMedium,
textAlign: TextAlign.left,
),
@ -50,7 +51,7 @@ class WeeklyDiscount extends StatelessWidget {
Icons.error_outline_rounded,
color: Colors.red,
),
Text(configuration.localizations.failedToLoadImageExplenation),
Text(configuration.translations.failedToLoadImageExplenation),
],
),
),
@ -86,7 +87,7 @@ class WeeklyDiscount extends StatelessWidget {
horizontal: 16,
),
child: Text(
configuration.localizations.discountTitle,
configuration.translations.discountTitle,
style: theme.textTheme.headlineSmall,
textAlign: TextAlign.left,
),

View file

@ -1,10 +1,10 @@
name: flutter_product_page
description: "A Flutter module for the product page"
publish_to: 'none'
publish_to: "none"
version: 2.0.0
environment:
sdk: '>=3.3.4 <4.0.0'
sdk: ">=3.3.4 <4.0.0"
dependencies:
flutter:
@ -15,11 +15,17 @@ dependencies:
git:
url: https://github.com/Iconica-Development/flutter_nested_categories
ref: 0.0.1
flutter_shopping:
flutter_shopping_interface:
git:
url: https://github.com/Iconica-Development/flutter_shopping
path: packages/flutter_shopping
path: packages/flutter_shopping_interface
ref: 2.0.0
collection: ^1.18.0
provider: ^6.1.2
dependency_overrides:
flutter_shopping_interface:
path: ../flutter_shopping_interface
dev_dependencies:
flutter_test:

View file

@ -95,90 +95,109 @@ Widget _defaultProductItemBuilder(
ShoppingCartConfig configuration,
) {
var theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.only(bottom: 20),
child: ListTile(
contentPadding: const EdgeInsets.only(top: 3, left: 4, bottom: 3),
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
product.name,
style: theme.textTheme.titleMedium,
),
IconButton(
onPressed: () async {
await showModalBottomSheet(
context: context,
backgroundColor: theme.colorScheme.surface,
builder: (context) => ProductItemPopup(
product: product,
configuration: configuration,
),
);
},
icon: Icon(
Icons.info_outline,
color: theme.colorScheme.primary,
return ListenableBuilder(
listenable: configuration.service,
builder: (context, _) => Padding(
padding: const EdgeInsets.only(bottom: 20),
child: ListTile(
contentPadding: const EdgeInsets.only(top: 3, left: 4, bottom: 3),
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
product.name,
style: theme.textTheme.titleMedium,
),
),
],
),
leading: ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Image.network(
product.imageUrl,
IconButton(
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
onPressed: () async {
await showModalBottomSheet(
context: context,
backgroundColor: theme.colorScheme.surface,
builder: (context) => ProductItemPopup(
product: product,
configuration: configuration,
),
);
},
icon: Icon(
Icons.info_outline,
color: theme.colorScheme.primary,
),
),
],
),
),
trailing: Column(
children: [
Text(
product.price.toStringAsFixed(2),
style: theme.textTheme.labelSmall,
leading: ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Image.network(
product.imageUrl,
),
Row(
mainAxisAlignment: MainAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
constraints: const BoxConstraints(),
padding: EdgeInsets.zero,
icon: const Icon(
Icons.remove,
color: Colors.black,
),
onPressed: () =>
configuration.service.removeOneProduct(product),
),
Padding(
padding: const EdgeInsets.all(2),
child: Container(
alignment: Alignment.center,
decoration: BoxDecoration(
color: theme.colorScheme.primary,
borderRadius: BorderRadius.circular(4),
),
trailing: Column(
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (product.hasDiscount && product.discountPrice != null) ...[
Text(
product.discountPrice!.toStringAsFixed(2),
style: theme.textTheme.labelSmall,
),
height: 30,
width: 30,
child: Text(
"${product.quantity}",
style: theme.textTheme.titleSmall,
textAlign: TextAlign.center,
] else ...[
Text(
product.price.toStringAsFixed(2),
style: theme.textTheme.labelSmall,
),
],
],
),
Row(
mainAxisAlignment: MainAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
constraints: const BoxConstraints(),
padding: EdgeInsets.zero,
icon: const Icon(
Icons.remove,
color: Colors.black,
),
onPressed: () =>
configuration.service.removeOneProduct(product),
),
Padding(
padding: const EdgeInsets.all(2),
child: Container(
alignment: Alignment.center,
decoration: BoxDecoration(
color: theme.colorScheme.primary,
borderRadius: BorderRadius.circular(4),
),
height: 30,
width: 30,
child: Text(
"${product.quantity}",
style: theme.textTheme.titleSmall,
textAlign: TextAlign.center,
),
),
),
),
IconButton(
constraints: const BoxConstraints(),
padding: EdgeInsets.zero,
icon: const Icon(
Icons.add,
color: Colors.black,
IconButton(
constraints: const BoxConstraints(),
padding: EdgeInsets.zero,
icon: const Icon(
Icons.add,
color: Colors.black,
),
onPressed: () {
configuration.service.addProduct(product);
},
),
onPressed: () => configuration.service.addProduct(product),
),
],
),
],
],
),
],
),
),
),
);
@ -190,9 +209,12 @@ Widget _defaultSumBottomSheetBuilder(
) {
var theme = Theme.of(context);
var totalPrice = configuration.service.products
.map((product) => product.price * product.quantity)
.fold(0.0, (a, b) => a + b);
var totalPrice = configuration.service.products.fold<double>(
0,
(previousValue, element) =>
previousValue +
(element.discountPrice ?? element.price) * element.quantity,
);
return Padding(
padding: configuration.bottomPadding,
@ -218,15 +240,16 @@ Widget _defaultConfirmOrderButton(
Function(List<Product> products) onConfirmOrder,
) {
var theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 60),
child: SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: () => onConfirmOrder(
configuration.service.products,
),
onPressed: configuration.service.products.isEmpty
? null
: () => onConfirmOrder(
configuration.service.products,
),
style: theme.filledButtonTheme.style?.copyWith(
backgroundColor: WidgetStateProperty.all(
theme.colorScheme.primary,

View file

@ -16,57 +16,6 @@ class ShoppingCartScreen extends StatelessWidget {
Widget build(BuildContext context) {
var theme = Theme.of(context);
var productBuilder = SingleChildScrollView(
child: Column(
children: [
if (configuration.titleBuilder != null) ...{
configuration.titleBuilder!(
context,
configuration.translations.cartTitle,
),
} else ...{
Padding(
padding: const EdgeInsets.symmetric(
vertical: 32,
),
child: Row(
children: [
Text(
configuration.translations.cartTitle,
style: theme.textTheme.titleLarge,
textAlign: TextAlign.start,
),
],
),
),
},
ListenableBuilder(
listenable: configuration.service,
builder: (context, _) {
var products = configuration.service.products;
return Column(
children: [
for (var product in products)
configuration.productItemBuilder(
context,
product,
configuration,
),
// Additional whitespace at the bottom to make sure the
// last product(s) are not hidden by the bottom sheet.
SizedBox(
height: configuration.confirmOrderButtonHeight +
configuration.sumBottomSheetHeight,
),
],
);
},
),
],
),
);
return Scaffold(
appBar: configuration.appBar.call(context),
body: SafeArea(
@ -75,7 +24,54 @@ class ShoppingCartScreen extends StatelessWidget {
children: [
Padding(
padding: configuration.pagePadding,
child: productBuilder,
child: SingleChildScrollView(
child: Column(
children: [
if (configuration.titleBuilder != null) ...{
configuration.titleBuilder!(
context,
configuration.translations.cartTitle,
),
} else ...{
Padding(
padding: const EdgeInsets.symmetric(
vertical: 32,
),
child: Row(
children: [
Text(
configuration.translations.cartTitle,
style: theme.textTheme.titleLarge,
textAlign: TextAlign.start,
),
],
),
),
},
ListenableBuilder(
listenable: configuration.service,
builder: (context, _) => Column(
children: [
for (var product in configuration.service.products)
configuration.productItemBuilder(
context,
product,
configuration,
),
// Additional whitespace at
// the bottom to make sure the last
// product(s) are not hidden by the bottom sheet.
SizedBox(
height: configuration.confirmOrderButtonHeight +
configuration.sumBottomSheetHeight,
),
],
),
),
],
),
),
),
Align(
alignment: Alignment.bottomCenter,
@ -108,8 +104,7 @@ class _BottomSheet extends StatelessWidget {
),
ListenableBuilder(
listenable: configuration.service,
builder: (BuildContext context, Widget? child) =>
configuration.confirmOrderButtonBuilder(
builder: (context, _) => configuration.confirmOrderButtonBuilder(
context,
configuration,
configuration.onConfirmOrder,