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. /// detailed view of each product.
library flutter_product_page; 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_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/configuration/product_page_shop_selector_style.dart";
export "src/models/product_page_shop.dart"; export "src/configuration/product_page_translations.dart";
export "src/ui/product_page.dart"; export "src/product_page_screen.dart";
export "src/ui/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/material.dart";
import "package:flutter_product_page/src/services/shopping_cart_notifier.dart"; import "package:flutter_product_page/flutter_product_page.dart";
import "package:flutter_product_page/src/ui/widgets/product_item_popup.dart"; import "package:flutter_product_page/src/widgets/product_item_popup.dart";
import "package:flutter_shopping/flutter_shopping.dart"; import "package:flutter_shopping_interface/flutter_shopping_interface.dart";
/// Configuration for the product page. /// Configuration for the product page.
class ProductPageConfiguration { class ProductPageConfiguration {
/// Constructor for the product page configuration. /// Constructor for the product page configuration.
ProductPageConfiguration({ ProductPageConfiguration({
required this.shoppingService,
required this.shops, required this.shops,
required this.getProducts, required this.getProducts,
required this.onAddToCart, required this.onAddToCart,
required this.onNavigateToShoppingCart, required this.onNavigateToShoppingCart,
this.navigateToShoppingCartBuilder = _defaultNavigateToShoppingCartBuilder, required this.getProductsInShoppingCart,
this.shoppingCartButtonBuilder = _defaultShoppingCartButtonBuilder,
this.initialShopId, this.initialShopId,
this.productBuilder, this.productBuilder,
this.onShopSelectionChange, this.onShopSelectionChange,
this.getProductsInShoppingCart, this.translations = const ProductPageTranslations(),
this.localizations = const ProductPageLocalization(),
this.shopSelectorStyle = ShopSelectorStyle.spacedWrap, this.shopSelectorStyle = ShopSelectorStyle.spacedWrap,
this.categoryStylingConfiguration =
const ProductPageCategoryStylingConfiguration(),
this.pagePadding = const EdgeInsets.all(4), this.pagePadding = const EdgeInsets.all(4),
this.appBar = _defaultAppBar, this.appBar = _defaultAppBar,
this.bottomNavigationBar, this.bottomNavigationBar,
Function( this.onProductDetail = _onProductDetail,
BuildContext context, this.discountDescription = _defaultDiscountDescription,
Product product, this.noContentBuilder = _defaultNoContentBuilder,
)? onProductDetail, this.errorBuilder = _defaultErrorBuilder,
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,
);
_onProductDetail = onProductDetail; /// The shopping service that is used
_onProductDetail ??= (BuildContext context, Product product) async { final ShoppingService shoppingService;
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 shop that is initially selected. /// The shop that is initially selected.
final String? initialShopId; final String? initialShopId;
/// A list of all the shops that the user must be able to navigate from. /// 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. /// A function that returns all the products that belong to a certain shop.
/// The function must return a [ProductPageContent] object. /// The function must return a [List<Product>].
final Future<ProductPageContent> Function(ProductPageShop shop) getProducts; final Future<List<Product>> Function(Shop shop) getProducts;
/// The localizations for the product page. /// 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 /// Builder for the product item. These items will be displayed in the list
/// for each product in their seperated category. This builder should only /// 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. /// in-case the developer does not override it.
Widget Function(BuildContext context, Product product)? productBuilder; Widget Function(BuildContext context, Product product)? productBuilder;
late Widget Function(BuildContext context, Product product)? /// The builder for the product popup. This builder should return a widget
_productPopupBuilder; Function(
BuildContext context,
/// The builder for the product popup. This popup will be displayed when the Product product,
/// user clicks on a product. This builder should only build the widget that String closeText,
/// displays the content of one specific product. ) onProductDetail;
/// 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 shopping cart. This builder should return a widget /// The builder for the shopping cart. This builder should return a widget
/// that navigates to the shopping cart overview page. /// that navigates to the shopping cart overview page.
Widget Function( Widget Function(
BuildContext context, BuildContext context,
ProductPageConfiguration configuration, ProductPageConfiguration configuration,
ShoppingCartNotifier notifier, ) shoppingCartButtonBuilder;
) navigateToShoppingCartBuilder;
late Widget Function( /// The function that returns the discount description for a product.
BuildContext context, String Function(
Object? error, Product product,
StackTrace? stackTrace, ) discountDescription;
)? _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!;
/// This function must be implemented by the developer and should handle the /// This function must be implemented by the developer and should handle the
/// adding of a product to the cart. /// 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 gets executed when the user changes the shop selection.
/// This function always fires upon first load with the initial shop as well. /// 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 /// This function must be implemented by the developer and should handle the
/// navigation to the shopping cart overview page. /// 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 /// This function must be implemented by the developer and should handle the
/// navigation to the shopping cart overview page. /// navigation to the shopping cart overview page.
@ -180,9 +88,6 @@ class ProductPageConfiguration {
/// The style of the shop selector. /// The style of the shop selector.
final ShopSelectorStyle shopSelectorStyle; final ShopSelectorStyle shopSelectorStyle;
/// The styling configuration for the category list.
final ProductPageCategoryStylingConfiguration categoryStylingConfiguration;
/// The padding for the page. /// The padding for the page.
final EdgeInsets pagePadding; final EdgeInsets pagePadding;
@ -191,6 +96,20 @@ class ProductPageConfiguration {
/// Optional app bar that you can pass to the order detail screen. /// Optional app bar that you can pass to the order detail screen.
final AppBar Function(BuildContext context)? appBar; 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( AppBar _defaultAppBar(
@ -210,21 +129,21 @@ AppBar _defaultAppBar(
); );
} }
Widget _defaultNavigateToShoppingCartBuilder( Widget _defaultShoppingCartButtonBuilder(
BuildContext context, BuildContext context,
ProductPageConfiguration configuration, ProductPageConfiguration configuration,
ShoppingCartNotifier notifier,
) { ) {
var theme = Theme.of(context); var theme = Theme.of(context);
return ListenableBuilder( return ListenableBuilder(
listenable: notifier, listenable: configuration.shoppingService.shoppingCartService,
builder: (context, widget) => Padding( builder: (context, widget) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 60), padding: const EdgeInsets.symmetric(horizontal: 60),
child: SizedBox( child: SizedBox(
width: double.infinity, width: double.infinity,
child: FilledButton( child: FilledButton(
onPressed: configuration.getProductsInShoppingCart?.call() != 0 onPressed: configuration
.shoppingService.shoppingCartService.products.isNotEmpty
? configuration.onNavigateToShoppingCart ? configuration.onNavigateToShoppingCart
: null, : null,
style: theme.filledButtonTheme.style?.copyWith( style: theme.filledButtonTheme.style?.copyWith(
@ -238,7 +157,7 @@ Widget _defaultNavigateToShoppingCartBuilder(
vertical: 12, vertical: 12,
), ),
child: Text( child: Text(
configuration.localizations.navigateToShoppingCart, configuration.translations.navigateToShoppingCart,
style: theme.textTheme.displayLarge, 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 /// Localization for the product page
class ProductPageLocalization { class ProductPageTranslations {
/// Default constructor /// Default constructor
const ProductPageLocalization({ const ProductPageTranslations({
this.navigateToShoppingCart = "View shopping cart", this.navigateToShoppingCart = "View shopping cart",
this.discountTitle = "Weekly offer", this.discountTitle = "Weekly offer",
this.failedToLoadImageExplenation = "Failed to load image", 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/material.dart";
import "package:flutter_nested_categories/flutter_nested_categories.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/flutter_product_page.dart";
import "package:flutter_product_page/src/ui/components/product_item.dart"; import "package:flutter_product_page/src/widgets/product_item.dart";
import "package:flutter_shopping/flutter_shopping.dart"; import "package:flutter_shopping_interface/flutter_shopping_interface.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;
}
/// Generates a [CategoryList] from a list of [Product]s and a /// Generates a [CategoryList] from a list of [Product]s and a
/// [ProductPageConfiguration]. /// [ProductPageConfiguration].
Widget getCategoryList( Widget getCategoryList(
BuildContext context, BuildContext context,
ProductPageConfiguration configuration, ProductPageConfiguration configuration,
ShoppingCartNotifier shoppingCartNotifier,
List<Product> products, List<Product> products,
) { ) {
var theme = Theme.of(context); var theme = Theme.of(context);
@ -44,12 +30,9 @@ Widget getCategoryList(
: ProductItem( : ProductItem(
product: product, product: product,
onProductDetail: configuration.onProductDetail, onProductDetail: configuration.onProductDetail,
onAddToCart: (Product product) => onAddToCartWrapper( onAddToCart: (Product product) =>
configuration, configuration.onAddToCart(product),
shoppingCartNotifier, translations: configuration.translations,
product,
),
localizations: configuration.localizations,
), ),
) )
.toList(); .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/material.dart";
import "package:flutter_product_page/flutter_product_page.dart"; import "package:flutter_shopping_interface/flutter_shopping_interface.dart";
/// Horizontal list of items. /// Horizontal list of items.
class HorizontalListItems extends StatelessWidget { class HorizontalListItems extends StatelessWidget {
@ -14,7 +14,7 @@ class HorizontalListItems extends StatelessWidget {
}); });
/// List of items. /// List of items.
final List<ProductPageShop> shops; final List<Shop> shops;
/// Selected item. /// Selected item.
final String selectedItem; final String selectedItem;
@ -26,7 +26,7 @@ class HorizontalListItems extends StatelessWidget {
final double paddingOnButtons; final double paddingOnButtons;
/// Callback when an item is tapped. /// Callback when an item is tapped.
final Function(ProductPageShop shop) onTap; final Function(Shop shop) onTap;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -95,7 +95,9 @@ Widget _defaultProductItemBuilder(
ShoppingCartConfig configuration, ShoppingCartConfig configuration,
) { ) {
var theme = Theme.of(context); var theme = Theme.of(context);
return Padding( return ListenableBuilder(
listenable: configuration.service,
builder: (context, _) => Padding(
padding: const EdgeInsets.only(bottom: 20), padding: const EdgeInsets.only(bottom: 20),
child: ListTile( child: ListTile(
contentPadding: const EdgeInsets.only(top: 3, left: 4, bottom: 3), contentPadding: const EdgeInsets.only(top: 3, left: 4, bottom: 3),
@ -107,6 +109,8 @@ Widget _defaultProductItemBuilder(
style: theme.textTheme.titleMedium, style: theme.textTheme.titleMedium,
), ),
IconButton( IconButton(
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
onPressed: () async { onPressed: () async {
await showModalBottomSheet( await showModalBottomSheet(
context: context, context: context,
@ -132,10 +136,22 @@ Widget _defaultProductItemBuilder(
), ),
trailing: Column( trailing: Column(
children: [ children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (product.hasDiscount && product.discountPrice != null) ...[
Text(
product.discountPrice!.toStringAsFixed(2),
style: theme.textTheme.labelSmall,
),
] else ...[
Text( Text(
product.price.toStringAsFixed(2), product.price.toStringAsFixed(2),
style: theme.textTheme.labelSmall, style: theme.textTheme.labelSmall,
), ),
],
],
),
Row( Row(
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@ -174,13 +190,16 @@ Widget _defaultProductItemBuilder(
Icons.add, Icons.add,
color: Colors.black, 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 theme = Theme.of(context);
var totalPrice = configuration.service.products var totalPrice = configuration.service.products.fold<double>(
.map((product) => product.price * product.quantity) 0,
.fold(0.0, (a, b) => a + b); (previousValue, element) =>
previousValue +
(element.discountPrice ?? element.price) * element.quantity,
);
return Padding( return Padding(
padding: configuration.bottomPadding, padding: configuration.bottomPadding,
@ -218,13 +240,14 @@ Widget _defaultConfirmOrderButton(
Function(List<Product> products) onConfirmOrder, Function(List<Product> products) onConfirmOrder,
) { ) {
var theme = Theme.of(context); var theme = Theme.of(context);
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 60), padding: const EdgeInsets.symmetric(horizontal: 60),
child: SizedBox( child: SizedBox(
width: double.infinity, width: double.infinity,
child: FilledButton( child: FilledButton(
onPressed: () => onConfirmOrder( onPressed: configuration.service.products.isEmpty
? null
: () => onConfirmOrder(
configuration.service.products, configuration.service.products,
), ),
style: theme.filledButtonTheme.style?.copyWith( style: theme.filledButtonTheme.style?.copyWith(

View file

@ -16,7 +16,15 @@ class ShoppingCartScreen extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
var theme = Theme.of(context); var theme = Theme.of(context);
var productBuilder = SingleChildScrollView( return Scaffold(
appBar: configuration.appBar.call(context),
body: SafeArea(
child: Stack(
fit: StackFit.expand,
children: [
Padding(
padding: configuration.pagePadding,
child: SingleChildScrollView(
child: Column( child: Column(
children: [ children: [
if (configuration.titleBuilder != null) ...{ if (configuration.titleBuilder != null) ...{
@ -42,40 +50,28 @@ class ShoppingCartScreen extends StatelessWidget {
}, },
ListenableBuilder( ListenableBuilder(
listenable: configuration.service, listenable: configuration.service,
builder: (context, _) { builder: (context, _) => Column(
var products = configuration.service.products;
return Column(
children: [ children: [
for (var product in products) for (var product in configuration.service.products)
configuration.productItemBuilder( configuration.productItemBuilder(
context, context,
product, product,
configuration, configuration,
), ),
// Additional whitespace at the bottom to make sure the
// last product(s) are not hidden by the bottom sheet. // Additional whitespace at
// the bottom to make sure the last
// product(s) are not hidden by the bottom sheet.
SizedBox( SizedBox(
height: configuration.confirmOrderButtonHeight + height: configuration.confirmOrderButtonHeight +
configuration.sumBottomSheetHeight, configuration.sumBottomSheetHeight,
), ),
], ],
); ),
},
), ),
], ],
), ),
); ),
return Scaffold(
appBar: configuration.appBar.call(context),
body: SafeArea(
child: Stack(
fit: StackFit.expand,
children: [
Padding(
padding: configuration.pagePadding,
child: productBuilder,
), ),
Align( Align(
alignment: Alignment.bottomCenter, alignment: Alignment.bottomCenter,
@ -108,8 +104,7 @@ class _BottomSheet extends StatelessWidget {
), ),
ListenableBuilder( ListenableBuilder(
listenable: configuration.service, listenable: configuration.service,
builder: (BuildContext context, Widget? child) => builder: (context, _) => configuration.confirmOrderButtonBuilder(
configuration.confirmOrderButtonBuilder(
context, context,
configuration, configuration,
configuration.onConfirmOrder, configuration.onConfirmOrder,