diff --git a/packages/smooth_app/lib/pages/product/edit_language_tabbar.dart b/packages/smooth_app/lib/pages/product/edit_language_tabbar.dart new file mode 100644 index 000000000000..eb42af15596c --- /dev/null +++ b/packages/smooth_app/lib/pages/product/edit_language_tabbar.dart @@ -0,0 +1,529 @@ +import 'package:collection/collection.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/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/helpers/border_radius_helper.dart'; +import 'package:smooth_app/helpers/provider_helper.dart'; +import 'package:smooth_app/helpers/ui_helpers.dart'; +import 'package:smooth_app/pages/preferences/user_preferences_languages_list.dart'; +import 'package:smooth_app/query/product_query.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/smooth_tabbar.dart'; + +class EditLanguageTabBar extends StatefulWidget { + const EditLanguageTabBar({ + super.key, + required this.productEquality, + required this.productLanguages, + required this.onTabChanged, + required this.forceUserLanguage, + this.defaultLanguageMissingState = OpenFoodFactsLanguageState.warning, + this.mainLanguageMissingState = OpenFoodFactsLanguageState.warning, + this.userLanguageMissingState, + this.languageIndicatorNormal, + this.languageIndicatorWarning, + this.languageIndicatorError, + this.showLanguageIndicator = true, + this.padding = const EdgeInsetsDirectional.only(start: 55.0), + }); + + const EditLanguageTabBar.noIndicator({ + super.key, + required this.productEquality, + required this.productLanguages, + required this.onTabChanged, + required this.forceUserLanguage, + this.padding = const EdgeInsetsDirectional.only(start: 55.0), + }) : defaultLanguageMissingState = OpenFoodFactsLanguageState.normal, + mainLanguageMissingState = OpenFoodFactsLanguageState.normal, + userLanguageMissingState = OpenFoodFactsLanguageState.normal, + languageIndicatorNormal = null, + languageIndicatorWarning = null, + languageIndicatorError = null, + showLanguageIndicator = false; + + /// Compare two products to know if they are the same + final DidProductChanged productEquality; + + /// Return all languages of a product (eg: a gallery may return all languages + /// of its images) + final ProductLanguagesProvider productLanguages; + final void Function(OpenFoodFactsLanguage language) onTabChanged; + + /// Force the user language even if it is not in the list + final bool forceUserLanguage; + + /// When the main language is missing, how should it be displayed (normal/error/warning) + final OpenFoodFactsLanguageState mainLanguageMissingState; + + /// When the user language is missing, how should it be displayed (normal/error/warning) + final OpenFoodFactsLanguageState? userLanguageMissingState; + + /// When a language is added (= incomplete), how should it be considered? + final OpenFoodFactsLanguageState defaultLanguageMissingState; + + /// Show an indicator next to the language only if + /// [languageIndicatorNormal], [languageIndicatorWarning] or + /// [languageIndicatorError] are not null + final bool showLanguageIndicator; + + final Widget? languageIndicatorNormal; + final Widget? languageIndicatorWarning; + final Widget? languageIndicatorError; + + final EdgeInsetsGeometry padding; + + static const Size PREFERRED_SIZE = Size( + double.infinity, + SmoothTabBar.TAB_BAR_HEIGHT + BALANCED_SPACE, + ); + + @override + State createState() => _EditLanguageTabBarState(); +} + +class _EditLanguageTabBarState extends State + with TickerProviderStateMixin { + late final _EditLanguageProvider _provider; + late TabController? _tabController; + + @override + void initState() { + super.initState(); + _provider = _EditLanguageProvider( + productEquality: widget.productEquality, + languagesProvider: widget.productLanguages, + forceUserLanguage: widget.forceUserLanguage, + defaultLanguageMissingState: widget.defaultLanguageMissingState, + mainLanguageMissingState: widget.mainLanguageMissingState, + userLanguageMissingState: widget.userLanguageMissingState, + )..addListener(_updateListener); + _tabController = TabController(length: 0, vsync: this); + } + + @override + Widget build(BuildContext context) { + return SizedBox.fromSize( + size: EditLanguageTabBar.PREFERRED_SIZE, + child: Padding( + padding: const EdgeInsetsDirectional.only(top: BALANCED_SPACE), + child: Listener( + listener: (BuildContext context, _, Product product) { + _provider.attachProduct(product); + }, + child: ChangeNotifierProvider<_EditLanguageProvider>( + create: (_) => _provider, + child: Consumer<_EditLanguageProvider>( + builder: ( + final BuildContext context, + final _EditLanguageProvider provider, + _, + ) { + if (provider.value.languages == null) { + return const Center( + child: CircularProgressIndicator.adaptive(), + ); + } + + /// We need a Stack to have to tab bar shadow below the button + return Stack( + children: [ + PositionedDirectional( + top: 0.0, + start: 0.0, + bottom: 0.0, + end: 40.0, + child: SmoothTabBar( + tabController: _tabController!, + items: provider.value.languages!.map( + (final OpenFoodFactsLanguage language) => + SmoothTabBarItem( + label: Languages().getNameInLanguage(language), + value: language, + ), + ), + leadingItems: widget.showLanguageIndicator + ? provider.value.languagesStates!.map( + (final OpenFoodFactsLanguageState state) { + switch (state) { + case OpenFoodFactsLanguageState.normal: + return widget.languageIndicatorNormal; + case OpenFoodFactsLanguageState.warning: + return widget + .languageIndicatorWarning ?? + const icons.Warning(); + case OpenFoodFactsLanguageState.error: + return widget.languageIndicatorError ?? + const icons.Warning(); + } + }, + ) + : null, + onTabChanged: (final OpenFoodFactsLanguage value) { + widget.onTabChanged.call(value); + }, + padding: widget.padding.add( + const EdgeInsetsDirectional.only( + end: 20.0, + ), + ), + ), + ), + const PositionedDirectional( + top: 0.0, + end: 0.0, + bottom: 0.0, + child: _EditLanguageTabBarAddLanguageButton(), + ), + ], + ); + }, + ), + )), + ), + ); + } + + void _updateListener() { + final _EditLanguageTabBarProviderState value = _provider.value; + + if (value.languages != null) { + final int initialIndex = _tabController?.index ?? -1; + final int newIndex = value.selectedLanguage != null + ? value.languages!.indexOf(value.selectedLanguage!) + : initialIndex; + + if (_tabController?.length != value.languages!.length) { + _tabController = TabController( + length: value.languages!.length, + vsync: this, + initialIndex: newIndex >= 0 ? newIndex : 0, + ); + } else if (newIndex >= 0 && _tabController!.index != newIndex) { + onNextFrame(() { + _tabController!.animateTo(newIndex); + _provider.newLanguageSuccessfullyChanged(); + }); + } + + if (value.initialValue || (newIndex >= 0 && initialIndex != newIndex)) { + widget.onTabChanged.call(value.selectedLanguage!); + } + } + } +} + +class _EditLanguageTabBarAddLanguageButton extends StatelessWidget { + const _EditLanguageTabBarAddLanguageButton(); + + @override + Widget build(BuildContext context) { + final SmoothColorsThemeExtension theme = + context.extension(); + final bool lightTheme = context.lightTheme(); + + final BorderRadius borderRadius = BorderRadiusHelper.fromDirectional( + context: context, + topStart: const Radius.circular(10.0), + ); + + final String label = AppLocalizations.of(context).product_add_a_language; + + return Semantics( + label: label, + button: true, + excludeSemantics: true, + child: Tooltip( + message: label, + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: borderRadius, + border: Border( + bottom: BorderSide( + color: lightTheme ? theme.primarySemiDark : theme.primaryDark, + width: lightTheme ? 1.5 : 2.0, + ), + ), + color: lightTheme ? theme.primaryDark : theme.primaryNormal, + ), + child: Material( + type: MaterialType.transparency, + child: InkWell( + borderRadius: borderRadius, + onTap: () => _addLanguage(context), + child: const Padding( + padding: EdgeInsetsDirectional.only( + start: LARGE_SPACE, + end: MEDIUM_SPACE, + ), + child: icons.Add( + color: Colors.white, + ), + ), + ), + ), + ), + ), + ); + } + + Future _addLanguage(BuildContext context) async { + // TODO(g123k): Improve the language selector + final DaoStringList daoStringList = + DaoStringList(context.read()); + + final List? selectedLanguages = + context.read<_EditLanguageProvider>().value.languages; + + final LanguagePriority languagePriority = LanguagePriority( + product: context.read(), + selectedLanguages: selectedLanguages, + daoStringList: daoStringList, + ); + + final OpenFoodFactsLanguage? language = + await LanguageSelector.openLanguageSelector( + context, + selectedLanguages: selectedLanguages, + languagePriority: languagePriority, + ); + + if (language != null && context.mounted) { + context.read<_EditLanguageProvider>().addLanguage(language); + } + } +} + +class _EditLanguageProvider + extends ValueNotifier<_EditLanguageTabBarProviderState> { + _EditLanguageProvider({ + required this.productEquality, + required this.languagesProvider, + required this.forceUserLanguage, + required this.defaultLanguageMissingState, + required this.mainLanguageMissingState, + this.userLanguageMissingState, + }) : super(const _EditLanguageTabBarProviderState.empty()); + + final DidProductChanged productEquality; + final ProductLanguagesProvider languagesProvider; + final bool forceUserLanguage; + final OpenFoodFactsLanguageState defaultLanguageMissingState; + final OpenFoodFactsLanguageState mainLanguageMissingState; + final OpenFoodFactsLanguageState? userLanguageMissingState; + + Product? product; + + void attachProduct(final Product product) { + if (!equalsProduct(product)) { + this.product = product; + refreshLanguages(initial: true); + } + } + + void refreshLanguages({bool initial = false}) { + final ( + List imageLanguages, + List languagesStates + ) = _extractLanguages(); + + final OpenFoodFactsLanguage userLanguage = ProductQuery.getLanguage(); + final OpenFoodFactsLanguage? mainLanguage = product!.lang; + + final List languages = []; + final List states = + []; + + /// The main language is always the first + if (mainLanguage != null) { + final int index = imageLanguages.indexOf(mainLanguage); + + languages.add(mainLanguage); + states.add( + index >= 0 ? languagesStates[index] : mainLanguageMissingState, + ); + } + + /// Then, inject the user language + /// - Forced even if it is not in the list with [forceUserLanguage] + /// - Or if it is in the list + if (mainLanguage != userLanguage) { + final int index = imageLanguages.indexOf(userLanguage); + + if (forceUserLanguage || index >= 0) { + languages.add(userLanguage); + states.add( + index >= 0 + ? languagesStates[index] + : userLanguageMissingState ?? mainLanguageMissingState, + ); + } + } + + for (int i = 0; i < imageLanguages.length; i++) { + if (imageLanguages[i] != mainLanguage && + imageLanguages[i] != userLanguage) { + languages.add(imageLanguages[i]); + states.add(languagesStates[i]); + } + } + + if (!const ListEquality() + .equals(value.languages, languages)) { + value = _EditLanguageTabBarProviderState( + languages: languages, + languagesStates: states, + selectedLanguage: mainLanguage ?? userLanguage, + initialValue: initial, + ); + } + } + + ( + List, + List, + ) _extractLanguages() { + final List imageLanguagesWithState = + languagesProvider(product!) + ..sort((final ProductLanguageWithState a, + final ProductLanguageWithState b) { + return a.code.compareTo(b.code); + }); + + final List imageLanguages = + []; + final List languagesStates = + []; + + for (final ProductLanguageWithState language in imageLanguagesWithState) { + imageLanguages.add(language.language); + languagesStates.add(language.state); + } + + return (imageLanguages, languagesStates); + } + + void addLanguage(OpenFoodFactsLanguage language) { + if (value.languages == null) { + throw Exception('Languages are not loaded'); + } + + if (value.languages!.contains(language)) { + value = _EditLanguageTabBarProviderState( + languages: value.languages, + languagesStates: value.languagesStates, + selectedLanguage: language, + ); + } else { + value = _EditLanguageTabBarProviderState( + languages: [...value.languages!, language], + languagesStates: [ + ...value.languagesStates!, + defaultLanguageMissingState, + ], + selectedLanguage: language, + hasNewLanguage: true, + ); + } + } + + /// Generate a state with [hasNewLanguage] set to false + void newLanguageSuccessfullyChanged() { + value = _EditLanguageTabBarProviderState( + languages: value.languages, + languagesStates: value.languagesStates, + selectedLanguage: value.selectedLanguage, + hasNewLanguage: false, + ); + } + + bool equalsProduct(Product product) { + if (this.product == null) { + return false; + } + + return productEquality(this.product!, product); + } +} + +class _EditLanguageTabBarProviderState { + _EditLanguageTabBarProviderState({ + required this.languages, + required this.languagesStates, + required this.selectedLanguage, + this.hasNewLanguage = false, + this.initialValue = false, + }) : assert( + selectedLanguage == null || languages!.contains(selectedLanguage), + ), + assert( + languagesStates == null && languages == null || + languagesStates != null && + languages != null && + languagesStates.length == languages.length, + ); + + const _EditLanguageTabBarProviderState.empty() + : languages = null, + languagesStates = null, + selectedLanguage = null, + hasNewLanguage = false, + initialValue = false; + + final List? languages; + final List? languagesStates; + final OpenFoodFactsLanguage? selectedLanguage; + final bool initialValue; + final bool hasNewLanguage; +} + +typedef DidProductChanged = bool Function( + Product oldProduct, + Product newProduct, +); +typedef ProductLanguagesProvider = List Function( + Product product, +); + +@immutable +class ProductLanguageWithState { + const ProductLanguageWithState({ + required this.language, + required this.state, + }); + + const ProductLanguageWithState.normal({ + required this.language, + }) : state = OpenFoodFactsLanguageState.normal; + + final OpenFoodFactsLanguage language; + final OpenFoodFactsLanguageState state; + + String get code => language.code; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ProductLanguageWithState && + runtimeType == other.runtimeType && + language == other.language && + state == other.state; + + @override + int get hashCode => language.hashCode ^ state.hashCode; +} + +enum OpenFoodFactsLanguageState { + normal, + warning, + error, +} diff --git a/packages/smooth_app/lib/pages/product/edit_ocr/edit_ocr_page.dart b/packages/smooth_app/lib/pages/product/edit_ocr/edit_ocr_page.dart index 22cff1ef2eb4..f228cdd49d50 100644 --- a/packages/smooth_app/lib/pages/product/edit_ocr/edit_ocr_page.dart +++ b/packages/smooth_app/lib/pages/product/edit_ocr/edit_ocr_page.dart @@ -21,6 +21,7 @@ import 'package:smooth_app/helpers/provider_helper.dart'; import 'package:smooth_app/pages/image_crop_page.dart'; import 'package:smooth_app/pages/preferences/user_preferences_dev_mode.dart'; import 'package:smooth_app/pages/product/common/product_refresher.dart'; +import 'package:smooth_app/pages/product/edit_ocr/edit_ocr_tabbar.dart'; import 'package:smooth_app/pages/product/edit_ocr/ocr_helper.dart'; import 'package:smooth_app/pages/product/explanation_widget.dart'; import 'package:smooth_app/pages/product/multilingual_helper.dart'; @@ -144,22 +145,51 @@ class _EditOcrPageState extends State with UpToDateMixin { ); // TODO(monsieurtanuki): add WillPopScope / MayExitPage system - return SmoothScaffold( - extendBodyBehindAppBar: true, - appBar: buildEditProductAppBar( - context: context, - title: _helper.getTitle(appLocalizations), - product: upToDateProduct, - ), - body: Stack( - children: [ - _getImageWidget(transientFile), - _getOcrWidget(transientFile), - ], + return Provider( + create: (BuildContext context) => upToDateProduct, + child: SmoothScaffold( + extendBodyBehindAppBar: true, + appBar: buildEditProductAppBar( + context: context, + title: _helper.getTitle(appLocalizations), + product: upToDateProduct, + bottom: !_multilingualHelper.isMonolingual() + ? EditOcrTabBar( + onTabChanged: (OpenFoodFactsLanguage language) { + if (_multilingualHelper.changeLanguage(language)) { + setState(() {}); + } + }, + imageField: _helper.getImageField(), + languagesWithText: _getLanguagesWithText(), + ) + : null, + ), + body: Stack( + children: [ + _getImageWidget(transientFile), + _getOcrWidget(transientFile), + ], + ), ), ); } + List _getLanguagesWithText() { + final Map allLanguages = + _multilingualHelper.getInitialMultiLingualTexts(); + + final List languages = []; + + for (final OpenFoodFactsLanguage language in allLanguages.keys) { + if (allLanguages[language]?.isNotEmpty == true) { + languages.add(language); + } + } + + return languages; + } + Widget _getImageButton( final ProductImageButtonType type, final bool imageExists, @@ -311,11 +341,6 @@ class _EditOcrPageState extends State with UpToDateMixin { ), child: Column( children: [ - if (!_multilingualHelper.isMonolingual()) - _multilingualHelper.getLanguageSelector( - setState: setState, - product: upToDateProduct, - ), _EditOcrMainAction( onPressed: _extractData, helper: _helper, diff --git a/packages/smooth_app/lib/pages/product/edit_ocr/edit_ocr_tabbar.dart b/packages/smooth_app/lib/pages/product/edit_ocr/edit_ocr_tabbar.dart new file mode 100644 index 000000000000..33f66ebf2ea6 --- /dev/null +++ b/packages/smooth_app/lib/pages/product/edit_ocr/edit_ocr_tabbar.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; +import 'package:openfoodfacts/openfoodfacts.dart'; +import 'package:smooth_app/helpers/product_cards_helper.dart'; +import 'package:smooth_app/pages/product/edit_language_tabbar.dart'; + +class EditOcrTabBar extends StatelessWidget implements PreferredSizeWidget { + const EditOcrTabBar({ + required this.onTabChanged, + required this.imageField, + required this.languagesWithText, + super.key, + }); + + final void Function(OpenFoodFactsLanguage) onTabChanged; + final ImageField imageField; + final List languagesWithText; + + @override + Widget build(BuildContext context) { + return EditLanguageTabBar( + onTabChanged: onTabChanged, + productEquality: productEquality, + productLanguages: productLanguages, + forceUserLanguage: false, + ); + } + + List productLanguages(Product product) { + return getProductImageLanguages(product, imageField) + .map((OpenFoodFactsLanguage l) => ProductLanguageWithState( + language: l, + state: languagesWithText.contains(l) + ? OpenFoodFactsLanguageState.normal + : OpenFoodFactsLanguageState.warning, + )) + .toList(growable: false); + } + + bool productEquality(Product oldProduct, Product product) { + return product.barcode == oldProduct.barcode && + product.productType == oldProduct.productType && + product.imageIngredientsUrl == oldProduct.imageIngredientsUrl && + product.imageIngredientsSmallUrl == oldProduct.imageIngredientsSmallUrl; + } + + @override + Size get preferredSize => EditLanguageTabBar.PREFERRED_SIZE; +} diff --git a/packages/smooth_app/lib/pages/product/gallery_view/product_image_gallery_tabs.dart b/packages/smooth_app/lib/pages/product/gallery_view/product_image_gallery_tabs.dart index f5713f25237a..d0c2481c4b61 100644 --- a/packages/smooth_app/lib/pages/product/gallery_view/product_image_gallery_tabs.dart +++ b/packages/smooth_app/lib/pages/product/gallery_view/product_image_gallery_tabs.dart @@ -1,363 +1,62 @@ import 'package:collection/collection.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/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/helpers/border_radius_helper.dart'; import 'package:smooth_app/helpers/product_cards_helper.dart'; -import 'package:smooth_app/helpers/provider_helper.dart'; -import 'package:smooth_app/helpers/ui_helpers.dart'; -import 'package:smooth_app/pages/preferences/user_preferences_languages_list.dart'; -import 'package:smooth_app/query/product_query.dart'; -import 'package:smooth_app/resources/app_icons.dart'; -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/smooth_tabbar.dart'; +import 'package:smooth_app/pages/product/edit_language_tabbar.dart'; -class ProductImageGalleryTabBar extends StatefulWidget +class ProductImageGalleryTabBar extends StatelessWidget implements PreferredSizeWidget { const ProductImageGalleryTabBar({ - required this.padding, required this.onTabChanged, }); - final EdgeInsetsGeometry padding; final void Function(OpenFoodFactsLanguage) onTabChanged; - @override - State createState() => - _ProductImageGalleryTabBarState(); - - @override - Size get preferredSize => const Size( - double.infinity, - SmoothTabBar.TAB_BAR_HEIGHT + BALANCED_SPACE, - ); -} - -class _ProductImageGalleryTabBarState extends State - with TickerProviderStateMixin { - late final _ImageGalleryLanguagesProvider _provider; - late TabController? _tabController; - - @override - void initState() { - super.initState(); - _provider = _ImageGalleryLanguagesProvider()..addListener(_updateListener); - _tabController = TabController(length: 0, vsync: this); - } - @override Widget build(BuildContext context) { - return SizedBox.fromSize( - size: widget.preferredSize, - child: Padding( - padding: const EdgeInsetsDirectional.only(top: BALANCED_SPACE), - child: Listener( - listener: (BuildContext context, _, Product product) { - _provider.attachProduct(product); - }, - child: ChangeNotifierProvider<_ImageGalleryLanguagesProvider>( - create: (_) => _provider, - child: Consumer<_ImageGalleryLanguagesProvider>( - builder: ( - final BuildContext context, - final _ImageGalleryLanguagesProvider provider, - _, - ) { - if (provider.value.languages == null) { - return const Center( - child: CircularProgressIndicator.adaptive(), - ); - } - - /// We need a Stack to have to tab bar shadow below the button - return Stack( - children: [ - PositionedDirectional( - top: 0.0, - start: 0.0, - bottom: 0.0, - end: 40.0, - child: SmoothTabBar( - tabController: _tabController!, - items: provider.value.languages!.map( - (final OpenFoodFactsLanguage language) => - SmoothTabBarItem( - label: Languages().getNameInLanguage(language), - value: language, - ), - ), - onTabChanged: (final OpenFoodFactsLanguage value) { - widget.onTabChanged.call(value); - }, - padding: widget.padding.add( - const EdgeInsetsDirectional.only( - end: 20.0, - ), - ), - ), - ), - const PositionedDirectional( - top: 0.0, - end: 0.0, - bottom: 0.0, - child: _ImageGalleryAddLanguageButton(), - ), - ], - ); - }, - ), - )), - ), + return EditLanguageTabBar.noIndicator( + onTabChanged: onTabChanged, + productEquality: productEquality, + productLanguages: productLanguages, + forceUserLanguage: true, ); } - void _updateListener() { - final _ImageGalleryLanguagesState value = _provider.value; - - if (value.languages != null) { - final int initialIndex = _tabController?.index ?? -1; - final int newIndex = value.selectedLanguage != null - ? value.languages!.indexOf(value.selectedLanguage!) - : initialIndex; - - if (_tabController?.length != value.languages!.length) { - _tabController = TabController( - length: value.languages!.length, - vsync: this, - initialIndex: newIndex >= 0 ? newIndex : 0, - ); - } else if (newIndex >= 0 && _tabController!.index != newIndex) { - onNextFrame(() { - _tabController!.animateTo(newIndex); - _provider.newLanguageSuccessfullyChanged(); - }); - } - - if (value.initialValue || (newIndex >= 0 && initialIndex != newIndex)) { - widget.onTabChanged.call(value.selectedLanguage!); - } + List productLanguages(Product product) { + return { + ...getProductImageLanguages(product, ImageField.FRONT), + ...getProductImageLanguages(product, ImageField.INGREDIENTS), + ...getProductImageLanguages(product, ImageField.NUTRITION), + ...getProductImageLanguages(product, ImageField.PACKAGING), } + .map( + (OpenFoodFactsLanguage l) => + ProductLanguageWithState.normal(language: l), + ) + .toList(growable: false); } -} - -class _ImageGalleryAddLanguageButton extends StatelessWidget { - const _ImageGalleryAddLanguageButton(); - @override - Widget build(BuildContext context) { - final SmoothColorsThemeExtension theme = - context.extension(); - final bool lightTheme = context.lightTheme(); - - final BorderRadius borderRadius = BorderRadiusHelper.fromDirectional( - context: context, - topStart: const Radius.circular(10.0), - ); - - final String label = AppLocalizations.of(context).product_add_a_language; - - return Semantics( - label: label, - button: true, - excludeSemantics: true, - child: Tooltip( - message: label, - child: DecoratedBox( - decoration: BoxDecoration( - borderRadius: borderRadius, - border: Border( - bottom: BorderSide( - color: lightTheme ? theme.primarySemiDark : theme.primaryDark, - width: lightTheme ? 1.5 : 2.0, - ), - ), - color: lightTheme ? theme.primaryDark : theme.primaryNormal, - ), - child: Material( - type: MaterialType.transparency, - child: InkWell( - borderRadius: borderRadius, - onTap: () => _addLanguage(context), - child: const Padding( - padding: EdgeInsetsDirectional.only( - start: LARGE_SPACE, - end: MEDIUM_SPACE, - ), - child: Add( - color: Colors.white, - ), - ), - ), - ), - ), - ), - ); - } - - Future _addLanguage(BuildContext context) async { - // TODO(g123k): Improve the language selector - final DaoStringList daoStringList = - DaoStringList(context.read()); - - final List? selectedLanguages = - context.read<_ImageGalleryLanguagesProvider>().value.languages; - - final LanguagePriority languagePriority = LanguagePriority( - product: context.read(), - selectedLanguages: selectedLanguages, - daoStringList: daoStringList, - ); - - final OpenFoodFactsLanguage? language = - await LanguageSelector.openLanguageSelector( - context, - selectedLanguages: selectedLanguages, - languagePriority: languagePriority, - ); - - if (language != null && context.mounted) { - context.read<_ImageGalleryLanguagesProvider>().addLanguage(language); - } - } -} - -class _ImageGalleryLanguagesProvider - extends ValueNotifier<_ImageGalleryLanguagesState> { - _ImageGalleryLanguagesProvider() - : super(const _ImageGalleryLanguagesState.empty()); - - Product? product; - - void attachProduct(final Product product) { - if (!equalsProduct(product)) { - this.product = product; - refreshLanguages(initial: true); - } - } - - void refreshLanguages({bool initial = false}) { - final List imageLanguages = { - ...getProductImageLanguages(product!, ImageField.FRONT), - ...getProductImageLanguages(product!, ImageField.INGREDIENTS), - ...getProductImageLanguages(product!, ImageField.NUTRITION), - ...getProductImageLanguages(product!, ImageField.PACKAGING), - }.toList(growable: false) - ..sort((final OpenFoodFactsLanguage a, final OpenFoodFactsLanguage b) { - return a.code.compareTo(b.code); - }); - - /// The main language is always the first, then the user one - final OpenFoodFactsLanguage userLanguage = ProductQuery.getLanguage(); - final OpenFoodFactsLanguage? mainLanguage = product!.lang; - - final List languages = []; - - if (mainLanguage != null) { - languages.add(mainLanguage); - } - - if (mainLanguage != userLanguage) { - languages.add(userLanguage); - } - - for (final OpenFoodFactsLanguage language in imageLanguages) { - if (language != mainLanguage && language != userLanguage) { - languages.add(language); - } - } - - if (!const ListEquality() - .equals(value.languages, languages)) { - value = _ImageGalleryLanguagesState( - languages: languages, - selectedLanguage: mainLanguage ?? userLanguage, - initialValue: initial, - ); - } - } - - void addLanguage(OpenFoodFactsLanguage language) { - if (value.languages == null) { - throw Exception('Languages are not loaded'); - } - - if (value.languages!.contains(language)) { - value = _ImageGalleryLanguagesState( - languages: value.languages, - selectedLanguage: language, - ); - } else { - value = _ImageGalleryLanguagesState( - languages: [...value.languages!, language], - selectedLanguage: language, - hasNewLanguage: true, - ); - } - } - - /// Generate a state with [hasNewLanguage] set to false - void newLanguageSuccessfullyChanged() { - value = _ImageGalleryLanguagesState( - languages: value.languages, - selectedLanguage: value.selectedLanguage, - hasNewLanguage: false, - ); - } - - bool equalsProduct(Product product) { - if (this.product == null) { - return false; - } - - return product.barcode == this.product!.barcode && - product.productType == this.product!.productType && - product.imageFrontUrl == this.product!.imageFrontUrl && - product.imageFrontSmallUrl == this.product!.imageFrontSmallUrl && - product.imageIngredientsUrl == this.product!.imageIngredientsUrl && + bool productEquality(Product oldProduct, Product product) { + return product.barcode == oldProduct.barcode && + product.productType == oldProduct.productType && + product.imageFrontUrl == oldProduct.imageFrontUrl && + product.imageFrontSmallUrl == oldProduct.imageFrontSmallUrl && + product.imageIngredientsUrl == oldProduct.imageIngredientsUrl && product.imageIngredientsSmallUrl == - this.product!.imageIngredientsSmallUrl && - product.imageNutritionUrl == this.product!.imageNutritionUrl && - product.imageNutritionSmallUrl == - this.product!.imageNutritionSmallUrl && - product.imagePackagingUrl == this.product!.imagePackagingUrl && - product.imagePackagingSmallUrl == - this.product!.imagePackagingSmallUrl && + oldProduct.imageIngredientsSmallUrl && + product.imageNutritionUrl == oldProduct.imageNutritionUrl && + product.imageNutritionSmallUrl == oldProduct.imageNutritionSmallUrl && + product.imagePackagingUrl == oldProduct.imagePackagingUrl && + product.imagePackagingSmallUrl == oldProduct.imagePackagingSmallUrl && const ListEquality() - .equals(product.selectedImages, this.product!.selectedImages) && + .equals(product.selectedImages, oldProduct.selectedImages) && const ListEquality() - .equals(product.images, this.product!.images) && - product.lastImage == this.product!.lastImage && + .equals(product.images, oldProduct.images) && + product.lastImage == oldProduct.lastImage && const ListEquality() - .equals(product.lastImageDates, this.product!.lastImageDates); + .equals(product.lastImageDates, oldProduct.lastImageDates); } -} - -class _ImageGalleryLanguagesState { - _ImageGalleryLanguagesState({ - required this.languages, - required this.selectedLanguage, - this.hasNewLanguage = false, - this.initialValue = false, - }) : assert( - selectedLanguage == null || languages!.contains(selectedLanguage), - ); - const _ImageGalleryLanguagesState.empty() - : languages = null, - selectedLanguage = null, - hasNewLanguage = false, - initialValue = false; - - final List? languages; - final OpenFoodFactsLanguage? selectedLanguage; - final bool initialValue; - final bool hasNewLanguage; + @override + Size get preferredSize => EditLanguageTabBar.PREFERRED_SIZE; } diff --git a/packages/smooth_app/lib/pages/product/gallery_view/product_image_gallery_view.dart b/packages/smooth_app/lib/pages/product/gallery_view/product_image_gallery_view.dart index a771358be4ac..253742fe16e5 100644 --- a/packages/smooth_app/lib/pages/product/gallery_view/product_image_gallery_view.dart +++ b/packages/smooth_app/lib/pages/product/gallery_view/product_image_gallery_view.dart @@ -121,7 +121,6 @@ class _ProductImageGalleryViewState extends State }, ), ), - padding: const EdgeInsetsDirectional.only(start: 55.0), ), ), body: Stack( diff --git a/packages/smooth_app/lib/pages/product/multilingual_helper.dart b/packages/smooth_app/lib/pages/product/multilingual_helper.dart index 1fd735f8810e..f3bdc9d73118 100644 --- a/packages/smooth_app/lib/pages/product/multilingual_helper.dart +++ b/packages/smooth_app/lib/pages/product/multilingual_helper.dart @@ -118,23 +118,30 @@ class MultilingualHelper { setLanguage: ( final OpenFoodFactsLanguage? newLanguage, ) async { - if (newLanguage == null) { - return; + if (changeLanguage(newLanguage)) { + setState(() {}); } - if (_currentLanguage == newLanguage) { - return; - } - _saveCurrentName(); - setState(() { - _currentLanguage = newLanguage; - _currentMultilingualTexts[_currentLanguage] ??= ''; - controller.text = _currentMultilingualTexts[_currentLanguage]!; - }); }, selectedLanguages: _currentMultilingualTexts.keys, displayedLanguage: _currentLanguage, ); + bool changeLanguage(OpenFoodFactsLanguage? newLanguage) { + if (newLanguage == null) { + return false; + } + if (_currentLanguage == newLanguage) { + return false; + } + _saveCurrentName(); + + _currentLanguage = newLanguage; + _currentMultilingualTexts[_currentLanguage] ??= ''; + controller.text = _currentMultilingualTexts[_currentLanguage]!; + + return true; + } + /// Returns the new text, if any change happened. String? getChangedMonolingualText() { if (!isMonolingual()) { @@ -182,5 +189,10 @@ class MultilingualHelper { OpenFoodFactsLanguage getCurrentLanguage() => isMonolingual() ? ProductQuery.getLanguage() : _currentLanguage; + Map getInitialMultiLingualTexts() { + assert(!isMonolingual()); + return _initialMultilingualTexts; + } + static String getCleanText(final String? name) => (name ?? '').trim(); } diff --git a/packages/smooth_app/lib/widgets/smooth_tabbar.dart b/packages/smooth_app/lib/widgets/smooth_tabbar.dart index 2f58040e08f0..c1093d96530a 100644 --- a/packages/smooth_app/lib/widgets/smooth_tabbar.dart +++ b/packages/smooth_app/lib/widgets/smooth_tabbar.dart @@ -2,6 +2,7 @@ import 'dart:ui' as ui; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; +import 'package:smooth_app/generic_lib/design_constants.dart'; import 'package:smooth_app/helpers/num_utils.dart'; import 'package:smooth_app/helpers/ui_helpers.dart'; import 'package:smooth_app/themes/smooth_theme.dart'; @@ -14,6 +15,8 @@ class SmoothTabBar extends StatefulWidget { required this.items, required this.onTabChanged, this.padding, + this.leadingItems, + this.trailingItems, super.key, }) : assert(items.length > 0); @@ -21,6 +24,8 @@ class SmoothTabBar extends StatefulWidget { final TabController tabController; final Iterable> items; + final Iterable? leadingItems; + final Iterable? trailingItems; final Function(T) onTabChanged; final EdgeInsetsGeometry? padding; @@ -65,6 +70,8 @@ class _SmoothTabBarState extends State> { .mapIndexed( (int position, SmoothTabBarItem item) => _SmoothTab( item: item, + leading: widget.leadingItems?.elementAtOrNull(position), + trailing: widget.trailingItems?.elementAtOrNull(position), selected: widget.tabController.index == position, ), ) @@ -128,18 +135,45 @@ class _SmoothTab extends StatelessWidget { const _SmoothTab({ required this.item, required this.selected, + this.leading, + this.trailing, }); final SmoothTabBarItem item; + final Widget? leading; + final Widget? trailing; final bool selected; @override Widget build(BuildContext context) { + final Widget child; + if (leading == null && trailing == null) { + child = Text(item.label); + } else { + child = IconTheme( + data: IconThemeData( + color: DefaultTextStyle.of(context).style.color, + size: 15.0, + ), + child: Row( + children: [ + if (leading != null) leading!, + Padding( + padding: EdgeInsetsDirectional.only( + start: leading != null ? SMALL_SPACE : 0.0, + end: trailing != null ? SMALL_SPACE : 0.0, + ), + child: Text(item.label), + ), + if (trailing != null) trailing!, + ], + ), + ); + } + return Padding( padding: const EdgeInsets.symmetric(horizontal: 18.0), - child: Center( - child: Text(item.label), - ), + child: Center(child: child), ); } }