improve weblibre search

This commit is contained in:
Fabian Freund
2026-06-01 07:17:37 +02:00
parent 88f128621f
commit 453e1a6c30
23 changed files with 1095 additions and 796 deletions
+3 -1
View File
@@ -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
@@ -0,0 +1,8 @@
[
{
"s": "WebLibre Search",
"d": "weblibre.eu",
"t": "wl",
"u": "https://weblibre.eu/?q={{{s}}}"
}
]
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

@@ -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});
@@ -3374,6 +3374,7 @@ class DefinitionsDrift extends i7.ModularAccessor {
).map(
(i0.QueryRow row) => i8.SearchHistoryEntry(
searchQuery: row.read<String>('search_query'),
group: i3.BangHistory.$convertergroup.fromSql(row.read<int>('group')),
trigger: row.read<String>('trigger'),
searchDate: row.read<DateTime>('search_date'),
),
@@ -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;
@@ -18,18 +18,21 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
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<Object?> get hashParameters => [searchQuery, trigger, searchDate];
List<Object?> get hashParameters => [searchQuery, group, trigger, searchDate];
}
@@ -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;
@@ -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<Result<void>> _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<Map<BangGroup, Result<void>>> syncBundledBangGroups({
Set<BangGroup>? 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
@@ -42,7 +42,7 @@ final class BangSyncRepositoryProvider
}
String _$bangSyncRepositoryHash() =>
r'cdb78ef0243224fdb7b3a8660f0920c471987296';
r'd1c6709f099165c17db535ab03334fcd1c610492';
abstract class _$BangSyncRepository extends $Notifier<void> {
void build();
@@ -18,10 +18,12 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
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),
@@ -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 <http://www.gnu.org/licenses/>.
*/
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),
],
);
}
}
@@ -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,
@@ -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);
@@ -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 {
@@ -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,
),
@@ -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<void> 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);
}
}
@@ -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),
],
@@ -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<void> 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<void> Function(Uri url) onFetch;
final Future<void> Function(Uri url) onPreview;
final Future<void> 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<void> 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<FetchMethodChoice> capturing;
final Map<FetchMethodChoice, CapturedPageState> 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<FetchMethodChoice> 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<FetchMethodChoice, CapturedPageState> 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<void> Function(Uri url) onFetch;
final Future<void> Function(Uri url) onPreview;
final Future<void> 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 = <Widget>[];
final busyChips = <Widget>[];
final errorChips = <Widget>[];
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<void> 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<void> Function() onRetry;
const _ErrorChip({
required this.choice,
required this.errorMessage,
required this.canRetry,
required this.onRetry,
});
Future<void> _showDetail(BuildContext context) async {
final retry = await showDialog<bool>(
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;
@@ -49,8 +49,8 @@ const _keyOrder = <String>[
];
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<MetadataItem> _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<MetadataItem> metadata,
required PageMetadata? pageMetadata,
required String? queryLanguage,
required String? publishedDate,
}) {
final seen = <String>{};
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<MetadataItem> 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<MetadataItem> resolveSnippetEntries(List<MetadataItem> 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<MetadataItem> 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 = <String>{};
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 = <InlineSpan>[];
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<MetadataItem> 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<MetadataItem> 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,
);
}
}
@@ -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<void> 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 <InfoboxAttribute>[];
final urls = info.urls ?? const <InfoboxUrl>[];
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<InfoboxAttribute> _meaningfulAttributes(CompactInfobox info) {
final attributes = (info.attributes ?? const <InfoboxAttribute>[])
.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<InfoboxUrl> _meaningfulUrls(CompactInfobox info) {
return (info.urls ?? const <InfoboxUrl>[])
.where((urlObj) => urlObj.title.trim().isNotEmpty)
.toList();
}
}
class _Body extends StatelessWidget {
@@ -137,6 +208,10 @@ class _Body extends StatelessWidget {
final List<InfoboxAttribute> attributes;
final List<InfoboxUrl> urls;
final Future<void> 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<InfoboxAttribute> 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<InfoboxUrl> links;
final bool showAll;
final VoidCallback onToggleShowAll;
final Future<void> 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<void> 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<InfoboxAttribute> 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<CompactInfobox> infos;
final Future<void> 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<double?>>(
List<double?>.filled(infos.length, null),
);
// Reset the measurement cache when the page set changes.
useEffect(() {
heights.value = List<double?>.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<Size> 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<Size> 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));
}
}
@@ -179,8 +179,6 @@ class WebSearchResultsSection extends HookConsumerWidget {
result: result,
onOpen: openUri,
onFetch: onFetch,
onPreview: showPreview,
onOpenCapture: openCapture,
);
},
),
@@ -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<Uint8List?> 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,