improve weblibre search
This commit is contained in:
@@ -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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -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,
|
||||
|
||||
+2
-1
@@ -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 {
|
||||
|
||||
+2
-1
@@ -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,
|
||||
),
|
||||
|
||||
+65
-25
@@ -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),
|
||||
],
|
||||
|
||||
+132
-401
@@ -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;
|
||||
|
||||
+294
-154
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+483
-157
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
-2
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user