From 5785c0615a89cd46d80364187f7f9b1c27653492 Mon Sep 17 00:00:00 2001 From: Edouard Marquez Date: Sat, 18 Jan 2025 15:27:11 +0100 Subject: [PATCH] Use the same layout between the country and language picker (in the settings) --- ..._selector.dart => languages_selector.dart} | 4 +- packages/smooth_app/lib/l10n/app_en.arb | 8 + packages/smooth_app/lib/l10n/app_fr.arb | 8 + .../lib/pages/onboarding/welcome_page.dart | 18 +- .../country_selector/country_selector.dart | 482 ++++-------------- .../country_selector_provider.dart | 165 +----- .../language_selector/language_selector.dart | 289 +++++++++++ .../language_selector_provider.dart | 109 ++++ .../user_preferences_country_selector.dart | 10 +- .../user_preferences_currency_selector.dart | 2 +- .../user_preferences_language_selector.dart | 44 +- .../pages/product/edit_language_tabbar.dart | 4 +- .../pages/product/multilingual_helper.dart | 4 +- .../pages/product/product_image_viewer.dart | 4 +- .../smooth_screen_list_choice.dart | 374 ++++++++++++++ .../smooth_screen_selector_provider.dart | 140 +++++ 16 files changed, 1098 insertions(+), 567 deletions(-) rename packages/smooth_app/lib/generic_lib/widgets/{language_selector.dart => languages_selector.dart} (99%) create mode 100644 packages/smooth_app/lib/pages/preferences/language_selector/language_selector.dart create mode 100644 packages/smooth_app/lib/pages/preferences/language_selector/language_selector_provider.dart create mode 100644 packages/smooth_app/lib/widgets/selector_screen/smooth_screen_list_choice.dart create mode 100644 packages/smooth_app/lib/widgets/selector_screen/smooth_screen_selector_provider.dart diff --git a/packages/smooth_app/lib/generic_lib/widgets/language_selector.dart b/packages/smooth_app/lib/generic_lib/widgets/languages_selector.dart similarity index 99% rename from packages/smooth_app/lib/generic_lib/widgets/language_selector.dart rename to packages/smooth_app/lib/generic_lib/widgets/languages_selector.dart index d2c5e32e3199..cc324992a332 100644 --- a/packages/smooth_app/lib/generic_lib/widgets/language_selector.dart +++ b/packages/smooth_app/lib/generic_lib/widgets/languages_selector.dart @@ -12,8 +12,8 @@ import 'package:smooth_app/pages/preferences/user_preferences_languages_list.dar import 'package:smooth_app/query/product_query.dart'; import 'package:smooth_app/widgets/smooth_text.dart'; -class LanguageSelector extends StatelessWidget { - const LanguageSelector({ +class LanguagesSelector extends StatelessWidget { + const LanguagesSelector({ required this.setLanguage, this.selectedLanguages, this.displayedLanguage, diff --git a/packages/smooth_app/lib/l10n/app_en.arb b/packages/smooth_app/lib/l10n/app_en.arb index 3cd7598b52ff..fd96ad03511a 100644 --- a/packages/smooth_app/lib/l10n/app_en.arb +++ b/packages/smooth_app/lib/l10n/app_en.arb @@ -2681,6 +2681,14 @@ "@language_picker_label": { "description": "Choose Application Language" }, + "country_picker_label": "Your country", + "@country_picker_label": { + "description": "Choose Application Country" + }, + "currency_picker_label": "Your currency", + "@currency_picker_label": { + "description": "Choose Application Country" + }, "help_with_openfoodfacts": "Help with OpenFoodFacts", "@help_with_openfoodfacts": { "description": "Label for the email title" diff --git a/packages/smooth_app/lib/l10n/app_fr.arb b/packages/smooth_app/lib/l10n/app_fr.arb index f76567351528..83c36afe847c 100644 --- a/packages/smooth_app/lib/l10n/app_fr.arb +++ b/packages/smooth_app/lib/l10n/app_fr.arb @@ -2681,6 +2681,14 @@ "@language_picker_label": { "description": "Choose Application Language" }, + "country_picker_label": "Votre pays", + "@country_picker_label": { + "description": "Choose Application Country" + }, + "currency_picker_label": "Votre devise", + "@currency_picker_label": { + "description": "Choose Application Country" + }, "help_with_openfoodfacts": "Bienvenue sur OpenFoodFacts", "@help_with_openfoodfacts": { "description": "Label for the email title" diff --git a/packages/smooth_app/lib/pages/onboarding/welcome_page.dart b/packages/smooth_app/lib/pages/onboarding/welcome_page.dart index 3aafa112c3bd..4b504aae750b 100644 --- a/packages/smooth_app/lib/pages/onboarding/welcome_page.dart +++ b/packages/smooth_app/lib/pages/onboarding/welcome_page.dart @@ -105,10 +105,24 @@ class WelcomePage extends StatelessWidget { padding: const EdgeInsetsDirectional.only( start: SMALL_SPACE, end: LARGE_SPACE, + top: SMALL_SPACE, + bottom: SMALL_SPACE, ), inkWellBorderRadius: ANGULAR_BORDER_RADIUS, - icon: const icons.Arrow.right( - size: 15.0, + icon: const DecoratedBox( + decoration: BoxDecoration( + color: Colors.black, + shape: BoxShape.circle, + ), + child: Padding( + padding: EdgeInsetsDirectional.all( + SMALL_SPACE, + ), + child: icons.Arrow.right( + size: 15.0, + color: Colors.white, + ), + ), ), 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 index ddab1d609f98..a09398047701 100644 --- a/packages/smooth_app/lib/pages/preferences/country_selector/country_selector.dart +++ b/packages/smooth_app/lib/pages/preferences/country_selector/country_selector.dart @@ -9,17 +9,11 @@ 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.dart'; -import 'package:smooth_app/themes/smooth_theme_colors.dart'; -import 'package:smooth_app/themes/theme_provider.dart'; +import 'package:smooth_app/widgets/selector_screen/smooth_screen_list_choice.dart'; +import 'package:smooth_app/widgets/selector_screen/smooth_screen_selector_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'; @@ -31,6 +25,7 @@ class CountrySelector extends StatelessWidget { this.padding, this.icon, this.inkWellBorderRadius, + this.loadingHeight = 48.0, this.autoValidate = true, }); @@ -39,6 +34,7 @@ class CountrySelector extends StatelessWidget { final BorderRadius? inkWellBorderRadius; final Widget? icon; final bool forceCurrencyChange; + final double loadingHeight; /// A click on a new country will automatically save it final bool autoValidate; @@ -53,14 +49,15 @@ class CountrySelector extends StatelessWidget { child: Consumer<_CountrySelectorProvider>( builder: (BuildContext context, _CountrySelectorProvider provider, _) { return switch (provider.value) { - _CountrySelectorLoadingState _ => const Center( - child: CircularProgressIndicator.adaptive(), + PreferencesSelectorLoadingState _ => SizedBox( + height: loadingHeight, + child: const Center( + child: CircularProgressIndicator.adaptive(), + ), ), - _CountrySelectorLoadedState _ => _CountrySelectorButton( + PreferencesSelectorLoadedState _ => _CountrySelectorButton( icon: icon, - innerPadding: const EdgeInsetsDirectional.symmetric( - vertical: SMALL_SPACE, - ).add(padding ?? EdgeInsets.zero), + innerPadding: padding ?? EdgeInsets.zero, textStyle: textStyle, inkWellBorderRadius: inkWellBorderRadius, forceCurrencyChange: forceCurrencyChange, @@ -99,47 +96,56 @@ class _CountrySelectorButton extends StatelessWidget { 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), + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 40.0), + child: ConsumerValueNotifierFilter<_CountrySelectorProvider, + PreferencesSelectorState>( + buildWhen: (PreferencesSelectorState? previousValue, + PreferencesSelectorState currentValue) => + previousValue != null && + currentValue is! PreferencesSelectorEditingState && + (currentValue as PreferencesSelectorLoadedState) + .selectedItem != + (previousValue as PreferencesSelectorLoadedState) + .selectedItem, + builder: (_, PreferencesSelectorState value, __) { + final Country? country = + (value as PreferencesSelectorLoadedState) + .selectedItem; + + 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), + const SizedBox(width: SMALL_SPACE), + Expanded( + child: Text( + country?.name ?? AppLocalizations.of(context).loading, + style: Theme.of(context) + .textTheme + .displaySmall + ?.merge(textStyle), ), - ) - 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), - ], - ), - ); - }, + icon ?? const Icon(Icons.arrow_drop_down), + ], + ), + ); + }, + ), ), ), ); @@ -149,14 +155,8 @@ class _CountrySelectorButton extends StatelessWidget { 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(), + pageBuilder: (_, __, ___) => _CountrySelectorScreen( + provider: context.read<_CountrySelectorProvider>(), ), transitionsBuilder: (BuildContext context, Animation animation, @@ -189,7 +189,7 @@ class _CountrySelectorButton extends StatelessWidget { /// Ensure to restore the previous state /// (eg: the user uses the Android back button). if (newCountry == null) { - context.read<_CountrySelectorProvider>().dismissSelectedCountry(); + context.read<_CountrySelectorProvider>().dismissSelectedItem(); } else if (newCountry is Country) { _changeCurrencyIfRelevant(context, newCountry); } @@ -248,242 +248,64 @@ class _CountrySelectorButton extends StatelessWidget { } class _CountrySelectorScreen extends StatelessWidget { - const _CountrySelectorScreen(); + const _CountrySelectorScreen({ + required this.provider, + }); + + final _CountrySelectorProvider provider; @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, + return SmoothSelectorScreen( + provider: provider, + title: appLocalizations.country_selector_title, + itemBuilder: ( + BuildContext context, + Country country, + bool selected, + String filter, + ) { + return Row( + children: [ + Expanded( + flex: 1, + child: Text( + EmojiHelper.getEmojiByCountryCode(country.countryCode) ?? '', + style: const TextStyle(fontSize: 25.0), ), ), - _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, + Expanded( + flex: 2, + child: Text( + country.countryCode.toUpperCase(), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, ), ), - focusedBorder: OutlineInputBorder( - borderRadius: const BorderRadius.all(Radius.circular(15.0)), - borderSide: BorderSide( - color: darkMode ? colors.primaryNormal : colors.primarySemiDark, - width: 2.0, + Expanded( + flex: 7, + child: TextHighlighter( + text: country.name, + filter: filter, + textStyle: const TextStyle( + fontWeight: FontWeight.w600, + ), ), - ), - 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; - } - - final SmoothColorsThemeExtension colors = - context.extension(); - - return SmoothButtonsBar2( - animate: true, - backgroundColor: context.lightTheme() - ? colors.primaryMedium - : colors.primaryUltraBlack, - 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, + ) + ], ); }, + itemsFilter: (List list, Country? selectedItem, + Country? selectedItemOverride, String filter) => + _filterCountries( + list, + selectedItem, + selectedItemOverride, + filter, + ), ); } @@ -510,91 +332,3 @@ class _CountrySelectorListState extends State<_CountrySelectorList> { ); } } - -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 index f0584570cbb9..642dd2ae846b 100644 --- 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 @@ -6,89 +6,48 @@ part of 'country_selector.dart'; /// * [_CountrySelectorLoadedState]: countries loaded and/or saved /// * [_CountrySelectorEditingState]: the user has selected a country /// (temporary selection) -class _CountrySelectorProvider extends ValueNotifier<_CountrySelectorState> { +class _CountrySelectorProvider extends PreferencesSelectorProvider { _CountrySelectorProvider({ - required this.preferences, - required this.autoValidate, - }) : super(const _CountrySelectorInitialState()) { - preferences.addListener(_onPreferencesChanged); - _onPreferencesChanged(); - } + required super.preferences, + required super.autoValidate, + }); - 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 { + @override + Future onPreferencesChanged() async { final String? newCountryCode = preferences.userCountryCode; - final String? newAppLanguageCode = preferences.appLanguageCode; + final String? newLanguageCode = preferences.appLanguageCode; - if (newAppLanguageCode != userAppLanguageCode) { - userAppLanguageCode = newAppLanguageCode; + if (newLanguageCode != userAppLanguageCode) { userCountryCode = newCountryCode; - - return _loadCountries(); + userAppLanguageCode = newLanguageCode; + return loadValues(); } else if (newCountryCode != userCountryCode) { - userAppLanguageCode = newAppLanguageCode; userCountryCode = newCountryCode; + userAppLanguageCode = newLanguageCode; - if (value is _CountrySelectorInitialState) { - return _loadCountries(); + if (value is PreferencesSelectorInitialState) { + return loadValues(); } else { - final _CountrySelectorLoadedState state = - value as _CountrySelectorLoadedState; + final PreferencesSelectorLoadedState state = + value as PreferencesSelectorLoadedState; /// Reorder items - final List countries = state.countries; + final List countries = state.items; _reorderCountries(countries, userCountryCode); value = state.copyWith( - country: _getSelectedCountry(state.countries), - countries: countries, + selectedItem: getSelectedValue(state.items), + items: countries, ); } } } - Future _loadCountries() async { - if (userAppLanguageCode == null) { - return; - } - - value = const _CountrySelectorLoadingState(); - + @override + Future> onLoadValues() async { List localizedCountries; try { @@ -109,10 +68,7 @@ class _CountrySelectorProvider extends ValueNotifier<_CountrySelectorState> { (localizedCountries, userCountryCode), ); - value = _CountrySelectorLoadedState( - country: _getSelectedCountry(countries), - countries: countries, - ); + return countries; } static Future> _reformatCountries( @@ -129,7 +85,8 @@ class _CountrySelectorProvider extends ValueNotifier<_CountrySelectorState> { /// * and providing a fallback English name for countries that are in /// [OpenFoodFactsCountry] but not in [localizedCountries]. static List _sanitizeCountriesList( - List localizedCountries) { + List localizedCountries, + ) { final List finalCountriesList = []; final Map oFFIsoCodeToCountry = {}; @@ -204,7 +161,8 @@ class _CountrySelectorProvider extends ValueNotifier<_CountrySelectorState> { ); } - Country _getSelectedCountry(List countries) { + @override + Country getSelectedValue(List countries) { if (userCountryCode != null) { for (final Country country in countries) { if (country.countryCode.toLowerCase() == @@ -217,76 +175,7 @@ class _CountrySelectorProvider extends ValueNotifier<_CountrySelectorState> { } @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, + Future onSaveItem(Country country) => preferences.setUserCountryCode( + country.countryCode, ); - - @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/language_selector/language_selector.dart b/packages/smooth_app/lib/pages/preferences/language_selector/language_selector.dart new file mode 100644 index 000000000000..808b794fe5f5 --- /dev/null +++ b/packages/smooth_app/lib/pages/preferences/language_selector/language_selector.dart @@ -0,0 +1,289 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart' hide Listener; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:openfoodfacts/openfoodfacts.dart'; +import 'package:provider/provider.dart'; +import 'package:smooth_app/background/background_task_language_refresh.dart'; +import 'package:smooth_app/data_models/news_feed/newsfeed_provider.dart'; +import 'package:smooth_app/data_models/preferences/user_preferences.dart'; +import 'package:smooth_app/data_models/product_preferences.dart'; +import 'package:smooth_app/database/local_database.dart'; +import 'package:smooth_app/generic_lib/design_constants.dart'; +import 'package:smooth_app/helpers/provider_helper.dart'; +import 'package:smooth_app/pages/preferences/user_preferences_languages_list.dart'; +import 'package:smooth_app/query/product_query.dart'; +import 'package:smooth_app/widgets/selector_screen/smooth_screen_list_choice.dart'; +import 'package:smooth_app/widgets/selector_screen/smooth_screen_selector_provider.dart'; +import 'package:smooth_app/widgets/smooth_text.dart'; + +part 'language_selector_provider.dart'; + +/// A button that will open a list of countries and save it in the preferences. +class LanguageSelector extends StatelessWidget { + const LanguageSelector({ + this.textStyle, + this.padding, + this.icon, + this.inkWellBorderRadius, + this.loadingHeight = 48.0, + this.autoValidate = true, + }); + + final TextStyle? textStyle; + final EdgeInsetsGeometry? padding; + final BorderRadius? inkWellBorderRadius; + final Widget? icon; + final double loadingHeight; + + /// A click on a new language will automatically save it + final bool autoValidate; + + @override + Widget build(BuildContext context) { + return ChangeNotifierProvider<_LanguageSelectorProvider>( + create: (_) => _LanguageSelectorProvider( + preferences: context.read(), + autoValidate: autoValidate, + ), + child: Consumer<_LanguageSelectorProvider>( + builder: (BuildContext context, _LanguageSelectorProvider provider, _) { + return switch (provider.value) { + PreferencesSelectorLoadingState _ => + SizedBox( + height: loadingHeight, + child: const Center( + child: CircularProgressIndicator.adaptive(), + ), + ), + PreferencesSelectorLoadedState _ => + _LanguageSelectorButton( + icon: icon, + innerPadding: const EdgeInsetsDirectional.symmetric( + vertical: SMALL_SPACE, + ).add(padding ?? EdgeInsets.zero), + textStyle: textStyle, + inkWellBorderRadius: inkWellBorderRadius, + autoValidate: autoValidate, + ), + }; + }, + ), + ); + } + + static String _getCompleteName(final OpenFoodFactsLanguage language) { + final String nameInLanguage = Languages().getNameInLanguage(language); + final String nameInEnglish = Languages().getNameInEnglish(language); + return '$nameInLanguage ($nameInEnglish)'; + } +} + +class _LanguageSelectorButton extends StatelessWidget { + const _LanguageSelectorButton({ + required this.innerPadding, + required this.autoValidate, + this.icon, + this.textStyle, + this.inkWellBorderRadius, + }); + + final Widget? icon; + final EdgeInsetsGeometry innerPadding; + final TextStyle? textStyle; + final BorderRadius? inkWellBorderRadius; + final bool autoValidate; + + @override + Widget build(BuildContext context) { + return InkWell( + borderRadius: inkWellBorderRadius ?? ANGULAR_BORDER_RADIUS, + onTap: () => _openLanguageSelector(context), + child: DecoratedBox( + decoration: const BoxDecoration( + borderRadius: BorderRadius.all(Radius.circular(10)), + ), + child: ConsumerValueNotifierFilter<_LanguageSelectorProvider, + PreferencesSelectorState>( + buildWhen: + (PreferencesSelectorState? previousValue, + PreferencesSelectorState + currentValue) => + previousValue != null && + currentValue is! PreferencesSelectorEditingState && + (currentValue as PreferencesSelectorLoadedState< + OpenFoodFactsLanguage>) + .selectedItem != + (previousValue as PreferencesSelectorLoadedState< + OpenFoodFactsLanguage>) + .selectedItem, + builder: + (_, PreferencesSelectorState value, __) { + final OpenFoodFactsLanguage? language = + (value as PreferencesSelectorLoadedState) + .selectedItem; + + return Padding( + padding: innerPadding, + child: Row( + children: [ + const Icon(Icons.language), + const SizedBox(width: LARGE_SPACE), + Expanded( + child: Text( + LanguageSelector._getCompleteName(language!), + style: Theme.of(context) + .textTheme + .displaySmall + ?.merge(textStyle), + ), + ), + icon ?? const Icon(Icons.arrow_drop_down), + ], + ), + ); + }, + ), + ), + ); + } + + Future _openLanguageSelector(BuildContext context) async { + final dynamic newLanguage = + await Navigator.of(context, rootNavigator: true).push( + PageRouteBuilder( + pageBuilder: (_, __, ___) => _LanguageSelectorScreen( + provider: context.read<_LanguageSelectorProvider>(), + ), + 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 (newLanguage == null) { + context.read<_LanguageSelectorProvider>().dismissSelectedItem(); + return; + } else if (newLanguage is! OpenFoodFactsLanguage) { + return; + } + + ProductQuery.setLanguage( + context, + context.read(), + languageCode: newLanguage.code, + ); + final ProductPreferences productPreferences = + context.read(); + await BackgroundTaskLanguageRefresh.addTask( + context.read(), + ); + + // Refresh the news feed + if (context.mounted) { + context.read().loadLatestNews(); + } + // TODO(monsieurtanuki): make it a background task also? + // no await + productPreferences.refresh(); + } +} + +class _LanguageSelectorScreen extends StatelessWidget { + const _LanguageSelectorScreen({ + required this.provider, + }); + + final _LanguageSelectorProvider provider; + + @override + Widget build(BuildContext context) { + final AppLocalizations appLocalizations = AppLocalizations.of(context); + + return SmoothSelectorScreen( + provider: provider, + title: appLocalizations.language_selector_title, + itemBuilder: ( + BuildContext context, + OpenFoodFactsLanguage language, + bool selected, + String filter, + ) { + return Row( + children: [ + const Icon(Icons.language), + const SizedBox(width: LARGE_SPACE), + Align( + alignment: AlignmentDirectional.centerStart, + child: Padding( + padding: const EdgeInsetsDirectional.symmetric( + horizontal: SMALL_SPACE, + ), + child: TextHighlighter( + text: LanguageSelector._getCompleteName(language), + filter: filter, + textStyle: const TextStyle( + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + ); + }, + itemsFilter: (List list, + OpenFoodFactsLanguage? selectedItem, + OpenFoodFactsLanguage? selectedItemOverride, + String filter) => + _filterCountries( + list, + selectedItem, + selectedItemOverride, + filter, + ), + ); + } + + Iterable _filterCountries( + List countries, + OpenFoodFactsLanguage? userLanguage, + OpenFoodFactsLanguage? selectedLanguage, + String? filter, + ) { + if (filter == null || filter.isEmpty) { + return countries; + } + + return countries.where( + (OpenFoodFactsLanguage language) => + language == userLanguage || + language == selectedLanguage || + Languages().getNameInLanguage(language).toLowerCase().contains( + filter.toLowerCase(), + ), + ); + } +} diff --git a/packages/smooth_app/lib/pages/preferences/language_selector/language_selector_provider.dart b/packages/smooth_app/lib/pages/preferences/language_selector/language_selector_provider.dart new file mode 100644 index 000000000000..aad78c1d285b --- /dev/null +++ b/packages/smooth_app/lib/pages/preferences/language_selector/language_selector_provider.dart @@ -0,0 +1,109 @@ +part of 'language_selector.dart'; + +/// A provider with 4 states: +/// * [_LanguageSelectorInitialState]: initial state, no languages +/// * [_LanguageSelectorLoadingState]: loading languages +/// * [_LanguageSelectorLoadedState]: languages loaded and/or saved +/// * [_LanguageSelectorEditingState]: the user has selected a country +/// (temporary selection) +class _LanguageSelectorProvider + extends PreferencesSelectorProvider { + _LanguageSelectorProvider({ + required super.preferences, + required super.autoValidate, + }); + + String? userAppLanguageCode; + + @override + Future onPreferencesChanged() async { + final String? newLanguageCode = preferences.appLanguageCode; + + if (newLanguageCode != userAppLanguageCode) { + userAppLanguageCode = newLanguageCode; + + if (value is PreferencesSelectorInitialState) { + return loadValues(); + } else { + final PreferencesSelectorLoadedState state = + value as PreferencesSelectorLoadedState; + + /// Reorder items + final List languages = state.items; + _reorderLanguages(languages, userAppLanguageCode!); + + value = state.copyWith( + selectedItem: getSelectedValue(state.items), + items: languages, + ); + } + } + } + + @override + Future> onLoadValues() async { + List localizedLanguages; + + localizedLanguages = Languages().getSupportedLanguagesNameInEnglish(); + + final List languages = await compute( + _reformatLanguages, + (localizedLanguages, userAppLanguageCode!), + ); + + return languages; + } + + static Future> _reformatLanguages( + ( + List languages, + String userAppLanguageCode, + ) val) async { + _reorderLanguages(val.$1, val.$2); + return val.$1; + } + + static void _reorderLanguages( + List languagesList, + String userAppLanguageCode, + ) { + final Languages languages = Languages(); + final String userLanguageName = languages.getNameInEnglish( + OpenFoodFactsLanguage.fromOffTag(userAppLanguageCode)!); + + languagesList.sort( + (final OpenFoodFactsLanguage a, final OpenFoodFactsLanguage b) { + final String aName = languages.getNameInEnglish(a); + final String bName = languages.getNameInEnglish(b); + + if (aName == userLanguageName) { + return -1; + } + if (bName == userLanguageName) { + return 1; + } + return aName.compareTo(bName); + }, + ); + } + + @override + OpenFoodFactsLanguage getSelectedValue( + List languages, + ) { + if (userAppLanguageCode != null) { + for (final OpenFoodFactsLanguage language in languages) { + if (language.offTag == userAppLanguageCode) { + return language; + } + } + } + return languages[0]; + } + + @override + Future onSaveItem(OpenFoodFactsLanguage country) => + preferences.setAppLanguageCode( + country.offTag, + ); +} 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 6977710b3d9a..2809612772a1 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 @@ -12,9 +12,7 @@ class UserPreferencesCountrySelector extends StatelessWidget { ) { final AppLocalizations appLocalizations = AppLocalizations.of(context); return UserPreferencesItemSimple( - labels: [ - appLocalizations.country_chooser_label, - ], + labels: [appLocalizations.country_picker_label], builder: (_) => const UserPreferencesCountrySelector(), ); } @@ -25,7 +23,7 @@ class UserPreferencesCountrySelector extends StatelessWidget { final ThemeData themeData = Theme.of(context); return ListTile( title: Text( - appLocalizations.country_chooser_label, + appLocalizations.country_picker_label, style: themeData.textTheme.headlineMedium, ), subtitle: Padding( @@ -38,9 +36,7 @@ class UserPreferencesCountrySelector extends StatelessWidget { forceCurrencyChange: false, textStyle: themeData.textTheme.bodyMedium, icon: const Icon(Icons.edit), - padding: const EdgeInsetsDirectional.only( - start: SMALL_SPACE, - ), + loadingHeight: 40.0, ), ), minVerticalPadding: MEDIUM_SPACE, diff --git a/packages/smooth_app/lib/pages/preferences/user_preferences_currency_selector.dart b/packages/smooth_app/lib/pages/preferences/user_preferences_currency_selector.dart index dcf755977b95..97a63f7ec70b 100644 --- a/packages/smooth_app/lib/pages/preferences/user_preferences_currency_selector.dart +++ b/packages/smooth_app/lib/pages/preferences/user_preferences_currency_selector.dart @@ -19,7 +19,7 @@ class UserPreferencesCurrencySelector extends StatelessWidget { } static String _getLabel(final AppLocalizations appLocalizations) => - appLocalizations.currency_chooser_label; + appLocalizations.currency_picker_label; @override Widget build(BuildContext context) { diff --git a/packages/smooth_app/lib/pages/preferences/user_preferences_language_selector.dart b/packages/smooth_app/lib/pages/preferences/user_preferences_language_selector.dart index 3757d7d9bfc7..16f0843336ab 100644 --- a/packages/smooth_app/lib/pages/preferences/user_preferences_language_selector.dart +++ b/packages/smooth_app/lib/pages/preferences/user_preferences_language_selector.dart @@ -1,16 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -import 'package:openfoodfacts/openfoodfacts.dart'; -import 'package:provider/provider.dart'; -import 'package:smooth_app/background/background_task_language_refresh.dart'; -import 'package:smooth_app/data_models/news_feed/newsfeed_provider.dart'; -import 'package:smooth_app/data_models/preferences/user_preferences.dart'; -import 'package:smooth_app/data_models/product_preferences.dart'; -import 'package:smooth_app/database/local_database.dart'; import 'package:smooth_app/generic_lib/design_constants.dart'; -import 'package:smooth_app/generic_lib/widgets/language_selector.dart'; +import 'package:smooth_app/pages/preferences/language_selector/language_selector.dart'; import 'package:smooth_app/pages/preferences/user_preferences_item.dart'; -import 'package:smooth_app/query/product_query.dart'; class UserPreferencesLanguageSelector extends StatelessWidget { const UserPreferencesLanguageSelector(); @@ -30,11 +22,12 @@ class UserPreferencesLanguageSelector extends StatelessWidget { @override Widget build(BuildContext context) { final AppLocalizations appLocalizations = AppLocalizations.of(context); - final UserPreferences userPreferences = context.watch(); + final ThemeData themeData = Theme.of(context); + return ListTile( title: Text( appLocalizations.language_picker_label, - style: Theme.of(context).textTheme.headlineMedium, + style: themeData.textTheme.headlineMedium, ), subtitle: Padding( padding: const EdgeInsetsDirectional.only( @@ -42,36 +35,13 @@ class UserPreferencesLanguageSelector extends StatelessWidget { bottom: SMALL_SPACE, ), child: LanguageSelector( - setLanguage: (final OpenFoodFactsLanguage? language) async { - if (language == null) { - return; - } - ProductQuery.setLanguage( - context, - userPreferences, - languageCode: language.code, - ); - final ProductPreferences productPreferences = - context.read(); - await BackgroundTaskLanguageRefresh.addTask( - context.read(), - ); - - // Refresh the news feed - if (context.mounted) { - context.read().loadLatestNews(); - } - // TODO(monsieurtanuki): make it a background task also? - // no await - productPreferences.refresh(); - }, - selectedLanguages: [ - ProductQuery.getLanguage(), - ], + autoValidate: false, + textStyle: themeData.textTheme.bodyMedium, icon: const Icon(Icons.edit), padding: const EdgeInsetsDirectional.only( start: SMALL_SPACE, ), + loadingHeight: 40.0, ), ), minVerticalPadding: MEDIUM_SPACE, diff --git a/packages/smooth_app/lib/pages/product/edit_language_tabbar.dart b/packages/smooth_app/lib/pages/product/edit_language_tabbar.dart index d0e0dcdee5d9..dab44d6f1937 100644 --- a/packages/smooth_app/lib/pages/product/edit_language_tabbar.dart +++ b/packages/smooth_app/lib/pages/product/edit_language_tabbar.dart @@ -7,7 +7,7 @@ import 'package:smooth_app/database/dao_string_list.dart'; import 'package:smooth_app/database/local_database.dart'; import 'package:smooth_app/generic_lib/design_constants.dart'; import 'package:smooth_app/generic_lib/widgets/language_priority.dart'; -import 'package:smooth_app/generic_lib/widgets/language_selector.dart'; +import 'package:smooth_app/generic_lib/widgets/languages_selector.dart'; import 'package:smooth_app/helpers/border_radius_helper.dart'; import 'package:smooth_app/helpers/provider_helper.dart'; import 'package:smooth_app/helpers/ui_helpers.dart'; @@ -292,7 +292,7 @@ class _EditLanguageTabBarAddLanguageButton extends StatelessWidget { ); final OpenFoodFactsLanguage? language = - await LanguageSelector.openLanguageSelector( + await LanguagesSelector.openLanguageSelector( context, selectedLanguages: selectedLanguages, languagePriority: languagePriority, diff --git a/packages/smooth_app/lib/pages/product/multilingual_helper.dart b/packages/smooth_app/lib/pages/product/multilingual_helper.dart index 72ff0be59e82..757e0eebc50c 100644 --- a/packages/smooth_app/lib/pages/product/multilingual_helper.dart +++ b/packages/smooth_app/lib/pages/product/multilingual_helper.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:openfoodfacts/openfoodfacts.dart'; -import 'package:smooth_app/generic_lib/widgets/language_selector.dart'; +import 'package:smooth_app/generic_lib/widgets/languages_selector.dart'; import 'package:smooth_app/query/product_query.dart'; /// Helper for multilingual inputs (e.g. product name). @@ -116,7 +116,7 @@ class MultilingualHelper { BorderRadius? borderRadius, Widget? icon, }) => - LanguageSelector( + LanguagesSelector( product: product, icon: icon, padding: padding, diff --git a/packages/smooth_app/lib/pages/product/product_image_viewer.dart b/packages/smooth_app/lib/pages/product/product_image_viewer.dart index 39c7577b736c..54ec73f4822f 100644 --- a/packages/smooth_app/lib/pages/product/product_image_viewer.dart +++ b/packages/smooth_app/lib/pages/product/product_image_viewer.dart @@ -10,7 +10,7 @@ import 'package:smooth_app/database/local_database.dart'; import 'package:smooth_app/database/transient_file.dart'; import 'package:smooth_app/generic_lib/design_constants.dart'; import 'package:smooth_app/generic_lib/duration_constants.dart'; -import 'package:smooth_app/generic_lib/widgets/language_selector.dart'; +import 'package:smooth_app/generic_lib/widgets/languages_selector.dart'; import 'package:smooth_app/generic_lib/widgets/picture_not_found.dart'; import 'package:smooth_app/helpers/product_cards_helper.dart'; import 'package:smooth_app/pages/image_crop_page.dart'; @@ -233,7 +233,7 @@ class _ProductImageViewerState extends State width: 3, ), ), - child: LanguageSelector( + child: LanguagesSelector( setLanguage: widget.setLanguage, displayedLanguage: widget.language, selectedLanguages: selectedLanguages, diff --git a/packages/smooth_app/lib/widgets/selector_screen/smooth_screen_list_choice.dart b/packages/smooth_app/lib/widgets/selector_screen/smooth_screen_list_choice.dart new file mode 100644 index 000000000000..3b89c0930241 --- /dev/null +++ b/packages/smooth_app/lib/widgets/selector_screen/smooth_screen_list_choice.dart @@ -0,0 +1,374 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:provider/provider.dart'; +import 'package:provider/single_child_widget.dart'; +import 'package:smooth_app/generic_lib/design_constants.dart'; +import 'package:smooth_app/generic_lib/duration_constants.dart'; +import 'package:smooth_app/helpers/provider_helper.dart'; +import 'package:smooth_app/resources/app_icons.dart' as icons; +import 'package:smooth_app/themes/smooth_theme.dart'; +import 'package:smooth_app/themes/smooth_theme_colors.dart'; +import 'package:smooth_app/themes/theme_provider.dart'; +import 'package:smooth_app/widgets/selector_screen/smooth_screen_selector_provider.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'; + +class SmoothSelectorScreen extends StatelessWidget { + const SmoothSelectorScreen({ + required this.provider, + required this.title, + required this.itemBuilder, + required this.itemsFilter, + this.onSave, + super.key, + }); + + final PreferencesSelectorProvider provider; + final String title; + final Function(T value)? onSave; + final Widget Function( + BuildContext context, + T value, + bool selected, + String filter, + ) itemBuilder; + final Iterable Function( + List list, + T? selectedItem, + T? selectedItemOverride, + String filter, + ) itemsFilter; + + @override + Widget build(BuildContext context) { + return MultiProvider( + providers: [ + ListenableProvider>.value( + value: provider, + ), + ChangeNotifierProvider( + create: (_) => TextEditingController(), + ), + ], + child: ValueNotifierListener, + PreferencesSelectorState>( + listenerWithValueNotifier: _onValueChanged, + child: SmoothScaffold2( + topBar: SmoothTopBar2( + title: title, + leadingAction: provider.autoValidate + ? SmoothTopBarLeadingAction.minimize + : SmoothTopBarLeadingAction.close, + ), + bottomBar: !provider.autoValidate + ? _SmoothSelectorScreenBottomBar( + onSave: () { + provider.saveSelectedItem(); + + if (provider.value is PreferencesSelectorEditingState) { + Navigator.of(context).pop( + (provider.value as PreferencesSelectorEditingState) + .selectedItemOverride, + ); + } + }, + ) + : null, + injectPaddingInBody: false, + children: [ + const _SmoothSelectorScreenSearchBar(), + const SliverPadding( + padding: EdgeInsetsDirectional.only( + top: SMALL_SPACE, + ), + ), + _SmoothSelectorScreenList( + itemsFilter: itemsFilter, + itemBuilder: itemBuilder, + ), + ], + ), + ), + ); + } + + /// When the value changed in [autoValidate] mode, we close the screen + void _onValueChanged( + BuildContext context, + PreferencesSelectorProvider provider, + PreferencesSelectorState? oldValue, + PreferencesSelectorState currentValue, + ) { + if (provider.autoValidate && + oldValue != null && + currentValue is! PreferencesSelectorEditingState && + currentValue is PreferencesSelectorLoadedState) { + WidgetsBinding.instance.addPostFrameCallback((_) { + final NavigatorState navigator = Navigator.of(context); + if (navigator.canPop()) { + navigator.pop(currentValue.selectedItem); + } + }); + } + } +} + +class _SmoothSelectorScreenSearchBar extends StatelessWidget { + const _SmoothSelectorScreenSearchBar(); + + @override + Widget build(BuildContext context) { + return SliverPersistentHeader( + pinned: true, + floating: false, + delegate: _SmoothSelectorScreenSearchBarDelegate(), + ); + } +} + +class _SmoothSelectorScreenSearchBarDelegate + 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 minExtent => 48.0; + + @override + double get maxExtent => 48.0; + + @override + bool shouldRebuild( + covariant _SmoothSelectorScreenSearchBarDelegate oldDelegate) => + false; +} + +class _SmoothSelectorScreenBottomBar extends StatelessWidget { + const _SmoothSelectorScreenBottomBar({ + required this.onSave, + }); + + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ConsumerValueNotifierFilter, + PreferencesSelectorState>( + builder: ( + BuildContext context, + PreferencesSelectorState value, + _, + ) { + if (value is! PreferencesSelectorEditingState) { + return EMPTY_WIDGET; + } + + final SmoothColorsThemeExtension colors = + context.extension(); + + return SmoothButtonsBar2( + animate: true, + backgroundColor: context.lightTheme() + ? colors.primaryMedium + : colors.primaryUltraBlack, + positiveButton: SmoothActionButton2( + text: AppLocalizations.of(context).validate, + icon: const icons.Arrow.right(), + onPressed: onSave, + ), + ); + }, + ); + } +} + +class _SmoothSelectorScreenList extends StatefulWidget { + const _SmoothSelectorScreenList({ + required this.itemsFilter, + required this.itemBuilder, + }); + + final Iterable Function( + List list, + T? selectedItem, + T? selectedItemOverride, + String filter, + ) itemsFilter; + final Widget Function( + BuildContext context, + T value, + bool selected, + String filter, + ) itemBuilder; + + @override + State<_SmoothSelectorScreenList> createState() => + _SmoothSelectorScreenListState(); +} + +class _SmoothSelectorScreenListState + extends State<_SmoothSelectorScreenList> { + @override + Widget build(BuildContext context) { + return Consumer2, TextEditingController>( + builder: ( + BuildContext context, + PreferencesSelectorProvider provider, + TextEditingController controller, + _, + ) { + final PreferencesSelectorLoadedState state = + provider.value as PreferencesSelectorLoadedState; + final T? selectedItem = state is PreferencesSelectorEditingState + ? (state as PreferencesSelectorEditingState).selectedItemOverride + : state.selectedItem; + + final Iterable values = widget.itemsFilter( + state.items, + state.selectedItem, + selectedItem, + controller.text, + ); + + return SliverFixedExtentList( + delegate: SliverChildBuilderDelegate( + (BuildContext context, int index) { + final T value = values.elementAt(index); + final bool selected = selectedItem == value; + + return _SmoothSelectorScreenListItem( + builder: widget.itemBuilder, + item: value, + selected: selected, + filter: controller.text, + ); + }, + childCount: values.length, + addAutomaticKeepAlives: false, + ), + itemExtent: 60.0, + ); + }, + ); + } +} + +class _SmoothSelectorScreenListItem extends StatelessWidget { + const _SmoothSelectorScreenListItem({ + required this.item, + required this.builder, + required this.selected, + required this.filter, + }); + + final Widget Function( + BuildContext context, + T value, + bool selected, + String filter, + ) builder; + + final T item; + final bool selected; + final String filter; + + @override + Widget build(BuildContext context) { + final SmoothColorsThemeExtension colors = + Theme.of(context).extension()!; + final PreferencesSelectorProvider provider = + context.watch>(); + + return Semantics( + value: item.toString(), + 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.changeSelectedItem(item), + child: Padding( + padding: const EdgeInsetsDirectional.symmetric( + horizontal: SMALL_SPACE, + vertical: VERY_SMALL_SPACE, + ), + child: builder(context, item, selected, filter), + ), + ), + ), + ); + } +} diff --git a/packages/smooth_app/lib/widgets/selector_screen/smooth_screen_selector_provider.dart b/packages/smooth_app/lib/widgets/selector_screen/smooth_screen_selector_provider.dart new file mode 100644 index 000000000000..77f57af8a960 --- /dev/null +++ b/packages/smooth_app/lib/widgets/selector_screen/smooth_screen_selector_provider.dart @@ -0,0 +1,140 @@ +import 'package:flutter/foundation.dart'; +import 'package:smooth_app/data_models/preferences/user_preferences.dart'; + +/// A provider with 4 states: +/// * [PreferencesSelectorInitialState]: initial state, no value +/// * [PreferencesSelectorLoadingState]: loading values +/// * [PreferencesSelectorLoadedState]: values loaded and/or saved +/// * [PreferencesSelectorEditingState]: the user has selected a value +/// (temporary selection) +abstract class PreferencesSelectorProvider + extends ValueNotifier> { + PreferencesSelectorProvider({ + required this.preferences, + required this.autoValidate, + }) : super(PreferencesSelectorInitialState()) { + preferences.addListener(onPreferencesChanged); + onPreferencesChanged(); + } + + final UserPreferences preferences; + final bool autoValidate; + + Future onSaveItem(T item); + Future onPreferencesChanged(); + Future> onLoadValues(); + T getSelectedValue(List values); + + @immutable + void changeSelectedItem(T item) { + final PreferencesSelectorLoadedState state = + value as PreferencesSelectorLoadedState; + + value = PreferencesSelectorEditingState.fromLoadedState( + loadedState: state, + selectedItemOverride: item, + ); + + if (autoValidate) { + saveSelectedItem(); + } + } + + @immutable + Future saveSelectedItem() async { + if (value is! PreferencesSelectorEditingState) { + return; + } + + /// No need to refresh the state here, the [UserPreferences] will notify + return onSaveItem( + (value as PreferencesSelectorEditingState).selectedItemOverride as T, + ); + } + + @immutable + void dismissSelectedItem() { + if (value is PreferencesSelectorEditingState) { + value = (value as PreferencesSelectorEditingState).toLoadedState(); + } + } + + @protected + Future loadValues() async { + value = PreferencesSelectorLoadingState(); + + final List values = await onLoadValues(); + value = PreferencesSelectorLoadedState( + selectedItem: getSelectedValue(values), + items: values, + ); + } + + @override + void dispose() { + preferences.removeListener(onPreferencesChanged); + super.dispose(); + } +} + +@immutable +sealed class PreferencesSelectorState { + const PreferencesSelectorState(); +} + +class PreferencesSelectorInitialState + extends PreferencesSelectorLoadingState { + const PreferencesSelectorInitialState(); +} + +class PreferencesSelectorLoadingState extends PreferencesSelectorState { + const PreferencesSelectorLoadingState(); +} + +class PreferencesSelectorLoadedState extends PreferencesSelectorState { + const PreferencesSelectorLoadedState({ + required this.selectedItem, + required this.items, + }); + + final T? selectedItem; + final List items; + + PreferencesSelectorLoadedState copyWith({ + T? selectedItem, + List? items, + }) => + PreferencesSelectorLoadedState( + selectedItem: selectedItem ?? this.selectedItem, + items: items ?? this.items, + ); + + @override + String toString() { + return 'PreferencesSelectorLoadedState{selectedItem: $selectedItem, items: $items}'; + } +} + +class PreferencesSelectorEditingState + extends PreferencesSelectorLoadedState { + PreferencesSelectorEditingState.fromLoadedState({ + required this.selectedItemOverride, + required PreferencesSelectorLoadedState loadedState, + }) : super( + selectedItem: loadedState.selectedItem, + items: loadedState.items, + ); + + final T? selectedItemOverride; + + PreferencesSelectorLoadedState toLoadedState() => + PreferencesSelectorLoadedState( + selectedItem: selectedItem, + items: items, + ); + + @override + String toString() { + return 'PreferencesSelectorEditingState{selectedItem: $selectedItem}'; + } +}