diff --git a/packages/smooth_app/lib/l10n/app_en.arb b/packages/smooth_app/lib/l10n/app_en.arb index bfad1e2f3de..72cf11612ef 100644 --- a/packages/smooth_app/lib/l10n/app_en.arb +++ b/packages/smooth_app/lib/l10n/app_en.arb @@ -497,6 +497,10 @@ "@search": { "description": "Hint text of a search text input field" }, + "search_store": "Search for a store", + "@search_store": { + "description": "Hint text of a search store text input field" + }, "tap_for_more": "Tap to see more info…", "@Product": {}, "product": "Product", diff --git a/packages/smooth_app/lib/pages/locations/search_location_helper.dart b/packages/smooth_app/lib/pages/locations/search_location_helper.dart index c371bb098ef..2c086f223d3 100644 --- a/packages/smooth_app/lib/pages/locations/search_location_helper.dart +++ b/packages/smooth_app/lib/pages/locations/search_location_helper.dart @@ -8,14 +8,14 @@ import 'package:smooth_app/pages/product/common/search_helper.dart'; /// Search helper dedicated to location search. class SearchLocationHelper extends SearchHelper { - const SearchLocationHelper(); + SearchLocationHelper(); @override String get historyKey => DaoStringList.keySearchLocationHistory; @override String getHintText(final AppLocalizations appLocalizations) => - 'Rechercher un magasin'; + appLocalizations.search_store; @override void search( diff --git a/packages/smooth_app/lib/pages/navigator/app_navigator.dart b/packages/smooth_app/lib/pages/navigator/app_navigator.dart index 68f33560b2b..44c9086a5f7 100644 --- a/packages/smooth_app/lib/pages/navigator/app_navigator.dart +++ b/packages/smooth_app/lib/pages/navigator/app_navigator.dart @@ -18,8 +18,8 @@ import 'package:smooth_app/pages/product/edit_product_page.dart'; import 'package:smooth_app/pages/product/new_product_page.dart'; import 'package:smooth_app/pages/product/product_loader_page.dart'; import 'package:smooth_app/pages/scan/carousel/scan_carousel_manager.dart'; -import 'package:smooth_app/pages/scan/search_page.dart'; -import 'package:smooth_app/pages/scan/search_product_helper.dart'; +import 'package:smooth_app/pages/search/search_page.dart'; +import 'package:smooth_app/pages/search/search_product_helper.dart'; import 'package:smooth_app/pages/user_management/sign_up_page.dart'; import 'package:smooth_app/query/product_query.dart'; @@ -204,7 +204,13 @@ class _SmoothGoRouter { ), GoRoute( path: _InternalAppRoutes.SEARCH_PAGE, - builder: (_, __) => const SearchPage(SearchProductHelper()), + builder: (_, GoRouterState state) { + if (state.extra != null) { + return SearchPage.fromExtra(state.extra! as SearchPageExtra); + } else { + return SearchPage(SearchProductHelper()); + } + }, ), GoRoute( path: _InternalAppRoutes._GUIDES, @@ -452,6 +458,7 @@ class AppRoutes { '/${_InternalAppRoutes._GUIDES}/${_InternalAppRoutes.GUIDE_NUTRISCORE_V2_PAGE}'; static String get SIGNUP => '/${_InternalAppRoutes.SIGNUP_PAGE}'; + // Open an external link (where path is relative to the OFF website) static String EXTERNAL(String path) => '/${_InternalAppRoutes.EXTERNAL_PAGE}/?path=$path'; diff --git a/packages/smooth_app/lib/pages/preferences/user_preferences_account.dart b/packages/smooth_app/lib/pages/preferences/user_preferences_account.dart index 3121ddff96c..73974491aeb 100644 --- a/packages/smooth_app/lib/pages/preferences/user_preferences_account.dart +++ b/packages/smooth_app/lib/pages/preferences/user_preferences_account.dart @@ -443,7 +443,7 @@ class UserPreferencesAccount extends AbstractUserPreferences { }) => _getListTile( title, - () async => ProductQueryPageHelper().openBestChoice( + () async => ProductQueryPageHelper.openBestChoice( name: title, localDatabase: localDatabase, productQuery: productQuery, diff --git a/packages/smooth_app/lib/pages/preferences/user_preferences_contribute.dart b/packages/smooth_app/lib/pages/preferences/user_preferences_contribute.dart index 530c2e94062..12ca3580a73 100644 --- a/packages/smooth_app/lib/pages/preferences/user_preferences_contribute.dart +++ b/packages/smooth_app/lib/pages/preferences/user_preferences_contribute.dart @@ -172,7 +172,7 @@ class UserPreferencesContribute extends AbstractUserPreferences { final LocalDatabase localDatabase = context.read(); Navigator.of(context).pop(); - ProductQueryPageHelper().openBestChoice( + ProductQueryPageHelper.openBestChoice( name: appLocalizations.all_search_to_be_completed_title, localDatabase: localDatabase, productQuery: PagedToBeCompletedProductQuery(), diff --git a/packages/smooth_app/lib/pages/preferences/user_preferences_dev_mode.dart b/packages/smooth_app/lib/pages/preferences/user_preferences_dev_mode.dart index f14ae46d4f6..fc0bfdbb1ec 100644 --- a/packages/smooth_app/lib/pages/preferences/user_preferences_dev_mode.dart +++ b/packages/smooth_app/lib/pages/preferences/user_preferences_dev_mode.dart @@ -27,7 +27,7 @@ import 'package:smooth_app/pages/preferences/user_preferences_item.dart'; import 'package:smooth_app/pages/preferences/user_preferences_page.dart'; import 'package:smooth_app/pages/preferences/user_preferences_search_page.dart'; import 'package:smooth_app/pages/preferences/user_preferences_widgets.dart'; -import 'package:smooth_app/pages/scan/search_page.dart'; +import 'package:smooth_app/pages/search/search_page.dart'; import 'package:smooth_app/query/product_query.dart'; /// Full page display of "dev mode" for the preferences page. @@ -421,7 +421,7 @@ class UserPreferencesDevMode extends AbstractUserPreferences { context, MaterialPageRoute( builder: (BuildContext context) => SearchPage( - const SearchLocationHelper(), + SearchLocationHelper(), preloadedList: preloadedList, autofocus: false, ), diff --git a/packages/smooth_app/lib/pages/prices/price_location_card.dart b/packages/smooth_app/lib/pages/prices/price_location_card.dart index bd3d9f14998..ad34b1457d6 100644 --- a/packages/smooth_app/lib/pages/prices/price_location_card.dart +++ b/packages/smooth_app/lib/pages/prices/price_location_card.dart @@ -10,7 +10,7 @@ import 'package:smooth_app/pages/locations/osm_location.dart'; import 'package:smooth_app/pages/locations/search_location_helper.dart'; import 'package:smooth_app/pages/locations/search_location_preloaded_item.dart'; import 'package:smooth_app/pages/prices/price_model.dart'; -import 'package:smooth_app/pages/scan/search_page.dart'; +import 'package:smooth_app/pages/search/search_page.dart'; /// Card that displays the location for price adding. class PriceLocationCard extends StatelessWidget { @@ -52,7 +52,7 @@ class PriceLocationCard extends StatelessWidget { context, MaterialPageRoute( builder: (BuildContext context) => SearchPage( - const SearchLocationHelper(), + SearchLocationHelper(), preloadedList: preloadedList, autofocus: false, ), diff --git a/packages/smooth_app/lib/pages/product/common/product_query_page.dart b/packages/smooth_app/lib/pages/product/common/product_query_page.dart index 05e8a9ad268..ef14c35be99 100644 --- a/packages/smooth_app/lib/pages/product/common/product_query_page.dart +++ b/packages/smooth_app/lib/pages/product/common/product_query_page.dart @@ -32,17 +32,19 @@ import 'package:smooth_app/widgets/ranking_floating_action_button.dart'; import 'package:smooth_app/widgets/smooth_app_bar.dart'; import 'package:smooth_app/widgets/smooth_scaffold.dart'; +/// A page that can be used like a screen, if [includeAppBar] is true. +/// Otherwise, it can be embedded in another screen. class ProductQueryPage extends StatefulWidget { const ProductQueryPage({ required this.productListSupplier, required this.name, - required this.editableAppBarTitle, + this.includeAppBar = true, this.searchResult = true, }); final ProductListSupplier productListSupplier; final String name; - final bool editableAppBarTitle; + final bool includeAppBar; final bool searchResult; @override @@ -57,7 +59,7 @@ class _ProductQueryPageState extends State late ScrollController _scrollController; late ProductQueryModel _model; - late final OpenFoodFactsCountry? _country; + late OpenFoodFactsCountry? _country; @override String get actionName => 'Opened search_page'; @@ -88,9 +90,13 @@ class _ProductQueryPageState extends State } @override - void dispose() { - _scrollController.dispose(); - super.dispose(); + void didUpdateWidget(ProductQueryPage oldWidget) { + super.didUpdateWidget(oldWidget); + + if (oldWidget.productListSupplier != widget.productListSupplier) { + _model = _getModel(widget.productListSupplier); + _country = widget.productListSupplier.productQuery.country; + } } @override @@ -124,11 +130,11 @@ class _ProductQueryPageState extends State // TODO(monsieurtanuki): should be tracked as well, shouldn't it? return SearchEmptyScreen( name: widget.name, + includeAppBar: widget.includeAppBar, emptiness: _getEmptyText( themeData, appLocalizations.no_product_found, ), - actions: _getAppBarButtons(), ); } AnalyticsHelper.trackSearch( @@ -149,6 +155,12 @@ class _ProductQueryPageState extends State ); } + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + // TODO(monsieurtanuki): put that in a specific Widget class Widget _getNotEmptyScreen( final Size screenSize, @@ -162,8 +174,8 @@ class _ProductQueryPageState extends State children: [ Expanded( child: RankingFloatingActionButton( - onPressed: () => Navigator.push( - context, + onPressed: () => + Navigator.of(context, rootNavigator: true).push( MaterialPageRoute( builder: (BuildContext context) => PersonalizedRankingPage( @@ -212,26 +224,27 @@ class _ProductQueryPageState extends State ), ], ), - appBar: SmoothAppBar( - backgroundColor: themeData.scaffoldBackgroundColor, - elevation: 2, - automaticallyImplyLeading: false, - leading: const SmoothBackButton(), - title: SearchAppBarTitle( - title: widget.searchResult - ? widget.name - : appLocalizations.product_search_same_category, - editableAppBarTitle: - widget.searchResult && widget.editableAppBarTitle, - multiLines: !widget.searchResult, - ), - subTitle: !widget.searchResult ? Text(widget.name) : null, - actions: _getAppBarButtons(), - ), + appBar: widget.includeAppBar + ? SmoothAppBar( + backgroundColor: themeData.scaffoldBackgroundColor, + elevation: 2, + automaticallyImplyLeading: false, + leading: const SmoothBackButton(), + title: SearchAppBarTitle( + title: widget.searchResult + ? widget.name + : appLocalizations.product_search_same_category, + editableAppBarTitle: widget.searchResult, + multiLines: !widget.searchResult, + ), + subTitle: !widget.searchResult ? Text(widget.name) : null, + ) + : null, body: RefreshIndicator( onRefresh: () async => _refreshList(), child: ListView.builder( controller: _scrollController, + padding: widget.includeAppBar ? null : EdgeInsets.zero, // To allow refresh even when not the whole page is filled physics: const AlwaysScrollableScrollPhysics(), itemBuilder: (BuildContext context, int index) { @@ -298,6 +311,7 @@ class _ProductQueryPageState extends State ) { return SearchEmptyScreen( name: widget.name, + includeAppBar: false, emptiness: Padding( padding: const EdgeInsets.all(SMALL_SPACE), child: SmoothErrorCard( @@ -331,25 +345,21 @@ class _ProductQueryPageState extends State ), if (worldQuery != null) _getLargeButtonWithIcon( - _getWorldAction(appLocalizations, worldQuery), + _getWorldAction( + appLocalizations, + worldQuery, + widget.includeAppBar, + ), ), ], ), ); } - List _getAppBarButtons() { - final AppLocalizations appLocalizations = AppLocalizations.of(context); + Widget _getTopMessagesCard() { final PagedProductQuery pagedProductQuery = _model.supplier.productQuery; final PagedProductQuery? worldQuery = pagedProductQuery.getWorldQuery(); - return [ - if (worldQuery != null) - _getIconButton(_getWorldAction(appLocalizations, worldQuery)), - ]; - } - Widget _getTopMessagesCard() { - final PagedProductQuery pagedProductQuery = _model.supplier.productQuery; return FutureBuilder( future: _getTranslatedCountry(), builder: ( @@ -383,7 +393,19 @@ class _ProductQueryPageState extends State child: SmoothCard( child: Padding( padding: const EdgeInsets.all(SMALL_SPACE), - child: Text(messages.join('\n')), + child: Row( + children: [ + Expanded(child: Text(messages.join('\n'))), + if (pagedProductQuery.getWorldQuery() != null) + _getIconButton( + _getWorldAction( + appLocalizations, + worldQuery!, + widget.includeAppBar, + ), + ), + ], + ), ), ), ); @@ -399,7 +421,7 @@ class _ProductQueryPageState extends State final List localizedCountries = await IsoCountries.isoCountriesForLocale(locale); for (final Country country in localizedCountries) { - if (country.countryCode.toLowerCase() == _country.offTag.toLowerCase()) { + if (country.countryCode.toLowerCase() == _country?.offTag.toLowerCase()) { return country.name; } } @@ -422,15 +444,17 @@ class _ProductQueryPageState extends State _Action _getWorldAction( final AppLocalizations appLocalizations, final PagedProductQuery worldQuery, + final bool editableAppBarTitle, ) => _Action( text: appLocalizations.world_results_action, iconData: Icons.public, - onPressed: () async => ProductQueryPageHelper().openBestChoice( + onPressed: () async => ProductQueryPageHelper.openBestChoice( productQuery: worldQuery, localDatabase: context.read(), name: widget.name, context: context, + editableAppBarTitle: editableAppBarTitle, ), ); diff --git a/packages/smooth_app/lib/pages/product/common/product_query_page_helper.dart b/packages/smooth_app/lib/pages/product/common/product_query_page_helper.dart index fd4cb0e10ec..80201057e86 100644 --- a/packages/smooth_app/lib/pages/product/common/product_query_page_helper.dart +++ b/packages/smooth_app/lib/pages/product/common/product_query_page_helper.dart @@ -8,32 +8,55 @@ import 'package:smooth_app/pages/product/common/search_helper.dart'; import 'package:smooth_app/query/paged_product_query.dart'; class ProductQueryPageHelper { - Future openBestChoice({ + const ProductQueryPageHelper._(); + + static Future getBestChoiceWidget({ required final PagedProductQuery productQuery, required final LocalDatabase localDatabase, required final String name, required final BuildContext context, bool editableAppBarTitle = true, bool searchResult = true, - SearchQueryCallback? editQueryCallback, }) async { final ProductListSupplier supplier = await ProductListSupplier.getBestSupplier( productQuery, localDatabase, ); + + return ProductQueryPage( + productListSupplier: supplier, + name: name, + includeAppBar: editableAppBarTitle, + searchResult: searchResult, + ); + } + + static Future openBestChoice({ + required final PagedProductQuery productQuery, + required final LocalDatabase localDatabase, + required final String name, + required final BuildContext context, + bool editableAppBarTitle = true, + bool searchResult = true, + SearchQueryCallback? editQueryCallback, + }) async { + final Widget widget = await getBestChoiceWidget( + productQuery: productQuery, + localDatabase: localDatabase, + name: name, + context: context, + editableAppBarTitle: editableAppBarTitle, + searchResult: searchResult, + ); + if (!context.mounted) { return; } - final bool? result = await Navigator.push( - context, + + final bool? result = await Navigator.of(context).push( MaterialPageRoute( - builder: (BuildContext context) => ProductQueryPage( - productListSupplier: supplier, - name: name, - editableAppBarTitle: editableAppBarTitle, - searchResult: searchResult, - ), + builder: (BuildContext context) => widget, ), ); diff --git a/packages/smooth_app/lib/pages/product/common/search_empty_screen.dart b/packages/smooth_app/lib/pages/product/common/search_empty_screen.dart index 2ab31bed112..98dbf68f7ad 100644 --- a/packages/smooth_app/lib/pages/product/common/search_empty_screen.dart +++ b/packages/smooth_app/lib/pages/product/common/search_empty_screen.dart @@ -8,25 +8,29 @@ class SearchEmptyScreen extends StatelessWidget { required this.name, required this.emptiness, this.actions, + this.includeAppBar = true, Key? key, }) : super(key: key); final String name; final Widget emptiness; final List? actions; + final bool includeAppBar; @override Widget build(BuildContext context) { return SmoothScaffold( - appBar: AppBar( - backgroundColor: Theme.of(context).scaffoldBackgroundColor, - leading: const SmoothBackButton(), - title: SearchAppBarTitle( - title: name, - editableAppBarTitle: false, - ), - actions: actions, - ), + appBar: includeAppBar + ? AppBar( + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + leading: const SmoothBackButton(), + title: SearchAppBarTitle( + title: name, + editableAppBarTitle: false, + ), + actions: actions, + ) + : null, body: Center(child: emptiness), ); } diff --git a/packages/smooth_app/lib/pages/product/common/search_helper.dart b/packages/smooth_app/lib/pages/product/common/search_helper.dart index e3313f562c4..fb171d7779a 100644 --- a/packages/smooth_app/lib/pages/product/common/search_helper.dart +++ b/packages/smooth_app/lib/pages/product/common/search_helper.dart @@ -6,8 +6,10 @@ import 'package:smooth_app/database/local_database.dart'; typedef SearchQueryCallback = void Function(String query); /// Common "text-field + history" search helper. -abstract class SearchHelper { - const SearchHelper(); +/// Will emit a [SearchQuery] when a search is performed. +/// By default (with the [null] value), the history will be displayed. +abstract class SearchHelper extends ValueNotifier { + SearchHelper() : super(null); /// Action to perform for a search. @protected @@ -58,3 +60,13 @@ abstract class SearchHelper { }, ); } + +class SearchQuery { + const SearchQuery({ + required this.search, + required this.widget, + }) : assert(search.length > 0); + + final String search; + final Widget widget; +} diff --git a/packages/smooth_app/lib/pages/product/common/search_loading_screen.dart b/packages/smooth_app/lib/pages/product/common/search_loading_screen.dart index d04fd382412..6c60417a585 100644 --- a/packages/smooth_app/lib/pages/product/common/search_loading_screen.dart +++ b/packages/smooth_app/lib/pages/product/common/search_loading_screen.dart @@ -19,6 +19,7 @@ class SearchLoadingScreen extends StatelessWidget { return SearchEmptyScreen( name: title, + includeAppBar: false, emptiness: FractionallySizedBox( widthFactor: 0.75, child: Column( diff --git a/packages/smooth_app/lib/pages/product/summary_card.dart b/packages/smooth_app/lib/pages/product/summary_card.dart index 6245291f22e..df54b727673 100644 --- a/packages/smooth_app/lib/pages/product/summary_card.dart +++ b/packages/smooth_app/lib/pages/product/summary_card.dart @@ -298,7 +298,7 @@ class _SummaryCardState extends State with UpToDateMixin { addPanelButton( localizations.product_search_same_category, iconData: Icons.leaderboard, - onPressed: () async => ProductQueryPageHelper().openBestChoice( + onPressed: () async => ProductQueryPageHelper.openBestChoice( name: categoryLabel!, localDatabase: context.read(), productQuery: CategoryProductQuery(categoryTag!), diff --git a/packages/smooth_app/lib/pages/scan/carousel/main_card/scan_main_card.dart b/packages/smooth_app/lib/pages/scan/carousel/main_card/scan_main_card.dart index 389cc3aa0e6..044350ca9ec 100644 --- a/packages/smooth_app/lib/pages/scan/carousel/main_card/scan_main_card.dart +++ b/packages/smooth_app/lib/pages/scan/carousel/main_card/scan_main_card.dart @@ -9,8 +9,9 @@ import 'package:smooth_app/helpers/provider_helper.dart'; import 'package:smooth_app/helpers/strings_helper.dart'; import 'package:smooth_app/pages/navigator/app_navigator.dart'; import 'package:smooth_app/pages/scan/carousel/main_card/scan_tagline.dart'; -import 'package:smooth_app/resources/app_icons.dart'; -import 'package:smooth_app/themes/smooth_theme_colors.dart'; +import 'package:smooth_app/pages/search/search_field.dart'; +import 'package:smooth_app/pages/search/search_page.dart'; +import 'package:smooth_app/pages/search/search_product_helper.dart'; import 'package:smooth_app/themes/theme_provider.dart'; class ScanMainCard extends StatelessWidget { @@ -121,65 +122,51 @@ class _SearchCard extends StatelessWidget { class _SearchBar extends StatelessWidget { const _SearchBar(); - static const double SEARCH_BAR_HEIGHT = 47.0; + static const String HERO_TAG = 'search_field'; @override Widget build(BuildContext context) { final AppLocalizations localizations = AppLocalizations.of(context); - final SmoothColorsThemeExtension theme = - Theme.of(context).extension()!; - final bool lightTheme = !context.watch().isDarkMode(context); return Semantics( button: true, - child: SizedBox( - height: SEARCH_BAR_HEIGHT, - child: InkWell( - onTap: () => AppNavigator.of(context).push(AppRoutes.SEARCH), - borderRadius: BorderRadius.circular(30.0), - child: Ink( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(30.0), - color: lightTheme ? Colors.white : theme.greyDark, - border: Border.all(color: theme.primaryBlack), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded( - child: Padding( - padding: const EdgeInsetsDirectional.only( - start: 20.0, - end: BALANCED_SPACE, - bottom: 3.0, - ), - child: Text( - localizations.homepage_main_card_search_field_hint, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: lightTheme ? Colors.black : Colors.white, - ), - ), - ), + child: Hero( + tag: HERO_TAG, + child: Material( + // ↑ Needed by the Hero Widget + type: MaterialType.transparency, + child: SizedBox( + height: SearchFieldUIHelper.SEARCH_BAR_HEIGHT, + child: InkWell( + onTap: () => AppNavigator.of(context).push( + AppRoutes.SEARCH, + extra: SearchPageExtra( + searchHelper: SearchProductHelper(), + autofocus: true, + heroTag: HERO_TAG, ), - AspectRatio( - aspectRatio: 1.0, - child: DecoratedBox( - decoration: BoxDecoration( - color: theme.primaryDark, - shape: BoxShape.circle, - ), - child: const Padding( - padding: EdgeInsets.all(BALANCED_SPACE), - child: Search( - size: 20.0, - color: Colors.white, + ), + borderRadius: SearchFieldUIHelper.SEARCH_BAR_BORDER_RADIUS, + child: Ink( + decoration: SearchFieldUIHelper.decoration(context), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Padding( + padding: SearchFieldUIHelper.SEARCH_BAR_PADDING, + child: Text( + localizations.homepage_main_card_search_field_hint, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: SearchFieldUIHelper.textStyle(context), + ), ), ), - ), - ) - ], + const SearchBarIcon(), + ], + ), + ), ), ), ), diff --git a/packages/smooth_app/lib/pages/scan/search_page.dart b/packages/smooth_app/lib/pages/scan/search_page.dart deleted file mode 100644 index 68d11d70918..00000000000 --- a/packages/smooth_app/lib/pages/scan/search_page.dart +++ /dev/null @@ -1,267 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -import 'package:provider/provider.dart'; -import 'package:smooth_app/generic_lib/design_constants.dart'; -import 'package:smooth_app/generic_lib/duration_constants.dart'; -import 'package:smooth_app/pages/product/common/search_helper.dart'; -import 'package:smooth_app/pages/product/common/search_preloaded_item.dart'; -import 'package:smooth_app/pages/scan/search_history_view.dart'; -import 'package:smooth_app/widgets/smooth_app_bar.dart'; -import 'package:smooth_app/widgets/smooth_scaffold.dart'; - -class SearchPage extends StatefulWidget { - const SearchPage( - this.searchHelper, { - this.preloadedList, - this.autofocus = true, - }); - - final SearchHelper searchHelper; - final List? preloadedList; - final bool autofocus; - - @override - State createState() => _SearchPageState(); -} - -class _SearchPageState extends State { - // https://github.com/openfoodfacts/smooth-app/pull/2219 - final TextEditingController _searchTextController = TextEditingController(); - final FocusNode _searchFocusNode = FocusNode(); - - @override - Widget build(BuildContext context) { - return SmoothScaffold( - appBar: SmoothAppBar(toolbarHeight: 0.0), - body: ChangeNotifierProvider( - create: (_) => _searchTextController, - child: Column( - children: [ - Padding( - padding: const EdgeInsets.all(BALANCED_SPACE), - child: SearchField( - autofocus: widget.autofocus, - focusNode: _searchFocusNode, - searchHelper: widget.searchHelper, - ), - ), - Expanded( - child: SearchHistoryView( - focusNode: _searchFocusNode, - onTap: (String query) => - widget.searchHelper.searchWithController( - context, - query, - _searchTextController, - _searchFocusNode, - ), - searchHelper: widget.searchHelper, - preloadedList: widget.preloadedList ?? [], - ), - ), - ], - ), - ), - ); - } -} - -class SearchField extends StatefulWidget { - const SearchField({ - required this.searchHelper, - this.autofocus = false, - this.showClearButton = true, - this.readOnly = false, - this.onFocus, - this.backgroundColor, - this.foregroundColor, - this.focusNode, - }); - - final SearchHelper searchHelper; - final bool autofocus; - final bool showClearButton; - - /// If true, the Widget will only display the UI - final bool readOnly; - final void Function()? onFocus; - final Color? backgroundColor; - final Color? foregroundColor; - - final FocusNode? focusNode; - - @override - State createState() => _SearchFieldState(); -} - -class _SearchFieldState extends State { - late FocusNode _focusNode; - late TextEditingController _controller; - - bool _isEmpty = true; - - @override - void initState() { - super.initState(); - - _focusNode = widget.focusNode ?? FocusNode(); - _focusNode.addListener(_handleFocusChange); - - if (widget.autofocus) { - _focusNode.requestFocus(); - } - } - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - - try { - _controller = Provider.of(context); - } catch (err) { - _controller = TextEditingController(); - } - - _controller.removeListener(_handleTextChange); - _controller.addListener(_handleTextChange); - } - - @override - void dispose() { - _focusNode.removeListener(_handleFocusChange); - _focusNode.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final AppLocalizations localizations = AppLocalizations.of(context); - - try { - _controller = Provider.of(context); - } catch (err) { - _controller = TextEditingController(); - } - - final InputDecoration inputDecoration = InputDecoration( - fillColor: widget.backgroundColor, - labelStyle: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: widget.foregroundColor, - ), - filled: true, - border: const OutlineInputBorder( - borderRadius: CIRCULAR_BORDER_RADIUS, - borderSide: BorderSide.none, - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 25.0, - vertical: 17.0, - ), - hintText: widget.searchHelper.getHintText(localizations), - suffixIcon: - widget.showClearButton ? _buildClearButton(localizations) : null, - ); - - const TextStyle textStyle = TextStyle(fontSize: 18.0); - - if (widget.readOnly) { - return InkWell( - borderRadius: CIRCULAR_BORDER_RADIUS, - splashColor: Theme.of(context).primaryColor, - onTap: () { - widget.onFocus?.call(); - }, - child: Ink( - decoration: BoxDecoration( - borderRadius: CIRCULAR_BORDER_RADIUS, - color: Theme.of(context).brightness == Brightness.light - ? Colors.white - : null, - ), - child: InputDecorator( - decoration: inputDecoration, - child: Text( - inputDecoration.hintText!, - style: Theme.of(context) - .textTheme - .titleMedium! - .copyWith(color: Theme.of(context).hintColor) - .merge(textStyle), - ), - ), - ), - ); - } else { - return TextField( - textInputAction: TextInputAction.search, - controller: _controller, - focusNode: _focusNode, - onSubmitted: (String query) => widget.searchHelper.searchWithController( - context, - query, - _controller, - _focusNode, - ), - decoration: inputDecoration, - style: textStyle, - ); - } - } - - Widget _buildClearButton(AppLocalizations localizations) { - return Padding( - padding: const EdgeInsetsDirectional.only(end: MEDIUM_SPACE), - child: ClipOval( - child: Material( - type: MaterialType.transparency, - child: IconButton( - tooltip: localizations.clear_search, - onPressed: _handleClear, - icon: AnimatedCrossFade( - duration: SmoothAnimationsDuration.short, - crossFadeState: _isEmpty - ? CrossFadeState.showFirst - : CrossFadeState.showSecond, - // Closes the page. - firstChild: Icon( - Icons.close, - semanticLabel: localizations.clear_search, - ), - // Clears the text. - secondChild: Icon( - Icons.cancel, - semanticLabel: localizations.clear_search, - ), - ), - ), - ), - ), - ); - } - - void _handleTextChange() { - //Only rebuild the widget if the text length is 0 or 1 as we only check if - //the text length is empty or not - if (_controller.text.isEmpty || _controller.text.length == 1) { - setState(() { - _isEmpty = _controller.text.isEmpty; - }); - } - } - - void _handleFocusChange() { - if (_focusNode.hasFocus && widget.onFocus != null) { - _focusNode.unfocus(); - widget.onFocus?.call(); - } - } - - // FIXME(monsieurtanuki): when we paste from the clipboard and then clear, _isEmpty is not changed and therefore we pop instead of clearing. - void _handleClear() { - if (_isEmpty) { - Navigator.pop(context); - } else { - _controller.clear(); - } - } -} diff --git a/packages/smooth_app/lib/pages/search/search_field.dart b/packages/smooth_app/lib/pages/search/search_field.dart new file mode 100644 index 00000000000..baefe1ab293 --- /dev/null +++ b/packages/smooth_app/lib/pages/search/search_field.dart @@ -0,0 +1,287 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:provider/provider.dart'; +import 'package:smooth_app/generic_lib/design_constants.dart'; +import 'package:smooth_app/pages/product/common/search_helper.dart'; +import 'package:smooth_app/resources/app_icons.dart' as icons; +import 'package:smooth_app/themes/constant_icons.dart'; +import 'package:smooth_app/themes/smooth_theme_colors.dart'; +import 'package:smooth_app/themes/theme_provider.dart'; +import 'package:smooth_app/widgets/smooth_hero.dart'; + +class SearchField extends StatefulWidget { + const SearchField({ + required this.searchHelper, + this.autofocus = false, + this.showClearButton = true, + this.heroTag, + this.onFocus, + this.backgroundColor, + this.foregroundColor, + this.focusNode, + this.enableSuggestions = false, + this.autocorrect = false, + }); + + final SearchHelper searchHelper; + final bool autofocus; + final bool showClearButton; + final bool enableSuggestions; + final bool autocorrect; + + final String? heroTag; + final void Function()? onFocus; + final Color? backgroundColor; + final Color? foregroundColor; + + final FocusNode? focusNode; + + @override + State createState() => _SearchFieldState(); +} + +class _SearchFieldState extends State { + late FocusNode _focusNode; + TextEditingController? _controller; + + @override + void initState() { + super.initState(); + _focusNode = widget.focusNode ?? FocusNode(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + try { + _controller = Provider.of(context); + } catch (err) { + _controller = TextEditingController(); + } + } + + @override + Widget build(BuildContext context) { + final AppLocalizations localizations = AppLocalizations.of(context); + + try { + _controller ??= Provider.of(context); + } catch (err) { + _controller = TextEditingController(); + } + + final TextStyle textStyle = SearchFieldUIHelper.textStyle(context); + + return ChangeNotifierProvider.value( + value: _controller!, + child: SmoothHero( + tag: widget.heroTag, + enabled: widget.heroTag != null, + onAnimationEnded: widget.autofocus + ? (HeroFlightDirection direction) { + /// The autofocus should only be requested once the Animation is over + if (direction == HeroFlightDirection.push) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _focusNode.requestFocus(); + }); + } + } + : null, + child: Material( + // ↑ Needed by the Hero Widget + child: TextField( + controller: _controller, + focusNode: _focusNode, + onSubmitted: (String query) => _performSearch(context, query), + textInputAction: TextInputAction.search, + enableSuggestions: widget.enableSuggestions, + autocorrect: widget.autocorrect, + style: textStyle, + decoration: _getInputDecoration( + context, + localizations, + ), + cursorColor: textStyle.color, + ), + ), + ), + ); + } + + InputDecoration _getInputDecoration( + BuildContext context, + AppLocalizations localizations, + ) { + final BoxDecoration decoration = SearchFieldUIHelper.decoration(context); + final OutlineInputBorder border = OutlineInputBorder( + borderRadius: decoration.borderRadius! as BorderRadius, + borderSide: decoration.border!.top.copyWith(width: 2.0), + ); + + return InputDecoration( + fillColor: decoration.color, + filled: true, + constraints: const BoxConstraints.tightFor( + height: SearchFieldUIHelper.SEARCH_BAR_HEIGHT, + ), + border: border, + enabledBorder: border, + focusedBorder: border, + contentPadding: SearchFieldUIHelper.SEARCH_BAR_PADDING, + hintText: widget.searchHelper.getHintText(localizations), + prefixIcon: const Align( + alignment: AlignmentDirectional.centerStart, + child: _BackIcon(), + ), + prefixIconConstraints: BoxConstraints.tightFor( + width: SearchFieldUIHelper.SEARCH_BAR_HEIGHT + + (SearchFieldUIHelper.SEARCH_BAR_PADDING.horizontal) / 2, + ), + suffixIcon: widget.showClearButton + ? _SearchIcon( + onTap: () => _performSearch(context, _controller!.text), + ) + : null, + ); + } + + void _performSearch(BuildContext context, String query) => + widget.searchHelper.searchWithController( + context, + query, + _controller!, + _focusNode, + ); + + @override + void dispose() { + /// The [FocusNode] provided to this Widget is disposed elsewhere + if (_focusNode != widget.focusNode) { + _focusNode.dispose(); + } + + super.dispose(); + } +} + +class _BackIcon extends StatelessWidget { + const _BackIcon(); + + @override + Widget build(BuildContext context) { + return SearchBarIcon( + icon: Icon(ConstantIcons.instance.getBackIcon()), + label: MaterialLocalizations.of(context).closeButtonTooltip, + onTap: () => Navigator.of(context).pop(), + ); + } +} + +class _SearchIcon extends StatelessWidget { + const _SearchIcon({required this.onTap}); + + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final AppLocalizations localizations = AppLocalizations.of(context); + + return SearchBarIcon( + icon: const icons.Search(), + label: localizations.search, + onTap: onTap, + ); + } +} + +class SearchBarIcon extends StatelessWidget { + const SearchBarIcon({ + this.icon, + this.onTap, + this.label, + super.key, + }) : assert(label == null || onTap != null); + + final VoidCallback? onTap; + final String? label; + final Widget? icon; + + @override + Widget build(BuildContext context) { + final SmoothColorsThemeExtension theme = + Theme.of(context).extension()!; + + final Widget widget = AspectRatio( + aspectRatio: 1.0, + child: DecoratedBox( + decoration: BoxDecoration( + color: theme.primaryDark, + shape: BoxShape.circle, + ), + child: Padding( + padding: const EdgeInsets.all(BALANCED_SPACE), + child: IconTheme( + data: const IconThemeData( + size: 20.0, + color: Colors.white, + ), + child: icon ?? const icons.Search(), + ), + ), + ), + ); + + if (onTap == null) { + return widget; + } else { + return Semantics( + label: label, + button: true, + excludeSemantics: true, + child: Tooltip( + message: label ?? '', + child: InkWell( + borderRadius: SearchFieldUIHelper.SEARCH_BAR_BORDER_RADIUS, + onTap: onTap, + child: widget, + ), + ), + ); + } + } +} + +/// Constants shared between [SearchField] and [_SearchBar] +class SearchFieldUIHelper { + const SearchFieldUIHelper._(); + + static const double SEARCH_BAR_HEIGHT = 47.0; + static const BorderRadius SEARCH_BAR_BORDER_RADIUS = BorderRadius.all( + Radius.circular(30.0), + ); + static const EdgeInsetsGeometry SEARCH_BAR_PADDING = + EdgeInsetsDirectional.only( + start: 20.0, + end: BALANCED_SPACE, + bottom: 3.0, + ); + + static TextStyle textStyle(BuildContext context) { + final bool lightTheme = !context.watch().isDarkMode(context); + return TextStyle(color: lightTheme ? Colors.black : Colors.white); + } + + static BoxDecoration decoration(BuildContext context) { + final SmoothColorsThemeExtension theme = + Theme.of(context).extension()!; + final bool lightTheme = !context.watch().isDarkMode(context); + + return BoxDecoration( + borderRadius: SearchFieldUIHelper.SEARCH_BAR_BORDER_RADIUS, + color: lightTheme ? Colors.white : theme.greyDark, + border: Border.all( + color: lightTheme ? theme.primaryBlack : theme.primarySemiDark), + ); + } +} diff --git a/packages/smooth_app/lib/pages/scan/search_history_view.dart b/packages/smooth_app/lib/pages/search/search_history_view.dart similarity index 96% rename from packages/smooth_app/lib/pages/scan/search_history_view.dart rename to packages/smooth_app/lib/pages/search/search_history_view.dart index a4b0a94507a..6f56e568d3e 100644 --- a/packages/smooth_app/lib/pages/scan/search_history_view.dart +++ b/packages/smooth_app/lib/pages/search/search_history_view.dart @@ -44,12 +44,13 @@ class _SearchHistoryViewState extends State { Widget build(BuildContext context) { return ListTileTheme( data: ListTileThemeData( - titleTextStyle: const TextStyle(fontSize: 20.0), - minLeadingWidth: 18.0, + titleTextStyle: const TextStyle(fontSize: 18.0), + minLeadingWidth: 10.0, iconColor: Theme.of(context).colorScheme.onSurface, textColor: Theme.of(context).colorScheme.onSurface, ), child: ListView.builder( + padding: EdgeInsets.zero, itemBuilder: (BuildContext context, int i) { if (i == 0) { return _SearchItemPasteFromClipboard( @@ -150,7 +151,7 @@ class _SearchHistoryTile extends StatelessWidget { child: InkWell( onTap: () => onTap(), child: Padding( - padding: const EdgeInsetsDirectional.only(start: 18.0, end: 13.0), + padding: const EdgeInsetsDirectional.only(start: 8.0), child: ListTile( leading: const Padding( padding: EdgeInsetsDirectional.only(top: VERY_SMALL_SPACE), @@ -204,7 +205,7 @@ class _SearchItemPasteFromClipboard extends StatelessWidget { } }, child: Padding( - padding: const EdgeInsetsDirectional.only(start: 18.0, end: 13.0), + padding: const EdgeInsetsDirectional.only(start: 8.0, end: 13.0), child: ListTile( title: Text(localizations.paste_from_clipboard), leading: const Icon(Icons.copy), diff --git a/packages/smooth_app/lib/pages/search/search_page.dart b/packages/smooth_app/lib/pages/search/search_page.dart new file mode 100644 index 00000000000..cda42435bbd --- /dev/null +++ b/packages/smooth_app/lib/pages/search/search_page.dart @@ -0,0 +1,164 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:smooth_app/generic_lib/design_constants.dart'; +import 'package:smooth_app/helpers/provider_helper.dart'; +import 'package:smooth_app/pages/product/common/search_helper.dart'; +import 'package:smooth_app/pages/product/common/search_preloaded_item.dart'; +import 'package:smooth_app/pages/search/search_field.dart'; +import 'package:smooth_app/pages/search/search_history_view.dart'; +import 'package:smooth_app/widgets/smooth_scaffold.dart'; + +/// The [SearchPage] screen. +/// It can opened directly with the [SearchPageExtra] constructor. +/// From GoRouter, the page is named [AppRoutes.SEARCH] and we need to pass a +/// [SearchPageExtra] object for extras. +class SearchPage extends StatefulWidget { + const SearchPage( + this.searchHelper, { + this.preloadedList, + this.autofocus = true, + this.heroTag, + }); + + SearchPage.fromExtra(SearchPageExtra extra) + : this( + extra.searchHelper, + preloadedList: extra.preloadedList, + autofocus: extra.autofocus ?? true, + heroTag: extra.heroTag, + ); + + final SearchHelper searchHelper; + final List? preloadedList; + final bool autofocus; + final String? heroTag; + + @override + State createState() => _SearchPageState(); +} + +class SearchPageExtra { + const SearchPageExtra({ + required this.searchHelper, + this.preloadedList, + this.autofocus, + this.heroTag, + }); + + final SearchHelper searchHelper; + final List? preloadedList; + + /// If not passed, will default to [false] + final bool? autofocus; + final String? heroTag; +} + +class _SearchPageState extends State { + // https://github.com/openfoodfacts/smooth-app/pull/2219 + final TextEditingController _searchTextController = TextEditingController(); + final FocusNode _searchFocusNode = FocusNode(); + final GlobalKey _navigatorKey = GlobalKey(); + + @override + Widget build(BuildContext context) { + return MultiProvider( + providers: >[ + ChangeNotifierProvider.value( + value: _searchTextController, + ), + ChangeNotifierProvider.value( + value: widget.searchHelper, + ), + ], + child: SmoothScaffold( + body: Column( + children: [ + ValueNotifierListener( + listener: _onSearchChanged, + child: SafeArea( + bottom: false, + child: Padding( + padding: const EdgeInsetsDirectional.symmetric( + vertical: SMALL_SPACE, + horizontal: BALANCED_SPACE, + ), + child: SearchField( + autofocus: widget.autofocus, + focusNode: _searchFocusNode, + searchHelper: widget.searchHelper, + heroTag: widget.heroTag, + ), + ), + ), + ), + Expanded( + child: Consumer( + builder: ( + BuildContext context, + SearchHelper searchHelper, + _, + ) { + /// Show the history when there is no search + if (searchHelper.value == null) { + return SearchHistoryView( + focusNode: _searchFocusNode, + onTap: (String query) => + widget.searchHelper.searchWithController( + context, + query, + _searchTextController, + _searchFocusNode, + ), + searchHelper: widget.searchHelper, + preloadedList: + widget.preloadedList ?? [], + ); + } else { + /// A custom [Navigator] is used to intercept the World + /// results to be embedded in this part of the screen and + /// not on a new one. + return Navigator( + key: _navigatorKey, + pages: >[ + MaterialPage( + child: searchHelper.value!.widget, + ), + ], + onPopPage: (Route route, dynamic result) { + if (!route.didPop(result)) { + return false; + } + return true; + }, + ); + } + }, + ), + ), + ], + ), + ), + ); + } + + void _onSearchChanged( + BuildContext context, + SearchQuery? oldValue, + SearchQuery? value, + ) { + if (value != null && _searchTextController.text != value.search) { + /// Update the search field when an history item is selected + WidgetsBinding.instance.addPostFrameCallback((_) { + _searchTextController.text = value.search; + }); + } else if (oldValue != null) { + /// If we were on the world results, ensure to go back to + /// the main list of results + WidgetsBinding.instance.addPostFrameCallback((_) { + _navigatorKey.currentState?.popUntil((Route route) { + return route.isFirst; + }); + }); + } + } +} diff --git a/packages/smooth_app/lib/pages/scan/search_product_helper.dart b/packages/smooth_app/lib/pages/search/search_product_helper.dart similarity index 87% rename from packages/smooth_app/lib/pages/scan/search_product_helper.dart rename to packages/smooth_app/lib/pages/search/search_product_helper.dart index 94fab981bbe..d2f6b3a71dd 100644 --- a/packages/smooth_app/lib/pages/scan/search_product_helper.dart +++ b/packages/smooth_app/lib/pages/search/search_product_helper.dart @@ -5,6 +5,7 @@ import 'package:smooth_app/data_models/fetched_product.dart'; import 'package:smooth_app/database/dao_string_list.dart'; import 'package:smooth_app/database/local_database.dart'; import 'package:smooth_app/helpers/analytics_helper.dart'; +import 'package:smooth_app/helpers/provider_helper.dart'; import 'package:smooth_app/helpers/string_extension.dart'; import 'package:smooth_app/pages/navigator/app_navigator.dart'; import 'package:smooth_app/pages/product/common/product_dialog_helper.dart'; @@ -14,7 +15,7 @@ import 'package:smooth_app/query/keywords_product_query.dart'; /// Search helper dedicated to product search. class SearchProductHelper extends SearchHelper { - const SearchProductHelper(); + SearchProductHelper(); @override String get historyKey => DaoStringList.keySearchProductHistory; @@ -52,7 +53,6 @@ class SearchProductHelper extends SearchHelper { query, context, localDatabase, - editProductQueryCallback: searchQueryCallback, ); } } @@ -99,14 +99,19 @@ class SearchProductHelper extends SearchHelper { Future _onSubmittedText( final String value, final BuildContext context, - final LocalDatabase localDatabase, { - SearchQueryCallback? editProductQueryCallback, - }) async => - ProductQueryPageHelper().openBestChoice( - name: value, - localDatabase: localDatabase, - productQuery: KeywordsProductQuery(value), - context: context, - editQueryCallback: editProductQueryCallback, - ); + final LocalDatabase localDatabase, + ) async { + emit( + SearchQuery( + search: value, + widget: await ProductQueryPageHelper.getBestChoiceWidget( + name: value, + localDatabase: localDatabase, + productQuery: KeywordsProductQuery(value), + context: context, + editableAppBarTitle: false, + ), + ), + ); + } } diff --git a/packages/smooth_app/lib/widgets/smooth_hero.dart b/packages/smooth_app/lib/widgets/smooth_hero.dart new file mode 100644 index 00000000000..aa0f33d3d1c --- /dev/null +++ b/packages/smooth_app/lib/widgets/smooth_hero.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; + +/// A custom [Hero] widget that allows to listen to the end of the animation. +/// This code is mainly a copy/paste from the Flutter widget, but some methods +/// are private. +/// +/// The goal here, is to be notified when the animation is finished and +/// thus trigger an autofocus event at the perfect timing. +class SmoothHero extends StatelessWidget { + const SmoothHero({ + required this.tag, + required this.enabled, + required this.child, + this.onAnimationEnded, + super.key, + }) : assert(!enabled || tag != null); + + final Object? tag; + final bool enabled; + final Widget child; + final Function(HeroFlightDirection direction)? onAnimationEnded; + + @override + Widget build(BuildContext context) { + return HeroMode( + enabled: enabled, + child: Hero( + tag: tag ?? '', + flightShuttleBuilder: + onAnimationEnded == null ? null : _flightShuttleBuilder, + child: child, + ), + ); + } + + Widget _flightShuttleBuilder( + BuildContext flightContext, + Animation animation, + HeroFlightDirection flightDirection, + BuildContext fromHeroContext, + BuildContext toHeroContext, + ) { + animation.addStatusListener((AnimationStatus status) { + _onAnimationStatusChanged(status, flightDirection); + }); + + /// Code from [heroes.dart] + final Hero toHero = toHeroContext.widget as Hero; + + final MediaQueryData? toMediaQueryData = MediaQuery.maybeOf(toHeroContext); + final MediaQueryData? fromMediaQueryData = + MediaQuery.maybeOf(fromHeroContext); + + if (toMediaQueryData == null || fromMediaQueryData == null) { + return toHero.child; + } + + final EdgeInsets fromHeroPadding = fromMediaQueryData.padding; + final EdgeInsets toHeroPadding = toMediaQueryData.padding; + + return AnimatedBuilder( + animation: animation, + builder: (BuildContext context, Widget? child) { + return MediaQuery( + data: toMediaQueryData.copyWith( + padding: (flightDirection == HeroFlightDirection.push) + ? EdgeInsetsTween( + begin: fromHeroPadding, + end: toHeroPadding, + ).evaluate(animation) + : EdgeInsetsTween( + begin: toHeroPadding, + end: fromHeroPadding, + ).evaluate(animation), + ), + child: toHero.child, + ); + }); + } + + void _onAnimationStatusChanged( + AnimationStatus status, HeroFlightDirection direction) { + if (status == AnimationStatus.completed) { + onAnimationEnded?.call(direction); + } + } +}