diff --git a/apps/weblibre/.gitignore b/apps/weblibre/.gitignore index 8fd9764b..a0b27641 100644 --- a/apps/weblibre/.gitignore +++ b/apps/weblibre/.gitignore @@ -31,7 +31,9 @@ migrate_working_dir/ .pub/ /build/ assets/quotes/quotes.db -assets/bangs/ +assets/bangs/* +!assets/bangs/ +!assets/bangs/weblibre_bangs.json assets/preferences/builtin-bridges.json assets/preferences/url-shortener-list.json assets/preferences/url_cleaner_data.minify.json diff --git a/apps/weblibre/assets/bangs/weblibre_bangs.json b/apps/weblibre/assets/bangs/weblibre_bangs.json new file mode 100644 index 00000000..6bca41ea --- /dev/null +++ b/apps/weblibre/assets/bangs/weblibre_bangs.json @@ -0,0 +1,8 @@ +[ + { + "s": "WebLibre Search", + "d": "weblibre.eu", + "t": "wl", + "u": "https://weblibre.eu/?q={{{s}}}" + } +] diff --git a/apps/weblibre/assets/icon/bang_icon.png b/apps/weblibre/assets/icon/bang_icon.png new file mode 100644 index 00000000..02d52048 Binary files /dev/null and b/apps/weblibre/assets/icon/bang_icon.png differ diff --git a/apps/weblibre/lib/core/providers/persisted_bool.dart b/apps/weblibre/lib/core/providers/persisted_bool.dart index cf586714..d4fb116a 100644 --- a/apps/weblibre/lib/core/providers/persisted_bool.dart +++ b/apps/weblibre/lib/core/providers/persisted_bool.dart @@ -32,6 +32,7 @@ enum PersistedBoolKey { key: 'SearchSuggestionsExpanded', defaultValue: true, ), + infoboxExpanded(key: 'InfoboxExpanded', defaultValue: true), tabSuggestions(key: 'TabSuggestions', defaultValue: false); const PersistedBoolKey({required this.key, required this.defaultValue}); diff --git a/apps/weblibre/lib/features/bangs/data/database/definitions.drift.dart b/apps/weblibre/lib/features/bangs/data/database/definitions.drift.dart index a92948cd..3a2c22e0 100644 --- a/apps/weblibre/lib/features/bangs/data/database/definitions.drift.dart +++ b/apps/weblibre/lib/features/bangs/data/database/definitions.drift.dart @@ -3374,6 +3374,7 @@ class DefinitionsDrift extends i7.ModularAccessor { ).map( (i0.QueryRow row) => i8.SearchHistoryEntry( searchQuery: row.read('search_query'), + group: i3.BangHistory.$convertergroup.fromSql(row.read('group')), trigger: row.read('trigger'), searchDate: row.read('search_date'), ), diff --git a/apps/weblibre/lib/features/bangs/data/models/bang_group.dart b/apps/weblibre/lib/features/bangs/data/models/bang_group.dart index 75646600..1ef0aac4 100644 --- a/apps/weblibre/lib/features/bangs/data/models/bang_group.dart +++ b/apps/weblibre/lib/features/bangs/data/models/bang_group.dart @@ -29,7 +29,7 @@ enum BangGroup { bundled: 'assets/bangs/kagi_bangs.json', ), user(remote: null, bundled: null), - weblibre(remote: null, bundled: null); + weblibre(remote: null, bundled: 'assets/bangs/weblibre_bangs.json'); final String? bundled; final String? remote; diff --git a/apps/weblibre/lib/features/bangs/data/models/search_history_entry.dart b/apps/weblibre/lib/features/bangs/data/models/search_history_entry.dart index 26e981e5..678dfc45 100644 --- a/apps/weblibre/lib/features/bangs/data/models/search_history_entry.dart +++ b/apps/weblibre/lib/features/bangs/data/models/search_history_entry.dart @@ -18,18 +18,21 @@ * along with this program. If not, see . */ import 'package:fast_equatable/fast_equatable.dart'; +import 'package:weblibre/features/bangs/data/models/bang_group.dart'; class SearchHistoryEntry with FastEquatable { final String searchQuery; + final BangGroup group; final String trigger; final DateTime searchDate; SearchHistoryEntry({ required this.searchQuery, + required this.group, required this.trigger, required this.searchDate, }); @override - List get hashParameters => [searchQuery, trigger, searchDate]; + List get hashParameters => [searchQuery, group, trigger, searchDate]; } diff --git a/apps/weblibre/lib/features/bangs/data/models/web_search_bang.dart b/apps/weblibre/lib/features/bangs/data/models/web_search_bang.dart index dca4544f..0759da8c 100644 --- a/apps/weblibre/lib/features/bangs/data/models/web_search_bang.dart +++ b/apps/weblibre/lib/features/bangs/data/models/web_search_bang.dart @@ -2,23 +2,7 @@ import 'package:weblibre/features/bangs/data/models/bang_data.dart'; import 'package:weblibre/features/bangs/data/models/bang_group.dart'; import 'package:weblibre/features/bangs/data/models/bang_key.dart'; -const webSearchBangTrigger = 'wl'; - -final webSearchBang = BangData( - websiteName: 'WebLibre Search', - domain: 'weblibre.eu', - trigger: webSearchBangTrigger, - // Placeholder template — selecting this bang routes the query through the - // token-based search backend instead of opening this URL. - urlTemplate: 'https://weblibre.eu/?q={{{s}}}', - group: BangGroup.weblibre, - searxngApi: false, -); - -const webSearchBangKey = BangKey( - group: BangGroup.weblibre, - trigger: webSearchBangTrigger, -); +const webSearchBangKey = BangKey(group: BangGroup.weblibre, trigger: 'wl'); bool isWebSearchBang(BangData? bang) => bang != null && bang.toKey() == webSearchBangKey; diff --git a/apps/weblibre/lib/features/bangs/domain/repositories/sync.dart b/apps/weblibre/lib/features/bangs/domain/repositories/sync.dart index da15b1a5..48b8de15 100644 --- a/apps/weblibre/lib/features/bangs/domain/repositories/sync.dart +++ b/apps/weblibre/lib/features/bangs/domain/repositories/sync.dart @@ -21,7 +21,6 @@ import 'package:exceptions/exceptions.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:weblibre/features/bangs/data/database/database.dart'; import 'package:weblibre/features/bangs/data/models/bang_group.dart'; -import 'package:weblibre/features/bangs/data/models/web_search_bang.dart'; import 'package:weblibre/features/bangs/data/providers.dart'; import 'package:weblibre/features/bangs/data/services/data_source.dart'; @@ -70,12 +69,6 @@ class BangSyncRepository extends _$BangSyncRepository { ); } - if (group.remote == null) { - return Result.failure( - const ErrorMessage(source: 'BangSync', message: 'No remote source'), - ); - } - final lastSync = await db.syncDao .getLastSyncOfGroup(group) .getSingleOrNull(); @@ -171,21 +164,6 @@ class BangSyncRepository extends _$BangSyncRepository { .watchSingleOrNull(); } - Future> _syncSyntheticBangs() async { - try { - await ref.read(bangDatabaseProvider).bangDao.upsertBang(webSearchBang); - return Result.success(null); - } catch (e) { - return Result.failure( - ErrorMessage( - message: 'Failed to sync synthetic Bangs', - source: 'BangSync', - details: e, - ), - ); - } - } - Future>> syncBundledBangGroups({ Set? groups, }) async { @@ -199,10 +177,7 @@ class BangSyncRepository extends _$BangSyncRepository { ).then((result) => MapEntry(source, result)), ); - return { - ...Map.fromEntries(await Future.wait(futures)), - BangGroup.weblibre: await _syncSyntheticBangs(), - }; + return Map.fromEntries(await Future.wait(futures)); } @override diff --git a/apps/weblibre/lib/features/bangs/domain/repositories/sync.g.dart b/apps/weblibre/lib/features/bangs/domain/repositories/sync.g.dart index 941ed2a5..75dcba9b 100644 --- a/apps/weblibre/lib/features/bangs/domain/repositories/sync.g.dart +++ b/apps/weblibre/lib/features/bangs/domain/repositories/sync.g.dart @@ -42,7 +42,7 @@ final class BangSyncRepositoryProvider } String _$bangSyncRepositoryHash() => - r'cdb78ef0243224fdb7b3a8660f0920c471987296'; + r'd1c6709f099165c17db535ab03334fcd1c610492'; abstract class _$BangSyncRepository extends $Notifier { void build(); diff --git a/apps/weblibre/lib/features/bangs/presentation/widgets/bang_details.dart b/apps/weblibre/lib/features/bangs/presentation/widgets/bang_details.dart index 10e4d561..6204feff 100644 --- a/apps/weblibre/lib/features/bangs/presentation/widgets/bang_details.dart +++ b/apps/weblibre/lib/features/bangs/presentation/widgets/bang_details.dart @@ -18,10 +18,12 @@ * along with this program. If not, see . */ import 'package:flutter/material.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nullability/nullability.dart'; import 'package:weblibre/core/routing/routes.dart'; import 'package:weblibre/features/bangs/data/models/bang_data.dart'; +import 'package:weblibre/features/bangs/data/models/bang_group.dart'; import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; @@ -77,6 +79,15 @@ class BangDetails extends HookConsumerWidget { ], ), ), + if (bangData.group == BangGroup.weblibre) + const Tooltip( + message: 'Official WebLibre search', + child: Icon( + MdiIcons.crown, + color: Colors.amber, + size: 20.0, + ), + ), ], ), const SizedBox(height: 8.0), diff --git a/apps/weblibre/lib/features/bangs/presentation/widgets/bang_label.dart b/apps/weblibre/lib/features/bangs/presentation/widgets/bang_label.dart new file mode 100644 index 00000000..d7ea3528 --- /dev/null +++ b/apps/weblibre/lib/features/bangs/presentation/widgets/bang_label.dart @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:flutter/material.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:weblibre/features/bangs/data/models/bang.dart'; +import 'package:weblibre/features/bangs/data/models/bang_group.dart'; + +/// Renders a bang's website name, appending an amber crown for the official +/// WebLibre bang ([BangGroup.weblibre]) so it stands out as first-party. +class BangLabel extends StatelessWidget { + final Bang bang; + + const BangLabel(this.bang, {super.key}); + + @override + Widget build(BuildContext context) { + final label = Text(bang.websiteName); + + if (bang.group != BangGroup.weblibre) { + return label; + } + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible(child: label), + const SizedBox(width: 4.0), + const Icon(MdiIcons.crown, color: Colors.amber, size: 16.0), + ], + ); + } +} diff --git a/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/bang_chip_strip.dart b/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/bang_chip_strip.dart index 735c583b..0c579fca 100644 --- a/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/bang_chip_strip.dart +++ b/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/bang_chip_strip.dart @@ -20,6 +20,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; import 'package:weblibre/features/bangs/data/models/bang_data.dart'; +import 'package:weblibre/features/bangs/presentation/widgets/bang_label.dart'; import 'package:weblibre/presentation/widgets/selectable_chips.dart'; import 'package:weblibre/presentation/widgets/url_icon.dart'; @@ -97,7 +98,7 @@ class BangChipStrip extends StatelessWidget { itemId: (bang) => bang.trigger, itemAvatar: (bang) => UrlIcon([bang.getDefaultUrl()], iconSize: 20), - itemLabel: (bang) => Text(bang.websiteName), + itemLabel: (bang) => BangLabel(bang), itemTooltip: (bang) => bang.trigger, availableItems: bangs, selectedItem: selectedBang, diff --git a/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/smart_bang_selector.dart b/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/smart_bang_selector.dart index 240a6de3..bfd3b8a9 100644 --- a/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/smart_bang_selector.dart +++ b/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/smart_bang_selector.dart @@ -28,6 +28,7 @@ import 'package:weblibre/features/bangs/data/models/bang_key.dart'; import 'package:weblibre/features/bangs/domain/providers/bangs.dart'; import 'package:weblibre/features/bangs/domain/providers/search.dart'; import 'package:weblibre/features/bangs/domain/repositories/data.dart'; +import 'package:weblibre/features/bangs/presentation/widgets/bang_label.dart'; import 'package:weblibre/features/geckoview/features/browser/domain/providers.dart'; import 'package:weblibre/features/geckoview/features/search/presentation/dialogs/reset_bang_dialog.dart'; import 'package:weblibre/features/geckoview/features/search/presentation/widgets/bang_chip_strip.dart'; @@ -235,7 +236,7 @@ class _DefaultSearchProviderChip extends ConsumerWidget { return ActionChip( avatar: UrlIcon([bang.getDefaultUrl()], iconSize: 20), - label: Text(bang.websiteName), + label: BangLabel(bang), onPressed: () async { // Open bang search when tapped await const BangSearchRoute().push(context); diff --git a/apps/weblibre/lib/features/onboarding/presentation/pages/default_search.dart b/apps/weblibre/lib/features/onboarding/presentation/pages/default_search.dart index faaa44ac..69d3b767 100644 --- a/apps/weblibre/lib/features/onboarding/presentation/pages/default_search.dart +++ b/apps/weblibre/lib/features/onboarding/presentation/pages/default_search.dart @@ -24,6 +24,7 @@ import 'package:nullability/nullability.dart'; import 'package:weblibre/core/routing/routes.dart'; import 'package:weblibre/features/bangs/data/models/bang_key.dart'; import 'package:weblibre/features/bangs/domain/providers/bangs.dart'; +import 'package:weblibre/features/bangs/presentation/widgets/bang_label.dart'; import 'package:weblibre/features/search/domain/entities/abstract/i_search_suggestion_provider.dart'; import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart'; import 'package:weblibre/features/settings/presentation/widgets/bang_icon.dart'; @@ -85,7 +86,7 @@ class DefaultSearchPage extends HookConsumerWidget { alignment: Alignment.centerLeft, child: FilterChip( showCheckmark: false, - label: Text(activeBang.websiteName), + label: BangLabel(activeBang), avatar: UrlIcon([ activeBang.getDefaultUrl(), ], iconSize: 20), @@ -100,7 +101,7 @@ class DefaultSearchPage extends HookConsumerWidget { ...availableBangs.map( (bang) => FilterChip( showCheckmark: false, - label: Text(bang.websiteName), + label: BangLabel(bang), avatar: UrlIcon([bang.getDefaultUrl()], iconSize: 20), selected: activeBang?.trigger == bang.trigger, onSelected: (selected) async { diff --git a/apps/weblibre/lib/features/settings/presentation/widgets/default_search_selector.dart b/apps/weblibre/lib/features/settings/presentation/widgets/default_search_selector.dart index ed1d45a3..9930bfe5 100644 --- a/apps/weblibre/lib/features/settings/presentation/widgets/default_search_selector.dart +++ b/apps/weblibre/lib/features/settings/presentation/widgets/default_search_selector.dart @@ -22,6 +22,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:weblibre/core/routing/routes.dart'; import 'package:weblibre/features/bangs/data/models/bang_key.dart'; import 'package:weblibre/features/bangs/domain/providers/bangs.dart'; +import 'package:weblibre/features/bangs/presentation/widgets/bang_label.dart'; import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart'; import 'package:weblibre/features/user/data/models/general_settings.dart'; import 'package:weblibre/presentation/widgets/url_icon.dart'; @@ -64,7 +65,7 @@ class DefaultSearchSelector extends HookConsumerWidget { ) : ActionChip( avatar: UrlIcon([activeBang.getDefaultUrl()], iconSize: 20), - label: Text(activeBang.websiteName), + label: BangLabel(activeBang), tooltip: activeBang.trigger, onPressed: pickProvider, ), diff --git a/apps/weblibre/lib/features/web_search/presentation/dialogs/fetch_method_dialog.dart b/apps/weblibre/lib/features/web_search/presentation/dialogs/fetch_method_dialog.dart index 8f4abb2c..c38cb389 100644 --- a/apps/weblibre/lib/features/web_search/presentation/dialogs/fetch_method_dialog.dart +++ b/apps/weblibre/lib/features/web_search/presentation/dialogs/fetch_method_dialog.dart @@ -4,6 +4,8 @@ import 'package:weblibre/features/web_search/domain/controllers/search_controlle import 'package:weblibre/features/web_search/domain/entities/captured_page_state.dart'; import 'package:weblibre/features/web_search/domain/entities/fetch_method.dart'; import 'package:weblibre/features/web_search/domain/services/capture_artifact_downloader.dart'; +import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart'; +import 'package:weblibre/presentation/widgets/url_icon.dart'; Future showFetchMethodSheet( BuildContext context, { @@ -55,13 +57,12 @@ class _FetchMethodSheet extends ConsumerWidget { children: [ Text('Fetch Page Data', style: textTheme.titleLarge), const SizedBox(height: 4), - Text( - url.toString(), + UriBreadcrumb( + uri: url, + icon: UrlIcon([url], iconSize: 14, cacheOnly: true), style: textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, ), - maxLines: 1, - overflow: TextOverflow.ellipsis, ), ], ), @@ -99,35 +100,63 @@ class _MethodTile extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final colorScheme = Theme.of(context).colorScheme; - final textTheme = Theme.of(context).textTheme; + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final textTheme = theme.textTheme; final busy = state.isMethodBusy(url, choice); final ready = state.isMethodReady(url, choice); final captured = state.capturedPage(url, choice); - final failed = captured?.status == CapturedPageStatus.downloadFailed; + + // The trafilatura "preview" goes through fetchPage/fetchErrorByUrl rather + // than the capture pipeline, so its failures live in a different place + // than a capture's downloadFailed status. Surface either as the tile's + // error here (the card no longer shows per-method error chips). + final String? errorMessage; + if (busy || ready) { + errorMessage = null; + } else if (choice == FetchMethodChoice.trafilatura) { + errorMessage = state.fetchError(url); + } else if (captured?.status == CapturedPageStatus.downloadFailed) { + errorMessage = captured?.errorMessage ?? 'Download failed — tap to retry'; + } else { + errorMessage = null; + } + final failed = errorMessage != null; + + // Starting (or retrying) anything needs an open session; already-ready + // artifacts can still be opened after the session closes. + final enabled = !busy && (ready || state.hasOpenSession); + + // A disabled ListTile only dims its title automatically; the leading icon + // and subtitle carry explicit colors, so dim them too (matching the title's + // disabled color) to keep the whole tile reading as deactivated. + final leadingColor = enabled + ? colorScheme.onSurfaceVariant + : theme.disabledColor; + final subtitleColor = !enabled + ? theme.disabledColor + : (failed ? colorScheme.error : colorScheme.onSurfaceVariant); return ListTile( + enabled: enabled, contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4), - leading: Icon(choice.icon, color: colorScheme.onSurfaceVariant), + leading: Icon(choice.icon, color: leadingColor), title: Text(choice.title), subtitle: Text( - failed - ? (captured?.errorMessage ?? 'Download failed — tap to retry') - : choice.subtitle, - style: textTheme.bodySmall?.copyWith( - color: failed ? colorScheme.error : colorScheme.onSurfaceVariant, - ), + failed ? errorMessage : choice.subtitle, + style: textTheme.bodySmall?.copyWith(color: subtitleColor), ), trailing: _StatusIndicator( busy: busy, ready: ready, failed: failed, + idleColor: leadingColor, colorScheme: colorScheme, ), - onTap: busy - ? null - : () => _handleTap(context, ref, ready, failed, captured), + onTap: enabled + ? () => _handleTap(context, ref, ready, failed, captured) + : null, ); } @@ -148,17 +177,25 @@ class _MethodTile extends ConsumerWidget { return; } - Navigator.of(context).pop(); + // Keep the sheet open while a fetch/download runs so its per-method tile + // shows the live spinner; the user opens the result from here once ready. - if (failed && captured != null) { - await ref - .read(metaSearchControllerProvider.notifier) - .retryCaptureDownload(url, choice); + // Trafilatura (re)fetch — the same call covers a first fetch and a retry + // after a previous fetch error. + if (choice == FetchMethodChoice.trafilatura) { + await ref.read(metaSearchControllerProvider.notifier).fetchPage(url); return; } - if (choice == FetchMethodChoice.trafilatura) { - await ref.read(metaSearchControllerProvider.notifier).fetchPage(url); + // A failed *download* (we still hold the captureId/downloadToken) only + // needs the artifact re-fetched — no new upstream render. A server-side + // capture failure has no token, so it falls through to a fresh capture. + if (failed && + captured?.captureId != null && + captured?.downloadToken != null) { + await ref + .read(metaSearchControllerProvider.notifier) + .retryCaptureDownload(url, choice); return; } @@ -178,12 +215,14 @@ class _StatusIndicator extends StatelessWidget { final bool busy; final bool ready; final bool failed; + final Color idleColor; final ColorScheme colorScheme; const _StatusIndicator({ required this.busy, required this.ready, required this.failed, + required this.idleColor, required this.colorScheme, }); @@ -202,6 +241,7 @@ class _StatusIndicator extends StatelessWidget { if (failed) { return Icon(Icons.refresh, color: colorScheme.error); } - return Icon(Icons.download_rounded, color: colorScheme.onSurfaceVariant); + // Idle: mirror the leading icon so a disabled tile dims uniformly. + return Icon(Icons.download_rounded, color: idleColor); } } diff --git a/apps/weblibre/lib/features/web_search/presentation/screens/page_preview.dart b/apps/weblibre/lib/features/web_search/presentation/screens/page_preview.dart index 3bf86571..df9281a1 100644 --- a/apps/weblibre/lib/features/web_search/presentation/screens/page_preview.dart +++ b/apps/weblibre/lib/features/web_search/presentation/screens/page_preview.dart @@ -74,14 +74,19 @@ class PagePreviewScreen extends ConsumerWidget { uri: uri, icon: UrlIcon([uri], iconSize: 16, cacheOnly: true), ), - const SizedBox(height: 16), - SearchResultMetadataChips( + SearchResultMetadataSection( metadata: result?.metadata ?? const [], pageMetadata: document.metadata, + padding: const EdgeInsets.only(top: 16), ), - if (result?.metadata case final metadata? - when metadata.isNotEmpty) - SearchResultMetadataExpandable(metadata: metadata), + if (resolveSnippetEntries(result?.metadata ?? const []) + case final entries when entries.isNotEmpty) ...[ + const SizedBox(height: 12), + SearchResultSnippetsPanel( + entries: entries, + borderRadius: BorderRadius.circular(12), + ), + ], const SizedBox(height: 16), MarkdownBody(selectable: true, data: document.content), ], diff --git a/apps/weblibre/lib/features/web_search/presentation/widgets/search_result_card.dart b/apps/weblibre/lib/features/web_search/presentation/widgets/search_result_card.dart index 8a2ce9b2..76203b37 100644 --- a/apps/weblibre/lib/features/web_search/presentation/widgets/search_result_card.dart +++ b/apps/weblibre/lib/features/web_search/presentation/widgets/search_result_card.dart @@ -3,32 +3,30 @@ import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:intl/intl.dart'; import 'package:nullability/nullability.dart'; import 'package:search_protocol/search_protocol.dart'; import 'package:weblibre/features/search_credits/domain/repositories/web_search_settings.dart'; import 'package:weblibre/features/web_search/domain/controllers/search_controller.dart'; -import 'package:weblibre/features/web_search/domain/entities/captured_page_state.dart'; import 'package:weblibre/features/web_search/domain/entities/fetch_method.dart'; import 'package:weblibre/features/web_search/presentation/widgets/search_result_metadata_chips.dart'; import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart'; import 'package:weblibre/presentation/widgets/url_icon.dart'; -class WebSearchResultCard extends ConsumerWidget { +class WebSearchResultCard extends HookConsumerWidget { final CompactSearchResult result; final Future Function(Uri url) onOpen; + + /// Opens the fetch sheet, which is now the single place that drives every + /// per-method action (preview, open capture, retry) and surfaces their + /// status and errors. The card only needs to launch it. final Future Function(Uri url) onFetch; - final Future Function(Uri url) onPreview; - final Future Function(CapturedPageState captured) onOpenCapture; const WebSearchResultCard({ super.key, required this.result, required this.onOpen, required this.onFetch, - required this.onPreview, - required this.onOpenCapture, }); @override @@ -56,457 +54,190 @@ class WebSearchResultCard extends ConsumerWidget { webSearchSettingsControllerProvider.select((s) => s.language), ); + final snippetEntries = resolveSnippetEntries(result.metadata ?? const []); + final snippetsExpanded = useState(false); + return Card( color: colorScheme.surfaceContainerHigh, clipBehavior: Clip.antiAlias, margin: EdgeInsets.zero, child: InkWell( onTap: () => onOpen(result.url), - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _UrlRow(result: result), - const SizedBox(height: 16), - _ContentRow( - result: result, - imageBytes: imageBytes, - colorScheme: colorScheme, - textTheme: textTheme, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _Header( + result: result, + imageBytes: imageBytes, + onFetch: onFetch, + colorScheme: colorScheme, + textTheme: textTheme, + ), + if (result.content case final String content + when content.trim().isNotEmpty) ...[ + const SizedBox(height: 8), + _ExpandableDescription( + text: content.trim(), + style: textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + height: 1.4, + ), + ), + ], + SearchResultMetadataSection( + metadata: result.metadata ?? const [], + pageMetadata: document?.metadata, + queryLanguage: queryLanguage, + publishedDate: result.publishedDate, + padding: const EdgeInsets.only(top: 10), + trailing: snippetEntries.isEmpty + ? null + : SearchResultSnippetsToggle( + expanded: snippetsExpanded.value, + onToggle: () => snippetsExpanded.value = + !snippetsExpanded.value, + ), + ), + ], ), - if ((result.metadata ?? const []).isNotEmpty || - document != null) ...[ - const SizedBox(height: 12), - SearchResultMetadataChips( - metadata: result.metadata ?? const [], - pageMetadata: document?.metadata, - queryLanguage: queryLanguage, - ), - ], - if (result.metadata case final metadata? when metadata.isNotEmpty) - SearchResultMetadataExpandable(metadata: metadata), - const SizedBox(height: 16), - _FetchFooter( - url: result.url, - onFetch: onFetch, - onPreview: onPreview, - onOpenCapture: onOpenCapture, - ), - ], - ), + ), + // Full-bleed expanding snippets panel, outside the content padding + // so it spans the card edge to edge; the card's antiAlias clip + // rounds its bottom corners. + AnimatedSize( + duration: const Duration(milliseconds: 250), + curve: Curves.fastOutSlowIn, + alignment: Alignment.topCenter, + child: (snippetEntries.isNotEmpty && snippetsExpanded.value) + ? SearchResultSnippetsPanel(entries: snippetEntries) + : const SizedBox.shrink(), + ), + ], ), ), ); } } -class _UrlRow extends StatelessWidget { - final CompactSearchResult result; - - const _UrlRow({required this.result}); - - @override - Widget build(BuildContext context) { - return UriBreadcrumb( - uri: result.url, - icon: UrlIcon([result.url], iconSize: 16, cacheOnly: true), - style: Theme.of(context).textTheme.labelMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ); - } -} - -class _ContentRow extends StatelessWidget { +/// Compact header: small content thumbnail (when present), the site breadcrumb +/// and the result title, plus a trailing tonal "Fetch" icon button. The button +/// carries a count badge of the artifacts already fetched for this result; the +/// fetch sheet it opens is where their status and any errors are surfaced. +class _Header extends ConsumerWidget { final CompactSearchResult result; final Uint8List? imageBytes; + final Future Function(Uri url) onFetch; final ColorScheme colorScheme; final TextTheme textTheme; - const _ContentRow({ + const _Header({ required this.result, required this.imageBytes, + required this.onFetch, required this.colorScheme, required this.textTheme, }); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + // A single record select rebuilds this header only when the session state + // or the number of ready artifacts for *this* result changes. + final fetch = ref.watch( + metaSearchControllerProvider.select((s) { + var ready = 0; + for (final choice in FetchMethodChoice.values) { + if (s.isMethodReady(result.url, choice)) ready++; + } + final busy = s.isFetching(result.url) || s.isAnyCapturing(result.url); + return (open: s.hasOpenSession, ready: ready, busy: busy); + }), + ); + + // Once the WebSocket session has closed no further fetch/capture commands + // can be issued — keep the button only while the session is open or there + // are already-fetched artifacts to (re)open via the sheet. + final showFetch = fetch.open || fetch.ready > 0; + return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (imageBytes != null) ...[ ClipRRect( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(10), child: Image.memory( imageBytes!, - width: 88, - height: 88, + width: 54, + height: 54, fit: BoxFit.cover, gaplessPlayback: true, errorBuilder: (_, _, _) => const SizedBox.shrink(), ), ), - const SizedBox(width: 16), + const SizedBox(width: 8), ], Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - result.title, - style: textTheme.titleMedium?.copyWith( - color: colorScheme.primary, - fontWeight: FontWeight.w600, - height: 1.3, + UriBreadcrumb( + uri: result.url, + icon: UrlIcon([result.url], iconSize: 14, cacheOnly: true), + style: textTheme.labelMedium?.copyWith( + color: colorScheme.onSurfaceVariant, ), ), - if (result.publishedDate case final String date - when date.isNotEmpty) ...[ - const SizedBox(height: 4), - Text( - _formatDate(date), - style: textTheme.labelSmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), + const SizedBox(height: 2), + Text( + result.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: textTheme.titleSmall?.copyWith( + color: colorScheme.primary, + fontWeight: FontWeight.w600, + height: 1.25, ), - ], - if (result.content case final String content - when content.trim().isNotEmpty) ...[ - const SizedBox(height: 6), - _ExpandableDescription( - text: content.trim(), - style: textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - height: 1.5, - ), - ), - ], + ), ], ), ), - ], - ); - } - - String _formatDate(String date) { - final parsed = DateTime.tryParse(date); - - if (parsed != null) { - return DateFormat.yMMMd().format(parsed); - } - - return date; - } -} - -/// Per-URL footer state, batched into a single select() so the footer only -/// rebuilds when something relevant to *this* URL changes. -/// -/// `capturing`/`captures` are exposed for rendering, but they are NOT used in -/// [hashParameters] — Dart's built-in `Set`/`Map` compare by identity, so -/// including them would defeat the dedupe (the controller produces fresh -/// instances on every state mutation). Equality is driven instead by scalar -/// signatures derived from those collections, which is enough to detect any -/// change a single result card cares about. -class _FooterState { - final bool hasOpenSession; - final bool isFetching; - final bool isFetched; - final String? fetchError; - final Set capturing; - final Map captures; - final int _capturingSignature; - final int _capturesSignature; - - _FooterState({ - required this.hasOpenSession, - required this.isFetching, - required this.isFetched, - required this.fetchError, - required this.capturing, - required this.captures, - }) : _capturingSignature = _hashCapturing(capturing), - _capturesSignature = _hashCaptures(captures); - - static int _hashCapturing(Set set) { - // Order-independent hash so we don't depend on Set iteration order. - var h = 0; - for (final choice in set) { - h ^= choice.index; - } - return h; - } - - static int _hashCaptures(Map map) { - var h = 0; - for (final entry in map.entries) { - // status + errorMessage + localPath cover everything the footer renders - // about a capture; any change that affects the rendered chip flips one - // of these fields. - final v = Object.hash( - entry.key.index, - entry.value.status.index, - entry.value.errorMessage, - entry.value.localPath, - entry.value.captureId, - entry.value.downloadToken, - ); - h ^= v; - } - return h; - } - - @override - bool operator ==(Object other) { - return identical(this, other) || - other is _FooterState && - hasOpenSession == other.hasOpenSession && - isFetching == other.isFetching && - isFetched == other.isFetched && - fetchError == other.fetchError && - _capturingSignature == other._capturingSignature && - _capturesSignature == other._capturesSignature; - } - - @override - int get hashCode => Object.hash( - hasOpenSession, - isFetching, - isFetched, - fetchError, - _capturingSignature, - _capturesSignature, - ); -} - -class _FetchFooter extends ConsumerWidget { - final Uri url; - - final Future Function(Uri url) onFetch; - final Future Function(Uri url) onPreview; - final Future Function(CapturedPageState captured) onOpenCapture; - - const _FetchFooter({ - required this.url, - required this.onFetch, - required this.onPreview, - required this.onOpenCapture, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final footerState = ref.watch( - metaSearchControllerProvider.select( - (s) => _FooterState( - hasOpenSession: s.hasOpenSession, - isFetching: s.fetchingUrls.contains(url), - isFetched: s.documentsByUrl.containsKey(url), - fetchError: s.fetchErrorByUrl[url], - capturing: s.capturingByUrl[url] ?? const {}, - captures: s.capturedPagesByUrl[url] ?? const {}, - ), - ), - ); - - final readyChips = []; - final busyChips = []; - final errorChips = []; - - for (final choice in FetchMethodChoice.values) { - if (choice == FetchMethodChoice.trafilatura) { - if (footerState.isFetched) { - readyChips.add( - _CaptureChip(choice: choice, onTap: () => onPreview(url)), - ); - } else if (footerState.fetchError case final String err) { - errorChips.add( - _ErrorChip( - choice: choice, - errorMessage: err, - canRetry: footerState.hasOpenSession, - onRetry: () => ref - .read(metaSearchControllerProvider.notifier) - .fetchPage(url), - ), - ); - } - continue; - } - - final captured = footerState.captures[choice]; - - if (captured != null && captured.status == CapturedPageStatus.ready) { - readyChips.add( - _CaptureChip(choice: choice, onTap: () => onOpenCapture(captured)), - ); - } else if (captured != null && - captured.status == CapturedPageStatus.downloadFailed) { - // Distinguish "download failed" (we have a captureId/downloadToken - // and can retry just the artifact download — no new server work) - // from "capture failed on server" (no captureId; would need a fresh - // capture command, which costs another upstream render). - final canRetryDownload = - captured.captureId != null && captured.downloadToken != null; - - errorChips.add( - _ErrorChip( - choice: choice, - errorMessage: - captured.errorMessage ?? 'Capture failed for unknown reason.', - canRetry: canRetryDownload && footerState.hasOpenSession, - onRetry: () => ref - .read(metaSearchControllerProvider.notifier) - .retryCaptureDownload(url, choice), - ), - ); - } else if (_isMethodBusy(footerState, choice)) { - busyChips.add(_BusyChip(choice: choice)); - } - } - - final chips = [...readyChips, ...busyChips, ...errorChips]; - - return Row( - children: [ - Expanded( - child: chips.isEmpty - ? const SizedBox.shrink() - : Wrap(spacing: 6, runSpacing: 6, children: chips), - ), - // Once the WebSocket session has closed, no further fetch/capture - // commands can be issued — hide the button rather than have it - // surface a generic "session is no longer available" error on every - // tap. The chips above remain interactive (read-only). - if (footerState.hasOpenSession) ...[ + if (showFetch) ...[ const SizedBox(width: 8), - FilledButton.tonalIcon( - onPressed: () => onFetch(url), - icon: const Icon(Icons.download_rounded, size: 18), - label: const Text('Fetch'), - style: FilledButton.styleFrom(elevation: 0), + Badge.count( + count: fetch.ready, + isLabelVisible: fetch.ready > 0, + child: Stack( + alignment: Alignment.center, + children: [ + // Live progress ring while any method for this result is + // fetching/capturing — the badge keeps counting ready artifacts. + if (fetch.busy) + const SizedBox.square( + dimension: 40, + child: CircularProgressIndicator(strokeWidth: 2), + ), + IconButton.filledTonal( + onPressed: () => onFetch(result.url), + icon: const Icon(Icons.download_rounded, size: 16), + visualDensity: VisualDensity.compact, + tooltip: 'Fetch', + ), + ], + ), ), ], ], ); } - - bool _isMethodBusy(_FooterState s, FetchMethodChoice method) { - if (method == FetchMethodChoice.trafilatura) { - return s.isFetching; - } - if (s.capturing.contains(method)) { - return true; - } - final captured = s.captures[method]; - return captured != null && - (captured.status == CapturedPageStatus.capturing || - captured.status == CapturedPageStatus.downloading); - } -} - -class _CaptureChip extends StatelessWidget { - final FetchMethodChoice choice; - final Future Function() onTap; - - const _CaptureChip({required this.choice, required this.onTap}); - - @override - Widget build(BuildContext context) { - return ActionChip( - avatar: Icon(choice.icon, size: 16), - label: Text(choice.shortLabel), - visualDensity: VisualDensity.compact, - onPressed: () => onTap(), - ); - } -} - -/// Failed-fetch / failed-capture chip. Always rendered (red, with an error -/// outline icon) so the user can see *which* method failed; tapping pops a -/// dialog with the verbatim server message and an optional retry action. -class _ErrorChip extends StatelessWidget { - final FetchMethodChoice choice; - final String errorMessage; - final bool canRetry; - final Future Function() onRetry; - - const _ErrorChip({ - required this.choice, - required this.errorMessage, - required this.canRetry, - required this.onRetry, - }); - - Future _showDetail(BuildContext context) async { - final retry = await showDialog( - context: context, - builder: (dialogContext) { - return AlertDialog( - title: Row( - children: [ - const Icon(Icons.error_outline, color: Colors.red), - const SizedBox(width: 8), - Expanded(child: Text('${choice.title} failed')), - ], - ), - content: SingleChildScrollView(child: Text(errorMessage)), - actions: [ - TextButton( - onPressed: () => Navigator.of(dialogContext).pop(false), - child: const Text('Close'), - ), - if (canRetry) - FilledButton( - onPressed: () => Navigator.of(dialogContext).pop(true), - child: const Text('Retry'), - ), - ], - ); - }, - ); - - if (retry == true) { - await onRetry(); - } - } - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - return ActionChip( - avatar: Icon(Icons.error_outline, size: 16, color: colorScheme.error), - label: Text(choice.shortLabel), - visualDensity: VisualDensity.compact, - side: BorderSide(color: colorScheme.error), - labelStyle: TextStyle(color: colorScheme.error), - onPressed: () => _showDetail(context), - ); - } -} - -class _BusyChip extends StatelessWidget { - final FetchMethodChoice choice; - - const _BusyChip({required this.choice}); - - @override - Widget build(BuildContext context) { - return Chip( - avatar: const SizedBox( - width: 14, - height: 14, - child: CircularProgressIndicator(strokeWidth: 2), - ), - label: Text(choice.shortLabel), - visualDensity: VisualDensity.compact, - ); - } } class _ExpandableDescription extends HookWidget { - static const _collapsedMaxLines = 4; + static const _collapsedMaxLines = 2; final String text; final TextStyle? style; diff --git a/apps/weblibre/lib/features/web_search/presentation/widgets/search_result_metadata_chips.dart b/apps/weblibre/lib/features/web_search/presentation/widgets/search_result_metadata_chips.dart index cb7a7787..932c6a3e 100644 --- a/apps/weblibre/lib/features/web_search/presentation/widgets/search_result_metadata_chips.dart +++ b/apps/weblibre/lib/features/web_search/presentation/widgets/search_result_metadata_chips.dart @@ -49,8 +49,8 @@ const _keyOrder = [ ]; const _hiddenKeys = { - // The publisher-declared article date duplicates the inline `publishedDate` - // shown under the title. Keep only the inline version. + // The publisher-declared article date duplicates the result's own + // `publishedDate`, surfaced as the `released` chip. Keep only that one. 'date', 'score', 'gravity', @@ -191,7 +191,76 @@ List _metadataFromPage(PageMetadata? pageMetadata) { return items; } -class SearchResultMetadataChips extends StatelessWidget { +/// Resolves and de-duplicates the decorative chips for a result. The result's +/// own published date wins over any publisher-declared date from the fetched +/// page; page metadata then takes priority over result metadata on +/// per-key deduplication. Returns the list already sorted into [_keyOrder]. +List<_ResolvedMetadataItem> _resolveChips({ + required List metadata, + required PageMetadata? pageMetadata, + required String? queryLanguage, + required String? publishedDate, +}) { + final seen = {}; + final merged = <_ResolvedMetadataItem>[]; + + if (publishedDate?.trim() case final String date when date.isNotEmpty) { + seen.add('released'); + merged.add( + _ResolvedMetadataItem( + item: MetadataItem(key: 'released', value: date), + isLanguage: false, + ), + ); + } + + void absorb(List items) { + for (final item in items) { + if (_expandableKeys.contains(item.key)) continue; + if (_hiddenKeys.contains(item.key)) continue; + if (item.value.trim().isEmpty) continue; + if (item.key == 'language' && + _languageMatchesQuery(item.value, queryLanguage)) { + continue; + } + if (!seen.add(item.key)) continue; + merged.add( + _ResolvedMetadataItem(item: item, isLanguage: item.key == 'language'), + ); + } + } + + // Page metadata first so it takes priority on deduplication. + absorb(_metadataFromPage(pageMetadata)); + absorb(metadata); + + merged.sort((a, b) { + final ai = _keyOrderIndex[a.item.key] ?? _keyOrder.length; + final bi = _keyOrderIndex[b.item.key] ?? _keyOrder.length; + return ai.compareTo(bi); + }); + + return merged; +} + +/// The snippet/review/question entries worth revealing in the snippets panel. +/// Exposed so a parent (e.g. the result card) can decide whether to show the +/// snippets toggle and render the panel itself. +List resolveSnippetEntries(List metadata) { + return metadata + .where((item) => _expandableKeys.contains(item.key)) + .where((item) => item.value.trim().isNotEmpty) + .toList(); +} + +/// Decorative metadata chips for a search result. An optional [trailing] action +/// (the snippets toggle) is pinned to the end of the same row, outside the +/// horizontal scroll. The expanding snippets panel itself is *not* rendered +/// here — the parent owns it so it can sit full-bleed at the card's edge rather +/// than nested inside the chip row's padding. +/// +/// Collapses to nothing when there are neither chips nor a trailing action. +class SearchResultMetadataSection extends StatelessWidget { final List metadata; final PageMetadata? pageMetadata; @@ -200,95 +269,146 @@ class SearchResultMetadataChips extends StatelessWidget { /// chip is suppressed as redundant. final String? queryLanguage; - const SearchResultMetadataChips({ + /// The result's published date, surfaced as a leading `released` chip + /// (calendar icon, formatted). + final String? publishedDate; + + /// Applied only when the section actually renders content, so callers can + /// reserve spacing without having to predict whether the section is empty. + final EdgeInsetsGeometry padding; + + /// Optional trailing action pinned to the end of the chip row (typically a + /// [SearchResultSnippetsToggle]). + final Widget? trailing; + + const SearchResultMetadataSection({ super.key, required this.metadata, this.pageMetadata, this.queryLanguage, + this.publishedDate, + this.padding = EdgeInsets.zero, + this.trailing, }); @override Widget build(BuildContext context) { + final chips = _resolveChips( + metadata: metadata, + pageMetadata: pageMetadata, + queryLanguage: queryLanguage, + publishedDate: publishedDate, + ); + + final trailingAction = trailing; + + if (chips.isEmpty && trailingAction == null) { + return const SizedBox.shrink(); + } + + final Widget header; + if (trailingAction == null) { + header = _MetadataChipRow(items: chips); + } else if (chips.isEmpty) { + header = Align(alignment: Alignment.centerLeft, child: trailingAction); + } else { + header = Row( + children: [ + Expanded(child: _MetadataChipRow(items: chips)), + const SizedBox(width: 8), + trailingAction, + ], + ); + } + + return Padding(padding: padding, child: header); + } +} + +/// Horizontally-scrolling run of decorative metadata, rendered as a single +/// [Text.rich] rather than bordered chips: each item is an inline icon followed +/// by its value, with neighbouring items joined by a faded middot. This is far +/// more horizontally compact than a row of pills (no per-chip border, padding +/// or background), and since the items are purely decorative there's no tap +/// target to preserve. The leading icon of each group doubles as a visual +/// separator; the middot keeps multi-word values from blurring into the next +/// icon. +class _MetadataChipRow extends StatelessWidget { + final List<_ResolvedMetadataItem> items; + + const _MetadataChipRow({required this.items}); + + @override + Widget build(BuildContext context) { + if (items.isEmpty) return const SizedBox.shrink(); + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; - final seen = {}; - final merged = <_ResolvedMetadataItem>[]; + final labelStyle = textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ); - // Page metadata items first so they take priority on deduplication. - for (final item in _metadataFromPage(pageMetadata)) { - if (_expandableKeys.contains(item.key)) continue; - if (_hiddenKeys.contains(item.key)) continue; - if (item.value.trim().isEmpty) continue; - if (item.key == 'language' && - _languageMatchesQuery(item.value, queryLanguage)) { - continue; + final spans = []; + for (var i = 0; i < items.length; i++) { + if (i > 0) { + spans.add( + TextSpan( + text: ' · ', + style: labelStyle?.copyWith(color: colorScheme.outlineVariant), + ), + ); } - if (!seen.add(item.key)) continue; - merged.add( - _ResolvedMetadataItem(item: item, isLanguage: item.key == 'language'), - ); - } - for (final item in metadata) { - if (_expandableKeys.contains(item.key)) continue; - if (_hiddenKeys.contains(item.key)) continue; - if (item.value.trim().isEmpty) continue; - if (item.key == 'language' && - _languageMatchesQuery(item.value, queryLanguage)) { - continue; + final item = items[i]; + + spans.add( + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: Icon( + _iconFor(item.item.key), + size: 14, + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ); + + if (item.isLanguage) { + // The language value resolves asynchronously via a provider, so it has + // to be an embedded consumer widget rather than a plain TextSpan. + spans.add( + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: _ResolvedLanguageLabel( + languageTag: item.item.value, + style: labelStyle, + ), + ), + ); + } else { + spans.add( + TextSpan( + text: + _formatValue(item.item.key, item.item.value) ?? item.item.value, + style: labelStyle, + ), + ); } - // Skip if page metadata already provided this key (preview takes priority). - if (!seen.add(item.key)) continue; - merged.add( - _ResolvedMetadataItem(item: item, isLanguage: item.key == 'language'), - ); } - if (merged.isEmpty) return const SizedBox.shrink(); - - merged.sort((a, b) { - final ai = _keyOrderIndex[a.item.key] ?? _keyOrder.length; - final bi = _keyOrderIndex[b.item.key] ?? _keyOrder.length; - return ai.compareTo(bi); - }); - return FadingScroll( fadingSize: 15, builder: (context, controller) { return SingleChildScrollView( controller: controller, scrollDirection: Axis.horizontal, - child: Row( - children: [ - for (var i = 0; i < merged.length; i++) ...[ - if (i > 0) const SizedBox(width: 8), - // Decorative — these chips don't filter or navigate. Using a - // plain Chip (instead of an OutlinedButton with an empty - // onPressed) avoids the misleading tap ripple. - Chip( - avatar: Icon( - _iconFor(merged[i].item.key), - size: 16, - color: colorScheme.onSurfaceVariant, - ), - label: merged[i].isLanguage - ? _ResolvedLanguageLabel( - languageTag: merged[i].item.value, - ) - : Text( - _formatValue( - merged[i].item.key, - merged[i].item.value, - ) ?? - merged[i].item.value, - ), - side: BorderSide(color: colorScheme.outlineVariant), - labelStyle: TextStyle(color: colorScheme.onSurfaceVariant), - backgroundColor: Colors.transparent, - visualDensity: VisualDensity.compact, - ), - ], - ], + child: Text.rich( + TextSpan(children: spans), + maxLines: 1, + softWrap: false, ), ); }, @@ -296,6 +416,106 @@ class SearchResultMetadataChips extends StatelessWidget { } } +/// The trailing chevron toggle for the snippets panel. Mirrors the card's +/// Fetch button (tonal fill, compact density) so the two trailing actions read +/// as a consistent pair; the chevron rotates to reflect the expanded state. +class SearchResultSnippetsToggle extends StatelessWidget { + final bool expanded; + final VoidCallback onToggle; + + const SearchResultSnippetsToggle({ + super.key, + required this.expanded, + required this.onToggle, + }); + + @override + Widget build(BuildContext context) { + return IconButton( + onPressed: onToggle, + icon: AnimatedRotation( + turns: expanded ? 0.5 : 0, + duration: const Duration(milliseconds: 200), + child: const Icon(Icons.expand_more, size: 16), + ), + visualDensity: VisualDensity.compact, + tooltip: 'Snippets', + ); + } +} + +/// The expanded snippet/review/question entries, in a tinted full-bleed panel. +/// Intended to be placed *outside* the card's content padding so it spans edge +/// to edge and its bottom corners are clipped by the card (pass [borderRadius] +/// when used somewhere without a clipping ancestor). Questions get a bold `Q:` +/// prefix and italic body; snippets and reviews render plain. +class SearchResultSnippetsPanel extends StatelessWidget { + final List entries; + final BorderRadius borderRadius; + + const SearchResultSnippetsPanel({ + super.key, + required this.entries, + this.borderRadius = BorderRadius.zero, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + + return Container( + width: double.infinity, + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), + borderRadius: borderRadius, + ), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Additional Snippets', + style: textTheme.labelMedium?.copyWith( + color: colorScheme.primary, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 6), + for (final item in entries) ...[ + if (item != entries.first) const SizedBox(height: 8), + Text.rich( + TextSpan( + children: [ + if (item.key == 'question') + TextSpan( + text: 'Q: ', + style: textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurface, + fontWeight: FontWeight.w600, + height: 1.5, + ), + ), + TextSpan( + text: item.value, + style: textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + height: 1.5, + fontStyle: item.key == 'question' + ? FontStyle.italic + : null, + ), + ), + ], + ), + ), + ], + ], + ), + ); + } +} + class _ResolvedMetadataItem { final MetadataItem item; final bool isLanguage; @@ -304,14 +524,15 @@ class _ResolvedMetadataItem { class _ResolvedLanguageLabel extends ConsumerWidget { final String languageTag; + final TextStyle? style; - const _ResolvedLanguageLabel({required this.languageTag}); + const _ResolvedLanguageLabel({required this.languageTag, this.style}); @override Widget build(BuildContext context, WidgetRef ref) { final locale = intl.Locale.tryParse(languageTag); - if (locale == null) return Text(languageTag); + if (locale == null) return Text(languageTag, style: style); final resolved = ref.watch(resolveLocaleProvider(locale)); @@ -320,88 +541,7 @@ class _ResolvedLanguageLabel extends ConsumerWidget { data: (data) => data.languageName, orElse: () => languageTag, ), - ); - } -} - -class SearchResultMetadataExpandable extends StatelessWidget { - final List metadata; - - const SearchResultMetadataExpandable({super.key, required this.metadata}); - - @override - Widget build(BuildContext context) { - final entries = metadata - .where((item) => _expandableKeys.contains(item.key)) - .where((item) => item.value.trim().isNotEmpty) - .toList(); - - if (entries.isEmpty) return const SizedBox.shrink(); - - final colorScheme = Theme.of(context).colorScheme; - final textTheme = Theme.of(context).textTheme; - final hasSnippets = entries.any((e) => e.key == 'snippet'); - final hasReview = entries.any((e) => e.key == 'review'); - final hasQuestion = entries.any((e) => e.key == 'question'); - - final parts = [ - if (hasQuestion) 'question', - if (hasSnippets) 'snippets', - if (hasReview) 'review', - ]; - - final label = parts.isEmpty ? 'More' : 'Show ${parts.join(' & ')}'; - - return Theme( - data: Theme.of(context).copyWith( - dividerColor: Colors.transparent, - splashColor: Colors.transparent, - ), - child: ExpansionTile( - tilePadding: EdgeInsets.zero, - childrenPadding: const EdgeInsets.only(bottom: 8), - dense: true, - visualDensity: VisualDensity.compact, - title: Text( - label, - style: textTheme.labelLarge?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - children: [ - for (final item in entries) ...[ - if (item != entries.first) const SizedBox(height: 8), - Align( - alignment: Alignment.centerLeft, - child: Text.rich( - TextSpan( - children: [ - if (item.key == 'question') - TextSpan( - text: 'Q: ', - style: textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurface, - fontWeight: FontWeight.w600, - height: 1.5, - ), - ), - TextSpan( - text: item.value, - style: textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - height: 1.5, - fontStyle: item.key == 'question' - ? FontStyle.italic - : null, - ), - ), - ], - ), - ), - ), - ], - ], - ), + style: style, ); } } diff --git a/apps/weblibre/lib/features/web_search/presentation/widgets/web_search_infobox_card.dart b/apps/weblibre/lib/features/web_search/presentation/widgets/web_search_infobox_card.dart index a878131e..2e87951b 100644 --- a/apps/weblibre/lib/features/web_search/presentation/widgets/web_search_infobox_card.dart +++ b/apps/weblibre/lib/features/web_search/presentation/widgets/web_search_infobox_card.dart @@ -1,9 +1,11 @@ import 'dart:typed_data'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:search_protocol/search_protocol.dart'; +import 'package:weblibre/core/providers/persisted_bool.dart'; import 'package:weblibre/features/web_search/domain/controllers/search_controller.dart'; import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart'; import 'package:weblibre/presentation/widgets/url_icon.dart'; @@ -12,18 +14,23 @@ class WebSearchInfoboxCard extends HookConsumerWidget { final CompactInfobox info; final Future Function(Uri url) onOpen; - /// When non-null the card uses this externally-controlled expansion state - /// (e.g. shared across a carousel). When null, the card manages its own - /// state with a default of expanded. - final bool? expandedOverride; - final VoidCallback? onToggleExpanded; + /// "Show all links" and "factsheet expanded" states, mirrored from the + /// carousel so the off-stage height measurer and the visible card stay in + /// sync. When null the card manages them locally (single-card path), both + /// defaulting to collapsed. + final bool? showAllLinksOverride; + final VoidCallback? onToggleShowAllLinks; + final bool? factsheetExpandedOverride; + final VoidCallback? onToggleFactsheetExpanded; const WebSearchInfoboxCard({ super.key, required this.info, required this.onOpen, - this.expandedOverride, - this.onToggleExpanded, + this.showAllLinksOverride, + this.onToggleShowAllLinks, + this.factsheetExpandedOverride, + this.onToggleFactsheetExpanded, }); @override @@ -38,19 +45,45 @@ class WebSearchInfoboxCard extends HookConsumerWidget { metaSearchControllerProvider.select((s) => s.imagesByUrl[imageUrl]), ); - final attributes = info.attributes ?? const []; - final urls = info.urls ?? const []; final heading = _heading(info); + final attributes = _meaningfulAttributes(info); + final urls = _meaningfulUrls(info); - final localExpanded = useState(true); - final isExpanded = expandedOverride ?? localExpanded.value; + // Persisted + shared via the provider, so the carousel's off-stage height + // measurer and the visible card always agree (no per-instance state to + // desync), and the choice survives app restarts. + final isExpanded = ref.watch( + persistedBoolProvider(PersistedBoolKey.infoboxExpanded), + ); void toggle() { - final external = onToggleExpanded; + ref + .read(persistedBoolProvider(PersistedBoolKey.infoboxExpanded).notifier) + .toggle(); + } + + final localShowAllLinks = useState(false); + final showAllLinks = showAllLinksOverride ?? localShowAllLinks.value; + + void toggleShowAllLinks() { + final external = onToggleShowAllLinks; if (external != null) { external(); } else { - localExpanded.value = !localExpanded.value; + localShowAllLinks.value = !localShowAllLinks.value; + } + } + + final localFactsheetExpanded = useState(false); + final factsheetExpanded = + factsheetExpandedOverride ?? localFactsheetExpanded.value; + + void toggleFactsheetExpanded() { + final external = onToggleFactsheetExpanded; + if (external != null) { + external(); + } else { + localFactsheetExpanded.value = !localFactsheetExpanded.value; } } @@ -116,6 +149,10 @@ class WebSearchInfoboxCard extends HookConsumerWidget { attributes: attributes, urls: urls, onOpen: onOpen, + showAllLinks: showAllLinks, + onToggleShowAllLinks: toggleShowAllLinks, + factsheetExpanded: factsheetExpanded, + onToggleFactsheetExpanded: toggleFactsheetExpanded, colorScheme: colorScheme, textTheme: textTheme, ), @@ -129,6 +166,40 @@ class WebSearchInfoboxCard extends HookConsumerWidget { if (title != null && title.isNotEmpty) return title; return info.infobox.trim(); } + + /// Attributes worth rendering in the factsheet: those with a non-empty value, + /// minus low-signal entries. In particular a lone "type"/"kind" attribute + /// whose value just restates the infobox category (e.g. `Type: Code`) adds + /// nothing the header doesn't already convey, so we drop the whole factsheet + /// in that case rather than show a one-line, oddly-centered tile. + static List _meaningfulAttributes(CompactInfobox info) { + final attributes = (info.attributes ?? const []) + .where((attr) => attr.value?.trim().isNotEmpty ?? false) + .toList(); + + if (attributes.length == 1) { + final only = attributes.single; + final label = only.label.replaceAll(RegExp(r':+\s*$'), '').toLowerCase(); + final value = only.value!.trim().toLowerCase(); + if ((label == 'type' || label == 'kind') && + value == info.infobox.trim().toLowerCase()) { + return const []; + } + } + + return attributes; + } + + /// Link chips to show. These are the card's primary actionable content — the + /// header breadcrumb only toggles expand/collapse, so a chip is the only way + /// to actually open the destination (for some cards, e.g. a Brave "Code" + /// answer, it is the *entire* useful payload). We therefore keep every link + /// with a real title and only drop empty-title entries. + static List _meaningfulUrls(CompactInfobox info) { + return (info.urls ?? const []) + .where((urlObj) => urlObj.title.trim().isNotEmpty) + .toList(); + } } class _Body extends StatelessWidget { @@ -137,6 +208,10 @@ class _Body extends StatelessWidget { final List attributes; final List urls; final Future Function(Uri url) onOpen; + final bool showAllLinks; + final VoidCallback onToggleShowAllLinks; + final bool factsheetExpanded; + final VoidCallback onToggleFactsheetExpanded; final ColorScheme colorScheme; final TextTheme textTheme; @@ -146,6 +221,10 @@ class _Body extends StatelessWidget { required this.attributes, required this.urls, required this.onOpen, + required this.showAllLinks, + required this.onToggleShowAllLinks, + required this.factsheetExpanded, + required this.onToggleFactsheetExpanded, required this.colorScheme, required this.textTheme, }); @@ -178,7 +257,7 @@ class _Body extends StatelessWidget { const SizedBox(height: 16), ], if (info.content case final String content - when content.trim().isNotEmpty) ...[ + when content.trim().isNotEmpty) Text( content.trim(), style: textTheme.bodyMedium?.copyWith( @@ -186,126 +265,252 @@ class _Body extends StatelessWidget { height: 1.5, ), ), - const SizedBox(height: 12), - ], - if (info.source.isNotEmpty) - Text( - 'Source: ${info.source}', - style: textTheme.labelSmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), ], ), ), if (attributes.isNotEmpty) _Factsheet( attributes: attributes, + expanded: factsheetExpanded, + onToggle: onToggleFactsheetExpanded, colorScheme: colorScheme, textTheme: textTheme, ), - if (urls.isNotEmpty) - Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - for (final urlObj in urls) ...[ - OutlinedButton.icon( - onPressed: () => onOpen(urlObj.url), - icon: Icon(_iconForLink(urlObj.title), size: 16), - label: Text(urlObj.title), - style: OutlinedButton.styleFrom( - foregroundColor: colorScheme.onSurfaceVariant, - side: BorderSide(color: colorScheme.outlineVariant), - padding: const EdgeInsets.symmetric(horizontal: 12), - ), - ), - const SizedBox(width: 8), - ], - ], - ), - ), - ) - else + if (urls.isNotEmpty) ...[ + const SizedBox(height: 8), + _InfoboxLinks( + links: urls, + showAll: showAllLinks, + onToggleShowAll: onToggleShowAllLinks, + onOpen: onOpen, + colorScheme: colorScheme, + textTheme: textTheme, + ), + const SizedBox(height: 8), + ] else const SizedBox(height: 16), ], ); } - - IconData _iconForLink(String title) { - final lower = title.toLowerCase(); - - if (lower.contains('wikipedia') || lower.contains('wiki')) { - return Icons.article_outlined; - } - if (lower.contains('reddit')) return Icons.forum_outlined; - if (lower.contains('facebook')) return Icons.facebook_outlined; - if (lower.contains('youtube') || lower.contains('video')) { - return Icons.play_circle_outline; - } - if (lower.contains('twitter') || lower.contains('x.com')) { - return Icons.alternate_email; - } - if (lower.contains('instagram')) return Icons.photo_camera_outlined; - if (lower.contains('github')) return Icons.code; - if (lower.contains('mastodon')) return Icons.public; - - return Icons.link; - } } -class _Factsheet extends StatelessWidget { - final List attributes; +/// The infobox's links, capped at [_collapsedCount] rows by default with a +/// "Show N more" toggle for the rest. Keeps link-heavy cards (Wikipedia-style) +/// short so the user doesn't have to scroll far to reach the search results, +/// while leaving link-only answers (typically a single link) fully visible. +/// +/// The expand state is owned by [WebSearchInfoboxCard] (and, in a carousel, +/// shared with the off-stage height measurer) so expanding actually grows the +/// measured viewport instead of clipping the revealed rows. +class _InfoboxLinks extends StatelessWidget { + static const _collapsedCount = 3; + + final List links; + final bool showAll; + final VoidCallback onToggleShowAll; + final Future Function(Uri url) onOpen; final ColorScheme colorScheme; final TextTheme textTheme; - const _Factsheet({ - required this.attributes, + const _InfoboxLinks({ + required this.links, + required this.showAll, + required this.onToggleShowAll, + required this.onOpen, required this.colorScheme, required this.textTheme, }); @override Widget build(BuildContext context) { - return Theme( - data: Theme.of(context).copyWith(dividerColor: Colors.transparent), - child: ExpansionTile( - title: Text( - 'Factsheet', - style: textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600), - ), - tilePadding: const EdgeInsets.symmetric(horizontal: 16), - childrenPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16), - expandedCrossAxisAlignment: CrossAxisAlignment.start, - children: [ - for (final attr in attributes) - if (attr.value case final String value when value.trim().isNotEmpty) - Padding( - padding: const EdgeInsets.only(bottom: 8), - child: RichText( - text: TextSpan( - style: textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - height: 1.5, + final hasOverflow = links.length > _collapsedCount; + final visible = (hasOverflow && !showAll) + ? links.sublist(0, _collapsedCount) + : links; + final hiddenCount = links.length - _collapsedCount; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final link in visible) + _InfoboxLinkRow( + link: link, + onOpen: onOpen, + colorScheme: colorScheme, + textTheme: textTheme, + ), + if (hasOverflow) + Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + onPressed: onToggleShowAll, + icon: Icon( + showAll ? Icons.expand_less : Icons.expand_more, + size: 18, + ), + label: Text( + showAll ? 'Show less' : 'Show $hiddenCount more links', + ), + style: TextButton.styleFrom( + foregroundColor: colorScheme.primary, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + ), + ), + ), + ], + ); + } +} + +/// A single infobox link rendered as a full-width row: real favicon, link +/// title, and the destination URL breadcrumb underneath. Unlike a bare chip, +/// this makes it obvious *where* the link goes (the title alone often just +/// repeats the card heading, e.g. the F-Droid "Code" answer). +class _InfoboxLinkRow extends StatelessWidget { + final InfoboxUrl link; + final Future Function(Uri url) onOpen; + final ColorScheme colorScheme; + final TextTheme textTheme; + + const _InfoboxLinkRow({ + required this.link, + required this.onOpen, + required this.colorScheme, + required this.textTheme, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: () => onOpen(link.url), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row( + children: [ + UrlIcon([link.url], iconSize: 28), + const SizedBox(width: 12), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + link.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textTheme.bodyLarge?.copyWith( + color: colorScheme.onSurface, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + // Full destination URL (host › path …) with the breadcrumb's + // own horizontal fade when it overflows. + UriBreadcrumb( + uri: link.url, + style: textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant), + ], + ), + ), + ); + } +} + +/// Collapsible factsheet. Unlike a plain [ExpansionTile] it is *fully* +/// controlled by [expanded]/[onToggle] (owned by [WebSearchInfoboxCard] and, in +/// a carousel, shared with the off-stage height measurer) so expanding grows +/// the measured viewport instead of clipping the revealed rows — an +/// [ExpansionTile]'s private internal state would desync the two copies. The +/// state is ephemeral (not persisted). +class _Factsheet extends StatelessWidget { + final List attributes; + final bool expanded; + final VoidCallback onToggle; + final ColorScheme colorScheme; + final TextTheme textTheme; + + const _Factsheet({ + required this.attributes, + required this.expanded, + required this.onToggle, + required this.colorScheme, + required this.textTheme, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + InkWell( + onTap: onToggle, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 8, 8), + child: Row( + children: [ + Expanded( + child: Text( + 'Factsheet', + style: textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, ), - children: [ - TextSpan( - text: - '${attr.label.replaceAll(RegExp(r':+\s*$'), '')}: ', - style: TextStyle( - fontWeight: FontWeight.w600, - color: colorScheme.onSurface, - ), - ), - TextSpan(text: value), - ], ), ), - ), - ], - ), + AnimatedRotation( + turns: expanded ? 0.5 : 0, + duration: const Duration(milliseconds: 200), + child: Icon( + Icons.expand_more, + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + if (expanded) + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final attr in attributes) + if (attr.value case final String value + when value.trim().isNotEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: RichText( + text: TextSpan( + style: textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + height: 1.5, + ), + children: [ + TextSpan( + text: + '${attr.label.replaceAll(RegExp(r':+\s*$'), '')}: ', + style: TextStyle( + fontWeight: FontWeight.w600, + color: colorScheme.onSurface, + ), + ), + TextSpan(text: value), + ], + ), + ), + ), + ], + ), + ), + ], ); } } @@ -314,11 +519,11 @@ class WebSearchInfoboxCarousel extends HookConsumerWidget { final List infos; final Future Function(Uri url) onOpen; - /// Upper bound for the carousel viewport. The PageView gets exactly this - /// height; if a card is taller, the inner [SingleChildScrollView] handles - /// the overflow. Picked large enough to fit a typical Wikipedia-style - /// infobox without scrolling, small enough not to dominate the screen. - static const _maxCardHeight = 520.0; + /// Placeholder viewport height used for a page whose real height has not + /// been measured yet. Kept small so the very first frame errs on the side + /// of a slightly-too-short card (corrected within a frame) rather than + /// flashing a large empty box. + static const _estimatedCardHeight = 200.0; const WebSearchInfoboxCarousel({ super.key, @@ -332,7 +537,20 @@ class WebSearchInfoboxCarousel extends HookConsumerWidget { final controller = usePageController(); final currentPage = useState(0); - final expanded = useState(true); + final showAllLinks = useState(false); + final factsheetExpanded = useState(false); + + // Natural (content) height of each page, measured off-stage. `null` until + // a page has been laid out at least once. + final heights = useState>( + List.filled(infos.length, null), + ); + + // Reset the measurement cache when the page set changes. + useEffect(() { + heights.value = List.filled(infos.length, null); + return null; + }, [infos.length]); useEffect(() { void listener() { @@ -346,53 +564,161 @@ class WebSearchInfoboxCarousel extends HookConsumerWidget { return () => controller.removeListener(listener); }, [controller]); - // Earlier revisions measured each page's real height in a post-frame - // callback and animated the carousel to match. With SizeChangedLayout - // notifications + post-frame setState, this created a measurement - // feedback loop that was vulnerable to floating-point jitter and - // sometimes spent the whole expand/collapse animation re-measuring. - // Using a fixed maximum + per-page scrolling sidesteps the loop and is - // measurably cheaper. - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - SizedBox( - height: _maxCardHeight, - child: PageView.builder( - controller: controller, - itemCount: infos.length, - itemBuilder: (context, index) { - return SingleChildScrollView( - child: WebSearchInfoboxCard( - info: infos[index], - onOpen: onOpen, - expandedOverride: expanded.value, - onToggleExpanded: () => expanded.value = !expanded.value, - ), - ); - }, - ), - ), - const SizedBox(height: 8), - Row( - mainAxisAlignment: MainAxisAlignment.center, + // Records a freshly-measured page height. The equality guard is what + // breaks the measure -> resize -> re-measure feedback loop that plagued + // earlier dynamic-height attempts: measurement happens in a *separate* + // off-stage subtree (see below) whose constraints never depend on the + // visible viewport height, so once a height settles this no-ops. + void reportHeight(int index, double height) { + final current = heights.value[index]; + if (current != null && (current - height).abs() < 0.5) { + return; + } + final next = [...heights.value]; + next[index] = height; + heights.value = next; + } + + WebSearchInfoboxCard cardFor(int index) => WebSearchInfoboxCard( + info: infos[index], + onOpen: onOpen, + showAllLinksOverride: showAllLinks.value, + onToggleShowAllLinks: () => showAllLinks.value = !showAllLinks.value, + factsheetExpandedOverride: factsheetExpanded.value, + onToggleFactsheetExpanded: () => + factsheetExpanded.value = !factsheetExpanded.value, + ); + + final active = currentPage.value.clamp(0, infos.length - 1); + // The viewport is the active page's *full* natural height — deliberately + // not capped. A capped viewport made the inner scroll view scrollable, + // and a scrollable child traps vertical drags instead of letting them + // bubble up to the parent sheet (so the user could not scroll on to the + // results). At full height the inner content fits exactly, the scroll + // view has nothing to consume, and drags pass through to the sheet. + final viewportHeight = heights.value[active] ?? _estimatedCardHeight; + + return LayoutBuilder( + builder: (context, constraints) { + final pageWidth = constraints.maxWidth; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - for (var i = 0; i < infos.length; i++) - AnimatedContainer( - duration: const Duration(milliseconds: 200), - margin: const EdgeInsets.symmetric(horizontal: 3), - width: currentPage.value == i ? 18 : 6, - height: 6, - decoration: BoxDecoration( - color: currentPage.value == i - ? colorScheme.primary - : colorScheme.outlineVariant, - borderRadius: BorderRadius.circular(3), + Stack( + children: [ + // Off-stage measurers. Each card is laid out a second time at + // the real page width but with unbounded height, so it reports + // its natural content height. `Offstage` keeps them out of the + // layout/paint flow of the visible carousel (they report zero + // size and are never painted). + Offstage( + child: SizedBox( + width: pageWidth, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (var i = 0; i < infos.length; i++) + _MeasureSize( + onChange: (size) => reportHeight(i, size.height), + child: cardFor(i), + ), + ], + ), + ), ), - ), + // Visible carousel. The viewport tracks the active page's + // measured height; AnimatedSize smooths the change on swipe + // (height settles after the page snaps) and on expand/collapse. + AnimatedSize( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + alignment: Alignment.topCenter, + child: SizedBox( + height: viewportHeight, + child: PageView.builder( + controller: controller, + itemCount: infos.length, + itemBuilder: (context, index) { + // Wrapped in a scroll view only so a page that is + // momentarily taller than the viewport (before its + // height is measured, or mid-swipe toward a taller + // page) clips gracefully instead of throwing an + // overflow. Once measured the content fits exactly, so + // this never actually scrolls and vertical drags bubble + // up to the parent sheet. + return SingleChildScrollView(child: cardFor(index)); + }, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + for (var i = 0; i < infos.length; i++) + AnimatedContainer( + duration: const Duration(milliseconds: 200), + margin: const EdgeInsets.symmetric(horizontal: 3), + width: currentPage.value == i ? 18 : 6, + height: 6, + decoration: BoxDecoration( + color: currentPage.value == i + ? colorScheme.primary + : colorScheme.outlineVariant, + borderRadius: BorderRadius.circular(3), + ), + ), + ], + ), ], - ), - ], + ); + }, ); } } + +/// Reports its child's laid-out size via [onChange] whenever it changes. +/// +/// Used to measure infobox cards off-stage (at the real page width, unbounded +/// height) so the carousel viewport can size to the active page. The callback +/// fires from a post-frame callback to stay clear of the layout phase, and the +/// caller guards on size equality so a settled layout produces no further work. +class _MeasureSize extends SingleChildRenderObjectWidget { + final ValueChanged onChange; + + const _MeasureSize({required this.onChange, required Widget super.child}); + + @override + RenderObject createRenderObject(BuildContext context) { + return _MeasureSizeRenderObject(onChange); + } + + @override + void updateRenderObject( + BuildContext context, + _MeasureSizeRenderObject renderObject, + ) { + renderObject.onChange = onChange; + } +} + +class _MeasureSizeRenderObject extends RenderProxyBox { + ValueChanged onChange; + Size? _oldSize; + + _MeasureSizeRenderObject(this.onChange); + + @override + void performLayout() { + super.performLayout(); + final newSize = child?.size ?? Size.zero; + if (_oldSize == newSize) { + return; + } + _oldSize = newSize; + WidgetsBinding.instance.addPostFrameCallback((_) => onChange(newSize)); + } +} diff --git a/apps/weblibre/lib/features/web_search/presentation/widgets/web_search_results_section.dart b/apps/weblibre/lib/features/web_search/presentation/widgets/web_search_results_section.dart index 1a57792a..025cedf8 100644 --- a/apps/weblibre/lib/features/web_search/presentation/widgets/web_search_results_section.dart +++ b/apps/weblibre/lib/features/web_search/presentation/widgets/web_search_results_section.dart @@ -179,8 +179,6 @@ class WebSearchResultsSection extends HookConsumerWidget { result: result, onOpen: openUri, onFetch: onFetch, - onPreview: showPreview, - onOpenCapture: openCapture, ); }, ), diff --git a/apps/weblibre/lib/presentation/widgets/url_icon.dart b/apps/weblibre/lib/presentation/widgets/url_icon.dart index 6d8df6fc..cb42958d 100644 --- a/apps/weblibre/lib/presentation/widgets/url_icon.dart +++ b/apps/weblibre/lib/presentation/widgets/url_icon.dart @@ -32,6 +32,13 @@ import 'package:weblibre/features/user/domain/providers.dart'; import 'package:weblibre/presentation/hooks/cached_future.dart'; import 'package:weblibre/presentation/widgets/safe_raw_image.dart'; +/// Origins served by a bundled asset instead of a network-fetched favicon. +/// Lets first-party properties (e.g. the WebLibre search bang) show their +/// brand mark rather than a generic globe or a remotely fetched icon. +const _bundledIconByOrigin = { + 'https://weblibre.eu': 'assets/icon/bang_icon.png', +}; + Uint8List? selectFirstCachedIconBytes(Iterable cachedBytesByUrl) { for (final cachedBytes in cachedBytesByUrl) { if (cachedBytes != null) { @@ -60,6 +67,20 @@ class UrlIcon extends HookConsumerWidget { .where((u) => u.isScheme('http') || u.isScheme('https')) .toList(); + for (final url in eligibleUrls) { + final asset = _bundledIconByOrigin[url.origin]; + if (asset != null) { + return RepaintBoundary( + child: Image.asset( + asset, + height: iconSize, + width: iconSize, + fit: BoxFit.contain, + ), + ); + } + } + final cachedBytesByUrl = [ for (final url in eligibleUrls) ref.watch(watchCachedIconBytesProvider(url.origin)).value,