diff --git a/packages/smooth_app/lib/generic_lib/design_constants.dart b/packages/smooth_app/lib/generic_lib/design_constants.dart index 85efe016209..d9797ea520c 100644 --- a/packages/smooth_app/lib/generic_lib/design_constants.dart +++ b/packages/smooth_app/lib/generic_lib/design_constants.dart @@ -6,6 +6,7 @@ const Widget EMPTY_WIDGET = SizedBox.shrink(); const double VERY_SMALL_SPACE = 4.0; const double SMALL_SPACE = 8.0; +const double BALANCED_SPACE = 10.0; const double MEDIUM_SPACE = 12.0; const double LARGE_SPACE = 16.0; const double VERY_LARGE_SPACE = 20.0; @@ -21,6 +22,12 @@ const Radius ROUNDED_RADIUS = Radius.circular(20.0); //ignore: non_constant_identifier_names const BorderRadius ROUNDED_BORDER_RADIUS = BorderRadius.all(ROUNDED_RADIUS); +/// Topbarā€¦ +const Radius HEADER_ROUNDED_RADIUS = Radius.circular(30.0); +//ignore: non_constant_identifier_names +const BorderRadius HEADER_BORDER_RADIUS = + BorderRadius.all(HEADER_ROUNDED_RADIUS); + /// Full screen button, e.g. KnowledgePanel const Radius ANGULAR_RADIUS = Radius.circular(8.0); //ignore: non_constant_identifier_names diff --git a/packages/smooth_app/lib/helpers/provider_helper.dart b/packages/smooth_app/lib/helpers/provider_helper.dart index 3e5753cc5a4..84b86dbc5c5 100644 --- a/packages/smooth_app/lib/helpers/provider_helper.dart +++ b/packages/smooth_app/lib/helpers/provider_helper.dart @@ -39,6 +39,65 @@ class _ListenerState extends SingleChildState> { } } +/// Same as [Listener] but for [ValueNotifier] : notifies when the value changes +class ValueNotifierListener, S> + extends SingleChildStatefulWidget { + const ValueNotifierListener({ + this.listener, + this.listenerWithValueNotifier, + super.key, + super.child, + }) : assert( + listener != null || listenerWithValueNotifier != null, + 'At least one listener must be provided', + ); + + final void Function( + BuildContext context, + S? previousValue, + S currentValue, + )? listener; + + final void Function( + BuildContext context, + T valueNotifier, + S? previousValue, + S currentValue, + )? listenerWithValueNotifier; + + @override + State> createState() => + _ValueNotifierListenerState(); +} + +class _ValueNotifierListenerState, S> + extends SingleChildState> { + S? _oldValue; + + @override + Widget buildWithChild(BuildContext context, Widget? child) { + final S? oldValue = _oldValue; + final T valueNotifier = context.watch(); + final S newValue = valueNotifier.value; + _oldValue = newValue; + + widget.listener?.call( + context, + oldValue, + newValue, + ); + + widget.listenerWithValueNotifier?.call( + context, + valueNotifier, + oldValue, + newValue, + ); + + return child ?? const SizedBox.shrink(); + } +} + /// Same as [Consumer] but only rebuilds if [buildWhen] returns true /// (And on the first build) class ConsumerFilter extends StatefulWidget { @@ -91,6 +150,64 @@ class _ConsumerFilterState extends State> { } } +/// Same as [Consumer] for [ValueNotifier] but only rebuilds if [buildWhen] +/// returns true (and on the first build). +class ConsumerValueNotifierFilter, S> + extends StatefulWidget { + const ConsumerValueNotifierFilter({ + required this.builder, + this.buildWhen, + this.child, + super.key, + }); + + final Widget Function( + BuildContext context, + S value, + Widget? child, + ) builder; + final bool Function(S? previousValue, S currentValue)? buildWhen; + + final Widget? child; + + @override + State> createState() => + _ConsumerValueNotifierFilterState(); +} + +class _ConsumerValueNotifierFilterState, S> + extends State> { + S? oldValue; + Widget? oldWidget; + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (BuildContext context, T provider, Widget? child) { + if ((widget.buildWhen != null && + widget.buildWhen!.call(oldValue, provider.value)) || + widget.buildWhen == null && oldValue != provider.value || + oldWidget == null) { + oldWidget = widget.builder( + context, + provider.value, + child, + ); + } + + oldValue = provider.value; + + return widget.builder( + context, + provider.value, + oldWidget, + ); + }, + child: widget.child, + ); + } +} + extension ValueNotifierExtensions on ValueNotifier { void emit(T value) => this.value = value; } diff --git a/packages/smooth_app/lib/l10n/app_en.arb b/packages/smooth_app/lib/l10n/app_en.arb index 72f4d0ceced..4950480c499 100644 --- a/packages/smooth_app/lib/l10n/app_en.arb +++ b/packages/smooth_app/lib/l10n/app_en.arb @@ -14,6 +14,10 @@ "@reason": {}, "okay": "Okay", "@okay": {}, + "validate": "Validate", + "@validate": { + "description": "Button label: Validate the input" + }, "create": "Create", "@create": { "description": "An action to create" diff --git a/packages/smooth_app/lib/pages/guides/helpers/guides_content.dart b/packages/smooth_app/lib/pages/guides/helpers/guides_content.dart index 983f588dc27..38fe9a0dfde 100644 --- a/packages/smooth_app/lib/pages/guides/helpers/guides_content.dart +++ b/packages/smooth_app/lib/pages/guides/helpers/guides_content.dart @@ -179,7 +179,7 @@ class _GuidesParagraphTitle extends StatelessWidget { child: Padding( padding: const EdgeInsetsDirectional.symmetric( horizontal: GuidesParagraph._HORIZONTAL_PADDING, - vertical: 10.0, + vertical: BALANCED_SPACE, ), child: Row( crossAxisAlignment: CrossAxisAlignment.baseline, @@ -189,7 +189,7 @@ class _GuidesParagraphTitle extends StatelessWidget { padding: EdgeInsetsDirectional.only(top: 3.3), child: _GuidesParagraphArrow(), ), - const SizedBox(width: 10.0), + const SizedBox(width: BALANCED_SPACE), Expanded( child: Text( title, @@ -244,7 +244,7 @@ class GuidesText extends StatelessWidget { Widget build(BuildContext context) { return Padding( padding: const EdgeInsetsDirectional.only( - top: 10.0, + top: BALANCED_SPACE, start: GuidesParagraph._HORIZONTAL_PADDING, end: GuidesParagraph._HORIZONTAL_PADDING, ), @@ -453,7 +453,7 @@ class GuidesImage extends StatelessWidget { excludeSemantics: true, child: Padding( padding: const EdgeInsetsDirectional.only( - top: 10.0, + top: BALANCED_SPACE, start: GuidesParagraph._HORIZONTAL_PADDING, end: GuidesParagraph._HORIZONTAL_PADDING, ), diff --git a/packages/smooth_app/lib/pages/guides/helpers/guides_footer.dart b/packages/smooth_app/lib/pages/guides/helpers/guides_footer.dart index 1ff923eba8d..494cbe01b87 100644 --- a/packages/smooth_app/lib/pages/guides/helpers/guides_footer.dart +++ b/packages/smooth_app/lib/pages/guides/helpers/guides_footer.dart @@ -34,7 +34,7 @@ class GuidesFooter extends StatelessWidget { top: _FooterPainter.WAVE_SIZE + MEDIUM_SPACE, start: VERY_LARGE_SPACE, end: VERY_LARGE_SPACE, - bottom: 10.0 + MediaQuery.viewPaddingOf(context).bottom, + bottom: BALANCED_SPACE + MediaQuery.viewPaddingOf(context).bottom, ), child: TextButton( style: TextButton.styleFrom( diff --git a/packages/smooth_app/lib/pages/guides/helpers/guides_header.dart b/packages/smooth_app/lib/pages/guides/helpers/guides_header.dart index 9ac100a7827..d52dc3cd4b9 100644 --- a/packages/smooth_app/lib/pages/guides/helpers/guides_header.dart +++ b/packages/smooth_app/lib/pages/guides/helpers/guides_header.dart @@ -33,7 +33,7 @@ class GuidesHeader extends StatelessWidget { style: const TextStyle(color: Colors.white), child: SliverPadding( padding: const EdgeInsetsDirectional.only( - bottom: 10.0, + bottom: BALANCED_SPACE, ), // Pinned = for the header to stay at the top of the screen sliver: SliverPersistentHeader( @@ -79,7 +79,7 @@ class _GuidesHeaderDelegate extends SliverPersistentHeaderDelegate { decoration: ShapeDecoration( shape: RoundedRectangleBorder( borderRadius: BorderRadius.vertical( - bottom: Radius.circular(30.0 * (1 - progress)), + bottom: HEADER_ROUNDED_RADIUS * (1 - progress), ), ), color: colors.primaryDark, @@ -113,7 +113,7 @@ class _GuidesHeaderDelegate extends SliverPersistentHeaderDelegate { child: Align( alignment: Alignment.bottomLeft, child: Padding( - padding: const EdgeInsets.only(bottom: 10.0), + padding: const EdgeInsets.only(bottom: BALANCED_SPACE), child: AutoSizeText( title, maxLines: 4, @@ -193,8 +193,8 @@ class _GuidesHeaderLayout extends MultiChildLayoutDelegate { @override void performLayout(Size size) { - final double topMargin = topPadding + 10.0; - final double maxHeight = size.height - topPadding - (10.0 * 2); + final double topMargin = topPadding + BALANCED_SPACE; + final double maxHeight = size.height - topPadding - (BALANCED_SPACE * 2); final Size closeButtonSize = layoutChild( _GuidesHeaderLayoutId.closeButton, @@ -250,7 +250,7 @@ class _GuidesHeaderLayout extends MultiChildLayoutDelegate { positionChild( _GuidesHeaderLayoutId.minimizedTitle, Offset( - _CloseButtonLayout._CLOSE_BUTTON_SIZE + 10.0, + _CloseButtonLayout._CLOSE_BUTTON_SIZE + BALANCED_SPACE, topMargin + 5.0, ), ); @@ -295,7 +295,7 @@ class _BackButton extends StatelessWidget { child: ExcludeSemantics( child: Padding( padding: const EdgeInsetsDirectional.only( - start: 10.0, + start: BALANCED_SPACE, end: 24.0, ), child: Opacity( diff --git a/packages/smooth_app/lib/pages/image_crop_page.dart b/packages/smooth_app/lib/pages/image_crop_page.dart index 67f902807c1..98056485b21 100644 --- a/packages/smooth_app/lib/pages/image_crop_page.dart +++ b/packages/smooth_app/lib/pages/image_crop_page.dart @@ -98,7 +98,7 @@ Future _getUserPictureSource( closeButtonSemanticsOrder: 5.0, body: const _ImageSourcePicker(), bodyPadding: const EdgeInsetsDirectional.only( - start: 10.0, + start: BALANCED_SPACE, end: MEDIUM_SPACE, top: LARGE_SPACE, bottom: MEDIUM_SPACE, @@ -127,7 +127,7 @@ class _ImageSourcePickerState extends State<_ImageSourcePicker> { children: [ IntrinsicHeight( child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 10.0), + padding: const EdgeInsets.symmetric(horizontal: BALANCED_SPACE), child: Row( children: [ Expanded( diff --git a/packages/smooth_app/lib/pages/onboarding/country_selector.dart b/packages/smooth_app/lib/pages/onboarding/country_selector.dart deleted file mode 100644 index ebc0d0f6a3d..00000000000 --- a/packages/smooth_app/lib/pages/onboarding/country_selector.dart +++ /dev/null @@ -1,359 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -import 'package:iso_countries/iso_countries.dart'; -import 'package:openfoodfacts/openfoodfacts.dart'; -import 'package:provider/provider.dart'; -import 'package:smooth_app/data_models/preferences/user_preferences.dart'; -import 'package:smooth_app/generic_lib/design_constants.dart'; -import 'package:smooth_app/generic_lib/dialogs/smooth_alert_dialog.dart'; -import 'package:smooth_app/generic_lib/widgets/smooth_text_form_field.dart'; -import 'package:smooth_app/query/product_query.dart'; -import 'package:smooth_app/widgets/smooth_text.dart'; - -/// A selector for selecting user's country. -class CountrySelector extends StatefulWidget { - const CountrySelector({ - this.textStyle, - this.padding, - this.icon, - this.inkWellBorderRadius, - required this.forceCurrencyChange, - }); - - final TextStyle? textStyle; - final EdgeInsetsGeometry? padding; - final BorderRadius? inkWellBorderRadius; - final Widget? icon; - final bool forceCurrencyChange; - - @override - State createState() => _CountrySelectorState(); -} - -class _CountrySelectorState extends State { - final ScrollController _scrollController = ScrollController(); - final TextEditingController _countryController = TextEditingController(); - late List _countryList; - - Future _loadLocalizedCountryNames(final String languageCode) async { - List localizedCountries; - - try { - localizedCountries = - await IsoCountries.isoCountriesForLocale(languageCode); - } on MissingPluginException catch (_) { - // Locales are not implemented on desktop and web - // TODO(g123k): Add a complete list - localizedCountries = [ - const Country(name: 'United States', countryCode: 'US'), - const Country(name: 'France', countryCode: 'FR'), - const Country(name: 'Germany', countryCode: 'DE'), - const Country(name: 'India', countryCode: 'IN'), - ]; - } - _countryList = _sanitizeCountriesList(localizedCountries); - } - - @override - Widget build(BuildContext context) { - final AppLocalizations appLocalizations = AppLocalizations.of(context); - return Selector( - selector: (BuildContext buildContext, UserPreferences userPreferences) => - userPreferences.appLanguageCode, - builder: (BuildContext context, String? appLanguageCode, _) { - return FutureBuilder( - future: _loadLocalizedCountryNames(appLanguageCode!), - builder: (BuildContext context, AsyncSnapshot snapshot) { - if (snapshot.hasError) { - return Text('Fatal Error: ${snapshot.error}'); - } else if (snapshot.connectionState != ConnectionState.done) { - return const CircularProgressIndicator.adaptive(); - } - final UserPreferences userPreferences = - context.watch(); - final Country selectedCountry = _getSelectedCountry( - userPreferences.userCountryCode, - ); - final EdgeInsetsGeometry innerPadding = const EdgeInsets.symmetric( - vertical: SMALL_SPACE, - ).add(widget.padding ?? EdgeInsets.zero); - - return InkWell( - borderRadius: widget.inkWellBorderRadius ?? ANGULAR_BORDER_RADIUS, - onTap: () async { - _reorderCountries(selectedCountry); - List filteredList = List.from(_countryList); - final Country? country = await showDialog( - context: context, - builder: (BuildContext context) { - return StatefulBuilder( - builder: (BuildContext context, - void Function(VoidCallback fn) setState) { - const double horizontalPadding = 16.0 + SMALL_SPACE; - - return SmoothListAlertDialog( - title: appLocalizations.country_selector_title, - header: SmoothTextFormField( - type: TextFieldTypes.PLAIN_TEXT, - prefixIcon: const Icon(Icons.search), - controller: _countryController, - onChanged: (String? query) { - query = query!.trim().getComparisonSafeString(); - - setState( - () { - filteredList = _countryList - .where( - (Country item) => - item.name - .getComparisonSafeString() - .contains( - query!, - ) || - item.countryCode - .getComparisonSafeString() - .contains( - query, - ), - ) - .toList(growable: false); - }, - ); - }, - hintText: appLocalizations.search, - ), - scrollController: _scrollController, - list: ListView.separated( - controller: _scrollController, - itemBuilder: (BuildContext context, int index) { - final Country country = filteredList[index]; - final bool selected = country == selectedCountry; - return ListTile( - dense: true, - contentPadding: const EdgeInsets.symmetric( - horizontal: horizontalPadding, - ), - trailing: - selected ? const Icon(Icons.check) : null, - title: TextHighlighter( - text: country.name, - filter: _countryController.text, - selected: selected, - ), - onTap: () { - Navigator.of(context).pop(country); - _countryController.clear(); - }, - ); - }, - separatorBuilder: (_, __) => const Divider( - height: 1.0, - ), - itemCount: filteredList.length, - shrinkWrap: true, - ), - positiveAction: SmoothActionButton( - onPressed: () { - Navigator.pop(context); - _countryController.clear(); - }, - text: appLocalizations.cancel, - ), - ); - }, - ); - }, - ); - if (country != null) { - await ProductQuery.setCountry( - userPreferences, - country.countryCode, - ); - if (context.mounted) { - await _changeCurrencyIfRelevant( - country, - userPreferences, - context, - ); - } - } - }, - child: DecoratedBox( - decoration: const BoxDecoration( - borderRadius: BorderRadius.all(Radius.circular(10)), - ), - child: IntrinsicHeight( - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Padding( - padding: innerPadding, - child: const Icon(Icons.public), - ), - Expanded( - flex: 1, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: LARGE_SPACE), - child: Text( - selectedCountry.name, - style: Theme.of(context) - .textTheme - .displaySmall - ?.merge(widget.textStyle), - ), - ), - ), - widget.icon ?? const Icon(Icons.arrow_drop_down), - ], - ), - ), - ), - ); - }, - ); - }, - ); - } - - /// Sanitizes the country list, but without reordering it. - /// - /// * by removing countries that are not in [OpenFoodFactsCountry] - /// * and providing a fallback English name for countries that are in - /// [OpenFoodFactsCountry] but not in [localizedCountries]. - List _sanitizeCountriesList(List localizedCountries) { - final List finalCountriesList = []; - final Map oFFIsoCodeToCountry = - {}; - final Map localizedIsoCodeToCountry = {}; - for (final OpenFoodFactsCountry c in OpenFoodFactsCountry.values) { - oFFIsoCodeToCountry[c.offTag.toLowerCase()] = c; - } - for (final Country c in localizedCountries) { - localizedIsoCodeToCountry.putIfAbsent( - c.countryCode.toLowerCase(), () => c); - } - for (final String countryCode in oFFIsoCodeToCountry.keys) { - final Country? localizedCountry = localizedIsoCodeToCountry[countryCode]; - if (localizedCountry == null) { - // No localization for the country name was found, use English name as - // default. - String countryName = oFFIsoCodeToCountry[countryCode] - .toString() - .replaceAll('OpenFoodFactsCountry.', '') - .replaceAll('_', ' '); - countryName = - '${countryName[0].toUpperCase()}${countryName.substring(1).toLowerCase()}'; - finalCountriesList.add( - Country( - name: _fixCountryName(countryName), - countryCode: _fixCountryCode(countryCode)), - ); - continue; - } - final String fixedCountryCode = _fixCountryCode(countryCode); - final Country country = fixedCountryCode == countryCode - ? localizedCountry - : Country(name: localizedCountry.name, countryCode: countryCode); - finalCountriesList.add(country); - } - return finalCountriesList; - } - - /// Fix the countryCode if needed so Backend can process it. - String _fixCountryCode(String countryCode) { - // 'gb' is handled as 'uk' in the backend. - if (countryCode == 'gb') { - countryCode = 'uk'; - } - return countryCode; - } - - Country _getSelectedCountry(final String? cc) { - if (cc != null) { - for (final Country country in _countryList) { - if (country.countryCode.toLowerCase() == cc.toLowerCase()) { - return country; - } - } - } - return _countryList[0]; - } - - /// Fix the issues where United Kingdom appears with lowercase 'k'. - String _fixCountryName(String countryName) { - if (countryName == 'United kingdom') { - countryName = 'United Kingdom'; - } - return countryName; - } - - /// Reorder countries alphabetically, bring user's locale country to top. - void _reorderCountries(final Country selectedCountry) { - _countryList.sort( - (final Country a, final Country b) { - if (a == selectedCountry) { - return -1; - } - if (b == selectedCountry) { - return 1; - } - return a.name.compareTo(b.name); - }, - ); - } - - @override - void dispose() { - _countryController.dispose(); - super.dispose(); - } - - Future _changeCurrencyIfRelevant( - final Country country, - final UserPreferences userPreferences, - final BuildContext context, - ) async { - final OpenFoodFactsCountry? offCountry = - OpenFoodFactsCountry.fromOffTag(country.countryCode); - final String? possibleCurrencyCode = offCountry?.currency?.name; - if (possibleCurrencyCode == null) { - return; - } - bool? changeCurrency; - final String? currentCurrencyCode = userPreferences.userCurrencyCode; - if (currentCurrencyCode == null) { - changeCurrency = true; - } else if (widget.forceCurrencyChange) { - changeCurrency = true; - } else if (currentCurrencyCode != possibleCurrencyCode) { - final AppLocalizations appLocalizations = AppLocalizations.of(context); - changeCurrency = await showDialog( - context: context, - builder: (final BuildContext context) => SmoothAlertDialog( - body: Text( - '${appLocalizations.country_change_message}' - '\n' - '${appLocalizations.currency_auto_change_message( - currentCurrencyCode, - possibleCurrencyCode, - )}', - ), - negativeAction: SmoothActionButton( - onPressed: () => Navigator.of(context).pop(), - text: appLocalizations.no, - ), - positiveAction: SmoothActionButton( - onPressed: () => Navigator.of(context).pop(true), - text: appLocalizations.yes, - ), - ), - ); - } - if (changeCurrency == true) { - await userPreferences.setUserCurrencyCode(possibleCurrencyCode); - } - } -} diff --git a/packages/smooth_app/lib/pages/onboarding/welcome_page.dart b/packages/smooth_app/lib/pages/onboarding/welcome_page.dart index 736f8ba6425..3aafa112c3b 100644 --- a/packages/smooth_app/lib/pages/onboarding/welcome_page.dart +++ b/packages/smooth_app/lib/pages/onboarding/welcome_page.dart @@ -6,9 +6,10 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:smooth_app/generic_lib/design_constants.dart'; import 'package:smooth_app/helpers/app_helper.dart'; -import 'package:smooth_app/pages/onboarding/country_selector.dart'; import 'package:smooth_app/pages/onboarding/next_button.dart'; import 'package:smooth_app/pages/onboarding/onboarding_flow_navigator.dart'; +import 'package:smooth_app/pages/preferences/country_selector/country_selector.dart'; +import 'package:smooth_app/resources/app_icons.dart' as icons; import 'package:smooth_app/widgets/smooth_scaffold.dart'; import 'package:smooth_app/widgets/smooth_text.dart'; @@ -83,7 +84,7 @@ class WelcomePage extends StatelessWidget { Padding( padding: const EdgeInsets.symmetric( vertical: MEDIUM_SPACE), - child: Ink( + child: Container( decoration: BoxDecoration( border: Border.fromBorderSide( BorderSide( @@ -91,32 +92,27 @@ class WelcomePage extends StatelessWidget { width: 1, ), ), - borderRadius: ROUNDED_BORDER_RADIUS, + borderRadius: ANGULAR_BORDER_RADIUS, color: theme.colorScheme.onPrimary, ), child: SizedBox( width: double.infinity, - child: CountrySelector( - forceCurrencyChange: true, - padding: const EdgeInsets.symmetric( - horizontal: SMALL_SPACE, - ), - inkWellBorderRadius: ROUNDED_BORDER_RADIUS, - icon: Container( - height: double.infinity, - decoration: BoxDecoration( - color: theme.primaryColor, - borderRadius: ROUNDED_BORDER_RADIUS, + child: Material( + type: MaterialType.transparency, + child: CountrySelector( + autoValidate: true, + forceCurrencyChange: true, + padding: const EdgeInsetsDirectional.only( + start: SMALL_SPACE, + end: LARGE_SPACE, ), - child: AspectRatio( - aspectRatio: 1.0, - child: Icon( - Icons.edit, - color: Colors.white.withOpacity(0.9), - ), + inkWellBorderRadius: ANGULAR_BORDER_RADIUS, + icon: const icons.Arrow.right( + size: 15.0, ), + textStyle: + TextStyle(color: theme.primaryColor), ), - textStyle: TextStyle(color: theme.primaryColor), ), ), ), diff --git a/packages/smooth_app/lib/pages/preferences/country_selector/country_selector.dart b/packages/smooth_app/lib/pages/preferences/country_selector/country_selector.dart new file mode 100644 index 00000000000..7f6c09cf39e --- /dev/null +++ b/packages/smooth_app/lib/pages/preferences/country_selector/country_selector.dart @@ -0,0 +1,592 @@ +import 'package:auto_size_text/auto_size_text.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart' hide Listener; +import 'package:flutter/services.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:iso_countries/iso_countries.dart'; +import 'package:openfoodfacts/openfoodfacts.dart'; +import 'package:provider/provider.dart'; +import 'package:smooth_app/data_models/preferences/user_preferences.dart'; +import 'package:smooth_app/generic_lib/design_constants.dart'; +import 'package:smooth_app/generic_lib/dialogs/smooth_alert_dialog.dart'; +import 'package:smooth_app/generic_lib/duration_constants.dart'; +import 'package:smooth_app/helpers/provider_helper.dart'; +import 'package:smooth_app/pages/prices/emoji_helper.dart'; +import 'package:smooth_app/resources/app_icons.dart' as icons; +import 'package:smooth_app/themes/smooth_theme_colors.dart'; +import 'package:smooth_app/themes/theme_provider.dart'; +import 'package:smooth_app/widgets/smooth_text.dart'; +import 'package:smooth_app/widgets/v2/smooth_buttons_bar.dart'; +import 'package:smooth_app/widgets/v2/smooth_scaffold2.dart'; +import 'package:smooth_app/widgets/v2/smooth_topbar2.dart'; + +part 'country_selector_provider.dart'; + +/// A button that will open a list of countries and save it in the preferences. +class CountrySelector extends StatelessWidget { + const CountrySelector({ + required this.forceCurrencyChange, + this.textStyle, + this.padding, + this.icon, + this.inkWellBorderRadius, + this.autoValidate = true, + }); + + final TextStyle? textStyle; + final EdgeInsetsGeometry? padding; + final BorderRadius? inkWellBorderRadius; + final Widget? icon; + final bool forceCurrencyChange; + + /// A click on a new country will automatically save it + final bool autoValidate; + + @override + Widget build(BuildContext context) { + return ChangeNotifierProvider<_CountrySelectorProvider>( + create: (_) => _CountrySelectorProvider( + preferences: context.read(), + autoValidate: autoValidate, + ), + child: Consumer<_CountrySelectorProvider>( + builder: (BuildContext context, _CountrySelectorProvider provider, _) { + return switch (provider.value) { + _CountrySelectorLoadingState _ => const Center( + child: CircularProgressIndicator.adaptive(), + ), + _CountrySelectorLoadedState _ => _CountrySelectorButton( + icon: icon, + innerPadding: const EdgeInsetsDirectional.symmetric( + vertical: SMALL_SPACE, + ).add(padding ?? EdgeInsets.zero), + textStyle: textStyle, + inkWellBorderRadius: inkWellBorderRadius, + forceCurrencyChange: forceCurrencyChange, + autoValidate: autoValidate, + ), + }; + }, + ), + ); + } +} + +class _CountrySelectorButton extends StatelessWidget { + const _CountrySelectorButton({ + required this.innerPadding, + required this.forceCurrencyChange, + required this.autoValidate, + this.icon, + this.textStyle, + this.inkWellBorderRadius, + }); + + final Widget? icon; + final EdgeInsetsGeometry innerPadding; + final TextStyle? textStyle; + final BorderRadius? inkWellBorderRadius; + final bool forceCurrencyChange; + final bool autoValidate; + + @override + Widget build(BuildContext context) { + return InkWell( + borderRadius: inkWellBorderRadius ?? ANGULAR_BORDER_RADIUS, + onTap: () => _openCountrySelector(context), + child: DecoratedBox( + decoration: const BoxDecoration( + borderRadius: BorderRadius.all(Radius.circular(10)), + ), + child: ConsumerValueNotifierFilter<_CountrySelectorProvider, + _CountrySelectorState>( + buildWhen: (_CountrySelectorState? previousValue, + _CountrySelectorState currentValue) => + previousValue != null && + currentValue is! _CountrySelectorEditingState && + (currentValue as _CountrySelectorLoadedState).country != + (previousValue as _CountrySelectorLoadedState).country, + builder: (_, _CountrySelectorState value, __) { + final Country? country = + (value as _CountrySelectorLoadedState).country; + + return Padding( + padding: innerPadding, + child: Row( + children: [ + if (country != null) + SizedBox( + width: IconTheme.of(context).size! + LARGE_SPACE, + child: AutoSizeText( + EmojiHelper.getEmojiByCountryCode(country.countryCode)!, + textAlign: TextAlign.center, + style: TextStyle(fontSize: IconTheme.of(context).size), + ), + ) + else + const Icon(Icons.public), + Expanded( + child: Text( + country?.name ?? AppLocalizations.of(context).loading, + style: Theme.of(context) + .textTheme + .displaySmall + ?.merge(textStyle), + ), + ), + icon ?? const Icon(Icons.arrow_drop_down), + ], + ), + ); + }, + ), + ), + ); + } + + Future _openCountrySelector(BuildContext context) async { + final dynamic newCountry = + await Navigator.of(context, rootNavigator: true).push( + PageRouteBuilder( + pageBuilder: (_, __, ___) => + + /// We re-inject the [_CountrySelectorProvider], otherwise it's not in + /// the same tree. [ListenableProvider] allows to prevent the auto-dispose. + ListenableProvider<_CountrySelectorProvider>( + create: (_) => context.read<_CountrySelectorProvider>(), + dispose: (_, __) {}, + child: const _CountrySelectorScreen(), + ), + transitionsBuilder: (BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child) { + final Tween tween = Tween( + begin: const Offset(0.0, 1.0), + end: Offset.zero, + ); + final CurvedAnimation curvedAnimation = CurvedAnimation( + parent: animation, + curve: Curves.easeInOut, + ); + final Animation position = tween.animate(curvedAnimation); + + return SlideTransition( + position: position, + child: FadeTransition( + opacity: animation, + child: child, + ), + ); + }), + ); + + if (!context.mounted) { + return; + } + + /// Ensure to restore the previous state + /// (eg: the user uses the Android back button). + if (newCountry == null) { + context.read<_CountrySelectorProvider>().dismissSelectedCountry(); + } else if (newCountry is Country) { + _changeCurrencyIfRelevant(context, newCountry); + } + } + +// TODO(g123k): move this to a dedicated Provider + Future _changeCurrencyIfRelevant( + final BuildContext context, + final Country country, + ) async { + final UserPreferences userPreferences = context.read(); + final OpenFoodFactsCountry? offCountry = + OpenFoodFactsCountry.fromOffTag(country.countryCode); + final String? possibleCurrencyCode = offCountry?.currency?.name; + + if (possibleCurrencyCode == null) { + return; + } + + bool? changeCurrency; + final String? currentCurrencyCode = userPreferences.userCurrencyCode; + + if (currentCurrencyCode == null) { + changeCurrency = true; + } else if (forceCurrencyChange) { + changeCurrency = true; + } else if (currentCurrencyCode != possibleCurrencyCode) { + final AppLocalizations appLocalizations = AppLocalizations.of(context); + changeCurrency = await showDialog( + context: context, + builder: (final BuildContext context) => SmoothAlertDialog( + body: Text( + '${appLocalizations.country_change_message}' + '\n' + '${appLocalizations.currency_auto_change_message( + currentCurrencyCode, + possibleCurrencyCode, + )}', + ), + negativeAction: SmoothActionButton( + onPressed: () => Navigator.of(context, rootNavigator: true).pop(), + text: appLocalizations.no, + ), + positiveAction: SmoothActionButton( + onPressed: () => + Navigator.of(context, rootNavigator: true).pop(true), + text: appLocalizations.yes, + ), + ), + ); + } + if (changeCurrency == true) { + await userPreferences.setUserCurrencyCode(possibleCurrencyCode); + } + } +} + +class _CountrySelectorScreen extends StatelessWidget { + const _CountrySelectorScreen(); + + @override + Widget build(BuildContext context) { + final AppLocalizations appLocalizations = AppLocalizations.of(context); + final _CountrySelectorProvider provider = + context.read<_CountrySelectorProvider>(); + + return ValueNotifierListener<_CountrySelectorProvider, + _CountrySelectorState>( + listenerWithValueNotifier: _onValueChanged, + child: ChangeNotifierProvider( + create: (_) => TextEditingController(), + child: SmoothScaffold2( + topBar: SmoothTopBar2( + title: appLocalizations.country_selector_title, + leadingAction: provider.autoValidate + ? SmoothTopBarLeadingAction.minimize + : SmoothTopBarLeadingAction.close, + ), + bottomBar: + !provider.autoValidate ? const _CountrySelectorBottomBar() : null, + injectPaddingInBody: false, + children: const [ + _CountrySelectorSearchBar(), + SliverPadding( + padding: EdgeInsetsDirectional.only( + top: SMALL_SPACE, + ), + ), + _CountrySelectorList() + ], + ), + ), + ); + } + + /// When the value changed in [autoValidate] mode, we close the screen + void _onValueChanged( + BuildContext context, + _CountrySelectorProvider provider, + _CountrySelectorState? oldValue, + _CountrySelectorState currentValue, + ) { + if (provider.autoValidate && + oldValue != null && + currentValue is! _CountrySelectorEditingState && + currentValue is _CountrySelectorLoadedState) { + WidgetsBinding.instance.addPostFrameCallback((_) { + final NavigatorState navigator = Navigator.of(context); + if (navigator.canPop()) { + navigator.pop(currentValue.country); + } + }); + } + } +} + +class _CountrySelectorSearchBar extends StatelessWidget { + const _CountrySelectorSearchBar(); + + @override + Widget build(BuildContext context) { + return SliverPersistentHeader( + pinned: true, + floating: false, + delegate: _CountrySelectorSearchBarDelegate(), + ); + } +} + +class _CountrySelectorSearchBarDelegate extends SliverPersistentHeaderDelegate { + @override + Widget build( + BuildContext context, + double shrinkOffset, + bool overlapsContent, + ) { + final SmoothColorsThemeExtension colors = + Theme.of(context).extension()!; + final bool darkMode = context.darkTheme(); + + return ColoredBox( + color: Theme.of(context).scaffoldBackgroundColor, + child: Padding( + padding: const EdgeInsetsDirectional.only( + top: SMALL_SPACE, + start: SMALL_SPACE, + end: SMALL_SPACE, + ), + child: TextFormField( + controller: context.read(), + textAlignVertical: TextAlignVertical.center, + style: const TextStyle( + fontSize: 15.0, + ), + decoration: InputDecoration( + hintText: AppLocalizations.of(context).search, + enabledBorder: OutlineInputBorder( + borderRadius: const BorderRadius.all(Radius.circular(10.0)), + borderSide: BorderSide( + color: colors.primaryNormal, + width: 2.0, + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: const BorderRadius.all(Radius.circular(15.0)), + borderSide: BorderSide( + color: darkMode ? colors.primaryNormal : colors.primarySemiDark, + width: 2.0, + ), + ), + contentPadding: const EdgeInsetsDirectional.only( + start: 100, + end: SMALL_SPACE, + top: 10, + bottom: 0, + ), + prefixIcon: icons.Search( + size: 20.0, + color: darkMode ? colors.primaryNormal : colors.primarySemiDark, + ), + ), + ), + ), + ); + } + + @override + double get maxExtent => 48.0; + + @override + double get minExtent => 48.0; + + @override + bool shouldRebuild(covariant _CountrySelectorSearchBarDelegate oldDelegate) => + false; +} + +class _CountrySelectorBottomBar extends StatelessWidget { + const _CountrySelectorBottomBar(); + + @override + Widget build(BuildContext context) { + return ConsumerValueNotifierFilter<_CountrySelectorProvider, + _CountrySelectorState>( + builder: ( + BuildContext context, + _CountrySelectorState value, + _, + ) { + if (value is! _CountrySelectorEditingState) { + return EMPTY_WIDGET; + } + + return SmoothButtonsBar2( + positiveButton: SmoothActionButton2( + text: AppLocalizations.of(context).validate, + icon: const icons.Arrow.right(), + onPressed: () => _saveCountry(context)), + ); + }, + ); + } + + void _saveCountry(BuildContext context) { + final _CountrySelectorProvider countryProvider = + context.read<_CountrySelectorProvider>(); + + /// Without autoValidate, we need to manually close the screen + countryProvider.saveSelectedCountry(); + + if (countryProvider.value is _CountrySelectorEditingState) { + Navigator.of(context).pop( + (countryProvider.value as _CountrySelectorEditingState).selectedCountry, + ); + } + } +} + +class _CountrySelectorList extends StatefulWidget { + const _CountrySelectorList(); + + @override + State<_CountrySelectorList> createState() => _CountrySelectorListState(); +} + +class _CountrySelectorListState extends State<_CountrySelectorList> { + @override + Widget build(BuildContext context) { + return Consumer2<_CountrySelectorProvider, TextEditingController>( + builder: ( + BuildContext context, + _CountrySelectorProvider provider, + TextEditingController controller, + _, + ) { + final _CountrySelectorLoadedState state = + provider.value as _CountrySelectorLoadedState; + final Country? selectedCountry = + state.runtimeType == _CountrySelectorEditingState + ? (state as _CountrySelectorEditingState).selectedCountry + : state.country; + + final Iterable countries = _filterCountries( + state.countries, + state.country, + selectedCountry, + controller.text, + ); + + return SliverFixedExtentList( + delegate: SliverChildBuilderDelegate( + (BuildContext context, int index) { + final Country country = countries.elementAt(index); + final bool selected = selectedCountry == country; + + return _CountrySelectorListItem( + country: country, + selected: selected, + filter: controller.text, + ); + }, + childCount: countries.length, + addAutomaticKeepAlives: false, + ), + itemExtent: 60.0, + ); + }, + ); + } + + Iterable _filterCountries( + List countries, + Country? userCountry, + Country? selectedCountry, + String? filter, + ) { + if (filter == null || filter.isEmpty) { + return countries; + } + + return countries.where( + (Country country) => + country == userCountry || + country == selectedCountry || + country.name.toLowerCase().contains( + filter.toLowerCase(), + ) || + country.countryCode.toLowerCase().contains( + filter.toLowerCase(), + ), + ); + } +} + +class _CountrySelectorListItem extends StatelessWidget { + const _CountrySelectorListItem({ + required this.country, + required this.selected, + required this.filter, + }); + + final Country country; + final bool selected; + final String filter; + + @override + Widget build(BuildContext context) { + final SmoothColorsThemeExtension colors = + Theme.of(context).extension()!; + final _CountrySelectorProvider provider = + context.read<_CountrySelectorProvider>(); + + return Semantics( + value: country.name, + button: true, + selected: selected, + excludeSemantics: true, + child: AnimatedContainer( + duration: SmoothAnimationsDuration.short, + margin: const EdgeInsetsDirectional.only( + start: SMALL_SPACE, + end: SMALL_SPACE, + bottom: SMALL_SPACE, + ), + decoration: BoxDecoration( + borderRadius: ANGULAR_BORDER_RADIUS, + border: Border.all( + color: selected ? colors.secondaryLight : colors.primaryMedium, + width: selected ? 3.0 : 1.0, + ), + color: selected + ? context.darkTheme() + ? colors.primarySemiDark + : colors.primaryLight + : Colors.transparent, + ), + child: InkWell( + borderRadius: ANGULAR_BORDER_RADIUS, + onTap: () => provider.changeSelectedCountry(country), + child: Padding( + padding: const EdgeInsetsDirectional.symmetric( + horizontal: SMALL_SPACE, + vertical: VERY_SMALL_SPACE, + ), + child: Row( + children: [ + Expanded( + flex: 1, + child: Text( + EmojiHelper.getEmojiByCountryCode(country.countryCode) ?? + '', + style: const TextStyle(fontSize: 25.0), + ), + ), + Expanded( + flex: 2, + child: Text( + country.countryCode.toUpperCase(), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + Expanded( + flex: 7, + child: TextHighlighter( + text: country.name, + filter: filter, + textStyle: const TextStyle( + fontWeight: FontWeight.w600, + ), + ), + ) + ], + ), + ), + ), + ), + ); + } +} diff --git a/packages/smooth_app/lib/pages/preferences/country_selector/country_selector_provider.dart b/packages/smooth_app/lib/pages/preferences/country_selector/country_selector_provider.dart new file mode 100644 index 00000000000..f0584570cbb --- /dev/null +++ b/packages/smooth_app/lib/pages/preferences/country_selector/country_selector_provider.dart @@ -0,0 +1,292 @@ +part of 'country_selector.dart'; + +/// A provider with 4 states: +/// * [_CountrySelectorInitialState]: initial state, no countries +/// * [_CountrySelectorLoadingState]: loading countries +/// * [_CountrySelectorLoadedState]: countries loaded and/or saved +/// * [_CountrySelectorEditingState]: the user has selected a country +/// (temporary selection) +class _CountrySelectorProvider extends ValueNotifier<_CountrySelectorState> { + _CountrySelectorProvider({ + required this.preferences, + required this.autoValidate, + }) : super(const _CountrySelectorInitialState()) { + preferences.addListener(_onPreferencesChanged); + _onPreferencesChanged(); + } + + final UserPreferences preferences; + final bool autoValidate; + String? userCountryCode; + String? userAppLanguageCode; + + void changeSelectedCountry(Country country) { + final _CountrySelectorLoadedState state = + value as _CountrySelectorLoadedState; + + value = _CountrySelectorEditingState.fromLoadedState( + loadedState: state, + selectedCountry: country, + ); + + if (autoValidate) { + saveSelectedCountry(); + } + } + + Future saveSelectedCountry() async { + if (value is! _CountrySelectorEditingState) { + return; + } + + /// No need to refresh the state here, the [UserPreferences] will notify + return preferences.setUserCountryCode( + (value as _CountrySelectorEditingState).selectedCountry!.countryCode, + ); + } + + void dismissSelectedCountry() { + if (value is _CountrySelectorEditingState) { + value = (value as _CountrySelectorEditingState).toLoadedState(); + } + } + + Future _onPreferencesChanged() async { + final String? newCountryCode = preferences.userCountryCode; + final String? newAppLanguageCode = preferences.appLanguageCode; + + if (newAppLanguageCode != userAppLanguageCode) { + userAppLanguageCode = newAppLanguageCode; + userCountryCode = newCountryCode; + + return _loadCountries(); + } else if (newCountryCode != userCountryCode) { + userAppLanguageCode = newAppLanguageCode; + userCountryCode = newCountryCode; + + if (value is _CountrySelectorInitialState) { + return _loadCountries(); + } else { + final _CountrySelectorLoadedState state = + value as _CountrySelectorLoadedState; + + /// Reorder items + final List countries = state.countries; + _reorderCountries(countries, userCountryCode); + + value = state.copyWith( + country: _getSelectedCountry(state.countries), + countries: countries, + ); + } + } + } + + Future _loadCountries() async { + if (userAppLanguageCode == null) { + return; + } + + value = const _CountrySelectorLoadingState(); + + List localizedCountries; + + try { + localizedCountries = + await IsoCountries.isoCountriesForLocale(userAppLanguageCode); + } on MissingPluginException catch (_) { + // Locales are not implemented on desktop and web + localizedCountries = [ + const Country(name: 'United States', countryCode: 'US'), + const Country(name: 'France', countryCode: 'FR'), + const Country(name: 'Germany', countryCode: 'DE'), + const Country(name: 'India', countryCode: 'IN'), + ]; + } + + final List countries = await compute( + _reformatCountries, + (localizedCountries, userCountryCode), + ); + + value = _CountrySelectorLoadedState( + country: _getSelectedCountry(countries), + countries: countries, + ); + } + + static Future> _reformatCountries( + (List, String?) localizedCountriesAndUserCountry, + ) async { + final List countries = + _sanitizeCountriesList(localizedCountriesAndUserCountry.$1); + _reorderCountries(countries, localizedCountriesAndUserCountry.$2); + return countries; + } + + /// Sanitizes the country list, but without reordering it. + /// * by removing countries that are not in [OpenFoodFactsCountry] + /// * and providing a fallback English name for countries that are in + /// [OpenFoodFactsCountry] but not in [localizedCountries]. + static List _sanitizeCountriesList( + List localizedCountries) { + final List finalCountriesList = []; + final Map oFFIsoCodeToCountry = + {}; + final Map localizedIsoCodeToCountry = {}; + for (final OpenFoodFactsCountry c in OpenFoodFactsCountry.values) { + oFFIsoCodeToCountry[c.offTag.toLowerCase()] = c; + } + for (final Country c in localizedCountries) { + localizedIsoCodeToCountry.putIfAbsent( + c.countryCode.toLowerCase(), () => c); + } + for (final String countryCode in oFFIsoCodeToCountry.keys) { + final Country? localizedCountry = localizedIsoCodeToCountry[countryCode]; + if (localizedCountry == null) { + // No localization for the country name was found, use English name as + // default. + String countryName = oFFIsoCodeToCountry[countryCode] + .toString() + .replaceAll('OpenFoodFactsCountry.', '') + .replaceAll('_', ' '); + countryName = + '${countryName[0].toUpperCase()}${countryName.substring(1).toLowerCase()}'; + finalCountriesList.add( + Country( + name: _fixCountryName(countryName), + countryCode: _fixCountryCode(countryCode)), + ); + continue; + } + final String fixedCountryCode = _fixCountryCode(countryCode); + final Country country = fixedCountryCode == countryCode + ? localizedCountry + : Country(name: localizedCountry.name, countryCode: countryCode); + finalCountriesList.add(country); + } + + return finalCountriesList; + } + + /// Fix the countryCode if needed so Backend can process it. + static String _fixCountryCode(String countryCode) { + // 'gb' is handled as 'uk' in the backend. + if (countryCode == 'gb') { + countryCode = 'uk'; + } + return countryCode; + } + + /// Fix the issues where United Kingdom appears with lowercase 'k'. + static String _fixCountryName(String countryName) { + if (countryName == 'United kingdom') { + countryName = 'United Kingdom'; + } + return countryName; + } + + /// Reorder countries alphabetically, bring user's locale country to top. + static void _reorderCountries( + List countries, + String? userCountryCode, + ) { + countries.sort( + (final Country a, final Country b) { + if (a.countryCode == userCountryCode) { + return -1; + } + if (b.countryCode == userCountryCode) { + return 1; + } + return a.name.compareTo(b.name); + }, + ); + } + + Country _getSelectedCountry(List countries) { + if (userCountryCode != null) { + for (final Country country in countries) { + if (country.countryCode.toLowerCase() == + userCountryCode?.toLowerCase()) { + return country; + } + } + } + return countries[0]; + } + + @override + void dispose() { + preferences.removeListener(_onPreferencesChanged); + super.dispose(); + } +} + +@immutable +sealed class _CountrySelectorState { + const _CountrySelectorState(); +} + +class _CountrySelectorInitialState extends _CountrySelectorLoadingState { + const _CountrySelectorInitialState(); +} + +class _CountrySelectorLoadingState extends _CountrySelectorState { + const _CountrySelectorLoadingState(); +} + +class _CountrySelectorLoadedState extends _CountrySelectorState { + const _CountrySelectorLoadedState({ + required this.country, + required this.countries, + this.estimatedCountry, + }); + + final Country? country; + final List countries; + + /// We be used later to provide an estimation based on the IP address. + final Country? estimatedCountry; + + _CountrySelectorLoadedState copyWith({ + Country? country, + Country? estimatedCountry, + List? countries, + }) => + _CountrySelectorLoadedState( + country: country ?? this.country, + estimatedCountry: estimatedCountry ?? this.estimatedCountry, + countries: countries ?? this.countries, + ); + + @override + String toString() { + return '_CountrySelectorLoadedState{country: $country, estimatedCountry: $estimatedCountry, countries: $countries}'; + } +} + +class _CountrySelectorEditingState extends _CountrySelectorLoadedState { + _CountrySelectorEditingState.fromLoadedState({ + required this.selectedCountry, + required _CountrySelectorLoadedState loadedState, + }) : super( + country: loadedState.country, + estimatedCountry: loadedState.estimatedCountry, + countries: loadedState.countries, + ); + + final Country? selectedCountry; + + /// Remove the selected country + _CountrySelectorLoadedState toLoadedState() => _CountrySelectorLoadedState( + country: country, + estimatedCountry: estimatedCountry, + countries: countries, + ); + + @override + String toString() { + return '_CountrySelectorEditingState{selectedCountry: $selectedCountry}'; + } +} diff --git a/packages/smooth_app/lib/pages/preferences/user_preferences_country_selector.dart b/packages/smooth_app/lib/pages/preferences/user_preferences_country_selector.dart index 6db20531a04..6977710b3d9 100644 --- a/packages/smooth_app/lib/pages/preferences/user_preferences_country_selector.dart +++ b/packages/smooth_app/lib/pages/preferences/user_preferences_country_selector.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:smooth_app/generic_lib/design_constants.dart'; -import 'package:smooth_app/pages/onboarding/country_selector.dart'; +import 'package:smooth_app/pages/preferences/country_selector/country_selector.dart'; import 'package:smooth_app/pages/preferences/user_preferences_item.dart'; class UserPreferencesCountrySelector extends StatelessWidget { @@ -34,6 +34,7 @@ class UserPreferencesCountrySelector extends StatelessWidget { bottom: SMALL_SPACE, ), child: CountrySelector( + autoValidate: false, forceCurrencyChange: false, textStyle: themeData.textTheme.bodyMedium, icon: const Icon(Icons.edit), diff --git a/packages/smooth_app/lib/pages/prices/emoji_helper.dart b/packages/smooth_app/lib/pages/prices/emoji_helper.dart index 77a545b1d9b..5b56715dccf 100644 --- a/packages/smooth_app/lib/pages/prices/emoji_helper.dart +++ b/packages/smooth_app/lib/pages/prices/emoji_helper.dart @@ -2,14 +2,23 @@ import 'package:openfoodfacts/openfoodfacts.dart'; /// Generic helper about emoji display. class EmojiHelper { + const EmojiHelper._(); + /// Returns the country flag emoji. /// /// cf. https://emojipedia.org/flag-italy - String? getCountryEmoji(final OpenFoodFactsCountry? country) { + static String? getCountryEmoji(final OpenFoodFactsCountry? country) { if (country == null) { return null; } - return _getCountryEmojiFromUnicode(country.offTag); + return getEmojiByCountryCode(country.offTag); + } + + static String? getEmojiByCountryCode(final String countryCode) { + if (countryCode.isEmpty) { + return null; + } + return _getCountryEmojiFromUnicode(countryCode); } static const int _emojiCountryLetterA = 0x1F1E6; diff --git a/packages/smooth_app/lib/pages/prices/price_data_widget.dart b/packages/smooth_app/lib/pages/prices/price_data_widget.dart index b246c3d3bb5..a2fea52a97c 100644 --- a/packages/smooth_app/lib/pages/prices/price_data_widget.dart +++ b/packages/smooth_app/lib/pages/prices/price_data_widget.dart @@ -131,7 +131,7 @@ class PriceDataWidget extends StatelessWidget { return null; } final StringBuffer result = StringBuffer(); - final String? countryEmoji = EmojiHelper().getCountryEmoji( + final String? countryEmoji = EmojiHelper.getCountryEmoji( _getCountry(location), ); if (location.name != null) { diff --git a/packages/smooth_app/lib/pages/product/add_new_product_page.dart b/packages/smooth_app/lib/pages/product/add_new_product_page.dart index 2b6aa487e87..92a54dab66f 100644 --- a/packages/smooth_app/lib/pages/product/add_new_product_page.dart +++ b/packages/smooth_app/lib/pages/product/add_new_product_page.dart @@ -318,9 +318,9 @@ class _AddNewProductPageState extends State Widget _backButton() { return Container( margin: const EdgeInsetsDirectional.only( - start: 10.0, - end: 10.0, - top: 10.0, + start: BALANCED_SPACE, + end: BALANCED_SPACE, + top: BALANCED_SPACE, bottom: 0.0, ), width: 20.0, @@ -367,7 +367,7 @@ class _AddNewProductPageState extends State ), ), ), - const SizedBox(width: 10.0), + const SizedBox(width: BALANCED_SPACE), ElevatedButton( style: ElevatedButton.styleFrom( minimumSize: Size(MediaQuery.sizeOf(context).width * 0.35, 40.0), @@ -459,7 +459,10 @@ class _AddNewProductPageState extends State setState(() => _ecoscoreExpanded = !_ecoscoreExpanded); }, child: Container( - padding: const EdgeInsets.symmetric(vertical: 10.0, horizontal: 15.0), + padding: const EdgeInsets.symmetric( + vertical: BALANCED_SPACE, + horizontal: 15.0, + ), decoration: BoxDecoration( borderRadius: ROUNDED_BORDER_RADIUS, color: _colorScheme.surface, @@ -566,7 +569,7 @@ class _AddNewProductPageState extends State rows.add(_buildMainImageButton(context, upToDateProduct, data)); rows.add( const Padding( - padding: EdgeInsets.symmetric(vertical: 10.0), + padding: EdgeInsets.symmetric(vertical: BALANCED_SPACE), child: UserPreferencesListItemDivider(), ), ); diff --git a/packages/smooth_app/lib/pages/product/edit_ocr/edit_ocr_main_action.dart b/packages/smooth_app/lib/pages/product/edit_ocr/edit_ocr_main_action.dart index 6fa71cb85e7..a6e797f0720 100644 --- a/packages/smooth_app/lib/pages/product/edit_ocr/edit_ocr_main_action.dart +++ b/packages/smooth_app/lib/pages/product/edit_ocr/edit_ocr_main_action.dart @@ -261,7 +261,7 @@ class _ExtractMainActionProgressIndicator extends StatelessWidget { bottom: SMALL_SPACE, ), child: CupertinoActivityIndicator( - radius: 10.0, + radius: BALANCED_SPACE, color: Colors.white, ), ); diff --git a/packages/smooth_app/lib/pages/scan/carousel/main_card/scan_main_card.dart b/packages/smooth_app/lib/pages/scan/carousel/main_card/scan_main_card.dart index 26e15c4bf4d..389cc3aa0e6 100644 --- a/packages/smooth_app/lib/pages/scan/carousel/main_card/scan_main_card.dart +++ b/packages/smooth_app/lib/pages/scan/carousel/main_card/scan_main_card.dart @@ -150,7 +150,7 @@ class _SearchBar extends StatelessWidget { child: Padding( padding: const EdgeInsetsDirectional.only( start: 20.0, - end: 10.0, + end: BALANCED_SPACE, bottom: 3.0, ), child: Text( @@ -171,7 +171,7 @@ class _SearchBar extends StatelessWidget { shape: BoxShape.circle, ), child: const Padding( - padding: EdgeInsets.all(10.0), + padding: EdgeInsets.all(BALANCED_SPACE), child: Search( size: 20.0, color: Colors.white, diff --git a/packages/smooth_app/lib/pages/scan/scan_page.dart b/packages/smooth_app/lib/pages/scan/scan_page.dart index 26f22f14e30..a5229d43e1a 100644 --- a/packages/smooth_app/lib/pages/scan/scan_page.dart +++ b/packages/smooth_app/lib/pages/scan/scan_page.dart @@ -91,7 +91,8 @@ class _ScanPageState extends State { Expanded( flex: _carouselHeightPct, child: Padding( - padding: const EdgeInsetsDirectional.only(bottom: 10.0), + padding: const EdgeInsetsDirectional.only( + bottom: BALANCED_SPACE), child: ScanPageCarousel( onPageChangedTo: (int page, String? barcode) async { if (barcode == null) { @@ -185,7 +186,7 @@ class _PermissionDeniedCard extends StatelessWidget { ), child: SmoothCard( padding: const EdgeInsetsDirectional.only( - top: 10.0, + top: BALANCED_SPACE, start: SMALL_SPACE, end: SMALL_SPACE, bottom: 5.0, @@ -206,8 +207,8 @@ class _PermissionDeniedCard extends StatelessWidget { child: SingleChildScrollView( child: Padding( padding: const EdgeInsets.symmetric( - horizontal: 10.0, - vertical: 10.0, + horizontal: BALANCED_SPACE, + vertical: BALANCED_SPACE, ), child: Text( localizations.permission_photo_denied_message( diff --git a/packages/smooth_app/lib/pages/scan/search_page.dart b/packages/smooth_app/lib/pages/scan/search_page.dart index f20a0ef87da..68d11d70918 100644 --- a/packages/smooth_app/lib/pages/scan/search_page.dart +++ b/packages/smooth_app/lib/pages/scan/search_page.dart @@ -38,7 +38,7 @@ class _SearchPageState extends State { child: Column( children: [ Padding( - padding: const EdgeInsets.all(10.0), + padding: const EdgeInsets.all(BALANCED_SPACE), child: SearchField( autofocus: widget.autofocus, focusNode: _searchFocusNode, diff --git a/packages/smooth_app/lib/pages/user_management/forgot_password_page.dart b/packages/smooth_app/lib/pages/user_management/forgot_password_page.dart index 42108e73d1e..ca298f71335 100644 --- a/packages/smooth_app/lib/pages/user_management/forgot_password_page.dart +++ b/packages/smooth_app/lib/pages/user_management/forgot_password_page.dart @@ -109,7 +109,7 @@ class _ForgotPasswordPageState extends State const Spacer(flex: 2), if (_message != '') ...[ SmoothCard( - padding: const EdgeInsets.all(10.0), + padding: const EdgeInsets.all(BALANCED_SPACE), color: _send ? Colors.green : Colors.red, child: Text(_message), ), diff --git a/packages/smooth_app/lib/pages/user_management/login_page.dart b/packages/smooth_app/lib/pages/user_management/login_page.dart index cbda47ad48b..49160ec250f 100644 --- a/packages/smooth_app/lib/pages/user_management/login_page.dart +++ b/packages/smooth_app/lib/pages/user_management/login_page.dart @@ -141,10 +141,10 @@ class _LoginPageState extends State with TraceableClientMixin { _loginResult!.type != LoginResultType.successful) Padding( padding: const EdgeInsets.only( - bottom: 10.0 + LARGE_SPACE * 2, + bottom: BALANCED_SPACE + LARGE_SPACE * 2, ), child: SmoothCard( - padding: const EdgeInsets.all(10.0), + padding: const EdgeInsets.all(BALANCED_SPACE), color: const Color(0xFFEB0004), child: Text( _loginResult!.getErrorMessage(appLocalizations), @@ -251,7 +251,7 @@ class _LoginPageState extends State with TraceableClientMixin { style: ButtonStyle( padding: WidgetStateProperty.all( const EdgeInsets.symmetric( - vertical: 10.0, + vertical: BALANCED_SPACE, horizontal: VERY_LARGE_SPACE, ), ), diff --git a/packages/smooth_app/lib/themes/theme_provider.dart b/packages/smooth_app/lib/themes/theme_provider.dart index 08862fb23ba..6acfd5e4c5f 100644 --- a/packages/smooth_app/lib/themes/theme_provider.dart +++ b/packages/smooth_app/lib/themes/theme_provider.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; import 'package:smooth_app/data_models/preferences/user_preferences.dart'; const String THEME_SYSTEM_DEFAULT = 'System Default'; @@ -78,3 +79,10 @@ class ThemeProvider with ChangeNotifier { super.dispose(); } } + +extension ThemeProviderExtension on BuildContext { + bool lightTheme({bool listen = true}) => !darkTheme(listen: listen); + + bool darkTheme({bool listen = true}) => + Provider.of(this, listen: listen).isDarkMode(this); +} diff --git a/packages/smooth_app/lib/widgets/smooth_text.dart b/packages/smooth_app/lib/widgets/smooth_text.dart index bfa2f17066c..dc6efaad081 100644 --- a/packages/smooth_app/lib/widgets/smooth_text.dart +++ b/packages/smooth_app/lib/widgets/smooth_text.dart @@ -66,6 +66,7 @@ class TextHighlighter extends StatelessWidget { this.textAlign, this.selected = false, this.softWrap = false, + this.textStyle, }); final String text; @@ -73,15 +74,17 @@ class TextHighlighter extends StatelessWidget { final TextAlign? textAlign; final bool? softWrap; final bool selected; + final TextStyle? textStyle; @override Widget build(BuildContext context) { List<(String, TextStyle?)> parts; try { + final TextStyle defaultStyle = + textStyle ?? TextStyle(fontWeight: selected ? FontWeight.bold : null); parts = _getParts( - defaultStyle: TextStyle(fontWeight: selected ? FontWeight.bold : null), - highlightedStyle: TextStyle( - fontWeight: selected ? FontWeight.bold : null, + defaultStyle: defaultStyle, + highlightedStyle: defaultStyle.copyWith( backgroundColor: Theme.of(context).primaryColor.withOpacity(0.2), ), ); diff --git a/packages/smooth_app/lib/widgets/v2/smooth_buttons_bar.dart b/packages/smooth_app/lib/widgets/v2/smooth_buttons_bar.dart new file mode 100644 index 00000000000..fb633d808b6 --- /dev/null +++ b/packages/smooth_app/lib/widgets/v2/smooth_buttons_bar.dart @@ -0,0 +1,203 @@ +import 'dart:io'; + +import 'package:auto_size_text/auto_size_text.dart'; +import 'package:flutter/material.dart'; +import 'package:smooth_app/generic_lib/design_constants.dart'; +import 'package:smooth_app/generic_lib/duration_constants.dart'; +import 'package:smooth_app/themes/smooth_theme_colors.dart'; +import 'package:smooth_app/themes/theme_provider.dart'; + +class SmoothButtonsBar2 extends StatefulWidget { + const SmoothButtonsBar2({ + required this.positiveButton, + this.negativeButton, + super.key, + }); + + final SmoothActionButton2 positiveButton; + final SmoothActionButton2? negativeButton; + + @override + State createState() => _SmoothButtonsBar2State(); +} + +class _SmoothButtonsBar2State extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: SmoothAnimationsDuration.brief, + vsync: this, + )..addListener(() => setState(() {})); + + _controller.drive( + Tween( + begin: 0.0, + end: 1.0, + ), + ); + + WidgetsBinding.instance.addPostFrameCallback((_) { + _controller.forward(); + }); + } + + @override + Widget build(BuildContext context) { + final double viewPadding = MediaQuery.viewPaddingOf(context).bottom; + final SmoothColorsThemeExtension? colors = + Theme.of(context).extension(); + + final Widget positiveButtonWidget = + _SmoothPositiveButton2(data: widget.positiveButton); + + final Widget child; + if (widget.negativeButton != null) { + child = Row( + mainAxisSize: MainAxisSize.min, + children: [ + Expanded( + child: _SmoothNegativeButton2(data: widget.negativeButton!), + ), + const SizedBox(width: SMALL_SPACE), + Expanded( + child: positiveButtonWidget, + ), + ], + ); + } else { + child = FractionallySizedBox( + widthFactor: 0.75, + child: positiveButtonWidget, + ); + } + + return Opacity( + opacity: _controller.value, + child: Container( + transform: Matrix4.translationValues( + 0.0, + (15.0 + viewPadding + MEDIUM_SPACE + BALANCED_SPACE * 2) * + (1.0 - _controller.value), + 0.0, + ), + width: double.infinity, + decoration: BoxDecoration( + color: + context.darkTheme() ? colors!.primaryDark : colors!.primaryMedium, + boxShadow: [ + BoxShadow( + color: context.darkTheme() ? Colors.white10 : Colors.black12, + blurRadius: 6.0, + offset: const Offset(0.0, -4.0), + ), + ], + ), + padding: EdgeInsetsDirectional.only( + start: BALANCED_SPACE, + end: BALANCED_SPACE, + top: MEDIUM_SPACE, + bottom: _bottomPadding + viewPadding, + ), + child: child, + ), + ); + } + + double get _bottomPadding { + if (Platform.isIOS) { + return 0.0; + } else if (Platform.isAndroid) { + return VERY_SMALL_SPACE; + } else { + return MEDIUM_SPACE; + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } +} + +class SmoothActionButton2 { + SmoothActionButton2({ + required this.text, + required this.onPressed, + this.icon, + }) : assert(text.isNotEmpty); + + final String text; + final Widget? icon; + final VoidCallback? onPressed; +} + +class _SmoothPositiveButton2 extends StatelessWidget { + const _SmoothPositiveButton2({required this.data}); + + final SmoothActionButton2 data; + + @override + Widget build(BuildContext context) { + final SmoothColorsThemeExtension colors = + Theme.of(context).extension()!; + + return TextButton( + style: TextButton.styleFrom( + backgroundColor: colors.primaryBlack, + foregroundColor: Colors.white, + shape: const RoundedRectangleBorder( + borderRadius: CIRCULAR_BORDER_RADIUS, + ), + padding: const EdgeInsetsDirectional.symmetric( + horizontal: BALANCED_SPACE, + vertical: MEDIUM_SPACE, + ), + ), + onPressed: data.onPressed, + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + textBaseline: TextBaseline.alphabetic, + children: [ + AutoSizeText( + data.text, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 15.0, + ), + maxLines: 1, + ), + if (data.icon != null) ...[ + const SizedBox(width: SMALL_SPACE), + Padding( + padding: const EdgeInsetsDirectional.only(top: 0.5), + child: SizedBox( + height: 13.0, + child: FittedBox( + child: data.icon, + ), + ), + ) + ], + ], + ), + ); + } +} + +// TODO(g123k): Not implemented +class _SmoothNegativeButton2 extends StatelessWidget { + const _SmoothNegativeButton2({required this.data}); + + final SmoothActionButton2 data; + + @override + Widget build(BuildContext context) { + throw Exception('Not implemented!'); + } +} diff --git a/packages/smooth_app/lib/widgets/v2/smooth_scaffold2.dart b/packages/smooth_app/lib/widgets/v2/smooth_scaffold2.dart new file mode 100644 index 00000000000..5986bd09c36 --- /dev/null +++ b/packages/smooth_app/lib/widgets/v2/smooth_scaffold2.dart @@ -0,0 +1,151 @@ +import 'package:flutter/material.dart'; +import 'package:smooth_app/generic_lib/design_constants.dart'; +import 'package:smooth_app/widgets/smooth_scaffold.dart'; +import 'package:smooth_app/widgets/v2/smooth_topbar2.dart'; + +class SmoothScaffold2 extends StatefulWidget { + const SmoothScaffold2({ + required this.children, + this.topBar, + this.bottomBar, + this.injectPaddingInBody = true, + super.key, + }); + + final SmoothTopBar2? topBar; + final List children; + final Widget? bottomBar; + final bool injectPaddingInBody; + + @override + State createState() => _SmoothScaffold2State(); +} + +class _SmoothScaffold2State extends State { + final ScrollController _controller = ScrollController(); + + @override + Widget build(BuildContext context) { + final EdgeInsets viewPadding = MediaQuery.viewPaddingOf(context); + + return SmoothScaffold( + body: PrimaryScrollController( + controller: _controller, + child: CustomMultiChildLayout( + delegate: _SmoothScaffold2Layout( + viewPadding: viewPadding, + injectPaddingInBody: widget.injectPaddingInBody, + ), + children: [ + LayoutId( + id: _SmoothScaffold2Widget.body, + child: CustomScrollView( + controller: _controller, + slivers: [ + if (widget.injectPaddingInBody) + SliverPadding( + padding: EdgeInsetsDirectional.only( + top: widget.topBar != null + ? HEADER_ROUNDED_RADIUS.x + MEDIUM_SPACE + : viewPadding.top, + ), + ), + ...widget.children, + SliverPadding( + padding: EdgeInsetsDirectional.only( + bottom: viewPadding.bottom, + ), + ) + ], + ), + ), + if (widget.topBar != null) + LayoutId( + id: _SmoothScaffold2Widget.topBar, + child: widget.topBar!, + ), + if (widget.bottomBar != null) + LayoutId( + id: _SmoothScaffold2Widget.bottomBar, + child: widget.bottomBar!, + ), + ], + ), + ), + ); + } +} + +enum _SmoothScaffold2Widget { + topBar, + body, + bottomBar, +} + +class _SmoothScaffold2Layout extends MultiChildLayoutDelegate { + _SmoothScaffold2Layout({ + required this.viewPadding, + required this.injectPaddingInBody, + }); + + final EdgeInsets viewPadding; + final bool injectPaddingInBody; + + @override + void performLayout(Size size) { + double topBarHeight; + + // Top bar + if (hasChild(_SmoothScaffold2Widget.topBar)) { + topBarHeight = layoutChild( + _SmoothScaffold2Widget.topBar, + BoxConstraints.loose(size), + ).height; + } else { + topBarHeight = 0.0; + } + + double bottomBarHeight; + + // Top bar + if (hasChild(_SmoothScaffold2Widget.bottomBar)) { + bottomBarHeight = layoutChild( + _SmoothScaffold2Widget.bottomBar, + BoxConstraints.loose( + size, + ), + ).height; + } else { + bottomBarHeight = 0.0; + } + + // Body + final double bodyTopPosition = topBarHeight > 0.0 + ? topBarHeight - (injectPaddingInBody ? HEADER_ROUNDED_RADIUS.x : 0.0) + : 0.0; + layoutChild( + _SmoothScaffold2Widget.body, + BoxConstraints( + minWidth: size.width, + maxWidth: size.width, + minHeight: 0.0, + maxHeight: size.height - bodyTopPosition - bottomBarHeight, + ), + ); + + positionChild(_SmoothScaffold2Widget.body, Offset(0.0, bodyTopPosition)); + + if (topBarHeight > 0.0) { + positionChild(_SmoothScaffold2Widget.topBar, Offset.zero); + } + if (bottomBarHeight > 0.0) { + positionChild( + _SmoothScaffold2Widget.bottomBar, + Offset(0.0, size.height - bottomBarHeight), + ); + } + } + + @override + bool shouldRelayout(covariant MultiChildLayoutDelegate oldDelegate) => false; +} diff --git a/packages/smooth_app/lib/widgets/v2/smooth_topbar2.dart b/packages/smooth_app/lib/widgets/v2/smooth_topbar2.dart new file mode 100644 index 00000000000..6a85406c5d4 --- /dev/null +++ b/packages/smooth_app/lib/widgets/v2/smooth_topbar2.dart @@ -0,0 +1,233 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:smooth_app/generic_lib/design_constants.dart'; +import 'package:smooth_app/helpers/num_utils.dart'; +import 'package:smooth_app/resources/app_icons.dart' as icons; +import 'package:smooth_app/themes/smooth_theme_colors.dart'; +import 'package:smooth_app/themes/theme_provider.dart'; + +class SmoothTopBar2 extends StatefulWidget { + const SmoothTopBar2({ + required this.title, + this.leadingAction, + this.elevation = 4.0, + super.key, + }) : assert(title.length > 0); + + /// Height without the top view padding + static double kTopBar2Height = 100; + + final String title; + final double elevation; + final SmoothTopBarLeadingAction? leadingAction; + + @override + State createState() => _SmoothTopBar2State(); +} + +class _SmoothTopBar2State extends State { + double _elevation = 0.0; + + @override + void initState() { + super.initState(); + + WidgetsBinding.instance.addPostFrameCallback( + (_) => PrimaryScrollController.maybeOf(context)?.addListener( + () => _onScroll(), + ), + ); + } + + void _onScroll() { + final double offset = PrimaryScrollController.of(context).offset; + final double newElevation = offset.progressAndClamp( + 0.0, + HEADER_ROUNDED_RADIUS.x * 2.0, + 1.0, + ) * + widget.elevation; + + if (newElevation != _elevation) { + setState(() { + _elevation = newElevation; + }); + } + } + + @override + Widget build(BuildContext context) { + final SmoothColorsThemeExtension colors = + Theme.of(context).extension()!; + final TextDirection textDirection = Directionality.of(context); + final bool darkTheme = context.darkTheme(); + + final double imageWidth = MediaQuery.sizeOf(context).width * 0.22; + final double imageHeight = imageWidth * 114 / 92; + + return PhysicalModel( + color: Colors.transparent, + elevation: _elevation, + shadowColor: context.darkTheme() ? Colors.white10 : Colors.black12, + borderRadius: const BorderRadius.vertical( + bottom: HEADER_ROUNDED_RADIUS, + ), + child: ClipRRect( + borderRadius: const BorderRadius.vertical( + bottom: HEADER_ROUNDED_RADIUS, + ), + child: ColoredBox( + color: darkTheme ? colors.primaryDark : colors.primaryMedium, + child: Padding( + padding: EdgeInsetsDirectional.only( + top: MediaQuery.viewPaddingOf(context).top, + ), + child: SizedBox( + height: SmoothTopBar2.kTopBar2Height, + child: Stack( + children: [ + Positioned.directional( + textDirection: textDirection, + bottom: -(imageHeight / 2.1), + end: -imageWidth * 0.15, + child: ExcludeSemantics( + child: SvgPicture.asset( + 'assets/product/product_completed_graphic_light.svg', + width: MediaQuery.sizeOf(context).width * 0.22, + height: imageHeight, + ), + ), + ), + Positioned.directional( + textDirection: textDirection, + top: MEDIUM_SPACE, + bottom: VERY_LARGE_SPACE, + start: widget.leadingAction != null + ? BALANCED_SPACE + : VERY_LARGE_SPACE, + end: imageWidth * 0.7, + child: Align( + alignment: AlignmentDirectional.topStart, + child: Row( + children: [ + if (widget.leadingAction != null) ...[ + _SmoothTopBarLeadingButton( + action: widget.leadingAction!), + const SizedBox(width: BALANCED_SPACE) + ], + Expanded( + child: Padding( + padding: widget.leadingAction != null + ? const EdgeInsets.only(bottom: 1.56) + : EdgeInsets.zero, + child: Text( + widget.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: darkTheme + ? colors.primaryMedium + : colors.primaryBlack, + fontSize: 20.0, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ], + ), + ), + ) + ], + ), + ), + ), + ), + ), + ); + } +} + +enum SmoothTopBarLeadingAction { + close, + back, + minimize, +} + +class _SmoothTopBarLeadingButton extends StatelessWidget { + const _SmoothTopBarLeadingButton({ + required this.action, + }); + + final SmoothTopBarLeadingAction action; + + @override + Widget build(BuildContext context) { + final MaterialLocalizations localizations = + MaterialLocalizations.of(context); + final SmoothColorsThemeExtension colors = + Theme.of(context).extension()!; + + final String message = getMessage(localizations); + final Color color = + context.darkTheme() ? colors.primaryMedium : colors.primaryBlack; + + return Semantics( + button: true, + value: message, + excludeSemantics: true, + child: Tooltip( + message: message, + child: Material( + type: MaterialType.transparency, + child: InkWell( + onTap: () => Navigator.of(context).maybePop(), + customBorder: const CircleBorder(), + splashColor: Colors.white70, + child: Ink( + decoration: BoxDecoration( + border: Border.all( + color: color, + width: 1.0, + ), + shape: BoxShape.circle, + ), + child: SizedBox.square( + dimension: 36.0, + child: appIcon( + size: 16.0, + color: color, + ), + ), + ), + ), + ), + ), + ); + } + + Widget appIcon({ + required double size, + required Color color, + }) { + assert(size >= 0.0); + + return switch (action) { + SmoothTopBarLeadingAction.close => icons.Close(size: size, color: color), + SmoothTopBarLeadingAction.back => + icons.Arrow.left(size: size, color: color), + SmoothTopBarLeadingAction.minimize => Padding( + padding: const EdgeInsetsDirectional.only(top: 1.0), + child: icons.Chevron.down(size: size, color: color), + ), + }; + } + + String getMessage(MaterialLocalizations localizations) { + return switch (action) { + SmoothTopBarLeadingAction.close => localizations.closeButtonTooltip, + SmoothTopBarLeadingAction.back => localizations.backButtonTooltip, + SmoothTopBarLeadingAction.minimize => localizations.closeButtonTooltip, + }; + } +} diff --git a/packages/smooth_app/macos/Podfile.lock b/packages/smooth_app/macos/Podfile.lock index b35a6160333..a01e826ae0d 100644 --- a/packages/smooth_app/macos/Podfile.lock +++ b/packages/smooth_app/macos/Podfile.lock @@ -15,7 +15,7 @@ PODS: - FlutterMacOS (1.0.0) - in_app_review (0.2.0): - FlutterMacOS - - mobile_scanner (3.5.2): + - mobile_scanner (3.5.6): - FlutterMacOS - package_info_plus (0.0.1): - FlutterMacOS @@ -113,7 +113,7 @@ SPEC CHECKSUMS: flutter_secure_storage_macos: 59459653abe1adb92abbc8ea747d79f8d19866c9 FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 in_app_review: a850789fad746e89bce03d4aeee8078b45a53fd0 - mobile_scanner: 621cf2c34e1c74ae7ce5c6793638ab600723bdea + mobile_scanner: 54ceceae0c8da2457e26a362a6be5c61154b1829 package_info_plus: 02d7a575e80f194102bef286361c6c326e4c29ce path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 ReachabilitySwift: 2128f3a8c9107e1ad33574c6e58e8285d460b149