From a25d09a331b6bcab8524d865a6e46d4948f781b9 Mon Sep 17 00:00:00 2001 From: Edouard Marquez Date: Thu, 7 Nov 2024 14:42:47 +0100 Subject: [PATCH] Many changes (#5804) (cherry picked from commit eb6b774dd0e9170aa9271f2cea33b2c5738b0a9d) --- .../bottom_sheets/smooth_bottom_sheet.dart | 6 + .../smooth_draggable_bottom_sheet.dart | 96 ++--- .../smooth_draggable_bottom_sheet_route.dart | 8 + packages/smooth_app/lib/l10n/app_en.arb | 2 +- .../pages/product/product_list_helper.dart | 350 ++++++++++++------ .../product_page/new_product_footer.dart | 14 +- .../smooth_app/lib/resources/app_icons.dart | 17 + 7 files changed, 332 insertions(+), 161 deletions(-) diff --git a/packages/smooth_app/lib/generic_lib/bottom_sheets/smooth_bottom_sheet.dart b/packages/smooth_app/lib/generic_lib/bottom_sheets/smooth_bottom_sheet.dart index 80608dfec81..7263078f328 100644 --- a/packages/smooth_app/lib/generic_lib/bottom_sheets/smooth_bottom_sheet.dart +++ b/packages/smooth_app/lib/generic_lib/bottom_sheets/smooth_bottom_sheet.dart @@ -32,14 +32,20 @@ Future showSmoothDraggableModalSheet({ /// You must return a Sliver Widget required WidgetBuilder bodyBuilder, double? initHeight, + double? minHeight, + double? maxHeight, + DraggableScrollableController? draggableScrollableController, }) { return showDraggableModalSheet( context: context, + draggableScrollableController: draggableScrollableController, borderRadius: const BorderRadius.vertical(top: ROUNDED_RADIUS), headerBuilder: (_) => header, headerHeight: header.computeHeight(context), bodyBuilder: bodyBuilder, initHeight: initHeight, + minHeight: minHeight, + maxHeight: maxHeight, ); } diff --git a/packages/smooth_app/lib/generic_lib/bottom_sheets/smooth_draggable_bottom_sheet.dart b/packages/smooth_app/lib/generic_lib/bottom_sheets/smooth_draggable_bottom_sheet.dart index 9d1b0790887..6cae831cf8b 100644 --- a/packages/smooth_app/lib/generic_lib/bottom_sheets/smooth_draggable_bottom_sheet.dart +++ b/packages/smooth_app/lib/generic_lib/bottom_sheets/smooth_draggable_bottom_sheet.dart @@ -10,6 +10,7 @@ class SmoothDraggableBottomSheet extends StatefulWidget { required this.bodyBuilder, required this.borderRadius, this.initHeightFraction = 0.5, + this.minHeightFraction, this.maxHeightFraction = 1.0, this.animationController, this.bottomSheetColor, @@ -17,6 +18,7 @@ class SmoothDraggableBottomSheet extends StatefulWidget { }) : assert(maxHeightFraction > 0.0 && maxHeightFraction <= 1.0); final double initHeightFraction; + final double? minHeightFraction; final double maxHeightFraction; final WidgetBuilder headerBuilder; final double headerHeight; @@ -52,55 +54,63 @@ class SmoothDraggableBottomSheetState Theme.of(context).scaffoldBackgroundColor; final double bottomPaddingHeight = MediaQuery.paddingOf(context).bottom; - return NotificationListener( - onNotification: _scrolling, - child: Column( - children: [ - Expanded( - child: SafeArea( - bottom: false, - child: DraggableScrollableSheet( - minChildSize: 0.0, - maxChildSize: widget.maxHeightFraction, - initialChildSize: widget.initHeightFraction, - snap: true, - controller: _controller, - builder: (BuildContext context, ScrollController controller) { - return DecoratedBox( - decoration: BoxDecoration( - borderRadius: widget.borderRadius, - color: backgroundColor, - ), - child: Material( - type: MaterialType.transparency, - child: ClipRRect( + // Fix keyboard glitch + final double keyboardFraction = MediaQuery.viewInsetsOf(context).bottom / + MediaQuery.sizeOf(context).height; + + return ChangeNotifierProvider( + create: (_) => _controller, + child: NotificationListener( + onNotification: _scrolling, + child: Column( + children: [ + Expanded( + child: SafeArea( + bottom: false, + child: DraggableScrollableSheet( + minChildSize: widget.minHeightFraction ?? 0.0, + maxChildSize: widget.maxHeightFraction, + initialChildSize: + widget.initHeightFraction + keyboardFraction, + snap: true, + controller: _controller, + builder: (BuildContext context, ScrollController controller) { + return DecoratedBox( + decoration: BoxDecoration( borderRadius: widget.borderRadius, - child: _SmoothDraggableContent( - bodyBuilder: widget.bodyBuilder, - headerBuilder: widget.headerBuilder, - headerHeight: widget.headerHeight, - currentExtent: _controller.isAttached - ? _controller.size - : widget.initHeightFraction, - scrollController: controller, - cacheExtent: _calculateCacheExtent( - MediaQuery.viewInsetsOf(context).bottom, + color: backgroundColor, + ), + child: Material( + type: MaterialType.transparency, + child: ClipRRect( + borderRadius: widget.borderRadius, + child: _SmoothDraggableContent( + bodyBuilder: widget.bodyBuilder, + headerBuilder: widget.headerBuilder, + headerHeight: widget.headerHeight, + currentExtent: _controller.isAttached + ? _controller.size + : widget.initHeightFraction, + scrollController: controller, + cacheExtent: _calculateCacheExtent( + MediaQuery.viewInsetsOf(context).bottom, + ), ), ), ), - ), - ); - }, + ); + }, + ), ), ), - ), - if (bottomPaddingHeight > 0) - SizedBox( - width: double.infinity, - height: bottomPaddingHeight, - child: ColoredBox(color: backgroundColor), - ), - ], + if (bottomPaddingHeight > 0) + SizedBox( + width: double.infinity, + height: bottomPaddingHeight, + child: ColoredBox(color: backgroundColor), + ), + ], + ), ), ); } diff --git a/packages/smooth_app/lib/generic_lib/bottom_sheets/smooth_draggable_bottom_sheet_route.dart b/packages/smooth_app/lib/generic_lib/bottom_sheets/smooth_draggable_bottom_sheet_route.dart index 3609f74faf5..60953385472 100644 --- a/packages/smooth_app/lib/generic_lib/bottom_sheets/smooth_draggable_bottom_sheet_route.dart +++ b/packages/smooth_app/lib/generic_lib/bottom_sheets/smooth_draggable_bottom_sheet_route.dart @@ -8,7 +8,9 @@ Future showDraggableModalSheet({ required double headerHeight, required WidgetBuilder bodyBuilder, required BorderRadiusGeometry borderRadius, + DraggableScrollableController? draggableScrollableController, double? initHeight, + double? minHeight, double? maxHeight, Color? bottomSheetColor, Color? barrierColor, @@ -19,6 +21,7 @@ Future showDraggableModalSheet({ return Navigator.of(context, rootNavigator: true).push( _FlexibleBottomSheetRoute( + draggableScrollableController: draggableScrollableController, barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel, initHeight: initHeight ?? 0.5, bodyBuilder: bodyBuilder, @@ -38,6 +41,8 @@ class _FlexibleBottomSheetRoute extends PopupRoute { required this.headerHeight, required this.bodyBuilder, required this.borderRadius, + this.minHeight, + this.draggableScrollableController, this.barrierLabel, this.bottomSheetBackgroundColor, super.settings, @@ -46,9 +51,11 @@ class _FlexibleBottomSheetRoute extends PopupRoute { final WidgetBuilder headerBuilder; final double headerHeight; final WidgetBuilder bodyBuilder; + final double? minHeight; final double initHeight; final BorderRadiusGeometry borderRadius; final Color? bottomSheetBackgroundColor; + final DraggableScrollableController? draggableScrollableController; @override final String? barrierLabel; @@ -85,6 +92,7 @@ class _FlexibleBottomSheetRoute extends PopupRoute { context: context, child: SmoothDraggableBottomSheet( initHeightFraction: initHeight, + draggableScrollableController: draggableScrollableController, headerBuilder: headerBuilder, bodyBuilder: bodyBuilder, animationController: _animationController, diff --git a/packages/smooth_app/lib/l10n/app_en.arb b/packages/smooth_app/lib/l10n/app_en.arb index 6d6f024366e..0e662c907ab 100644 --- a/packages/smooth_app/lib/l10n/app_en.arb +++ b/packages/smooth_app/lib/l10n/app_en.arb @@ -1584,7 +1584,7 @@ "@user_list_button_new": { "description": "Short label of a 'create a new list' button" }, - "user_list_empty_label": "No list available yet, please start by creating one", + "user_list_empty_label": "No list available yet!\nPlease start by creating one.", "@user_list_empty_label": { "description": "Content displayed when there is no list" }, diff --git a/packages/smooth_app/lib/pages/product/product_list_helper.dart b/packages/smooth_app/lib/pages/product/product_list_helper.dart index 728ee6611e8..0e73a66603d 100644 --- a/packages/smooth_app/lib/pages/product/product_list_helper.dart +++ b/packages/smooth_app/lib/pages/product/product_list_helper.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:provider/provider.dart'; import 'package:smooth_app/data_models/product_list.dart'; @@ -8,12 +9,14 @@ import 'package:smooth_app/database/dao_product_list.dart'; import 'package:smooth_app/database/local_database.dart'; import 'package:smooth_app/generic_lib/buttons/smooth_simple_button.dart'; import 'package:smooth_app/generic_lib/design_constants.dart'; +import 'package:smooth_app/generic_lib/duration_constants.dart'; import 'package:smooth_app/helpers/collections_helper.dart'; import 'package:smooth_app/helpers/haptic_feedback_helper.dart'; import 'package:smooth_app/helpers/provider_helper.dart'; import 'package:smooth_app/pages/product_list_user_dialog_helper.dart'; import 'package:smooth_app/resources/app_icons.dart'; import 'package:smooth_app/themes/smooth_theme_colors.dart'; +import 'package:smooth_app/themes/theme_provider.dart'; import 'package:smooth_app/widgets/smooth_checkbox.dart'; class AddProductToListContainer extends StatelessWidget { @@ -137,35 +140,51 @@ class _AddToProductListNoListAvailable extends StatelessWidget { class _AddToProductListWithLists extends StatelessWidget { const _AddToProductListWithLists(); - static const double MIN_ITEM_HEIGHT = 48.0; + static const double MIN_ITEM_HEIGHT = 58.0; @override Widget build(BuildContext context) { final _ProductUserListsWithState state = context .watch<_ProductUserListsProvider>() .value as _ProductUserListsWithState; + final List> userLists = state.userLists; + final bool? scrollBarVisible = userLists.length > 5 ? true : null; - return SliverList( - delegate: SliverChildBuilderDelegate( - (BuildContext context, int index) { - if (index == 0) { - return _AddToProductListAddNewList( - userLists: state.userLists.map((MapEntry entry) { - return entry.key; - }), - ); - } else if (index <= state.userLists.length) { - final MapEntry entry = state.userLists[index - 1]; - return _AddToProductListItem( - listId: entry.key, - selected: entry.value, - includeDivider: index < state.userLists.length, - ); - } else { - return SizedBox(height: MediaQuery.viewPaddingOf(context).bottom); - } - }, - childCount: state.userLists.length + 2, + return DefaultTextStyle.merge( + style: TextStyle( + fontSize: 15.0, + color: Theme.of(context).colorScheme.onSurface, + ), + child: SliverFillRemaining( + child: Column(children: [ + Expanded( + child: Scrollbar( + thumbVisibility: scrollBarVisible, + trackVisibility: scrollBarVisible, + child: ListView.builder( + padding: EdgeInsets.zero, + itemCount: userLists.length, + itemBuilder: (BuildContext context, int index) { + final MapEntry entry = userLists[index]; + return KeyedSubtree( + key: ValueKey(entry.key), + child: _AddToProductListItem( + listId: entry.key, + selected: entry.value, + // Force the divider when there is just one item + includeDivider: + userLists.length == 1 || index < userLists.length - 1, + ), + ); + }, + ), + ), + ), + _AddToProductListAddNewList( + userLists: + userLists.map((MapEntry entry) => entry.key), + ) + ]), ), ); } @@ -244,97 +263,165 @@ class _AddToProductListAddNewList extends StatefulWidget { } class _AddToProductListAddNewListState - extends State<_AddToProductListAddNewList> { + extends State<_AddToProductListAddNewList> + with WidgetsBindingObserver, SingleTickerProviderStateMixin { final TextEditingController _controller = TextEditingController(); + late final AnimationController _animationController; + Animation? _colorAnimation; + int animationRepeat = 0; + bool _editMode = false; bool _inputValid = false; + @override + void initState() { + super.initState(); + _animationController = AnimationController( + vsync: this, + duration: SmoothAnimationsDuration.short, + ); + + SchedulerBinding.instance.addPostFrameCallback((_) { + _initAnimation(); + }); + } + + void _initAnimation() { + final SmoothColorsThemeExtension extension = + Theme.of(context).extension()!; + final bool lightTheme = context.lightTheme(listen: false); + + _colorAnimation = ColorTween( + begin: lightTheme ? extension.primaryLight : extension.primarySemiDark, + end: extension.red, + ).animate(_animationController) + ..addListener(() { + setState(() {}); + }) + ..addStatusListener((AnimationStatus status) { + // Run back and forth the animation twice + if (status == AnimationStatus.completed && animationRepeat < 2) { + _animationController.reverse(); + } else if (status == AnimationStatus.dismissed && animationRepeat < 1) { + animationRepeat++; + _animationController.forward(); + } + }); + + setState(() {}); + } + @override Widget build(BuildContext context) { final AppLocalizations appLocalizations = AppLocalizations.of(context); - final Color? mainColor = Theme.of(context) - .checkboxTheme - .fillColor! - .resolve({WidgetState.selected}); - - return Column( - children: [ - IconTheme( - data: IconThemeData(color: mainColor), - child: InkWell( - onTap: () { - setState(() { - if (!_editMode) { - _controller.clear(); - _editMode = true; - _inputValid = false; - } - }); - }, - child: ConstrainedBox( - constraints: const BoxConstraints( - minHeight: _AddToProductListWithLists.MIN_ITEM_HEIGHT, + final SmoothColorsThemeExtension extension = + Theme.of(context).extension()!; + final bool lightTheme = context.lightTheme(); + + final Color iconColor = lightTheme + ? Theme.of(context) + .checkboxTheme + .fillColor! + .resolve({WidgetState.selected})! + : Colors.white; + + return IconTheme( + data: IconThemeData(color: iconColor), + child: Padding( + padding: const EdgeInsetsDirectional.symmetric(horizontal: SMALL_SPACE), + child: InkWell( + borderRadius: const BorderRadius.vertical(top: Radius.circular(10.0)), + onTap: () { + setState(() { + if (!_editMode) { + _controller.clear(); + _editMode = true; + _inputValid = false; + } + }); + }, + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(10.0)), + color: _colorAnimation?.value, + border: Border.all( + color: lightTheme + ? extension.primaryNormal + : extension.primaryLight, + ), + ), + child: Padding( + padding: EdgeInsetsDirectional.only( + bottom: MediaQuery.viewInsetsOf(context).bottom, ), - child: Padding( - padding: EdgeInsetsDirectional.symmetric( - horizontal: - (Platform.isIOS || Platform.isMacOS) ? 25.5 : 28.0, + child: ConstrainedBox( + constraints: const BoxConstraints( + minHeight: _AddToProductListWithLists.MIN_ITEM_HEIGHT, ), - child: Row( - children: [ - const Icon(Icons.add_circle_rounded), - const SizedBox(width: VERY_LARGE_SPACE), - Expanded( - child: _editMode - ? Padding( - padding: - const EdgeInsetsDirectional.only(bottom: 1.0), - child: TextField( - controller: _controller, - autofocus: true, - decoration: InputDecoration( - isDense: true, - hintText: appLocalizations - .user_list_name_input_hint, - hintStyle: TextStyle( - fontStyle: FontStyle.italic, - color: Theme.of(context).hintColor, + child: Padding( + padding: EdgeInsetsDirectional.only( + start: (Platform.isIOS || Platform.isMacOS) ? 18.5 : 21.0, + end: 5.0, + ), + child: Row( + children: [ + const Icon(Icons.add_circle_rounded), + const SizedBox(width: VERY_LARGE_SPACE), + Expanded( + child: _editMode + ? Padding( + padding: const EdgeInsetsDirectional.only( + bottom: 1.0), + child: TextField( + controller: _controller, + autofocus: true, + decoration: InputDecoration( + isDense: true, + hintText: appLocalizations + .user_list_name_input_hint, + hintStyle: TextStyle( + fontStyle: FontStyle.italic, + color: Theme.of(context).hintColor, + ), + contentPadding: EdgeInsets.zero, + border: InputBorder.none, ), - contentPadding: EdgeInsets.zero, - border: InputBorder.none, + textInputAction: TextInputAction.done, + maxLines: 1, + textAlignVertical: TextAlignVertical.top, + style: DefaultTextStyle.of(context).style, + onChanged: _checkInput, + onSubmitted: (_) => _addList(context), ), - textInputAction: TextInputAction.done, - maxLines: 1, - textAlignVertical: TextAlignVertical.top, - style: DefaultTextStyle.of(context).style, - onChanged: _checkInput, - onSubmitted: (_) => _inputValid - ? () => _addList(context) - : null, + ) + : Text( + appLocalizations.user_list_button_new, ), - ) - : Text( - appLocalizations.user_list_button_new, - ), - ), - if (_editMode) - IconButton( - icon: const Icon(Icons.cancel), - onPressed: () => setState(() => _editMode = false), - ), - if (_editMode) - IconButton( - icon: const Icon(Icons.check_circle), - onPressed: _inputValid ? () => _addList(context) : null, ), - ], + if (_editMode) + IconButton( + icon: const Icon(Icons.cancel), + onPressed: () => setState(() => _editMode = false), + tooltip: MaterialLocalizations.of(context) + .cancelButtonLabel, + ), + if (_editMode) + IconButton( + icon: const Icon(Icons.check_circle), + color: _inputValid + ? iconColor + : iconColor.withOpacity(0.4), + tooltip: appLocalizations.product_list_create_tooltip, + onPressed: () => _addList(context), + ), + ], + ), ), ), ), ), ), - const _AddToProductListDivider(), - ], + ), ); } @@ -351,6 +438,7 @@ class _AddToProductListAddNewListState void _addList(BuildContext context) { if (!_inputValid) { + _notifyWrongInput(); return; } @@ -360,30 +448,84 @@ class _AddToProductListAddNewListState setState(() => _editMode = false); } + + void _notifyWrongInput() { + animationRepeat = 0; + _animationController.forward(from: 0.0); + SmoothHapticFeedback.error(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } } +/// A dashed divider class _AddToProductListDivider extends StatelessWidget { const _AddToProductListDivider(); @override Widget build(BuildContext context) { + final SmoothColorsThemeExtension extension = + Theme.of(context).extension()!; return Padding( padding: const EdgeInsetsDirectional.symmetric( horizontal: LARGE_SPACE, ), - child: SizedBox( - height: 1.0, - width: double.infinity, - child: ColoredBox( - color: Theme.of(context) - .extension()! - .primaryLight, + child: CustomPaint( + size: const Size(double.infinity, 1.0), + painter: _AddToProductListDividerPainter( + dashWidth: 8.0, + dashSpace: 5.0, + color: context.lightTheme() + ? extension.primaryNormal + : extension.primaryLight, ), ), ); } } +class _AddToProductListDividerPainter extends CustomPainter { + _AddToProductListDividerPainter({ + required Color color, + required this.dashWidth, + required this.dashSpace, + }) : assert(color != Colors.transparent), + assert(dashWidth >= 0), + assert(dashSpace >= 0), + _paint = Paint() + ..color = color + ..strokeWidth = 1.0 + ..style = PaintingStyle.stroke; + + final Paint _paint; + final double dashWidth; + final double dashSpace; + + @override + void paint(Canvas canvas, Size size) { + double startX = 0.0; + while (startX < size.width) { + canvas.drawLine( + Offset(startX, 0), + Offset(startX + dashWidth, 0), + _paint, + ); + startX += dashWidth + dashSpace; + } + } + + @override + bool shouldRepaint(_AddToProductListDividerPainter oldDelegate) => false; + + @override + bool shouldRebuildSemantics(_AddToProductListDividerPainter oldDelegate) => + false; +} + /// Logic for the user lists class _ProductUserListsProvider extends ValueNotifier<_ProductUserListsState> { _ProductUserListsProvider(this.dao, this.barcode) @@ -403,12 +545,12 @@ class _ProductUserListsProvider extends ValueNotifier<_ProductUserListsState> { return; } - // Sort by ignoring case +// Sort by ignoring case lists.sort( (String a, String b) => a.toLowerCase().compareTo(b.toLowerCase()), ); - // Create a list of user lists with a boolean if the product is in it +// Create a list of user lists with a boolean if the product is in it final List listsWithProduct = await dao.getUserListsWithBarcodes([barcode]); diff --git a/packages/smooth_app/lib/pages/product/product_page/new_product_footer.dart b/packages/smooth_app/lib/pages/product/product_page/new_product_footer.dart index 388deabd097..2474a9d47e2 100644 --- a/packages/smooth_app/lib/pages/product/product_page/new_product_footer.dart +++ b/packages/smooth_app/lib/pages/product/product_page/new_product_footer.dart @@ -154,19 +154,7 @@ class _ProductAddPriceButton extends StatelessWidget { return _ProductFooterFilledButton( label: appLocalizations.prices_add_a_price, - icon: switch (currency) { - Currency.GBP => const icons.AddPrice.britishPound(), - Currency.USD => const icons.AddPrice.dollar(), - Currency.EUR => const icons.AddPrice.euro(), - Currency.RUB => const icons.AddPrice.ruble(), - Currency.INR => const icons.AddPrice.rupee(), - Currency.CHF => const icons.AddPrice.swissFranc(), - Currency.TRY => const icons.AddPrice.turkishLira(), - Currency.UAH => const icons.AddPrice.ukrainianHryvnia(), - Currency.KRW => const icons.AddPrice.won(), - Currency.JPY => const icons.AddPrice.yen(), - _ => const icons.AddPrice.dollar(), - }, + icon: icons.AddPrice(currency), onTap: () => _addAPrice(context, context.read()), ); }, diff --git a/packages/smooth_app/lib/resources/app_icons.dart b/packages/smooth_app/lib/resources/app_icons.dart index 3db92e610a1..755f7f851c6 100644 --- a/packages/smooth_app/lib/resources/app_icons.dart +++ b/packages/smooth_app/lib/resources/app_icons.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:openfoodfacts/openfoodfacts.dart'; part 'app_icons_font.dart'; @@ -14,6 +15,22 @@ class Add extends AppIcon { } class AddPrice extends AppIcon { + factory AddPrice(Currency currency) { + return switch (currency) { + Currency.GBP => const AddPrice.britishPound(), + Currency.USD => const AddPrice.dollar(), + Currency.EUR => const AddPrice.euro(), + Currency.RUB => const AddPrice.ruble(), + Currency.INR => const AddPrice.rupee(), + Currency.CHF => const AddPrice.swissFranc(), + Currency.TRY => const AddPrice.turkishLira(), + Currency.UAH => const AddPrice.ukrainianHryvnia(), + Currency.KRW => const AddPrice.won(), + Currency.JPY => const AddPrice.yen(), + _ => const AddPrice.dollar(), + }; + } + const AddPrice.britishPound({ super.color, super.size,