Add Supa account and search changes

This commit is contained in:
Fabian Freund
2026-05-22 18:10:22 +02:00
parent 3a19865b2e
commit 51289f1266
374 changed files with 54061 additions and 5013 deletions
@@ -0,0 +1,55 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:intl/intl.dart';
import 'package:nullability/nullability.dart';
import 'package:search_backend/search_backend.dart';
class PageMetadataChips extends HookWidget {
final PageMetadata metadata;
const PageMetadataChips({super.key, required this.metadata});
@override
Widget build(BuildContext context) {
final chips = useMemoized(() {
final chips = <Widget>[];
final parsedDate = metadata.date.mapNotNull(DateTime.tryParse);
if (parsedDate != null) {
chips.add(
Chip(
avatar: const Icon(Icons.calendar_month),
label: Text(DateFormat.yMMMd().format(parsedDate)),
),
);
}
if (metadata.sitename case final String sitename
when sitename.isNotEmpty) {
chips.add(
Chip(avatar: const Icon(MdiIcons.domain), label: Text(sitename)),
);
}
if (metadata.author case final String author when author.isNotEmpty) {
chips.add(Chip(avatar: const Icon(Icons.person), label: Text(author)));
}
if (metadata.license case final String license when license.isNotEmpty) {
chips.add(
Chip(avatar: const Icon(MdiIcons.license), label: Text(license)),
);
}
return chips;
});
if (chips.isEmpty) {
return const SizedBox.shrink();
}
return Wrap(spacing: 8, runSpacing: 8, children: chips);
}
}
@@ -0,0 +1,187 @@
/*
* 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:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/design/app_colors.dart';
import 'package:weblibre/features/search_credits/domain/providers/proxy_client.dart';
import 'package:weblibre/features/search_credits/domain/repositories/web_search_settings.dart';
import 'package:weblibre/features/tor/domain/services/tor_proxy.dart';
import 'package:weblibre/features/tor/presentation/controllers/start_tor_proxy.dart';
import 'package:weblibre/presentation/hooks/on_initialization.dart';
class RouteThroughTorToggle extends HookConsumerWidget {
const RouteThroughTorToggle({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
Future<bool> ensureTorBootstrap() async {
// Sync the latest native status into the stream first so subsequent
// listeners (toggle spinner, progress bar, search submit) don't see a
// stale `null`/`AsyncLoading` state on first build.
final status = await ref
.read(torProxyServiceProvider.notifier)
.requestSync();
if (status.isRunning) return false;
await ref.read(startProxyControllerProvider.notifier).startProxy();
return true;
}
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final routeThroughTor = ref.watch(
webSearchSettingsControllerProvider.select((s) => s.routeThroughTor),
);
final torStatus = ref.watch(torProxyServiceProvider).value;
final activePort = ref.watch(searchProxyPortProvider);
final bootstrapProgress = torStatus?.bootstrapProgress ?? 0;
final showSpinner =
routeThroughTor && (activePort == null || bootstrapProgress < 100);
// Push the latest native status into the stream on first build so the
// bar reflects real progress even when the user lands on the search
// screen with Tor already mid-bootstrap (otherwise the AsyncLoading
// state lingers and the bar appears to spin forever at zero).
useOnInitialization(() async {
await ref.read(torProxyServiceProvider.notifier).requestSync();
});
return InkWell(
borderRadius: BorderRadius.circular(20),
onTap: () async {
final wasTorEnabled = ref
.read(webSearchSettingsControllerProvider)
.routeThroughTor;
ref
.read(webSearchSettingsControllerProvider.notifier)
.setRouteThroughTor(!wasTorEnabled);
if (!wasTorEnabled) {
await ensureTorBootstrap();
}
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: routeThroughTor
? colorScheme.primaryContainer
: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (showSpinner)
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: routeThroughTor
? colorScheme.onPrimaryContainer
: colorScheme.primary,
),
)
else
Badge(
isLabelVisible: torStatus?.isRunning == true,
backgroundColor: AppColors.of(context).torActiveGreen,
child: Icon(
routeThroughTor
? Icons.shield_rounded
: Icons.shield_outlined,
color: routeThroughTor
? colorScheme.onPrimaryContainer
: colorScheme.primary,
size: 18,
),
),
const SizedBox(width: 6),
Text(
routeThroughTor ? 'Tor on' : 'Tor off',
style: textTheme.labelLarge?.copyWith(
color: routeThroughTor
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.bold,
),
),
],
),
),
);
}
}
/// Slim linear progress bar shown directly underneath the search-screen Tor
/// toggle while Tor is bootstrapping. Mirrors the bar on the Tor settings
/// screen so users get the same visual feedback regardless of where they
/// turned Tor on. Returns a zero-height widget when not relevant.
class WebSearchTorBootstrapProgress extends HookConsumerWidget {
const WebSearchTorBootstrapProgress({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final routeThroughTor = ref.watch(
webSearchSettingsControllerProvider.select((s) => s.routeThroughTor),
);
if (!routeThroughTor) return const SizedBox.shrink();
// Push the latest native status into the stream on first build so the
// bar reflects real progress even when the user lands on the search
// screen with Tor already mid-bootstrap (otherwise the AsyncLoading
// state lingers and the bar appears to spin forever at zero).
useOnInitialization(() async {
await ref.read(torProxyServiceProvider.notifier).requestSync();
});
final torAsync = ref.watch(torProxyServiceProvider);
final status = torAsync.value;
final isRunning = status?.isRunning ?? false;
final bootstrapProgress = status?.bootstrapProgress ?? 0;
// Hide once Tor is fully running and bootstrapped — otherwise, mirror
// the Tor settings screen and always show a determinate bar (value
// anchored to the live bootstrap progress, never indeterminate, so the
// user can actually track the percentage).
if (isRunning && bootstrapProgress >= 100) return const SizedBox.shrink();
final appColors = AppColors.of(context);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: ClipRRect(
borderRadius: BorderRadius.circular(2),
child: LinearProgressIndicator(
minHeight: 3,
backgroundColor: appColors.torBackgroundGrey,
color: appColors.torActiveGreen,
value: bootstrapProgress / 100,
),
),
);
}
}
@@ -0,0 +1,80 @@
import 'package:flutter/material.dart';
class WebSearchField extends StatelessWidget {
final TextEditingController controller;
final bool enabled;
final Future<void> Function(String query) onSubmitted;
final VoidCallback onClear;
const WebSearchField({
super.key,
required this.controller,
required this.enabled,
required this.onSubmitted,
required this.onClear,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
height: 60,
padding: const EdgeInsets.only(left: 20, right: 6),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(30),
border: Border.all(color: colorScheme.outlineVariant),
),
child: Row(
children: [
Icon(Icons.search, color: colorScheme.onSurfaceVariant),
const SizedBox(width: 16),
Expanded(
child: TextField(
controller: controller,
enabled: enabled,
minLines: 1,
maxLines: 5,
keyboardType: TextInputType.multiline,
textInputAction: TextInputAction.search,
style: Theme.of(
context,
).textTheme.bodyLarge?.copyWith(color: colorScheme.onSurface),
decoration: InputDecoration(
border: InputBorder.none,
hintText: 'Search the web...',
hintStyle: TextStyle(color: colorScheme.onSurfaceVariant),
isDense: true,
),
onSubmitted: enabled ? onSubmitted : null,
),
),
ValueListenableBuilder<TextEditingValue>(
valueListenable: controller,
builder: (context, value, child) {
if (value.text.isEmpty) {
return const SizedBox.shrink();
}
return IconButton(
tooltip: 'Clear',
onPressed: enabled ? onClear : null,
icon: Icon(Icons.close, color: colorScheme.onSurfaceVariant),
);
},
),
const SizedBox(width: 4),
FilledButton(
onPressed: enabled ? () => onSubmitted(controller.text) : null,
style: FilledButton.styleFrom(
elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
),
child: const Text('Search'),
),
],
),
);
}
}
@@ -0,0 +1,416 @@
/*
* 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:hooks_riverpod/hooks_riverpod.dart';
import 'package:search_backend/search_backend.dart';
import 'package:weblibre/features/search_credits/domain/repositories/web_search_settings.dart';
import 'package:weblibre/features/web_search/data/locale_options.dart';
class _FilterPill extends StatelessWidget {
final IconData icon;
final String label;
final bool isHighlighted;
final VoidCallback onTap;
const _FilterPill({
required this.icon,
required this.label,
required this.isHighlighted,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final fg = isHighlighted
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant;
return Material(
color: isHighlighted
? colorScheme.primaryContainer
: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20),
child: InkWell(
borderRadius: BorderRadius.circular(20),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
color: isHighlighted ? fg : colorScheme.primary,
size: 18,
),
const SizedBox(width: 6),
Text(
label,
style: textTheme.labelLarge?.copyWith(
color: fg,
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 2),
Icon(Icons.arrow_drop_down_rounded, size: 18, color: fg),
],
),
),
),
);
}
}
class _MenuRow extends StatelessWidget {
final String label;
final String? subtitle;
final bool isSelected;
final bool isHighlighted;
const _MenuRow({
required this.label,
this.subtitle,
required this.isSelected,
this.isHighlighted = false,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final labelColor = isHighlighted ? colorScheme.primary : null;
return Row(
children: [
Expanded(
child: subtitle != null
? RichText(
text: TextSpan(
children: [
TextSpan(
text: label,
style: textTheme.bodyMedium?.copyWith(
color: labelColor,
fontWeight: isSelected || isHighlighted
? FontWeight.bold
: null,
),
),
TextSpan(
text: ' $subtitle',
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
)
: Text(
label,
style: textTheme.bodyMedium?.copyWith(
color: labelColor,
fontWeight: isSelected || isHighlighted
? FontWeight.bold
: null,
),
),
),
if (isSelected)
Padding(
padding: const EdgeInsets.only(left: 8),
child: Icon(
Icons.check_rounded,
size: 18,
color: colorScheme.primary,
),
),
],
);
}
}
class LanguageSelector extends ConsumerWidget {
const LanguageSelector({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final locale = Localizations.localeOf(context);
final selected = ref.watch(
webSearchSettingsControllerProvider.select((s) => s.language),
);
final isHighlighted = selected != null && selected != locale.languageCode;
final selectedOption = findLanguage(selected);
final label = selected == null
? 'Auto'
: (selectedOption?.name ?? selected);
final defaultOption = findLanguage(locale.languageCode);
final others = [
for (final l in supportedLanguages)
if (l.code != defaultOption?.code) l,
]..sort((a, b) => a.name.compareTo(b.name));
return MenuAnchor(
builder: (context, controller, _) => _FilterPill(
icon: Icons.translate_rounded,
label: label,
isHighlighted: isHighlighted,
onTap: () => controller.isOpen ? controller.close() : controller.open(),
),
menuChildren: [
MenuItemButton(
onPressed: () {
ref
.read(webSearchSettingsControllerProvider.notifier)
.setLanguage(null);
},
child: _MenuRow(
label: 'Auto (device default)',
subtitle: defaultOption?.code ?? locale.languageCode,
isSelected: selected == null,
),
),
const Divider(),
for (final option in others)
MenuItemButton(
onPressed: () {
ref
.read(webSearchSettingsControllerProvider.notifier)
.setLanguage(option.code);
},
child: _MenuRow(
label: option.name,
subtitle: option.code,
isSelected: selected == option.code,
),
),
],
);
}
}
class CountrySelector extends ConsumerWidget {
const CountrySelector({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final locale = Localizations.localeOf(context);
final selected = ref.watch(
webSearchSettingsControllerProvider.select((s) => s.region),
);
final isHighlighted = selected != null && selected != locale.countryCode;
final selectedOption = findCountry(selected);
final label = selected == null ? 'Any' : (selectedOption?.name ?? selected);
final defaultOption = findCountry(locale.countryCode);
final others = [
for (final c in supportedCountries)
if (c.code != defaultOption?.code) c,
]..sort((a, b) => a.name.compareTo(b.name));
return MenuAnchor(
builder: (context, controller, _) => _FilterPill(
icon: Icons.public_rounded,
label: label,
isHighlighted: isHighlighted,
onTap: () => controller.isOpen ? controller.close() : controller.open(),
),
menuChildren: [
MenuItemButton(
onPressed: () {
ref
.read(webSearchSettingsControllerProvider.notifier)
.setRegion(null);
},
child: _MenuRow(label: 'Any region', isSelected: selected == null),
),
if (defaultOption != null) ...[
const Divider(),
MenuItemButton(
onPressed: () {
ref
.read(webSearchSettingsControllerProvider.notifier)
.setRegion(defaultOption.code);
},
child: _MenuRow(
label: '${defaultOption.name} (device)',
subtitle: defaultOption.code,
isSelected: selected == defaultOption.code,
),
),
],
const Divider(),
for (final option in others)
MenuItemButton(
onPressed: () {
ref
.read(webSearchSettingsControllerProvider.notifier)
.setRegion(option.code);
},
child: _MenuRow(
label: option.name,
subtitle: option.code,
isSelected: selected == option.code,
),
),
],
);
}
}
class SafeSearchSelector extends ConsumerWidget {
const SafeSearchSelector({super.key});
String _label(SafeSearch? value) => switch (value) {
null => 'Safe: default',
SafeSearch.none => 'Safe: off',
SafeSearch.moderate => 'Safe: moderate',
SafeSearch.strict => 'Safe: strict',
};
@override
Widget build(BuildContext context, WidgetRef ref) {
final selected = ref.watch(
webSearchSettingsControllerProvider.select((s) => s.safeSearch),
);
final isHighlighted = selected != null;
return MenuAnchor(
builder: (context, controller, _) => _FilterPill(
icon: Icons.shield_moon_outlined,
label: _label(selected),
isHighlighted: isHighlighted,
onTap: () => controller.isOpen ? controller.close() : controller.open(),
),
menuChildren: [
MenuItemButton(
onPressed: () {
ref
.read(webSearchSettingsControllerProvider.notifier)
.setSafeSearch(null);
},
child: _MenuRow(
label: 'Default (moderate)',
isSelected: selected == null,
),
),
const Divider(),
MenuItemButton(
onPressed: () {
ref
.read(webSearchSettingsControllerProvider.notifier)
.setSafeSearch(SafeSearch.none);
},
child: _MenuRow(
label: 'Off',
isSelected: selected == SafeSearch.none,
isHighlighted: true,
),
),
MenuItemButton(
onPressed: () {
ref
.read(webSearchSettingsControllerProvider.notifier)
.setSafeSearch(SafeSearch.moderate);
},
child: _MenuRow(
label: 'Moderate',
isSelected: selected == SafeSearch.moderate,
),
),
MenuItemButton(
onPressed: () {
ref
.read(webSearchSettingsControllerProvider.notifier)
.setSafeSearch(SafeSearch.strict);
},
child: _MenuRow(
label: 'Strict',
isSelected: selected == SafeSearch.strict,
isHighlighted: true,
),
),
],
);
}
}
class FreshnessSelector extends ConsumerWidget {
const FreshnessSelector({super.key});
String _label(TimeRange? value) => switch (value) {
null => 'Any time',
TimeRange.day => 'Past day',
TimeRange.week => 'Past week',
TimeRange.month => 'Past month',
TimeRange.year => 'Past year',
};
@override
Widget build(BuildContext context, WidgetRef ref) {
final selected = ref.watch(
webSearchSettingsControllerProvider.select((s) => s.timeRange),
);
final isHighlighted = selected != null;
return MenuAnchor(
builder: (context, controller, _) => _FilterPill(
icon: Icons.schedule_rounded,
label: _label(selected),
isHighlighted: isHighlighted,
onTap: () => controller.isOpen ? controller.close() : controller.open(),
),
menuChildren: [
MenuItemButton(
onPressed: () {
ref
.read(webSearchSettingsControllerProvider.notifier)
.setTimeRange(null);
},
child: _MenuRow(label: 'Any time', isSelected: selected == null),
),
const Divider(),
for (final value in TimeRange.values)
MenuItemButton(
onPressed: () {
ref
.read(webSearchSettingsControllerProvider.notifier)
.setTimeRange(value);
},
child: _MenuRow(
label: _label(value),
isSelected: selected == value,
),
),
],
);
}
}
@@ -0,0 +1,113 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:search_backend/search_backend.dart';
import 'package:weblibre/features/search_credits/domain/repositories/web_search_settings.dart';
class SearchModeSelector extends ConsumerWidget {
const SearchModeSelector({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final searchMode = ref.watch(
webSearchSettingsControllerProvider.select((s) => s.searchMode),
);
return MenuAnchor(
builder: (context, controller, _) => InkWell(
borderRadius: BorderRadius.circular(20),
onTap: () => controller.isOpen ? controller.close() : controller.open(),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(_iconFor(searchMode), color: colorScheme.primary, size: 18),
const SizedBox(width: 6),
Text(
_labelFor(searchMode),
style: textTheme.labelLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 2),
Icon(
Icons.arrow_drop_down_rounded,
size: 18,
color: colorScheme.onSurfaceVariant,
),
],
),
),
),
menuChildren: [
for (final mode in SearchMode.values)
MenuItemButton(
onPressed: () {
ref
.read(webSearchSettingsControllerProvider.notifier)
.setSearchMode(mode);
},
leadingIcon: Icon(
_iconFor(mode),
size: 20,
color: mode == searchMode
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
trailingIcon: mode == searchMode
? Icon(
Icons.check_rounded,
size: 18,
color: colorScheme.primary,
)
: null,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_labelFor(mode),
style: textTheme.bodyMedium?.copyWith(
fontWeight: mode == searchMode
? FontWeight.bold
: FontWeight.normal,
),
),
Text(
_descriptionFor(mode),
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
],
);
}
static IconData _iconFor(SearchMode mode) => switch (mode) {
SearchMode.general => Icons.public,
SearchMode.independentWeb => Icons.volunteer_activism,
SearchMode.smallWeb => Icons.explore,
};
static String _labelFor(SearchMode mode) => switch (mode) {
SearchMode.general => 'General',
SearchMode.independentWeb => 'Independent Web',
SearchMode.smallWeb => 'Small Web',
};
static String _descriptionFor(SearchMode mode) => switch (mode) {
SearchMode.general => 'Balanced results across the open web',
SearchMode.independentWeb => 'Favor smaller and less corporate sources',
SearchMode.smallWeb => 'Independent, personal & niche sites',
};
}
@@ -0,0 +1,527 @@
import 'dart:typed_data';
import 'package:fast_equatable/fast_equatable.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:intl/intl.dart';
import 'package:nullability/nullability.dart';
import 'package:search_backend/search_backend.dart';
import 'package:weblibre/features/search_credits/domain/repositories/web_search_settings.dart';
import 'package:weblibre/features/web_search/domain/controllers/search_controller.dart';
import 'package:weblibre/features/web_search/domain/entities/captured_page_state.dart';
import 'package:weblibre/features/web_search/domain/entities/fetch_method.dart';
import 'package:weblibre/features/web_search/presentation/widgets/search_result_metadata_chips.dart';
import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart';
import 'package:weblibre/presentation/widgets/url_icon.dart';
class WebSearchResultCard extends ConsumerWidget {
final CompactSearchResult result;
final Future<void> Function(Uri url) onOpen;
final Future<void> Function(Uri url) onFetch;
final Future<void> Function(Uri url) onPreview;
final Future<void> Function(CapturedPageState captured) onOpenCapture;
const WebSearchResultCard({
super.key,
required this.result,
required this.onOpen,
required this.onFetch,
required this.onPreview,
required this.onOpenCapture,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final imageUrl = (result.imgSrc?.isNotEmpty ?? false)
? result.imgSrc
: (result.thumbnail?.isNotEmpty ?? false)
? result.thumbnail
: null;
final imageBytes = imageUrl.mapNotNull(
(imageUrl) => ref.watch(
metaSearchControllerProvider.select((s) => s.imagesByUrl[imageUrl]),
),
);
final document = ref.watch(
metaSearchControllerProvider.select((s) => s.documentsByUrl[result.url]),
);
final queryLanguage = ref.watch(
webSearchSettingsControllerProvider.select((s) => s.language),
);
return Card(
color: colorScheme.surfaceContainerHigh,
clipBehavior: Clip.antiAlias,
margin: EdgeInsets.zero,
child: InkWell(
onTap: () => onOpen(result.url),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_UrlRow(result: result),
const SizedBox(height: 16),
_ContentRow(
result: result,
imageBytes: imageBytes,
colorScheme: colorScheme,
textTheme: textTheme,
),
if ((result.metadata ?? const []).isNotEmpty ||
document != null) ...[
const SizedBox(height: 12),
SearchResultMetadataChips(
metadata: result.metadata ?? const [],
pageMetadata: document?.metadata,
queryLanguage: queryLanguage,
),
],
if (result.metadata case final metadata? when metadata.isNotEmpty)
SearchResultMetadataExpandable(metadata: metadata),
const SizedBox(height: 16),
_FetchFooter(
url: result.url,
onFetch: onFetch,
onPreview: onPreview,
onOpenCapture: onOpenCapture,
),
],
),
),
),
);
}
}
class _UrlRow extends StatelessWidget {
final CompactSearchResult result;
const _UrlRow({required this.result});
@override
Widget build(BuildContext context) {
return UriBreadcrumb(
uri: result.url,
icon: UrlIcon([result.url], iconSize: 16, cacheOnly: true),
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
);
}
}
class _ContentRow extends StatelessWidget {
final CompactSearchResult result;
final Uint8List? imageBytes;
final ColorScheme colorScheme;
final TextTheme textTheme;
const _ContentRow({
required this.result,
required this.imageBytes,
required this.colorScheme,
required this.textTheme,
});
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (imageBytes != null) ...[
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.memory(
imageBytes!,
width: 88,
height: 88,
fit: BoxFit.cover,
gaplessPlayback: true,
errorBuilder: (_, _, _) => const SizedBox.shrink(),
),
),
const SizedBox(width: 16),
],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
result.title,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.primary,
fontWeight: FontWeight.w600,
height: 1.3,
),
),
if (result.publishedDate case final String date
when date.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
_formatDate(date),
style: textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
if (result.content case final String content
when content.trim().isNotEmpty) ...[
const SizedBox(height: 6),
_ExpandableDescription(
text: content.trim(),
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
height: 1.5,
),
),
],
],
),
),
],
);
}
String _formatDate(String date) {
final parsed = DateTime.tryParse(date);
if (parsed != null) {
return DateFormat.yMMMd().format(parsed);
}
return date;
}
}
/// Per-URL footer state, batched into a single select() so the footer only
/// rebuilds when something relevant to *this* URL changes.
///
/// `capturing`/`captures` are exposed for rendering, but they are NOT used in
/// [hashParameters] — Dart's built-in `Set`/`Map` compare by identity, so
/// including them would defeat the dedupe (the controller produces fresh
/// instances on every state mutation). Equality is driven instead by scalar
/// signatures derived from those collections, which is enough to detect any
/// change a single result card cares about.
class _FooterState with FastEquatable {
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
List<Object?> get hashParameters => [
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),
FilledButton.tonalIcon(
onPressed: () => onFetch(url),
icon: const Icon(Icons.download_rounded, size: 18),
label: const Text('Fetch'),
style: FilledButton.styleFrom(elevation: 0),
),
],
],
);
}
bool _isMethodBusy(_FooterState s, FetchMethodChoice method) {
if (method == FetchMethodChoice.trafilatura) {
return s.isFetching;
}
if (s.capturing.contains(method)) {
return true;
}
final captured = s.captures[method];
return captured != null &&
(captured.status == CapturedPageStatus.capturing ||
captured.status == CapturedPageStatus.downloading);
}
}
class _CaptureChip extends StatelessWidget {
final FetchMethodChoice choice;
final Future<void> Function() onTap;
const _CaptureChip({required this.choice, required this.onTap});
@override
Widget build(BuildContext context) {
return ActionChip(
avatar: Icon(choice.icon, size: 16),
label: Text(choice.shortLabel),
visualDensity: VisualDensity.compact,
onPressed: () => onTap(),
);
}
}
/// Failed-fetch / failed-capture chip. Always rendered (red, with an error
/// outline icon) so the user can see *which* method failed; tapping pops a
/// dialog with the verbatim server message and an optional retry action.
class _ErrorChip extends StatelessWidget {
final FetchMethodChoice choice;
final String errorMessage;
final bool canRetry;
final Future<void> Function() onRetry;
const _ErrorChip({
required this.choice,
required this.errorMessage,
required this.canRetry,
required this.onRetry,
});
Future<void> _showDetail(BuildContext context) async {
final retry = await showDialog<bool>(
context: context,
builder: (dialogContext) {
return AlertDialog(
title: Row(
children: [
const Icon(Icons.error_outline, color: Colors.red),
const SizedBox(width: 8),
Expanded(child: Text('${choice.title} failed')),
],
),
content: SingleChildScrollView(child: Text(errorMessage)),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('Close'),
),
if (canRetry)
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: const Text('Retry'),
),
],
);
},
);
if (retry == true) {
await onRetry();
}
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return ActionChip(
avatar: Icon(Icons.error_outline, size: 16, color: colorScheme.error),
label: Text(choice.shortLabel),
visualDensity: VisualDensity.compact,
side: BorderSide(color: colorScheme.error),
labelStyle: TextStyle(color: colorScheme.error),
onPressed: () => _showDetail(context),
);
}
}
class _BusyChip extends StatelessWidget {
final FetchMethodChoice choice;
const _BusyChip({required this.choice});
@override
Widget build(BuildContext context) {
return Chip(
avatar: const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
),
label: Text(choice.shortLabel),
visualDensity: VisualDensity.compact,
);
}
}
class _ExpandableDescription extends HookWidget {
static const _collapsedMaxLines = 4;
final String text;
final TextStyle? style;
const _ExpandableDescription({required this.text, required this.style});
@override
Widget build(BuildContext context) {
final expanded = useState(false);
return GestureDetector(
behavior: HitTestBehavior.opaque,
onLongPress: () {
expanded.value = !expanded.value;
},
child: AnimatedSize(
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
alignment: Alignment.topCenter,
child: Text(
text,
maxLines: expanded.value ? null : _collapsedMaxLines,
overflow: expanded.value ? TextOverflow.clip : TextOverflow.ellipsis,
style: style,
),
),
);
}
}
@@ -0,0 +1,407 @@
import 'package:fading_scroll/fading_scroll.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:intl/intl.dart';
import 'package:intl/locale.dart' as intl;
import 'package:search_backend/search_backend.dart';
import 'package:weblibre/domain/repositories/locale_resolver.dart';
const _expandableKeys = {'snippet', 'review', 'question'};
const _keyOrder = <String>[
// Badges
'type',
'subtype',
'access',
// Time / freshness
'released',
'duration',
'time',
'hours',
// Quality / popularity
'rating',
'answers',
'stars',
'forks',
// Identity
'author',
'publisher',
'organization',
'sitename',
'forum',
// Commerce
'price',
'price_range',
// Content descriptors
'genre',
'cuisine',
'categories',
'pages',
'servings',
'calories',
'version',
'distance',
// Meta
'language',
'license',
'pagetype',
];
const _hiddenKeys = {
// The publisher-declared article date duplicates the inline `publishedDate`
// shown under the title. Keep only the inline version.
'date',
'score',
'gravity',
'quality',
'phrases',
'size',
'format',
'results_from_domain',
'more_from_domain',
'content_type',
'tags',
};
// Built once at import time so the sort comparator doesn't rebuild it on
// every widget rebuild.
final Map<String, int> _keyOrderIndex = {
for (var i = 0; i < _keyOrder.length; i++) _keyOrder[i]: i,
};
IconData _iconFor(String key) {
switch (key) {
case 'type':
return Icons.label_outline;
case 'subtype':
return Icons.category_outlined;
case 'rating':
return Icons.star_outline;
case 'language':
return Icons.language;
case 'author':
return Icons.person_outline;
case 'publisher':
case 'organization':
case 'sitename':
return MdiIcons.domain;
case 'forum':
return MdiIcons.forum;
case 'answers':
return Icons.question_answer_outlined;
case 'price':
case 'price_range':
return MdiIcons.currencyUsd;
case 'access':
return Icons.lock_outline;
case 'duration':
case 'time':
case 'hours':
return Icons.schedule;
case 'pages':
return MdiIcons.bookOpenPageVariantOutline;
// `date` is in `_hiddenKeys`, so only `released` reaches this branch in
// practice — keep the case for safety should `date` ever be un-hidden.
case 'released':
case 'date':
return Icons.calendar_month;
case 'genre':
case 'cuisine':
case 'categories':
return Icons.local_offer_outlined;
case 'servings':
return MdiIcons.silverwareForkKnife;
case 'calories':
return MdiIcons.fire;
case 'stars':
return Icons.star_border;
case 'forks':
return MdiIcons.sourceFork;
case 'version':
return MdiIcons.tagOutline;
case 'distance':
return Icons.place_outlined;
case 'license':
return MdiIcons.license;
case 'pagetype':
return Icons.article_outlined;
default:
return Icons.info_outline;
}
}
String? _formatValue(String key, String value) {
switch (key) {
case 'released':
final parsed = DateTime.tryParse(value);
if (parsed != null) return DateFormat.yMMMd().format(parsed);
return value;
default:
return value;
}
}
/// Compare the primary subtag of two BCP 47-ish language strings, ignoring
/// region and case. `null`/empty `queryTag` means "no preference set", so
/// the result language is always shown.
bool _languageMatchesQuery(String resultLanguage, String? queryTag) {
if (queryTag == null || queryTag.isEmpty) return false;
final result = resultLanguage
.trim()
.toLowerCase()
.split(RegExp(r'[-_]'))
.first;
final query = queryTag.trim().toLowerCase().split(RegExp(r'[-_]')).first;
if (result.isEmpty || query.isEmpty) return false;
return result == query;
}
List<MetadataItem> _metadataFromPage(PageMetadata? pageMetadata) {
if (pageMetadata == null) return const [];
final items = <MetadataItem>[];
if (pageMetadata.date case final String date when date.isNotEmpty) {
items.add(MetadataItem(key: 'released', value: date));
}
if (pageMetadata.sitename case final String sitename
when sitename.isNotEmpty) {
items.add(MetadataItem(key: 'sitename', value: sitename));
}
if (pageMetadata.author case final String author when author.isNotEmpty) {
items.add(MetadataItem(key: 'author', value: author));
}
if (pageMetadata.language case final String language
when language.isNotEmpty) {
items.add(MetadataItem(key: 'language', value: language));
}
if (pageMetadata.license case final String license when license.isNotEmpty) {
items.add(MetadataItem(key: 'license', value: license));
}
if (pageMetadata.pagetype case final String pagetype
when pagetype.isNotEmpty) {
items.add(MetadataItem(key: 'pagetype', value: pagetype));
}
return items;
}
class SearchResultMetadataChips extends StatelessWidget {
final List<MetadataItem> metadata;
final PageMetadata? pageMetadata;
/// ISO 639-1 language code of the query (e.g. `en`, `de`). When the
/// result's `language` metadata matches by primary subtag, the language
/// chip is suppressed as redundant.
final String? queryLanguage;
const SearchResultMetadataChips({
super.key,
required this.metadata,
this.pageMetadata,
this.queryLanguage,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final seen = <String>{};
final merged = <_ResolvedMetadataItem>[];
// Page metadata items first so they take priority on deduplication.
for (final item in _metadataFromPage(pageMetadata)) {
if (_expandableKeys.contains(item.key)) continue;
if (_hiddenKeys.contains(item.key)) continue;
if (item.value.trim().isEmpty) continue;
if (item.key == 'language' &&
_languageMatchesQuery(item.value, queryLanguage)) {
continue;
}
if (!seen.add(item.key)) continue;
merged.add(
_ResolvedMetadataItem(item: item, isLanguage: item.key == 'language'),
);
}
for (final item in metadata) {
if (_expandableKeys.contains(item.key)) continue;
if (_hiddenKeys.contains(item.key)) continue;
if (item.value.trim().isEmpty) continue;
if (item.key == 'language' &&
_languageMatchesQuery(item.value, queryLanguage)) {
continue;
}
// Skip if page metadata already provided this key (preview takes priority).
if (!seen.add(item.key)) continue;
merged.add(
_ResolvedMetadataItem(item: item, isLanguage: item.key == 'language'),
);
}
if (merged.isEmpty) return const SizedBox.shrink();
merged.sort((a, b) {
final ai = _keyOrderIndex[a.item.key] ?? _keyOrder.length;
final bi = _keyOrderIndex[b.item.key] ?? _keyOrder.length;
return ai.compareTo(bi);
});
return FadingScroll(
fadingSize: 15,
builder: (context, controller) {
return SingleChildScrollView(
controller: controller,
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (var i = 0; i < merged.length; i++) ...[
if (i > 0) const SizedBox(width: 8),
// Decorative — these chips don't filter or navigate. Using a
// plain Chip (instead of an OutlinedButton with an empty
// onPressed) avoids the misleading tap ripple.
Chip(
avatar: Icon(
_iconFor(merged[i].item.key),
size: 16,
color: colorScheme.onSurfaceVariant,
),
label: merged[i].isLanguage
? _ResolvedLanguageLabel(
languageTag: merged[i].item.value,
)
: Text(
_formatValue(
merged[i].item.key,
merged[i].item.value,
) ??
merged[i].item.value,
),
side: BorderSide(color: colorScheme.outlineVariant),
labelStyle: TextStyle(color: colorScheme.onSurfaceVariant),
backgroundColor: Colors.transparent,
visualDensity: VisualDensity.compact,
),
],
],
),
);
},
);
}
}
class _ResolvedMetadataItem {
final MetadataItem item;
final bool isLanguage;
const _ResolvedMetadataItem({required this.item, required this.isLanguage});
}
class _ResolvedLanguageLabel extends ConsumerWidget {
final String languageTag;
const _ResolvedLanguageLabel({required this.languageTag});
@override
Widget build(BuildContext context, WidgetRef ref) {
final locale = intl.Locale.tryParse(languageTag);
if (locale == null) return Text(languageTag);
final resolved = ref.watch(resolveLocaleProvider(locale));
return Text(
resolved.maybeWhen(
data: (data) => data.languageName,
orElse: () => languageTag,
),
);
}
}
class SearchResultMetadataExpandable extends StatelessWidget {
final List<MetadataItem> metadata;
const SearchResultMetadataExpandable({super.key, required this.metadata});
@override
Widget build(BuildContext context) {
final entries = metadata
.where((item) => _expandableKeys.contains(item.key))
.where((item) => item.value.trim().isNotEmpty)
.toList();
if (entries.isEmpty) return const SizedBox.shrink();
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final hasSnippets = entries.any((e) => e.key == 'snippet');
final hasReview = entries.any((e) => e.key == 'review');
final hasQuestion = entries.any((e) => e.key == 'question');
final parts = [
if (hasQuestion) 'question',
if (hasSnippets) 'snippets',
if (hasReview) 'review',
];
final label = parts.isEmpty ? 'More' : 'Show ${parts.join(' & ')}';
return Theme(
data: Theme.of(context).copyWith(
dividerColor: Colors.transparent,
splashColor: Colors.transparent,
),
child: ExpansionTile(
tilePadding: EdgeInsets.zero,
childrenPadding: const EdgeInsets.only(bottom: 8),
dense: true,
visualDensity: VisualDensity.compact,
title: Text(
label,
style: textTheme.labelLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
children: [
for (final item in entries) ...[
if (item != entries.first) const SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
child: Text.rich(
TextSpan(
children: [
if (item.key == 'question')
TextSpan(
text: 'Q: ',
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w600,
height: 1.5,
),
),
TextSpan(
text: item.value,
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
height: 1.5,
fontStyle: item.key == 'question'
? FontStyle.italic
: null,
),
),
],
),
),
),
],
],
),
);
}
}
@@ -0,0 +1,398 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:search_backend/search_backend.dart';
import 'package:weblibre/features/web_search/domain/controllers/search_controller.dart';
import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart';
import 'package:weblibre/presentation/widgets/url_icon.dart';
class WebSearchInfoboxCard extends HookConsumerWidget {
final CompactInfobox info;
final Future<void> Function(Uri url) onOpen;
/// When non-null the card uses this externally-controlled expansion state
/// (e.g. shared across a carousel). When null, the card manages its own
/// state with a default of expanded.
final bool? expandedOverride;
final VoidCallback? onToggleExpanded;
const WebSearchInfoboxCard({
super.key,
required this.info,
required this.onOpen,
this.expandedOverride,
this.onToggleExpanded,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final imageUrl = info.imgSrc;
final imageBytes = (imageUrl == null || imageUrl.isEmpty)
? null
: ref.watch(
metaSearchControllerProvider.select((s) => s.imagesByUrl[imageUrl]),
);
final attributes = info.attributes ?? const <InfoboxAttribute>[];
final urls = info.urls ?? const <InfoboxUrl>[];
final heading = _heading(info);
final localExpanded = useState(true);
final isExpanded = expandedOverride ?? localExpanded.value;
void toggle() {
final external = onToggleExpanded;
if (external != null) {
external();
} else {
localExpanded.value = !localExpanded.value;
}
}
return Card(
color: colorScheme.surfaceContainerHigh,
clipBehavior: Clip.antiAlias,
margin: EdgeInsets.zero,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
InkWell(
onTap: toggle,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 8, 12),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (heading.isNotEmpty)
Text(
heading,
style: textTheme.headlineSmall?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w600,
),
),
if (info.url case final Uri sourceUrl) ...[
const SizedBox(height: 6),
UriBreadcrumb(
uri: sourceUrl,
icon: UrlIcon(
[sourceUrl],
iconSize: 16,
cacheOnly: true,
),
style: textTheme.labelMedium?.copyWith(
color: colorScheme.primary,
fontWeight: FontWeight.w500,
),
),
],
],
),
),
AnimatedRotation(
turns: isExpanded ? 0.5 : 0,
duration: const Duration(milliseconds: 200),
child: Icon(
Icons.expand_more,
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
),
if (isExpanded)
_Body(
info: info,
imageBytes: imageBytes,
attributes: attributes,
urls: urls,
onOpen: onOpen,
colorScheme: colorScheme,
textTheme: textTheme,
),
],
),
);
}
String _heading(CompactInfobox info) {
final title = info.title?.trim();
if (title != null && title.isNotEmpty) return title;
return info.infobox.trim();
}
}
class _Body extends StatelessWidget {
final CompactInfobox info;
final Uint8List? imageBytes;
final List<InfoboxAttribute> attributes;
final List<InfoboxUrl> urls;
final Future<void> Function(Uri url) onOpen;
final ColorScheme colorScheme;
final TextTheme textTheme;
const _Body({
required this.info,
required this.imageBytes,
required this.attributes,
required this.urls,
required this.onOpen,
required this.colorScheme,
required this.textTheme,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (imageBytes != null) ...[
Align(
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 260),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.memory(
imageBytes!,
fit: BoxFit.contain,
gaplessPlayback: true,
errorBuilder: (_, _, _) => const SizedBox.shrink(),
),
),
),
),
const SizedBox(height: 16),
],
if (info.content case final String content
when content.trim().isNotEmpty) ...[
Text(
content.trim(),
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
height: 1.5,
),
),
const SizedBox(height: 12),
],
if (info.source.isNotEmpty)
Text(
'Source: ${info.source}',
style: textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
if (attributes.isNotEmpty)
_Factsheet(
attributes: attributes,
colorScheme: colorScheme,
textTheme: textTheme,
),
if (urls.isNotEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (final urlObj in urls) ...[
OutlinedButton.icon(
onPressed: () => onOpen(urlObj.url),
icon: Icon(_iconForLink(urlObj.title), size: 16),
label: Text(urlObj.title),
style: OutlinedButton.styleFrom(
foregroundColor: colorScheme.onSurfaceVariant,
side: BorderSide(color: colorScheme.outlineVariant),
padding: const EdgeInsets.symmetric(horizontal: 12),
),
),
const SizedBox(width: 8),
],
],
),
),
)
else
const SizedBox(height: 16),
],
);
}
IconData _iconForLink(String title) {
final lower = title.toLowerCase();
if (lower.contains('wikipedia') || lower.contains('wiki')) {
return Icons.article_outlined;
}
if (lower.contains('reddit')) return Icons.forum_outlined;
if (lower.contains('facebook')) return Icons.facebook_outlined;
if (lower.contains('youtube') || lower.contains('video')) {
return Icons.play_circle_outline;
}
if (lower.contains('twitter') || lower.contains('x.com')) {
return Icons.alternate_email;
}
if (lower.contains('instagram')) return Icons.photo_camera_outlined;
if (lower.contains('github')) return Icons.code;
if (lower.contains('mastodon')) return Icons.public;
return Icons.link;
}
}
class _Factsheet extends StatelessWidget {
final List<InfoboxAttribute> attributes;
final ColorScheme colorScheme;
final TextTheme textTheme;
const _Factsheet({
required this.attributes,
required this.colorScheme,
required this.textTheme,
});
@override
Widget build(BuildContext context) {
return Theme(
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
child: ExpansionTile(
title: Text(
'Factsheet',
style: textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600),
),
tilePadding: const EdgeInsets.symmetric(horizontal: 16),
childrenPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
expandedCrossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final attr in attributes)
if (attr.value case final String value when value.trim().isNotEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: RichText(
text: TextSpan(
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
height: 1.5,
),
children: [
TextSpan(
text:
'${attr.label.replaceAll(RegExp(r':+\s*$'), '')}: ',
style: TextStyle(
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
),
TextSpan(text: value),
],
),
),
),
],
),
);
}
}
class WebSearchInfoboxCarousel extends HookConsumerWidget {
final List<CompactInfobox> infos;
final Future<void> Function(Uri url) onOpen;
/// Upper bound for the carousel viewport. The PageView gets exactly this
/// height; if a card is taller, the inner [SingleChildScrollView] handles
/// the overflow. Picked large enough to fit a typical Wikipedia-style
/// infobox without scrolling, small enough not to dominate the screen.
static const _maxCardHeight = 520.0;
const WebSearchInfoboxCarousel({
super.key,
required this.infos,
required this.onOpen,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
final controller = usePageController();
final currentPage = useState(0);
final expanded = useState(true);
useEffect(() {
void listener() {
final page = controller.page?.round() ?? 0;
if (page != currentPage.value) {
currentPage.value = page;
}
}
controller.addListener(listener);
return () => controller.removeListener(listener);
}, [controller]);
// Earlier revisions measured each page's real height in a post-frame
// callback and animated the carousel to match. With SizeChangedLayout
// notifications + post-frame setState, this created a measurement
// feedback loop that was vulnerable to floating-point jitter and
// sometimes spent the whole expand/collapse animation re-measuring.
// Using a fixed maximum + per-page scrolling sidesteps the loop and is
// measurably cheaper.
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
height: _maxCardHeight,
child: PageView.builder(
controller: controller,
itemCount: infos.length,
itemBuilder: (context, index) {
return SingleChildScrollView(
child: WebSearchInfoboxCard(
info: infos[index],
onOpen: onOpen,
expandedOverride: expanded.value,
onToggleExpanded: () => expanded.value = !expanded.value,
),
);
},
),
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.center,
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),
),
),
],
),
],
);
}
}
@@ -0,0 +1,305 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/account/data/supabase_config.dart';
import 'package:weblibre/features/search_credits/domain/repositories/search_credits_repository.dart';
import 'package:weblibre/features/search_credits/domain/repositories/search_token_stash_repository.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/presentation/dialogs/fetch_method_dialog.dart';
import 'package:weblibre/features/web_search/presentation/open_in_new_tab.dart';
import 'package:weblibre/features/web_search/presentation/screens/page_preview.dart';
import 'package:weblibre/features/web_search/presentation/widgets/search_result_card.dart';
import 'package:weblibre/features/web_search/presentation/widgets/web_search_infobox_card.dart';
import 'package:weblibre/presentation/widgets/failure_widget.dart';
/// Number of result cards from the bottom at which to prefetch the next
/// page. With a backend page size of 10, four cards of look-ahead means
/// the next page is requested when the user reaches result 6/10 — enough
/// runway for the round-trip on a typical mobile connection without
/// firing the request when it's still ambiguous whether the user will
/// actually scroll further.
const _loadMoreThreshold = 4;
/// Combined credits+tokens balance below which the small "low credits"
/// chip surfaces in the app bar. Picked so the user gets ample warning
/// (~one full page worth of token spend) before running out mid-session.
const _lowCreditWarningThreshold = 25;
/// Resolves [WebSearchOpenTarget] for the *next* result tap.
///
/// We resolve lazily (per-tap) instead of capturing once at section build
/// time so the user can change the tab-type or container selectors in the
/// search header *after* a search completes and have those choices honoured
/// on subsequent taps.
typedef WebSearchOpenTargetResolver = WebSearchOpenTarget Function();
class WebSearchResultsSection extends HookConsumerWidget {
final WebSearchOpenTargetResolver resolveOpenTarget;
const WebSearchResultsSection({super.key, required this.resolveOpenTarget});
@override
Widget build(BuildContext context, WidgetRef ref) {
useOnAppLifecycleStateChange((previous, current) async {
if (current == AppLifecycleState.resumed) {
await ref.read(searchCreditsRepositoryProvider.notifier).refresh();
}
});
Future<void> openUri(Uri uri) {
return ref
.read(webSearchTabOpenerProvider)
.open(context, ref, uri, target: resolveOpenTarget());
}
Future<void> showPreview(Uri uri) {
return Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) =>
PagePreviewScreen(uri: uri, resolveOpenTarget: resolveOpenTarget),
),
);
}
Future<void> openCapture(CapturedPageState captured) async {
final captureId = captured.captureId;
if (captureId == null || captured.localPath == null) {
return;
}
await ref
.read(webSearchTabOpenerProvider)
.openCapture(
context,
ref,
captureId: captureId,
sourceUrl: captured.sourceUrl,
target: resolveOpenTarget(),
contentType: captured.contentType,
method: captured.method,
variant: captured.variant,
);
}
Future<void> onFetch(Uri uri) {
return showFetchMethodSheet(
context,
url: uri,
onPreview: showPreview,
onOpenCapture: openCapture,
);
}
// We re-render the whole results sliver on any controller state change
// because we need the full results/infos lists below. The `select` for
// the empty-error message is therefore subsumed by this watch.
final state = ref.watch(metaSearchControllerProvider);
if (state.status == WebSearchStatus.needsCredits) {
return const SliverToBoxAdapter(child: _NeedsCredits());
}
if (state.status == WebSearchStatus.error && state.results.isEmpty) {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(24),
child: FailureWidget(
title: 'Search failed',
exception: state.errorMessage,
),
),
);
}
if (state.status == WebSearchStatus.submitting && state.results.isEmpty) {
return const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Searching the web...'),
],
),
),
);
}
if (state.results.isNotEmpty || state.infos.isNotEmpty) {
return SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
sliver: SliverMainAxisGroup(
slivers: [
if (state.infos.length == 1)
SliverToBoxAdapter(
child: WebSearchInfoboxCard(
info: state.infos.first,
onOpen: openUri,
),
)
else if (state.infos.length > 1)
SliverToBoxAdapter(
child: WebSearchInfoboxCarousel(
infos: state.infos,
onOpen: openUri,
),
),
if (state.infos.isNotEmpty && state.results.isNotEmpty)
const SliverToBoxAdapter(child: SizedBox(height: 12)),
SliverList.separated(
itemCount: state.results.length,
separatorBuilder: (_, _) => const SizedBox(height: 12),
itemBuilder: (context, index) {
// Prefetch the next page once the user is within
// _loadMoreThreshold cards of the end. Scheduled in a
// post-frame callback because triggering state writes
// during build is not allowed; the controller no-ops if
// a load is already in flight, so re-scheduling on
// rebuilds is safe.
if (state.hasMore &&
!state.isLoadingMore &&
index >= state.results.length - _loadMoreThreshold) {
WidgetsBinding.instance.addPostFrameCallback((_) {
unawaited(
ref
.read(metaSearchControllerProvider.notifier)
.loadNextPage(),
);
});
}
final result = state.results[index];
return WebSearchResultCard(
key: ValueKey(result.url),
result: result,
onOpen: openUri,
onFetch: onFetch,
onPreview: showPreview,
onOpenCapture: openCapture,
);
},
),
if (state.hasMore || state.isLoadingMore)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Center(
child: state.isLoadingMore
? const SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const SizedBox.shrink(),
),
),
),
],
),
);
}
if (state.query.isNotEmpty) {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(
'No results found for "${state.query}".',
textAlign: TextAlign.center,
),
),
);
}
return const SliverToBoxAdapter(child: SizedBox.shrink());
}
}
class WebSearchStatusChip extends ConsumerWidget {
const WebSearchStatusChip({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final creditsAsync = ref.watch(searchCreditsRepositoryProvider);
final stashAsync = ref.watch(searchTokenStashCountProvider);
if (!creditsAsync.hasValue || !stashAsync.hasValue) {
return const SizedBox.shrink();
}
final credits = creditsAsync.value!.availableCredits;
final stash = stashAsync.value!;
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
if (credits + stash >= _lowCreditWarningThreshold) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.only(right: 8.0),
child: InkWell(
borderRadius: BorderRadius.circular(20),
onTap: () => AccountSettingsRoute().push(context),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.stars_rounded, color: colorScheme.primary, size: 18),
const SizedBox(width: 6),
Text(
'$credits credits | $stash tokens',
style: textTheme.labelLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.bold,
),
),
],
),
),
),
);
}
}
class _NeedsCredits extends StatelessWidget {
const _NeedsCredits();
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'No search credits or tokens are available for a new web search.',
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
FilledButton.icon(
icon: const Icon(Icons.shopping_cart_outlined),
label: const Text('Buy a search pack'),
onPressed: () async {
await launchUrl(
Uri.parse('${SupabaseConfig.accountWebUrl}?view=search-pack'),
mode: LaunchMode.inAppBrowserView,
);
},
),
],
),
);
}
}