Add Supa account and search changes
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
+187
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+407
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+398
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
+305
@@ -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,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user