improve weblibre search
This commit is contained in:
@@ -31,7 +31,9 @@ migrate_working_dir/
|
|||||||
.pub/
|
.pub/
|
||||||
/build/
|
/build/
|
||||||
assets/quotes/quotes.db
|
assets/quotes/quotes.db
|
||||||
assets/bangs/
|
assets/bangs/*
|
||||||
|
!assets/bangs/
|
||||||
|
!assets/bangs/weblibre_bangs.json
|
||||||
assets/preferences/builtin-bridges.json
|
assets/preferences/builtin-bridges.json
|
||||||
assets/preferences/url-shortener-list.json
|
assets/preferences/url-shortener-list.json
|
||||||
assets/preferences/url_cleaner_data.minify.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',
|
key: 'SearchSuggestionsExpanded',
|
||||||
defaultValue: true,
|
defaultValue: true,
|
||||||
),
|
),
|
||||||
|
infoboxExpanded(key: 'InfoboxExpanded', defaultValue: true),
|
||||||
tabSuggestions(key: 'TabSuggestions', defaultValue: false);
|
tabSuggestions(key: 'TabSuggestions', defaultValue: false);
|
||||||
|
|
||||||
const PersistedBoolKey({required this.key, required this.defaultValue});
|
const PersistedBoolKey({required this.key, required this.defaultValue});
|
||||||
|
|||||||
@@ -3374,6 +3374,7 @@ class DefinitionsDrift extends i7.ModularAccessor {
|
|||||||
).map(
|
).map(
|
||||||
(i0.QueryRow row) => i8.SearchHistoryEntry(
|
(i0.QueryRow row) => i8.SearchHistoryEntry(
|
||||||
searchQuery: row.read<String>('search_query'),
|
searchQuery: row.read<String>('search_query'),
|
||||||
|
group: i3.BangHistory.$convertergroup.fromSql(row.read<int>('group')),
|
||||||
trigger: row.read<String>('trigger'),
|
trigger: row.read<String>('trigger'),
|
||||||
searchDate: row.read<DateTime>('search_date'),
|
searchDate: row.read<DateTime>('search_date'),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ enum BangGroup {
|
|||||||
bundled: 'assets/bangs/kagi_bangs.json',
|
bundled: 'assets/bangs/kagi_bangs.json',
|
||||||
),
|
),
|
||||||
user(remote: null, bundled: null),
|
user(remote: null, bundled: null),
|
||||||
weblibre(remote: null, bundled: null);
|
weblibre(remote: null, bundled: 'assets/bangs/weblibre_bangs.json');
|
||||||
|
|
||||||
final String? bundled;
|
final String? bundled;
|
||||||
final String? remote;
|
final String? remote;
|
||||||
|
|||||||
@@ -18,18 +18,21 @@
|
|||||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
import 'package:fast_equatable/fast_equatable.dart';
|
import 'package:fast_equatable/fast_equatable.dart';
|
||||||
|
import 'package:weblibre/features/bangs/data/models/bang_group.dart';
|
||||||
|
|
||||||
class SearchHistoryEntry with FastEquatable {
|
class SearchHistoryEntry with FastEquatable {
|
||||||
final String searchQuery;
|
final String searchQuery;
|
||||||
|
final BangGroup group;
|
||||||
final String trigger;
|
final String trigger;
|
||||||
final DateTime searchDate;
|
final DateTime searchDate;
|
||||||
|
|
||||||
SearchHistoryEntry({
|
SearchHistoryEntry({
|
||||||
required this.searchQuery,
|
required this.searchQuery,
|
||||||
|
required this.group,
|
||||||
required this.trigger,
|
required this.trigger,
|
||||||
required this.searchDate,
|
required this.searchDate,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@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_group.dart';
|
||||||
import 'package:weblibre/features/bangs/data/models/bang_key.dart';
|
import 'package:weblibre/features/bangs/data/models/bang_key.dart';
|
||||||
|
|
||||||
const webSearchBangTrigger = 'wl';
|
const webSearchBangKey = BangKey(group: BangGroup.weblibre, trigger: '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,
|
|
||||||
);
|
|
||||||
|
|
||||||
bool isWebSearchBang(BangData? bang) =>
|
bool isWebSearchBang(BangData? bang) =>
|
||||||
bang != null && bang.toKey() == webSearchBangKey;
|
bang != null && bang.toKey() == webSearchBangKey;
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import 'package:exceptions/exceptions.dart';
|
|||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
import 'package:weblibre/features/bangs/data/database/database.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/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/providers.dart';
|
||||||
import 'package:weblibre/features/bangs/data/services/data_source.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
|
final lastSync = await db.syncDao
|
||||||
.getLastSyncOfGroup(group)
|
.getLastSyncOfGroup(group)
|
||||||
.getSingleOrNull();
|
.getSingleOrNull();
|
||||||
@@ -171,21 +164,6 @@ class BangSyncRepository extends _$BangSyncRepository {
|
|||||||
.watchSingleOrNull();
|
.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({
|
Future<Map<BangGroup, Result<void>>> syncBundledBangGroups({
|
||||||
Set<BangGroup>? groups,
|
Set<BangGroup>? groups,
|
||||||
}) async {
|
}) async {
|
||||||
@@ -199,10 +177,7 @@ class BangSyncRepository extends _$BangSyncRepository {
|
|||||||
).then((result) => MapEntry(source, result)),
|
).then((result) => MapEntry(source, result)),
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return Map.fromEntries(await Future.wait(futures));
|
||||||
...Map.fromEntries(await Future.wait(futures)),
|
|
||||||
BangGroup.weblibre: await _syncSyntheticBangs(),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ final class BangSyncRepositoryProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _$bangSyncRepositoryHash() =>
|
String _$bangSyncRepositoryHash() =>
|
||||||
r'cdb78ef0243224fdb7b3a8660f0920c471987296';
|
r'd1c6709f099165c17db535ab03334fcd1c610492';
|
||||||
|
|
||||||
abstract class _$BangSyncRepository extends $Notifier<void> {
|
abstract class _$BangSyncRepository extends $Notifier<void> {
|
||||||
void build();
|
void build();
|
||||||
|
|||||||
@@ -18,10 +18,12 @@
|
|||||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
import 'package:flutter/material.dart';
|
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:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:nullability/nullability.dart';
|
import 'package:nullability/nullability.dart';
|
||||||
import 'package:weblibre/core/routing/routes.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_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/domain/repositories/tab.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
|
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
|
||||||
import 'package:weblibre/features/user/domain/repositories/general_settings.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),
|
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.dart';
|
||||||
import 'package:flutter_material_design_icons/flutter_material_design_icons.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/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/selectable_chips.dart';
|
||||||
import 'package:weblibre/presentation/widgets/url_icon.dart';
|
import 'package:weblibre/presentation/widgets/url_icon.dart';
|
||||||
|
|
||||||
@@ -97,7 +98,7 @@ class BangChipStrip extends StatelessWidget {
|
|||||||
itemId: (bang) => bang.trigger,
|
itemId: (bang) => bang.trigger,
|
||||||
itemAvatar: (bang) =>
|
itemAvatar: (bang) =>
|
||||||
UrlIcon([bang.getDefaultUrl()], iconSize: 20),
|
UrlIcon([bang.getDefaultUrl()], iconSize: 20),
|
||||||
itemLabel: (bang) => Text(bang.websiteName),
|
itemLabel: (bang) => BangLabel(bang),
|
||||||
itemTooltip: (bang) => bang.trigger,
|
itemTooltip: (bang) => bang.trigger,
|
||||||
availableItems: bangs,
|
availableItems: bangs,
|
||||||
selectedItem: selectedBang,
|
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/bangs.dart';
|
||||||
import 'package:weblibre/features/bangs/domain/providers/search.dart';
|
import 'package:weblibre/features/bangs/domain/providers/search.dart';
|
||||||
import 'package:weblibre/features/bangs/domain/repositories/data.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/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/dialogs/reset_bang_dialog.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/bang_chip_strip.dart';
|
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/bang_chip_strip.dart';
|
||||||
@@ -235,7 +236,7 @@ class _DefaultSearchProviderChip extends ConsumerWidget {
|
|||||||
|
|
||||||
return ActionChip(
|
return ActionChip(
|
||||||
avatar: UrlIcon([bang.getDefaultUrl()], iconSize: 20),
|
avatar: UrlIcon([bang.getDefaultUrl()], iconSize: 20),
|
||||||
label: Text(bang.websiteName),
|
label: BangLabel(bang),
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
// Open bang search when tapped
|
// Open bang search when tapped
|
||||||
await const BangSearchRoute().push(context);
|
await const BangSearchRoute().push(context);
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import 'package:nullability/nullability.dart';
|
|||||||
import 'package:weblibre/core/routing/routes.dart';
|
import 'package:weblibre/core/routing/routes.dart';
|
||||||
import 'package:weblibre/features/bangs/data/models/bang_key.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/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/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/controllers/save_settings.dart';
|
||||||
import 'package:weblibre/features/settings/presentation/widgets/bang_icon.dart';
|
import 'package:weblibre/features/settings/presentation/widgets/bang_icon.dart';
|
||||||
@@ -85,7 +86,7 @@ class DefaultSearchPage extends HookConsumerWidget {
|
|||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
child: FilterChip(
|
child: FilterChip(
|
||||||
showCheckmark: false,
|
showCheckmark: false,
|
||||||
label: Text(activeBang.websiteName),
|
label: BangLabel(activeBang),
|
||||||
avatar: UrlIcon([
|
avatar: UrlIcon([
|
||||||
activeBang.getDefaultUrl(),
|
activeBang.getDefaultUrl(),
|
||||||
], iconSize: 20),
|
], iconSize: 20),
|
||||||
@@ -100,7 +101,7 @@ class DefaultSearchPage extends HookConsumerWidget {
|
|||||||
...availableBangs.map(
|
...availableBangs.map(
|
||||||
(bang) => FilterChip(
|
(bang) => FilterChip(
|
||||||
showCheckmark: false,
|
showCheckmark: false,
|
||||||
label: Text(bang.websiteName),
|
label: BangLabel(bang),
|
||||||
avatar: UrlIcon([bang.getDefaultUrl()], iconSize: 20),
|
avatar: UrlIcon([bang.getDefaultUrl()], iconSize: 20),
|
||||||
selected: activeBang?.trigger == bang.trigger,
|
selected: activeBang?.trigger == bang.trigger,
|
||||||
onSelected: (selected) async {
|
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/core/routing/routes.dart';
|
||||||
import 'package:weblibre/features/bangs/data/models/bang_key.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/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/settings/presentation/controllers/save_settings.dart';
|
||||||
import 'package:weblibre/features/user/data/models/general_settings.dart';
|
import 'package:weblibre/features/user/data/models/general_settings.dart';
|
||||||
import 'package:weblibre/presentation/widgets/url_icon.dart';
|
import 'package:weblibre/presentation/widgets/url_icon.dart';
|
||||||
@@ -64,7 +65,7 @@ class DefaultSearchSelector extends HookConsumerWidget {
|
|||||||
)
|
)
|
||||||
: ActionChip(
|
: ActionChip(
|
||||||
avatar: UrlIcon([activeBang.getDefaultUrl()], iconSize: 20),
|
avatar: UrlIcon([activeBang.getDefaultUrl()], iconSize: 20),
|
||||||
label: Text(activeBang.websiteName),
|
label: BangLabel(activeBang),
|
||||||
tooltip: activeBang.trigger,
|
tooltip: activeBang.trigger,
|
||||||
onPressed: pickProvider,
|
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/captured_page_state.dart';
|
||||||
import 'package:weblibre/features/web_search/domain/entities/fetch_method.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/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(
|
Future<void> showFetchMethodSheet(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
@@ -55,13 +57,12 @@ class _FetchMethodSheet extends ConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
Text('Fetch Page Data', style: textTheme.titleLarge),
|
Text('Fetch Page Data', style: textTheme.titleLarge),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
UriBreadcrumb(
|
||||||
url.toString(),
|
uri: url,
|
||||||
|
icon: UrlIcon([url], iconSize: 14, cacheOnly: true),
|
||||||
style: textTheme.bodySmall?.copyWith(
|
style: textTheme.bodySmall?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -99,35 +100,63 @@ class _MethodTile extends ConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final theme = Theme.of(context);
|
||||||
final textTheme = Theme.of(context).textTheme;
|
final colorScheme = theme.colorScheme;
|
||||||
|
final textTheme = theme.textTheme;
|
||||||
|
|
||||||
final busy = state.isMethodBusy(url, choice);
|
final busy = state.isMethodBusy(url, choice);
|
||||||
final ready = state.isMethodReady(url, choice);
|
final ready = state.isMethodReady(url, choice);
|
||||||
final captured = state.capturedPage(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(
|
return ListTile(
|
||||||
|
enabled: enabled,
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
|
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),
|
title: Text(choice.title),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
failed
|
failed ? errorMessage : choice.subtitle,
|
||||||
? (captured?.errorMessage ?? 'Download failed — tap to retry')
|
style: textTheme.bodySmall?.copyWith(color: subtitleColor),
|
||||||
: choice.subtitle,
|
|
||||||
style: textTheme.bodySmall?.copyWith(
|
|
||||||
color: failed ? colorScheme.error : colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
trailing: _StatusIndicator(
|
trailing: _StatusIndicator(
|
||||||
busy: busy,
|
busy: busy,
|
||||||
ready: ready,
|
ready: ready,
|
||||||
failed: failed,
|
failed: failed,
|
||||||
|
idleColor: leadingColor,
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
),
|
),
|
||||||
onTap: busy
|
onTap: enabled
|
||||||
? null
|
? () => _handleTap(context, ref, ready, failed, captured)
|
||||||
: () => _handleTap(context, ref, ready, failed, captured),
|
: null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,17 +177,25 @@ class _MethodTile extends ConsumerWidget {
|
|||||||
return;
|
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) {
|
// Trafilatura (re)fetch — the same call covers a first fetch and a retry
|
||||||
await ref
|
// after a previous fetch error.
|
||||||
.read(metaSearchControllerProvider.notifier)
|
if (choice == FetchMethodChoice.trafilatura) {
|
||||||
.retryCaptureDownload(url, choice);
|
await ref.read(metaSearchControllerProvider.notifier).fetchPage(url);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (choice == FetchMethodChoice.trafilatura) {
|
// A failed *download* (we still hold the captureId/downloadToken) only
|
||||||
await ref.read(metaSearchControllerProvider.notifier).fetchPage(url);
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,12 +215,14 @@ class _StatusIndicator extends StatelessWidget {
|
|||||||
final bool busy;
|
final bool busy;
|
||||||
final bool ready;
|
final bool ready;
|
||||||
final bool failed;
|
final bool failed;
|
||||||
|
final Color idleColor;
|
||||||
final ColorScheme colorScheme;
|
final ColorScheme colorScheme;
|
||||||
|
|
||||||
const _StatusIndicator({
|
const _StatusIndicator({
|
||||||
required this.busy,
|
required this.busy,
|
||||||
required this.ready,
|
required this.ready,
|
||||||
required this.failed,
|
required this.failed,
|
||||||
|
required this.idleColor,
|
||||||
required this.colorScheme,
|
required this.colorScheme,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -202,6 +241,7 @@ class _StatusIndicator extends StatelessWidget {
|
|||||||
if (failed) {
|
if (failed) {
|
||||||
return Icon(Icons.refresh, color: colorScheme.error);
|
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,
|
uri: uri,
|
||||||
icon: UrlIcon([uri], iconSize: 16, cacheOnly: true),
|
icon: UrlIcon([uri], iconSize: 16, cacheOnly: true),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
SearchResultMetadataSection(
|
||||||
SearchResultMetadataChips(
|
|
||||||
metadata: result?.metadata ?? const [],
|
metadata: result?.metadata ?? const [],
|
||||||
pageMetadata: document.metadata,
|
pageMetadata: document.metadata,
|
||||||
|
padding: const EdgeInsets.only(top: 16),
|
||||||
),
|
),
|
||||||
if (result?.metadata case final metadata?
|
if (resolveSnippetEntries(result?.metadata ?? const [])
|
||||||
when metadata.isNotEmpty)
|
case final entries when entries.isNotEmpty) ...[
|
||||||
SearchResultMetadataExpandable(metadata: metadata),
|
const SizedBox(height: 12),
|
||||||
|
SearchResultSnippetsPanel(
|
||||||
|
entries: entries,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
],
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
MarkdownBody(selectable: true, data: document.content),
|
MarkdownBody(selectable: true, data: document.content),
|
||||||
],
|
],
|
||||||
|
|||||||
+132
-401
@@ -3,32 +3,30 @@ import 'dart:typed_data';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:intl/intl.dart';
|
|
||||||
import 'package:nullability/nullability.dart';
|
import 'package:nullability/nullability.dart';
|
||||||
import 'package:search_protocol/search_protocol.dart';
|
import 'package:search_protocol/search_protocol.dart';
|
||||||
import 'package:weblibre/features/search_credits/domain/repositories/web_search_settings.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/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/domain/entities/fetch_method.dart';
|
||||||
import 'package:weblibre/features/web_search/presentation/widgets/search_result_metadata_chips.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/uri_breadcrumb.dart';
|
||||||
import 'package:weblibre/presentation/widgets/url_icon.dart';
|
import 'package:weblibre/presentation/widgets/url_icon.dart';
|
||||||
|
|
||||||
class WebSearchResultCard extends ConsumerWidget {
|
class WebSearchResultCard extends HookConsumerWidget {
|
||||||
final CompactSearchResult result;
|
final CompactSearchResult result;
|
||||||
|
|
||||||
final Future<void> Function(Uri url) onOpen;
|
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) onFetch;
|
||||||
final Future<void> Function(Uri url) onPreview;
|
|
||||||
final Future<void> Function(CapturedPageState captured) onOpenCapture;
|
|
||||||
|
|
||||||
const WebSearchResultCard({
|
const WebSearchResultCard({
|
||||||
super.key,
|
super.key,
|
||||||
required this.result,
|
required this.result,
|
||||||
required this.onOpen,
|
required this.onOpen,
|
||||||
required this.onFetch,
|
required this.onFetch,
|
||||||
required this.onPreview,
|
|
||||||
required this.onOpenCapture,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -56,457 +54,190 @@ class WebSearchResultCard extends ConsumerWidget {
|
|||||||
webSearchSettingsControllerProvider.select((s) => s.language),
|
webSearchSettingsControllerProvider.select((s) => s.language),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final snippetEntries = resolveSnippetEntries(result.metadata ?? const []);
|
||||||
|
final snippetsExpanded = useState(false);
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
color: colorScheme.surfaceContainerHigh,
|
color: colorScheme.surfaceContainerHigh,
|
||||||
clipBehavior: Clip.antiAlias,
|
clipBehavior: Clip.antiAlias,
|
||||||
margin: EdgeInsets.zero,
|
margin: EdgeInsets.zero,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: () => onOpen(result.url),
|
onTap: () => onOpen(result.url),
|
||||||
child: Padding(
|
child: Column(
|
||||||
padding: const EdgeInsets.all(16),
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
child: Column(
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
children: [
|
||||||
children: [
|
Padding(
|
||||||
_UrlRow(result: result),
|
padding: const EdgeInsets.all(12),
|
||||||
const SizedBox(height: 16),
|
child: Column(
|
||||||
_ContentRow(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
result: result,
|
children: [
|
||||||
imageBytes: imageBytes,
|
_Header(
|
||||||
colorScheme: colorScheme,
|
result: result,
|
||||||
textTheme: textTheme,
|
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) ...[
|
// Full-bleed expanding snippets panel, outside the content padding
|
||||||
const SizedBox(height: 12),
|
// so it spans the card edge to edge; the card's antiAlias clip
|
||||||
SearchResultMetadataChips(
|
// rounds its bottom corners.
|
||||||
metadata: result.metadata ?? const [],
|
AnimatedSize(
|
||||||
pageMetadata: document?.metadata,
|
duration: const Duration(milliseconds: 250),
|
||||||
queryLanguage: queryLanguage,
|
curve: Curves.fastOutSlowIn,
|
||||||
),
|
alignment: Alignment.topCenter,
|
||||||
],
|
child: (snippetEntries.isNotEmpty && snippetsExpanded.value)
|
||||||
if (result.metadata case final metadata? when metadata.isNotEmpty)
|
? SearchResultSnippetsPanel(entries: snippetEntries)
|
||||||
SearchResultMetadataExpandable(metadata: metadata),
|
: const SizedBox.shrink(),
|
||||||
const SizedBox(height: 16),
|
),
|
||||||
_FetchFooter(
|
],
|
||||||
url: result.url,
|
|
||||||
onFetch: onFetch,
|
|
||||||
onPreview: onPreview,
|
|
||||||
onOpenCapture: onOpenCapture,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _UrlRow extends StatelessWidget {
|
/// Compact header: small content thumbnail (when present), the site breadcrumb
|
||||||
final CompactSearchResult result;
|
/// 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
|
||||||
const _UrlRow({required this.result});
|
/// fetch sheet it opens is where their status and any errors are surfaced.
|
||||||
|
class _Header extends ConsumerWidget {
|
||||||
@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 {
|
|
||||||
final CompactSearchResult result;
|
final CompactSearchResult result;
|
||||||
final Uint8List? imageBytes;
|
final Uint8List? imageBytes;
|
||||||
|
final Future<void> Function(Uri url) onFetch;
|
||||||
final ColorScheme colorScheme;
|
final ColorScheme colorScheme;
|
||||||
final TextTheme textTheme;
|
final TextTheme textTheme;
|
||||||
|
|
||||||
const _ContentRow({
|
const _Header({
|
||||||
required this.result,
|
required this.result,
|
||||||
required this.imageBytes,
|
required this.imageBytes,
|
||||||
|
required this.onFetch,
|
||||||
required this.colorScheme,
|
required this.colorScheme,
|
||||||
required this.textTheme,
|
required this.textTheme,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@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(
|
return Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
if (imageBytes != null) ...[
|
if (imageBytes != null) ...[
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(10),
|
||||||
child: Image.memory(
|
child: Image.memory(
|
||||||
imageBytes!,
|
imageBytes!,
|
||||||
width: 88,
|
width: 54,
|
||||||
height: 88,
|
height: 54,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
gaplessPlayback: true,
|
gaplessPlayback: true,
|
||||||
errorBuilder: (_, _, _) => const SizedBox.shrink(),
|
errorBuilder: (_, _, _) => const SizedBox.shrink(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 8),
|
||||||
],
|
],
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
UriBreadcrumb(
|
||||||
result.title,
|
uri: result.url,
|
||||||
style: textTheme.titleMedium?.copyWith(
|
icon: UrlIcon([result.url], iconSize: 14, cacheOnly: true),
|
||||||
color: colorScheme.primary,
|
style: textTheme.labelMedium?.copyWith(
|
||||||
fontWeight: FontWeight.w600,
|
color: colorScheme.onSurfaceVariant,
|
||||||
height: 1.3,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (result.publishedDate case final String date
|
const SizedBox(height: 2),
|
||||||
when date.isNotEmpty) ...[
|
Text(
|
||||||
const SizedBox(height: 4),
|
result.title,
|
||||||
Text(
|
maxLines: 2,
|
||||||
_formatDate(date),
|
overflow: TextOverflow.ellipsis,
|
||||||
style: textTheme.labelSmall?.copyWith(
|
style: textTheme.titleSmall?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
if (showFetch) ...[
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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) ...[
|
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
FilledButton.tonalIcon(
|
Badge.count(
|
||||||
onPressed: () => onFetch(url),
|
count: fetch.ready,
|
||||||
icon: const Icon(Icons.download_rounded, size: 18),
|
isLabelVisible: fetch.ready > 0,
|
||||||
label: const Text('Fetch'),
|
child: Stack(
|
||||||
style: FilledButton.styleFrom(elevation: 0),
|
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 {
|
class _ExpandableDescription extends HookWidget {
|
||||||
static const _collapsedMaxLines = 4;
|
static const _collapsedMaxLines = 2;
|
||||||
|
|
||||||
final String text;
|
final String text;
|
||||||
final TextStyle? style;
|
final TextStyle? style;
|
||||||
|
|||||||
+294
-154
@@ -49,8 +49,8 @@ const _keyOrder = <String>[
|
|||||||
];
|
];
|
||||||
|
|
||||||
const _hiddenKeys = {
|
const _hiddenKeys = {
|
||||||
// The publisher-declared article date duplicates the inline `publishedDate`
|
// The publisher-declared article date duplicates the result's own
|
||||||
// shown under the title. Keep only the inline version.
|
// `publishedDate`, surfaced as the `released` chip. Keep only that one.
|
||||||
'date',
|
'date',
|
||||||
'score',
|
'score',
|
||||||
'gravity',
|
'gravity',
|
||||||
@@ -191,7 +191,76 @@ List<MetadataItem> _metadataFromPage(PageMetadata? pageMetadata) {
|
|||||||
return items;
|
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 List<MetadataItem> metadata;
|
||||||
final PageMetadata? pageMetadata;
|
final PageMetadata? pageMetadata;
|
||||||
|
|
||||||
@@ -200,95 +269,146 @@ class SearchResultMetadataChips extends StatelessWidget {
|
|||||||
/// chip is suppressed as redundant.
|
/// chip is suppressed as redundant.
|
||||||
final String? queryLanguage;
|
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,
|
super.key,
|
||||||
required this.metadata,
|
required this.metadata,
|
||||||
this.pageMetadata,
|
this.pageMetadata,
|
||||||
this.queryLanguage,
|
this.queryLanguage,
|
||||||
|
this.publishedDate,
|
||||||
|
this.padding = EdgeInsets.zero,
|
||||||
|
this.trailing,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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 colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
|
||||||
final seen = <String>{};
|
final labelStyle = textTheme.bodySmall?.copyWith(
|
||||||
final merged = <_ResolvedMetadataItem>[];
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
);
|
||||||
|
|
||||||
// Page metadata items first so they take priority on deduplication.
|
final spans = <InlineSpan>[];
|
||||||
for (final item in _metadataFromPage(pageMetadata)) {
|
for (var i = 0; i < items.length; i++) {
|
||||||
if (_expandableKeys.contains(item.key)) continue;
|
if (i > 0) {
|
||||||
if (_hiddenKeys.contains(item.key)) continue;
|
spans.add(
|
||||||
if (item.value.trim().isEmpty) continue;
|
TextSpan(
|
||||||
if (item.key == 'language' &&
|
text: ' · ',
|
||||||
_languageMatchesQuery(item.value, queryLanguage)) {
|
style: labelStyle?.copyWith(color: colorScheme.outlineVariant),
|
||||||
continue;
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (!seen.add(item.key)) continue;
|
|
||||||
merged.add(
|
|
||||||
_ResolvedMetadataItem(item: item, isLanguage: item.key == 'language'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (final item in metadata) {
|
final item = items[i];
|
||||||
if (_expandableKeys.contains(item.key)) continue;
|
|
||||||
if (_hiddenKeys.contains(item.key)) continue;
|
spans.add(
|
||||||
if (item.value.trim().isEmpty) continue;
|
WidgetSpan(
|
||||||
if (item.key == 'language' &&
|
alignment: PlaceholderAlignment.middle,
|
||||||
_languageMatchesQuery(item.value, queryLanguage)) {
|
child: Padding(
|
||||||
continue;
|
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(
|
return FadingScroll(
|
||||||
fadingSize: 15,
|
fadingSize: 15,
|
||||||
builder: (context, controller) {
|
builder: (context, controller) {
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
child: Row(
|
child: Text.rich(
|
||||||
children: [
|
TextSpan(children: spans),
|
||||||
for (var i = 0; i < merged.length; i++) ...[
|
maxLines: 1,
|
||||||
if (i > 0) const SizedBox(width: 8),
|
softWrap: false,
|
||||||
// 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,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -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 {
|
class _ResolvedMetadataItem {
|
||||||
final MetadataItem item;
|
final MetadataItem item;
|
||||||
final bool isLanguage;
|
final bool isLanguage;
|
||||||
@@ -304,14 +524,15 @@ class _ResolvedMetadataItem {
|
|||||||
|
|
||||||
class _ResolvedLanguageLabel extends ConsumerWidget {
|
class _ResolvedLanguageLabel extends ConsumerWidget {
|
||||||
final String languageTag;
|
final String languageTag;
|
||||||
|
final TextStyle? style;
|
||||||
|
|
||||||
const _ResolvedLanguageLabel({required this.languageTag});
|
const _ResolvedLanguageLabel({required this.languageTag, this.style});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final locale = intl.Locale.tryParse(languageTag);
|
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));
|
final resolved = ref.watch(resolveLocaleProvider(locale));
|
||||||
|
|
||||||
@@ -320,88 +541,7 @@ class _ResolvedLanguageLabel extends ConsumerWidget {
|
|||||||
data: (data) => data.languageName,
|
data: (data) => data.languageName,
|
||||||
orElse: () => languageTag,
|
orElse: () => languageTag,
|
||||||
),
|
),
|
||||||
);
|
style: style,
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+483
-157
@@ -1,9 +1,11 @@
|
|||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/rendering.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:search_protocol/search_protocol.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/features/web_search/domain/controllers/search_controller.dart';
|
||||||
import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart';
|
import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart';
|
||||||
import 'package:weblibre/presentation/widgets/url_icon.dart';
|
import 'package:weblibre/presentation/widgets/url_icon.dart';
|
||||||
@@ -12,18 +14,23 @@ class WebSearchInfoboxCard extends HookConsumerWidget {
|
|||||||
final CompactInfobox info;
|
final CompactInfobox info;
|
||||||
final Future<void> Function(Uri url) onOpen;
|
final Future<void> Function(Uri url) onOpen;
|
||||||
|
|
||||||
/// When non-null the card uses this externally-controlled expansion state
|
/// "Show all links" and "factsheet expanded" states, mirrored from the
|
||||||
/// (e.g. shared across a carousel). When null, the card manages its own
|
/// carousel so the off-stage height measurer and the visible card stay in
|
||||||
/// state with a default of expanded.
|
/// sync. When null the card manages them locally (single-card path), both
|
||||||
final bool? expandedOverride;
|
/// defaulting to collapsed.
|
||||||
final VoidCallback? onToggleExpanded;
|
final bool? showAllLinksOverride;
|
||||||
|
final VoidCallback? onToggleShowAllLinks;
|
||||||
|
final bool? factsheetExpandedOverride;
|
||||||
|
final VoidCallback? onToggleFactsheetExpanded;
|
||||||
|
|
||||||
const WebSearchInfoboxCard({
|
const WebSearchInfoboxCard({
|
||||||
super.key,
|
super.key,
|
||||||
required this.info,
|
required this.info,
|
||||||
required this.onOpen,
|
required this.onOpen,
|
||||||
this.expandedOverride,
|
this.showAllLinksOverride,
|
||||||
this.onToggleExpanded,
|
this.onToggleShowAllLinks,
|
||||||
|
this.factsheetExpandedOverride,
|
||||||
|
this.onToggleFactsheetExpanded,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -38,19 +45,45 @@ class WebSearchInfoboxCard extends HookConsumerWidget {
|
|||||||
metaSearchControllerProvider.select((s) => s.imagesByUrl[imageUrl]),
|
metaSearchControllerProvider.select((s) => s.imagesByUrl[imageUrl]),
|
||||||
);
|
);
|
||||||
|
|
||||||
final attributes = info.attributes ?? const <InfoboxAttribute>[];
|
|
||||||
final urls = info.urls ?? const <InfoboxUrl>[];
|
|
||||||
final heading = _heading(info);
|
final heading = _heading(info);
|
||||||
|
final attributes = _meaningfulAttributes(info);
|
||||||
|
final urls = _meaningfulUrls(info);
|
||||||
|
|
||||||
final localExpanded = useState(true);
|
// Persisted + shared via the provider, so the carousel's off-stage height
|
||||||
final isExpanded = expandedOverride ?? localExpanded.value;
|
// 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() {
|
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) {
|
if (external != null) {
|
||||||
external();
|
external();
|
||||||
} else {
|
} 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,
|
attributes: attributes,
|
||||||
urls: urls,
|
urls: urls,
|
||||||
onOpen: onOpen,
|
onOpen: onOpen,
|
||||||
|
showAllLinks: showAllLinks,
|
||||||
|
onToggleShowAllLinks: toggleShowAllLinks,
|
||||||
|
factsheetExpanded: factsheetExpanded,
|
||||||
|
onToggleFactsheetExpanded: toggleFactsheetExpanded,
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
textTheme: textTheme,
|
textTheme: textTheme,
|
||||||
),
|
),
|
||||||
@@ -129,6 +166,40 @@ class WebSearchInfoboxCard extends HookConsumerWidget {
|
|||||||
if (title != null && title.isNotEmpty) return title;
|
if (title != null && title.isNotEmpty) return title;
|
||||||
return info.infobox.trim();
|
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 {
|
class _Body extends StatelessWidget {
|
||||||
@@ -137,6 +208,10 @@ class _Body extends StatelessWidget {
|
|||||||
final List<InfoboxAttribute> attributes;
|
final List<InfoboxAttribute> attributes;
|
||||||
final List<InfoboxUrl> urls;
|
final List<InfoboxUrl> urls;
|
||||||
final Future<void> Function(Uri url) onOpen;
|
final Future<void> Function(Uri url) onOpen;
|
||||||
|
final bool showAllLinks;
|
||||||
|
final VoidCallback onToggleShowAllLinks;
|
||||||
|
final bool factsheetExpanded;
|
||||||
|
final VoidCallback onToggleFactsheetExpanded;
|
||||||
final ColorScheme colorScheme;
|
final ColorScheme colorScheme;
|
||||||
final TextTheme textTheme;
|
final TextTheme textTheme;
|
||||||
|
|
||||||
@@ -146,6 +221,10 @@ class _Body extends StatelessWidget {
|
|||||||
required this.attributes,
|
required this.attributes,
|
||||||
required this.urls,
|
required this.urls,
|
||||||
required this.onOpen,
|
required this.onOpen,
|
||||||
|
required this.showAllLinks,
|
||||||
|
required this.onToggleShowAllLinks,
|
||||||
|
required this.factsheetExpanded,
|
||||||
|
required this.onToggleFactsheetExpanded,
|
||||||
required this.colorScheme,
|
required this.colorScheme,
|
||||||
required this.textTheme,
|
required this.textTheme,
|
||||||
});
|
});
|
||||||
@@ -178,7 +257,7 @@ class _Body extends StatelessWidget {
|
|||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
],
|
],
|
||||||
if (info.content case final String content
|
if (info.content case final String content
|
||||||
when content.trim().isNotEmpty) ...[
|
when content.trim().isNotEmpty)
|
||||||
Text(
|
Text(
|
||||||
content.trim(),
|
content.trim(),
|
||||||
style: textTheme.bodyMedium?.copyWith(
|
style: textTheme.bodyMedium?.copyWith(
|
||||||
@@ -186,126 +265,252 @@ class _Body extends StatelessWidget {
|
|||||||
height: 1.5,
|
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)
|
if (attributes.isNotEmpty)
|
||||||
_Factsheet(
|
_Factsheet(
|
||||||
attributes: attributes,
|
attributes: attributes,
|
||||||
|
expanded: factsheetExpanded,
|
||||||
|
onToggle: onToggleFactsheetExpanded,
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
textTheme: textTheme,
|
textTheme: textTheme,
|
||||||
),
|
),
|
||||||
if (urls.isNotEmpty)
|
if (urls.isNotEmpty) ...[
|
||||||
Padding(
|
const SizedBox(height: 8),
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
_InfoboxLinks(
|
||||||
child: SingleChildScrollView(
|
links: urls,
|
||||||
scrollDirection: Axis.horizontal,
|
showAll: showAllLinks,
|
||||||
child: Row(
|
onToggleShowAll: onToggleShowAllLinks,
|
||||||
children: [
|
onOpen: onOpen,
|
||||||
for (final urlObj in urls) ...[
|
colorScheme: colorScheme,
|
||||||
OutlinedButton.icon(
|
textTheme: textTheme,
|
||||||
onPressed: () => onOpen(urlObj.url),
|
),
|
||||||
icon: Icon(_iconForLink(urlObj.title), size: 16),
|
const SizedBox(height: 8),
|
||||||
label: Text(urlObj.title),
|
] else
|
||||||
style: OutlinedButton.styleFrom(
|
|
||||||
foregroundColor: colorScheme.onSurfaceVariant,
|
|
||||||
side: BorderSide(color: colorScheme.outlineVariant),
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
else
|
|
||||||
const SizedBox(height: 16),
|
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 {
|
/// The infobox's links, capped at [_collapsedCount] rows by default with a
|
||||||
final List<InfoboxAttribute> attributes;
|
/// "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 ColorScheme colorScheme;
|
||||||
final TextTheme textTheme;
|
final TextTheme textTheme;
|
||||||
|
|
||||||
const _Factsheet({
|
const _InfoboxLinks({
|
||||||
required this.attributes,
|
required this.links,
|
||||||
|
required this.showAll,
|
||||||
|
required this.onToggleShowAll,
|
||||||
|
required this.onOpen,
|
||||||
required this.colorScheme,
|
required this.colorScheme,
|
||||||
required this.textTheme,
|
required this.textTheme,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Theme(
|
final hasOverflow = links.length > _collapsedCount;
|
||||||
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
|
final visible = (hasOverflow && !showAll)
|
||||||
child: ExpansionTile(
|
? links.sublist(0, _collapsedCount)
|
||||||
title: Text(
|
: links;
|
||||||
'Factsheet',
|
final hiddenCount = links.length - _collapsedCount;
|
||||||
style: textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600),
|
|
||||||
),
|
return Column(
|
||||||
tilePadding: const EdgeInsets.symmetric(horizontal: 16),
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
childrenPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
children: [
|
||||||
expandedCrossAxisAlignment: CrossAxisAlignment.start,
|
for (final link in visible)
|
||||||
children: [
|
_InfoboxLinkRow(
|
||||||
for (final attr in attributes)
|
link: link,
|
||||||
if (attr.value case final String value when value.trim().isNotEmpty)
|
onOpen: onOpen,
|
||||||
Padding(
|
colorScheme: colorScheme,
|
||||||
padding: const EdgeInsets.only(bottom: 8),
|
textTheme: textTheme,
|
||||||
child: RichText(
|
),
|
||||||
text: TextSpan(
|
if (hasOverflow)
|
||||||
style: textTheme.bodyMedium?.copyWith(
|
Align(
|
||||||
color: colorScheme.onSurfaceVariant,
|
alignment: Alignment.centerLeft,
|
||||||
height: 1.5,
|
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 List<CompactInfobox> infos;
|
||||||
final Future<void> Function(Uri url) onOpen;
|
final Future<void> Function(Uri url) onOpen;
|
||||||
|
|
||||||
/// Upper bound for the carousel viewport. The PageView gets exactly this
|
/// Placeholder viewport height used for a page whose real height has not
|
||||||
/// height; if a card is taller, the inner [SingleChildScrollView] handles
|
/// been measured yet. Kept small so the very first frame errs on the side
|
||||||
/// the overflow. Picked large enough to fit a typical Wikipedia-style
|
/// of a slightly-too-short card (corrected within a frame) rather than
|
||||||
/// infobox without scrolling, small enough not to dominate the screen.
|
/// flashing a large empty box.
|
||||||
static const _maxCardHeight = 520.0;
|
static const _estimatedCardHeight = 200.0;
|
||||||
|
|
||||||
const WebSearchInfoboxCarousel({
|
const WebSearchInfoboxCarousel({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -332,7 +537,20 @@ class WebSearchInfoboxCarousel extends HookConsumerWidget {
|
|||||||
|
|
||||||
final controller = usePageController();
|
final controller = usePageController();
|
||||||
final currentPage = useState(0);
|
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(() {
|
useEffect(() {
|
||||||
void listener() {
|
void listener() {
|
||||||
@@ -346,53 +564,161 @@ class WebSearchInfoboxCarousel extends HookConsumerWidget {
|
|||||||
return () => controller.removeListener(listener);
|
return () => controller.removeListener(listener);
|
||||||
}, [controller]);
|
}, [controller]);
|
||||||
|
|
||||||
// Earlier revisions measured each page's real height in a post-frame
|
// Records a freshly-measured page height. The equality guard is what
|
||||||
// callback and animated the carousel to match. With SizeChangedLayout
|
// breaks the measure -> resize -> re-measure feedback loop that plagued
|
||||||
// notifications + post-frame setState, this created a measurement
|
// earlier dynamic-height attempts: measurement happens in a *separate*
|
||||||
// feedback loop that was vulnerable to floating-point jitter and
|
// off-stage subtree (see below) whose constraints never depend on the
|
||||||
// sometimes spent the whole expand/collapse animation re-measuring.
|
// visible viewport height, so once a height settles this no-ops.
|
||||||
// Using a fixed maximum + per-page scrolling sidesteps the loop and is
|
void reportHeight(int index, double height) {
|
||||||
// measurably cheaper.
|
final current = heights.value[index];
|
||||||
return Column(
|
if (current != null && (current - height).abs() < 0.5) {
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
return;
|
||||||
children: [
|
}
|
||||||
SizedBox(
|
final next = [...heights.value];
|
||||||
height: _maxCardHeight,
|
next[index] = height;
|
||||||
child: PageView.builder(
|
heights.value = next;
|
||||||
controller: controller,
|
}
|
||||||
itemCount: infos.length,
|
|
||||||
itemBuilder: (context, index) {
|
WebSearchInfoboxCard cardFor(int index) => WebSearchInfoboxCard(
|
||||||
return SingleChildScrollView(
|
info: infos[index],
|
||||||
child: WebSearchInfoboxCard(
|
onOpen: onOpen,
|
||||||
info: infos[index],
|
showAllLinksOverride: showAllLinks.value,
|
||||||
onOpen: onOpen,
|
onToggleShowAllLinks: () => showAllLinks.value = !showAllLinks.value,
|
||||||
expandedOverride: expanded.value,
|
factsheetExpandedOverride: factsheetExpanded.value,
|
||||||
onToggleExpanded: () => expanded.value = !expanded.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
|
||||||
const SizedBox(height: 8),
|
// not capped. A capped viewport made the inner scroll view scrollable,
|
||||||
Row(
|
// and a scrollable child traps vertical drags instead of letting them
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
// 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: [
|
children: [
|
||||||
for (var i = 0; i < infos.length; i++)
|
Stack(
|
||||||
AnimatedContainer(
|
children: [
|
||||||
duration: const Duration(milliseconds: 200),
|
// Off-stage measurers. Each card is laid out a second time at
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 3),
|
// the real page width but with unbounded height, so it reports
|
||||||
width: currentPage.value == i ? 18 : 6,
|
// its natural content height. `Offstage` keeps them out of the
|
||||||
height: 6,
|
// layout/paint flow of the visible carousel (they report zero
|
||||||
decoration: BoxDecoration(
|
// size and are never painted).
|
||||||
color: currentPage.value == i
|
Offstage(
|
||||||
? colorScheme.primary
|
child: SizedBox(
|
||||||
: colorScheme.outlineVariant,
|
width: pageWidth,
|
||||||
borderRadius: BorderRadius.circular(3),
|
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,
|
result: result,
|
||||||
onOpen: openUri,
|
onOpen: openUri,
|
||||||
onFetch: onFetch,
|
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/hooks/cached_future.dart';
|
||||||
import 'package:weblibre/presentation/widgets/safe_raw_image.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) {
|
Uint8List? selectFirstCachedIconBytes(Iterable<Uint8List?> cachedBytesByUrl) {
|
||||||
for (final cachedBytes in cachedBytesByUrl) {
|
for (final cachedBytes in cachedBytesByUrl) {
|
||||||
if (cachedBytes != null) {
|
if (cachedBytes != null) {
|
||||||
@@ -60,6 +67,20 @@ class UrlIcon extends HookConsumerWidget {
|
|||||||
.where((u) => u.isScheme('http') || u.isScheme('https'))
|
.where((u) => u.isScheme('http') || u.isScheme('https'))
|
||||||
.toList();
|
.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 = [
|
final cachedBytesByUrl = [
|
||||||
for (final url in eligibleUrls)
|
for (final url in eligibleUrls)
|
||||||
ref.watch(watchCachedIconBytesProvider(url.origin)).value,
|
ref.watch(watchCachedIconBytesProvider(url.origin)).value,
|
||||||
|
|||||||
Reference in New Issue
Block a user