Add Supa account and search changes
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
library;
|
||||
|
||||
/// Curated picker options for the web-search language and country selectors.
|
||||
///
|
||||
/// Codes intersect what Brave (`search_lang`/`country`) and Mojeek
|
||||
/// (`lb`/`rb`) accept; English display names so the menu doesn't need a
|
||||
/// per-locale translation pass. The protocol carries just the primary
|
||||
/// ISO 639-1 / ISO 3166-1 alpha-2 codes — region qualifiers (`en-gb`,
|
||||
/// `pt-br`, etc.) are reconstructed server-side from the language+country
|
||||
/// pair.
|
||||
|
||||
class LanguageOption {
|
||||
final String code; // ISO 639-1
|
||||
final String name; // English display name
|
||||
const LanguageOption(this.code, this.name);
|
||||
}
|
||||
|
||||
class CountryOption {
|
||||
final String code; // ISO 3166-1 alpha-2 (uppercase)
|
||||
final String name; // English display name
|
||||
const CountryOption(this.code, this.name);
|
||||
}
|
||||
|
||||
const supportedLanguages = <LanguageOption>[
|
||||
LanguageOption('ar', 'Arabic'),
|
||||
LanguageOption('bg', 'Bulgarian'),
|
||||
LanguageOption('bn', 'Bengali'),
|
||||
LanguageOption('ca', 'Catalan'),
|
||||
LanguageOption('cs', 'Czech'),
|
||||
LanguageOption('da', 'Danish'),
|
||||
LanguageOption('de', 'German'),
|
||||
LanguageOption('el', 'Greek'),
|
||||
LanguageOption('en', 'English'),
|
||||
LanguageOption('es', 'Spanish'),
|
||||
LanguageOption('et', 'Estonian'),
|
||||
LanguageOption('eu', 'Basque'),
|
||||
LanguageOption('fi', 'Finnish'),
|
||||
LanguageOption('fr', 'French'),
|
||||
LanguageOption('gl', 'Galician'),
|
||||
LanguageOption('gu', 'Gujarati'),
|
||||
LanguageOption('he', 'Hebrew'),
|
||||
LanguageOption('hi', 'Hindi'),
|
||||
LanguageOption('hr', 'Croatian'),
|
||||
LanguageOption('hu', 'Hungarian'),
|
||||
LanguageOption('id', 'Indonesian'),
|
||||
LanguageOption('is', 'Icelandic'),
|
||||
LanguageOption('it', 'Italian'),
|
||||
LanguageOption('ja', 'Japanese'),
|
||||
LanguageOption('kn', 'Kannada'),
|
||||
LanguageOption('ko', 'Korean'),
|
||||
LanguageOption('lt', 'Lithuanian'),
|
||||
LanguageOption('lv', 'Latvian'),
|
||||
LanguageOption('ml', 'Malayalam'),
|
||||
LanguageOption('mr', 'Marathi'),
|
||||
LanguageOption('ms', 'Malay'),
|
||||
LanguageOption('nb', 'Norwegian'),
|
||||
LanguageOption('nl', 'Dutch'),
|
||||
LanguageOption('pa', 'Punjabi'),
|
||||
LanguageOption('pl', 'Polish'),
|
||||
LanguageOption('pt', 'Portuguese'),
|
||||
LanguageOption('ro', 'Romanian'),
|
||||
LanguageOption('ru', 'Russian'),
|
||||
LanguageOption('sk', 'Slovak'),
|
||||
LanguageOption('sl', 'Slovenian'),
|
||||
LanguageOption('sr', 'Serbian'),
|
||||
LanguageOption('sv', 'Swedish'),
|
||||
LanguageOption('ta', 'Tamil'),
|
||||
LanguageOption('te', 'Telugu'),
|
||||
LanguageOption('th', 'Thai'),
|
||||
LanguageOption('tr', 'Turkish'),
|
||||
LanguageOption('uk', 'Ukrainian'),
|
||||
LanguageOption('vi', 'Vietnamese'),
|
||||
LanguageOption('zh', 'Chinese'),
|
||||
];
|
||||
|
||||
const supportedCountries = <CountryOption>[
|
||||
CountryOption('AR', 'Argentina'),
|
||||
CountryOption('AT', 'Austria'),
|
||||
CountryOption('AU', 'Australia'),
|
||||
CountryOption('BE', 'Belgium'),
|
||||
CountryOption('BR', 'Brazil'),
|
||||
CountryOption('CA', 'Canada'),
|
||||
CountryOption('CH', 'Switzerland'),
|
||||
CountryOption('CL', 'Chile'),
|
||||
CountryOption('CN', 'China'),
|
||||
CountryOption('DE', 'Germany'),
|
||||
CountryOption('DK', 'Denmark'),
|
||||
CountryOption('ES', 'Spain'),
|
||||
CountryOption('FI', 'Finland'),
|
||||
CountryOption('FR', 'France'),
|
||||
CountryOption('GB', 'United Kingdom'),
|
||||
CountryOption('GR', 'Greece'),
|
||||
CountryOption('HK', 'Hong Kong'),
|
||||
CountryOption('ID', 'Indonesia'),
|
||||
CountryOption('IN', 'India'),
|
||||
CountryOption('IT', 'Italy'),
|
||||
CountryOption('JP', 'Japan'),
|
||||
CountryOption('KR', 'South Korea'),
|
||||
CountryOption('MX', 'Mexico'),
|
||||
CountryOption('MY', 'Malaysia'),
|
||||
CountryOption('NL', 'Netherlands'),
|
||||
CountryOption('NO', 'Norway'),
|
||||
CountryOption('NZ', 'New Zealand'),
|
||||
CountryOption('PH', 'Philippines'),
|
||||
CountryOption('PL', 'Poland'),
|
||||
CountryOption('PT', 'Portugal'),
|
||||
CountryOption('RU', 'Russia'),
|
||||
CountryOption('SA', 'Saudi Arabia'),
|
||||
CountryOption('SE', 'Sweden'),
|
||||
CountryOption('TR', 'Turkey'),
|
||||
CountryOption('TW', 'Taiwan'),
|
||||
CountryOption('US', 'United States'),
|
||||
CountryOption('ZA', 'South Africa'),
|
||||
];
|
||||
|
||||
LanguageOption? findLanguage(String? code) {
|
||||
if (code == null) return null;
|
||||
final lower = code.toLowerCase();
|
||||
for (final l in supportedLanguages) {
|
||||
if (l.code == lower) return l;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
CountryOption? findCountry(String? code) {
|
||||
if (code == null) return null;
|
||||
final upper = code.toUpperCase();
|
||||
for (final c in supportedCountries) {
|
||||
if (c.code == upper) return c;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
+690
@@ -0,0 +1,690 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'
|
||||
as fmc;
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:search_backend/search_backend.dart';
|
||||
import 'package:search_client/search_client.dart';
|
||||
import 'package:weblibre/core/routing/routes.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/providers/tab_session.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/database/daos/capture_tab.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/database/definitions.drift.dart'
|
||||
show CaptureTabData;
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/providers.dart';
|
||||
import 'package:weblibre/features/search_credits/domain/controllers/search_token_issuance_controller.dart';
|
||||
import 'package:weblibre/features/search_credits/domain/providers.dart';
|
||||
import 'package:weblibre/features/search_credits/domain/repositories/search_token_stash_repository.dart';
|
||||
import 'package:weblibre/features/user/domain/repositories/cache.dart';
|
||||
import 'package:weblibre/features/search_credits/domain/providers/proxy_client.dart';
|
||||
import 'package:weblibre/features/web_search/domain/services/capture_artifact_downloader.dart';
|
||||
import 'package:weblibre/features/web_search/domain/services/capture_server.dart';
|
||||
import 'package:weblibre/features/web_search/domain/services/sandbox_capture_store.dart';
|
||||
|
||||
part 'sandbox_capture_controller.g.dart';
|
||||
|
||||
enum SandboxCaptureErrorKind {
|
||||
/// Stash is empty AND user has zero credits — needs to buy more.
|
||||
insufficientCredits,
|
||||
|
||||
/// Stash is empty, credits are available, but issuance failed
|
||||
/// (network/auth/server). Retrying may succeed.
|
||||
tokenIssuanceFailed,
|
||||
|
||||
fetchPolicyRejected,
|
||||
captureFailed,
|
||||
downloadFailed,
|
||||
unknown,
|
||||
}
|
||||
|
||||
class SandboxCaptureError {
|
||||
final SandboxCaptureErrorKind kind;
|
||||
final Uri targetUrl;
|
||||
final String? detail;
|
||||
const SandboxCaptureError({
|
||||
required this.kind,
|
||||
required this.targetUrl,
|
||||
this.detail,
|
||||
});
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
OneShotCaptureClient oneShotCaptureClient(Ref ref) {
|
||||
return OneShotCaptureClient(
|
||||
endpoints: ref.watch(searchBackendEndpointsProvider),
|
||||
logger: ref.watch(searchClientLoggerProvider),
|
||||
httpClient: ref.watch(searchProxyHttpClientProvider),
|
||||
);
|
||||
}
|
||||
|
||||
/// Streams capture-tab rows keyed by tabId. Watched by the address bar so
|
||||
/// the UI can show the canonical source URL (instead of the loopback
|
||||
/// loader/capture URL) and a sandbox indicator.
|
||||
@Riverpod(keepAlive: true)
|
||||
Stream<Map<String, CaptureTabData>> sandboxCaptureMap(Ref ref) {
|
||||
final dao = ref.watch(tabDatabaseProvider).captureTabDao;
|
||||
return dao.watchAll().map((rows) => {for (final row in rows) row.tabId: row});
|
||||
}
|
||||
|
||||
@Riverpod()
|
||||
CaptureTabData? sandboxCaptureForTab(Ref ref, {required String? tabId}) {
|
||||
if (tabId == null) return null;
|
||||
final map = ref.watch(sandboxCaptureMapProvider).value;
|
||||
return map?[tabId];
|
||||
}
|
||||
|
||||
/// The canonical source URL of a sandbox-captured tab, or `null` when the
|
||||
/// tab is not a sandbox capture (the regular `tabState.url` should be used in
|
||||
/// that case).
|
||||
@Riverpod()
|
||||
Uri? sandboxSourceUriForTab(Ref ref, {required String? tabId}) {
|
||||
final row = ref.watch(sandboxCaptureForTabProvider(tabId: tabId));
|
||||
return parseSandboxSource(row);
|
||||
}
|
||||
|
||||
Uri? parseSandboxSource(CaptureTabData? row) {
|
||||
if (row == null) return null;
|
||||
final raw = row.sourceUrl;
|
||||
if (raw.isEmpty) return null;
|
||||
return Uri.tryParse(raw);
|
||||
}
|
||||
|
||||
/// Search/edit text to pre-fill when the user taps the address bar of
|
||||
/// [tabState]. Sandbox-captured tabs surface their source URL so editing
|
||||
/// doesn't strip the user back to the loopback loader.
|
||||
String searchTextForTab(TabState tabState, [Uri? sandboxSourceUri]) {
|
||||
if (sandboxSourceUri != null) {
|
||||
return sandboxSourceUri.toString();
|
||||
}
|
||||
|
||||
final searchText = tabState.url.scheme == 'about'
|
||||
? ''
|
||||
: tabState.url.toString();
|
||||
|
||||
return searchText.isEmpty ? SearchRoute.emptySearchText : searchText;
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
SandboxCaptureStore sandboxCaptureStore(Ref ref) => SandboxCaptureStore();
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
Stream<SandboxCaptureError> sandboxCaptureErrors(Ref ref) {
|
||||
final ctrl = ref.watch(sandboxCaptureControllerProvider.notifier);
|
||||
return ctrl.errors;
|
||||
}
|
||||
|
||||
/// Orchestrates sandbox capture browsing:
|
||||
///
|
||||
/// 1. Listens for dispatch events from Kotlin (`onSandboxLinkClick`,
|
||||
/// `onSandboxNewTab`) and runs the capture pipeline.
|
||||
/// 2. Keeps the native `SandboxCaptureRegistry` and the on-disk JSON mirror
|
||||
/// in sync with the `capture_tab` table via a DAO subscription.
|
||||
/// 3. Listens on `CaptureServer.retryRequests` and re-runs failed captures.
|
||||
///
|
||||
/// This provider is kept alive for the lifetime of the app; `build()` wires
|
||||
/// up the subscriptions and returns nothing interesting.
|
||||
@Riverpod(keepAlive: true)
|
||||
class SandboxCaptureController extends _$SandboxCaptureController {
|
||||
final _errors = StreamController<SandboxCaptureError>.broadcast();
|
||||
// Keyed by (tabId, url) so two concurrent navigations in the same tab to
|
||||
// different URLs don't dedupe each other, and the same URL across tabs
|
||||
// doesn't either. Records compare structurally so this is safer than the
|
||||
// earlier "tabId|url" string concat.
|
||||
final _inFlight = <(String, Uri)>{};
|
||||
final _hostEvents = _SandboxHostEventsHandler();
|
||||
|
||||
/// In-memory record of the (method, variant) used to capture each sandbox
|
||||
/// tab. Children opened via link-click inherit their parent's mode so a
|
||||
/// PDF sandbox tab spawns more PDFs, a singlefile tab spawns more
|
||||
/// singlefile, etc. Lost across app restarts (capture_tab schema doesn't
|
||||
/// store these yet) — children fall back to singlefile/balanced in that
|
||||
/// case.
|
||||
final _tabCaptureMode = <String, ({String method, String variant})>{};
|
||||
|
||||
static const _defaultMethod = 'singlefile';
|
||||
static const _defaultVariant = 'balanced';
|
||||
|
||||
StreamSubscription<List<CaptureTabData>>? _captureTabSub;
|
||||
StreamSubscription<RetryRequest>? _retrySub;
|
||||
|
||||
Stream<SandboxCaptureError> get errors => _errors.stream;
|
||||
|
||||
@override
|
||||
void build() {
|
||||
_hostEvents.controller = this;
|
||||
fmc.SandboxCaptureHostEvents.setUp(_hostEvents);
|
||||
|
||||
final dao = ref.read(tabDatabaseProvider).captureTabDao;
|
||||
_captureTabSub = dao.watchAll().listen(_onCaptureTabChange);
|
||||
|
||||
_retrySub = ref
|
||||
.read(captureServerProvider)
|
||||
.retryRequests
|
||||
.listen(_onRetryRequest);
|
||||
|
||||
ref.onDispose(() {
|
||||
// Cancel inbound subscriptions first so no more events can land on
|
||||
// this controller. Only after that is it safe to detach the host
|
||||
// event handler, drop the back-reference, and close the error
|
||||
// stream — otherwise an in-flight Pigeon event or DAO emit could
|
||||
// race with handler cleanup, or _markFailed could try to add to a
|
||||
// closed _errors sink.
|
||||
final captureTabSub = _captureTabSub;
|
||||
final retrySub = _retrySub;
|
||||
if (captureTabSub != null) unawaited(captureTabSub.cancel());
|
||||
if (retrySub != null) unawaited(retrySub.cancel());
|
||||
fmc.SandboxCaptureHostEvents.setUp(null);
|
||||
_hostEvents.controller = null;
|
||||
unawaited(_errors.close());
|
||||
});
|
||||
}
|
||||
|
||||
// Entry point: search-results flow opened a root capture tab.
|
||||
Future<void> registerRootCapture({
|
||||
required String tabId,
|
||||
required CapturedPageReceiptLike receipt,
|
||||
String? method,
|
||||
String? variant,
|
||||
}) async {
|
||||
final server = ref.read(captureServerProvider);
|
||||
await server.publish(receipt.captureId);
|
||||
final dao = ref.read(tabDatabaseProvider).captureTabDao;
|
||||
await dao.upsert(
|
||||
tabId: tabId,
|
||||
captureId: receipt.captureId,
|
||||
sourceUrl: receipt.sourceUrl.toString(),
|
||||
status: CaptureTabStatus.ready,
|
||||
);
|
||||
_tabCaptureMode[tabId] = (
|
||||
method: method ?? _defaultMethod,
|
||||
variant: variant ?? _defaultVariant,
|
||||
);
|
||||
await _pushRegistry();
|
||||
}
|
||||
|
||||
// Called from Kotlin via pigeon when a sandbox tab tried to navigate to a
|
||||
// non-loopback URL. We open a fresh private tab and run the capture
|
||||
// pipeline into it.
|
||||
Future<void> captureIntoNewTab({
|
||||
required String parentTabId,
|
||||
required Uri targetUrl,
|
||||
}) async {
|
||||
if (_inFlight.contains((parentTabId, targetUrl))) return;
|
||||
|
||||
final parent = ref.read(tabStatesProvider)[parentTabId];
|
||||
final parentTabMode =
|
||||
parent?.tabMode ??
|
||||
await ref
|
||||
.read(tabDatabaseProvider)
|
||||
.tabDao
|
||||
.getTabMode(parentTabId)
|
||||
.getSingleOrNull() ??
|
||||
TabMode.private;
|
||||
final parentContainer = await ref
|
||||
.read(tabDatabaseProvider)
|
||||
.tabDao
|
||||
.getTabContainerData(parentTabId)
|
||||
.getSingleOrNull();
|
||||
final containerSelection = parentContainer == null
|
||||
? const TabContainerSelection.unassigned()
|
||||
: TabContainerSelection.specific(parentContainer);
|
||||
|
||||
// Create the tab without an initial URL — we'll load the loopback
|
||||
// loader URL in _runCapture. Never seed the tab with the real
|
||||
// targetUrl: Gecko's app-link resolution would fire on load/restore
|
||||
// (e.g. github.com → "Open in GitHub app?" dialog) before the
|
||||
// interceptor can redirect.
|
||||
final newTabId = await ref
|
||||
.read(tabRepositoryProvider.notifier)
|
||||
.addTab(
|
||||
tabMode: parentTabMode,
|
||||
selectTab: true,
|
||||
parentId: parentTabId,
|
||||
containerSelection: containerSelection,
|
||||
startLoading: false,
|
||||
);
|
||||
|
||||
await _runCapture(
|
||||
parentTabId: parentTabId,
|
||||
newTabId: newTabId,
|
||||
targetUrl: targetUrl,
|
||||
triggerInitialLoad: true,
|
||||
);
|
||||
}
|
||||
|
||||
// Called from Kotlin middleware when GeckoView created a new tab inside a
|
||||
// sandbox parent. The new tab is already at about:blank.
|
||||
Future<void> captureIntoExistingTab({
|
||||
required String parentTabId,
|
||||
required String newTabId,
|
||||
required Uri targetUrl,
|
||||
}) async {
|
||||
await _runCapture(
|
||||
parentTabId: parentTabId,
|
||||
newTabId: newTabId,
|
||||
targetUrl: targetUrl,
|
||||
triggerInitialLoad: true,
|
||||
);
|
||||
}
|
||||
|
||||
/// Resolve the (method, variant) for a child capture. Children inherit
|
||||
/// the parent tab's capture mode so a PDF sandbox spawns more PDFs, etc.
|
||||
/// Falls back to singlefile/balanced when the parent's mode is unknown
|
||||
/// (e.g. after an app restart — `_tabCaptureMode` is in-memory only).
|
||||
({String method, String variant}) _resolveCaptureMode(String parentTabId) {
|
||||
return _tabCaptureMode[parentTabId] ??
|
||||
(method: _defaultMethod, variant: _defaultVariant);
|
||||
}
|
||||
|
||||
Future<void> _runCapture({
|
||||
required String parentTabId,
|
||||
required String newTabId,
|
||||
required Uri targetUrl,
|
||||
required bool triggerInitialLoad,
|
||||
String? existingCaptureId,
|
||||
}) async {
|
||||
final key = (newTabId, targetUrl);
|
||||
if (!_inFlight.add(key)) return;
|
||||
|
||||
final server = ref.read(captureServerProvider);
|
||||
final dao = ref.read(tabDatabaseProvider).captureTabDao;
|
||||
final logger = ref.read(searchClientLoggerProvider);
|
||||
final mode = _resolveCaptureMode(parentTabId);
|
||||
// For retries, reuse the existing placeholder id so the tab's loader
|
||||
// URL stays valid and its in-flight long-poll picks up the new state.
|
||||
final placeholderId =
|
||||
existingCaptureId ??
|
||||
'pending-${DateTime.now().microsecondsSinceEpoch}-$newTabId';
|
||||
|
||||
final stash = ref.read(searchTokenStashProvider);
|
||||
ReservedStashToken? heldReservation;
|
||||
|
||||
try {
|
||||
server.clearFailed(placeholderId);
|
||||
await dao.upsert(
|
||||
tabId: newTabId,
|
||||
captureId: placeholderId,
|
||||
sourceUrl: targetUrl.toString(),
|
||||
status: CaptureTabStatus.pending,
|
||||
);
|
||||
_tabCaptureMode[newTabId] = mode;
|
||||
await _pushRegistry();
|
||||
|
||||
if (triggerInitialLoad) {
|
||||
final loaderUri = await server.loaderUrl(
|
||||
tabId: newTabId,
|
||||
captureId: placeholderId,
|
||||
);
|
||||
await ref
|
||||
.read(tabSessionProvider(tabId: newTabId).notifier)
|
||||
.loadUrl(url: loaderUri);
|
||||
}
|
||||
|
||||
var reserved = await stash.reserveOne();
|
||||
if (reserved == null) {
|
||||
// Stash empty — try to top up with the same 10-token batch we use
|
||||
// on the search submit path, then retry the reservation once.
|
||||
logger.i(
|
||||
'Sandbox capture: stash empty, attempting auto-issuance '
|
||||
'(tabId=$newTabId, host=${targetUrl.host})',
|
||||
);
|
||||
final outcome = await ref.read(searchTokenAvailabilityProvider).topUp();
|
||||
if (outcome == TokenTopUpOutcome.issued) {
|
||||
reserved = await stash.reserveOne();
|
||||
}
|
||||
if (reserved == null) {
|
||||
final kind = outcome == TokenTopUpOutcome.noCredits
|
||||
? SandboxCaptureErrorKind.insufficientCredits
|
||||
: SandboxCaptureErrorKind.tokenIssuanceFailed;
|
||||
final detail = outcome == TokenTopUpOutcome.noCredits
|
||||
? 'You have no search credits left. Purchase more to continue.'
|
||||
: 'Could not issue new search tokens. Check your connection and try again.';
|
||||
logger.w(
|
||||
'Sandbox capture: auto-issuance did not yield a token '
|
||||
'(outcome=$outcome, host=${targetUrl.host})',
|
||||
);
|
||||
await _markFailed(
|
||||
tabId: newTabId,
|
||||
captureId: placeholderId,
|
||||
url: targetUrl,
|
||||
kind: kind,
|
||||
detail: detail,
|
||||
);
|
||||
return;
|
||||
}
|
||||
logger.i(
|
||||
'Sandbox capture: auto-issuance succeeded, retrying capture '
|
||||
'(tabId=$newTabId)',
|
||||
);
|
||||
}
|
||||
|
||||
heldReservation = reserved;
|
||||
final reservation = reserved;
|
||||
logger.d(
|
||||
'Sandbox capture: reserved token #${reservation.id}, requesting '
|
||||
'capture (tabId=$newTabId, host=${targetUrl.host}, '
|
||||
'method=${mode.method}, variant=${mode.variant})',
|
||||
);
|
||||
|
||||
final CaptureArtifactReceipt receipt;
|
||||
try {
|
||||
// Inherit the parent tab's capture mode: a PDF sandbox tab spawns
|
||||
// more PDFs, a singlefile tab spawns more singlefile, etc. The
|
||||
// capture server happily serves PDF/PNG artifacts on the loopback
|
||||
// origin, and the loader/redirect machinery is content-type
|
||||
// agnostic.
|
||||
final result = await ref
|
||||
.read(oneShotCaptureClientProvider)
|
||||
.capture(
|
||||
targetUrl,
|
||||
method: mode.method,
|
||||
variant: mode.variant,
|
||||
token: reservation.token,
|
||||
dimensions: currentDisplayCaptureDimensions(),
|
||||
);
|
||||
receipt = result.receipt;
|
||||
|
||||
// Server accepted and consumed the token — drop it permanently.
|
||||
await stash.commitReserved(reservation.id);
|
||||
heldReservation = null;
|
||||
|
||||
if (result.faviconBytes != null) {
|
||||
unawaited(
|
||||
ref
|
||||
.read(cacheRepositoryProvider.notifier)
|
||||
.cacheIconIfAbsent(receipt.url, result.faviconBytes!),
|
||||
);
|
||||
}
|
||||
} on OneShotCaptureException catch (e) {
|
||||
// Token-affecting failure modes:
|
||||
// - fetch_not_allowed / invalid_url : server rejected before
|
||||
// redeem → safe to release.
|
||||
// - token_invalid / token_redeemed : server saw the token but
|
||||
// did not honor it → must commit (do not double-spend).
|
||||
// - any other 4xx/5xx after redemption attempt: ambiguous → commit.
|
||||
final preRedemption =
|
||||
e.code == 'fetch_not_allowed' ||
|
||||
e.code == 'invalid_url' ||
|
||||
e.code == 'invalid_request';
|
||||
if (preRedemption) {
|
||||
await stash.releaseReserved(reservation.id);
|
||||
logger.i(
|
||||
'Sandbox capture: released token #${reservation.id} '
|
||||
'(server rejected request before redemption: ${e.code})',
|
||||
);
|
||||
} else {
|
||||
await stash.commitReserved(reservation.id);
|
||||
logger.w(
|
||||
'Sandbox capture: committed token #${reservation.id} after '
|
||||
'ambiguous server failure (${e.code}) to avoid double-spend',
|
||||
);
|
||||
}
|
||||
heldReservation = null;
|
||||
final kind = e.code == 'fetch_not_allowed'
|
||||
? SandboxCaptureErrorKind.fetchPolicyRejected
|
||||
: SandboxCaptureErrorKind.captureFailed;
|
||||
await _markFailed(
|
||||
tabId: newTabId,
|
||||
captureId: placeholderId,
|
||||
url: targetUrl,
|
||||
kind: kind,
|
||||
detail: e.message,
|
||||
);
|
||||
return;
|
||||
} catch (e, s) {
|
||||
// Network/transport failure — we don't know whether the server
|
||||
// saw the token. Commit defensively to prevent double-spend on
|
||||
// retry. Stale reservations also get cleaned up on app start.
|
||||
await stash.commitReserved(reservation.id);
|
||||
heldReservation = null;
|
||||
logger.e(
|
||||
'Sandbox capture failed (committed token #${reservation.id} '
|
||||
'defensively)',
|
||||
error: e,
|
||||
stackTrace: s,
|
||||
);
|
||||
await _markFailed(
|
||||
tabId: newTabId,
|
||||
captureId: placeholderId,
|
||||
url: targetUrl,
|
||||
kind: SandboxCaptureErrorKind.unknown,
|
||||
detail: e.toString(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await ref.read(captureArtifactDownloaderProvider).download(receipt);
|
||||
} catch (e, s) {
|
||||
logger.e('Sandbox capture download failed', error: e, stackTrace: s);
|
||||
await _markFailed(
|
||||
tabId: newTabId,
|
||||
captureId: placeholderId,
|
||||
url: targetUrl,
|
||||
kind: SandboxCaptureErrorKind.downloadFailed,
|
||||
detail: e.toString(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final captureUri = await server.publish(receipt.captureId);
|
||||
await dao.upsert(
|
||||
tabId: newTabId,
|
||||
captureId: receipt.captureId,
|
||||
sourceUrl: targetUrl.toString(),
|
||||
status: CaptureTabStatus.ready,
|
||||
);
|
||||
_tabCaptureMode[newTabId] = mode;
|
||||
await _pushRegistry();
|
||||
|
||||
// Navigate directly to the loopback capture URL. Never bounce
|
||||
// through the real targetUrl — Gecko's app-link resolution fires
|
||||
// before the interceptor can redirect.
|
||||
await ref
|
||||
.read(tabSessionProvider(tabId: newTabId).notifier)
|
||||
.loadUrl(url: captureUri);
|
||||
} catch (e, s) {
|
||||
// Safety net: anything that escaped the inner blocks (e.g. DAO
|
||||
// failure, navigation error, exception while we held a reservation
|
||||
// outside the inner try) — make sure we don't strand the tab in
|
||||
// pending and don't leak the reservation. Commit defensively if a
|
||||
// reservation is still held: we may have already sent the token to
|
||||
// the server.
|
||||
logger.e('Sandbox capture: unexpected error', error: e, stackTrace: s);
|
||||
if (heldReservation != null) {
|
||||
try {
|
||||
await stash.commitReserved(heldReservation.id);
|
||||
} catch (commitError, commitStack) {
|
||||
logger.w(
|
||||
'Sandbox capture: failed to commit reservation '
|
||||
'#${heldReservation.id} during error cleanup',
|
||||
error: commitError,
|
||||
stackTrace: commitStack,
|
||||
);
|
||||
}
|
||||
}
|
||||
try {
|
||||
await _markFailed(
|
||||
tabId: newTabId,
|
||||
captureId: placeholderId,
|
||||
url: targetUrl,
|
||||
kind: SandboxCaptureErrorKind.unknown,
|
||||
detail: e.toString(),
|
||||
);
|
||||
} catch (markError, markStack) {
|
||||
logger.w(
|
||||
'Sandbox capture: failed to mark tab as failed during error '
|
||||
'cleanup',
|
||||
error: markError,
|
||||
stackTrace: markStack,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
_inFlight.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _markFailed({
|
||||
required String tabId,
|
||||
required String captureId,
|
||||
required Uri url,
|
||||
required SandboxCaptureErrorKind kind,
|
||||
required String detail,
|
||||
}) async {
|
||||
final dao = ref.read(tabDatabaseProvider).captureTabDao;
|
||||
await dao.updateStatus(tabId, CaptureTabStatus.failed);
|
||||
// Resolve any in-flight loader long-poll for this capture so the tab
|
||||
// flips to the error UI immediately instead of waiting for the poll
|
||||
// window to expire.
|
||||
ref.read(captureServerProvider).markFailed(captureId);
|
||||
await _pushRegistry();
|
||||
if (!_errors.isClosed) {
|
||||
_errors.add(
|
||||
SandboxCaptureError(kind: kind, targetUrl: url, detail: detail),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onRetryRequest(RetryRequest request) async {
|
||||
final dao = ref.read(tabDatabaseProvider).captureTabDao;
|
||||
final row = await dao.findByTabId(request.tabId);
|
||||
if (row == null) return;
|
||||
final sourceUrl = Uri.tryParse(row.sourceUrl);
|
||||
if (sourceUrl == null) return;
|
||||
// Re-enter the capture pipeline as if the user clicked the link again.
|
||||
// Reuse the existing capture id so the tab's loader URL (and its
|
||||
// in-flight long-poll) stays addressable.
|
||||
await _runCapture(
|
||||
parentTabId: row.tabId, // we don't persist parent; use self as parent
|
||||
newTabId: row.tabId,
|
||||
targetUrl: sourceUrl,
|
||||
triggerInitialLoad: false,
|
||||
existingCaptureId: row.captureId,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onCaptureTabChange(List<CaptureTabData> rows) async {
|
||||
final store = ref.read(sandboxCaptureStoreProvider);
|
||||
await store.write(await _rowsForPersistentMirror(rows));
|
||||
// Drop in-memory mode entries for tabs the DAO no longer knows about so
|
||||
// a recycled tabId doesn't accidentally inherit a stale mode.
|
||||
final liveTabIds = rows.map((r) => r.tabId).toSet();
|
||||
_tabCaptureMode.removeWhere((tabId, _) => !liveTabIds.contains(tabId));
|
||||
await _pushRegistry(rows: rows);
|
||||
}
|
||||
|
||||
Future<List<CaptureTabData>> _rowsForPersistentMirror(
|
||||
List<CaptureTabData> rows,
|
||||
) async {
|
||||
final tabDao = ref.read(tabDatabaseProvider).tabDao;
|
||||
final tabStates = ref.read(tabStatesProvider);
|
||||
final persistedRows = <CaptureTabData>[];
|
||||
|
||||
for (final row in rows) {
|
||||
final tabMode =
|
||||
tabStates[row.tabId]?.tabMode ??
|
||||
await tabDao.getTabMode(row.tabId).getSingleOrNull();
|
||||
if (tabMode is PrivateTabMode) {
|
||||
continue;
|
||||
}
|
||||
persistedRows.add(row);
|
||||
}
|
||||
|
||||
return persistedRows;
|
||||
}
|
||||
|
||||
Future<void> _pushRegistry({List<CaptureTabData>? rows}) async {
|
||||
final dao = ref.read(tabDatabaseProvider).captureTabDao;
|
||||
final server = ref.read(captureServerProvider);
|
||||
final all = rows ?? await dao.findAll();
|
||||
// Resolve redirect URLs in parallel. Each `_redirectUrlFor` awaits a
|
||||
// server publish/loaderUrl call; serializing was O(N) round-trips on
|
||||
// every DAO emit, which got noticeable with many sandbox tabs open.
|
||||
final redirectUrls = await Future.wait([
|
||||
for (final row in all) _redirectUrlFor(row, server),
|
||||
]);
|
||||
final entries = <fmc.SandboxCaptureEntry>[
|
||||
for (var i = 0; i < all.length; i++)
|
||||
fmc.SandboxCaptureEntry(
|
||||
tabId: all[i].tabId,
|
||||
captureId: all[i].captureId,
|
||||
sourceUrl: all[i].sourceUrl,
|
||||
redirectUrl: redirectUrls[i],
|
||||
status: all[i].status,
|
||||
),
|
||||
];
|
||||
await fmc.SandboxCaptureApi().resetAll(entries);
|
||||
}
|
||||
|
||||
Future<String> _redirectUrlFor(
|
||||
CaptureTabData row,
|
||||
CaptureServer server,
|
||||
) async {
|
||||
switch (row.status) {
|
||||
case 'ready':
|
||||
final uri = await server.publish(row.captureId);
|
||||
return uri.toString();
|
||||
case 'failed':
|
||||
final uri = await server.loaderUrl(
|
||||
tabId: row.tabId,
|
||||
captureId: row.captureId,
|
||||
error: true,
|
||||
);
|
||||
return uri.toString();
|
||||
case 'pending':
|
||||
default:
|
||||
final uri = await server.loaderUrl(
|
||||
tabId: row.tabId,
|
||||
captureId: row.captureId,
|
||||
);
|
||||
return uri.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _SandboxHostEventsHandler implements fmc.SandboxCaptureHostEvents {
|
||||
SandboxCaptureController? controller;
|
||||
|
||||
@override
|
||||
void onSandboxLinkClick(int sequence, String parentTabId, String targetUrl) {
|
||||
final uri = Uri.tryParse(targetUrl);
|
||||
final c = controller;
|
||||
if (uri == null || c == null) return;
|
||||
unawaited(c.captureIntoNewTab(parentTabId: parentTabId, targetUrl: uri));
|
||||
}
|
||||
|
||||
@override
|
||||
void onSandboxNewTab(
|
||||
int sequence,
|
||||
String parentTabId,
|
||||
String newTabId,
|
||||
String targetUrl,
|
||||
) {
|
||||
final uri = Uri.tryParse(targetUrl);
|
||||
final c = controller;
|
||||
if (uri == null || c == null) return;
|
||||
unawaited(
|
||||
c.captureIntoExistingTab(
|
||||
parentTabId: parentTabId,
|
||||
newTabId: newTabId,
|
||||
targetUrl: uri,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal receipt-like type for registerRootCapture — avoids a direct
|
||||
/// dependency on the capture_artifact_receipt pigeon in this file when the
|
||||
/// caller already has the right pieces to hand.
|
||||
class CapturedPageReceiptLike {
|
||||
final String captureId;
|
||||
final Uri sourceUrl;
|
||||
const CapturedPageReceiptLike({
|
||||
required this.captureId,
|
||||
required this.sourceUrl,
|
||||
});
|
||||
}
|
||||
+472
@@ -0,0 +1,472 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'sandbox_capture_controller.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(oneShotCaptureClient)
|
||||
final oneShotCaptureClientProvider = OneShotCaptureClientProvider._();
|
||||
|
||||
final class OneShotCaptureClientProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
OneShotCaptureClient,
|
||||
OneShotCaptureClient,
|
||||
OneShotCaptureClient
|
||||
>
|
||||
with $Provider<OneShotCaptureClient> {
|
||||
OneShotCaptureClientProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'oneShotCaptureClientProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$oneShotCaptureClientHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<OneShotCaptureClient> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
OneShotCaptureClient create(Ref ref) {
|
||||
return oneShotCaptureClient(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(OneShotCaptureClient value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<OneShotCaptureClient>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$oneShotCaptureClientHash() =>
|
||||
r'35c3dbeb91a5c9ca1663f43f4b48332bcde3ae3f';
|
||||
|
||||
/// Streams capture-tab rows keyed by tabId. Watched by the address bar so
|
||||
/// the UI can show the canonical source URL (instead of the loopback
|
||||
/// loader/capture URL) and a sandbox indicator.
|
||||
|
||||
@ProviderFor(sandboxCaptureMap)
|
||||
final sandboxCaptureMapProvider = SandboxCaptureMapProvider._();
|
||||
|
||||
/// Streams capture-tab rows keyed by tabId. Watched by the address bar so
|
||||
/// the UI can show the canonical source URL (instead of the loopback
|
||||
/// loader/capture URL) and a sandbox indicator.
|
||||
|
||||
final class SandboxCaptureMapProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
AsyncValue<Map<String, CaptureTabData>>,
|
||||
Map<String, CaptureTabData>,
|
||||
Stream<Map<String, CaptureTabData>>
|
||||
>
|
||||
with
|
||||
$FutureModifier<Map<String, CaptureTabData>>,
|
||||
$StreamProvider<Map<String, CaptureTabData>> {
|
||||
/// Streams capture-tab rows keyed by tabId. Watched by the address bar so
|
||||
/// the UI can show the canonical source URL (instead of the loopback
|
||||
/// loader/capture URL) and a sandbox indicator.
|
||||
SandboxCaptureMapProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'sandboxCaptureMapProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$sandboxCaptureMapHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$StreamProviderElement<Map<String, CaptureTabData>> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $StreamProviderElement(pointer);
|
||||
|
||||
@override
|
||||
Stream<Map<String, CaptureTabData>> create(Ref ref) {
|
||||
return sandboxCaptureMap(ref);
|
||||
}
|
||||
}
|
||||
|
||||
String _$sandboxCaptureMapHash() => r'1771bb1c618e34f24e718609d501c426ac49f192';
|
||||
|
||||
@ProviderFor(sandboxCaptureForTab)
|
||||
final sandboxCaptureForTabProvider = SandboxCaptureForTabFamily._();
|
||||
|
||||
final class SandboxCaptureForTabProvider
|
||||
extends
|
||||
$FunctionalProvider<CaptureTabData?, CaptureTabData?, CaptureTabData?>
|
||||
with $Provider<CaptureTabData?> {
|
||||
SandboxCaptureForTabProvider._({
|
||||
required SandboxCaptureForTabFamily super.from,
|
||||
required String? super.argument,
|
||||
}) : super(
|
||||
retry: null,
|
||||
name: r'sandboxCaptureForTabProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$sandboxCaptureForTabHash();
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return r'sandboxCaptureForTabProvider'
|
||||
''
|
||||
'($argument)';
|
||||
}
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<CaptureTabData?> $createElement($ProviderPointer pointer) =>
|
||||
$ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
CaptureTabData? create(Ref ref) {
|
||||
final argument = this.argument as String?;
|
||||
return sandboxCaptureForTab(ref, tabId: argument);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(CaptureTabData? value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<CaptureTabData?>(value),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is SandboxCaptureForTabProvider && other.argument == argument;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return argument.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
String _$sandboxCaptureForTabHash() =>
|
||||
r'1d64f68c85d40dbd7d5b220ee54a5436f1c1eaeb';
|
||||
|
||||
final class SandboxCaptureForTabFamily extends $Family
|
||||
with $FunctionalFamilyOverride<CaptureTabData?, String?> {
|
||||
SandboxCaptureForTabFamily._()
|
||||
: super(
|
||||
retry: null,
|
||||
name: r'sandboxCaptureForTabProvider',
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
isAutoDispose: true,
|
||||
);
|
||||
|
||||
SandboxCaptureForTabProvider call({required String? tabId}) =>
|
||||
SandboxCaptureForTabProvider._(argument: tabId, from: this);
|
||||
|
||||
@override
|
||||
String toString() => r'sandboxCaptureForTabProvider';
|
||||
}
|
||||
|
||||
/// The canonical source URL of a sandbox-captured tab, or `null` when the
|
||||
/// tab is not a sandbox capture (the regular `tabState.url` should be used in
|
||||
/// that case).
|
||||
|
||||
@ProviderFor(sandboxSourceUriForTab)
|
||||
final sandboxSourceUriForTabProvider = SandboxSourceUriForTabFamily._();
|
||||
|
||||
/// The canonical source URL of a sandbox-captured tab, or `null` when the
|
||||
/// tab is not a sandbox capture (the regular `tabState.url` should be used in
|
||||
/// that case).
|
||||
|
||||
final class SandboxSourceUriForTabProvider
|
||||
extends $FunctionalProvider<Uri?, Uri?, Uri?>
|
||||
with $Provider<Uri?> {
|
||||
/// The canonical source URL of a sandbox-captured tab, or `null` when the
|
||||
/// tab is not a sandbox capture (the regular `tabState.url` should be used in
|
||||
/// that case).
|
||||
SandboxSourceUriForTabProvider._({
|
||||
required SandboxSourceUriForTabFamily super.from,
|
||||
required String? super.argument,
|
||||
}) : super(
|
||||
retry: null,
|
||||
name: r'sandboxSourceUriForTabProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$sandboxSourceUriForTabHash();
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return r'sandboxSourceUriForTabProvider'
|
||||
''
|
||||
'($argument)';
|
||||
}
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<Uri?> $createElement($ProviderPointer pointer) =>
|
||||
$ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
Uri? create(Ref ref) {
|
||||
final argument = this.argument as String?;
|
||||
return sandboxSourceUriForTab(ref, tabId: argument);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(Uri? value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<Uri?>(value),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is SandboxSourceUriForTabProvider &&
|
||||
other.argument == argument;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return argument.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
String _$sandboxSourceUriForTabHash() =>
|
||||
r'62f55eefd22377109080ea328c9ac0c00a0f7196';
|
||||
|
||||
/// The canonical source URL of a sandbox-captured tab, or `null` when the
|
||||
/// tab is not a sandbox capture (the regular `tabState.url` should be used in
|
||||
/// that case).
|
||||
|
||||
final class SandboxSourceUriForTabFamily extends $Family
|
||||
with $FunctionalFamilyOverride<Uri?, String?> {
|
||||
SandboxSourceUriForTabFamily._()
|
||||
: super(
|
||||
retry: null,
|
||||
name: r'sandboxSourceUriForTabProvider',
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
isAutoDispose: true,
|
||||
);
|
||||
|
||||
/// The canonical source URL of a sandbox-captured tab, or `null` when the
|
||||
/// tab is not a sandbox capture (the regular `tabState.url` should be used in
|
||||
/// that case).
|
||||
|
||||
SandboxSourceUriForTabProvider call({required String? tabId}) =>
|
||||
SandboxSourceUriForTabProvider._(argument: tabId, from: this);
|
||||
|
||||
@override
|
||||
String toString() => r'sandboxSourceUriForTabProvider';
|
||||
}
|
||||
|
||||
@ProviderFor(sandboxCaptureStore)
|
||||
final sandboxCaptureStoreProvider = SandboxCaptureStoreProvider._();
|
||||
|
||||
final class SandboxCaptureStoreProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
SandboxCaptureStore,
|
||||
SandboxCaptureStore,
|
||||
SandboxCaptureStore
|
||||
>
|
||||
with $Provider<SandboxCaptureStore> {
|
||||
SandboxCaptureStoreProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'sandboxCaptureStoreProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$sandboxCaptureStoreHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<SandboxCaptureStore> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
SandboxCaptureStore create(Ref ref) {
|
||||
return sandboxCaptureStore(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(SandboxCaptureStore value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<SandboxCaptureStore>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$sandboxCaptureStoreHash() =>
|
||||
r'59cf9cfe1e6727ea2c049ed60e7e69cfe6e55d03';
|
||||
|
||||
@ProviderFor(sandboxCaptureErrors)
|
||||
final sandboxCaptureErrorsProvider = SandboxCaptureErrorsProvider._();
|
||||
|
||||
final class SandboxCaptureErrorsProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
AsyncValue<SandboxCaptureError>,
|
||||
SandboxCaptureError,
|
||||
Stream<SandboxCaptureError>
|
||||
>
|
||||
with
|
||||
$FutureModifier<SandboxCaptureError>,
|
||||
$StreamProvider<SandboxCaptureError> {
|
||||
SandboxCaptureErrorsProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'sandboxCaptureErrorsProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$sandboxCaptureErrorsHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$StreamProviderElement<SandboxCaptureError> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $StreamProviderElement(pointer);
|
||||
|
||||
@override
|
||||
Stream<SandboxCaptureError> create(Ref ref) {
|
||||
return sandboxCaptureErrors(ref);
|
||||
}
|
||||
}
|
||||
|
||||
String _$sandboxCaptureErrorsHash() =>
|
||||
r'd6ef9ebba2515f8177a380b822f344128e4ea8c4';
|
||||
|
||||
/// Orchestrates sandbox capture browsing:
|
||||
///
|
||||
/// 1. Listens for dispatch events from Kotlin (`onSandboxLinkClick`,
|
||||
/// `onSandboxNewTab`) and runs the capture pipeline.
|
||||
/// 2. Keeps the native `SandboxCaptureRegistry` and the on-disk JSON mirror
|
||||
/// in sync with the `capture_tab` table via a DAO subscription.
|
||||
/// 3. Listens on `CaptureServer.retryRequests` and re-runs failed captures.
|
||||
///
|
||||
/// This provider is kept alive for the lifetime of the app; `build()` wires
|
||||
/// up the subscriptions and returns nothing interesting.
|
||||
|
||||
@ProviderFor(SandboxCaptureController)
|
||||
final sandboxCaptureControllerProvider = SandboxCaptureControllerProvider._();
|
||||
|
||||
/// Orchestrates sandbox capture browsing:
|
||||
///
|
||||
/// 1. Listens for dispatch events from Kotlin (`onSandboxLinkClick`,
|
||||
/// `onSandboxNewTab`) and runs the capture pipeline.
|
||||
/// 2. Keeps the native `SandboxCaptureRegistry` and the on-disk JSON mirror
|
||||
/// in sync with the `capture_tab` table via a DAO subscription.
|
||||
/// 3. Listens on `CaptureServer.retryRequests` and re-runs failed captures.
|
||||
///
|
||||
/// This provider is kept alive for the lifetime of the app; `build()` wires
|
||||
/// up the subscriptions and returns nothing interesting.
|
||||
final class SandboxCaptureControllerProvider
|
||||
extends $NotifierProvider<SandboxCaptureController, void> {
|
||||
/// Orchestrates sandbox capture browsing:
|
||||
///
|
||||
/// 1. Listens for dispatch events from Kotlin (`onSandboxLinkClick`,
|
||||
/// `onSandboxNewTab`) and runs the capture pipeline.
|
||||
/// 2. Keeps the native `SandboxCaptureRegistry` and the on-disk JSON mirror
|
||||
/// in sync with the `capture_tab` table via a DAO subscription.
|
||||
/// 3. Listens on `CaptureServer.retryRequests` and re-runs failed captures.
|
||||
///
|
||||
/// This provider is kept alive for the lifetime of the app; `build()` wires
|
||||
/// up the subscriptions and returns nothing interesting.
|
||||
SandboxCaptureControllerProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'sandboxCaptureControllerProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$sandboxCaptureControllerHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
SandboxCaptureController create() => SandboxCaptureController();
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(void value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<void>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$sandboxCaptureControllerHash() =>
|
||||
r'fb582cb09504dafd1b9addc1bfc0e6f58fabc9b1';
|
||||
|
||||
/// Orchestrates sandbox capture browsing:
|
||||
///
|
||||
/// 1. Listens for dispatch events from Kotlin (`onSandboxLinkClick`,
|
||||
/// `onSandboxNewTab`) and runs the capture pipeline.
|
||||
/// 2. Keeps the native `SandboxCaptureRegistry` and the on-disk JSON mirror
|
||||
/// in sync with the `capture_tab` table via a DAO subscription.
|
||||
/// 3. Listens on `CaptureServer.retryRequests` and re-runs failed captures.
|
||||
///
|
||||
/// This provider is kept alive for the lifetime of the app; `build()` wires
|
||||
/// up the subscriptions and returns nothing interesting.
|
||||
|
||||
abstract class _$SandboxCaptureController extends $Notifier<void> {
|
||||
void build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final ref = this.ref as $Ref<void, void>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<void, void>,
|
||||
void,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleCreate(ref, build);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,134 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'search_controller.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(MetaSearchController)
|
||||
final metaSearchControllerProvider = MetaSearchControllerProvider._();
|
||||
|
||||
final class MetaSearchControllerProvider
|
||||
extends $NotifierProvider<MetaSearchController, MetaSearchState> {
|
||||
MetaSearchControllerProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'metaSearchControllerProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$metaSearchControllerHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
MetaSearchController create() => MetaSearchController();
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(MetaSearchState value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<MetaSearchState>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$metaSearchControllerHash() =>
|
||||
r'b040527dfb49d9407fe0ea22169f5961d1faef13';
|
||||
|
||||
abstract class _$MetaSearchController extends $Notifier<MetaSearchState> {
|
||||
MetaSearchState build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final ref = this.ref as $Ref<MetaSearchState, MetaSearchState>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<MetaSearchState, MetaSearchState>,
|
||||
MetaSearchState,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleCreate(ref, build);
|
||||
}
|
||||
}
|
||||
|
||||
/// Persisted vertical scroll offset of the web-search results list. Kept
|
||||
/// alive (like [MetaSearchController]) so returning to the search screen
|
||||
/// after opening a result restores the user's place instead of jumping
|
||||
/// back to the top. Reset to 0 on every fresh submit and on reset().
|
||||
|
||||
@ProviderFor(WebSearchScrollOffset)
|
||||
final webSearchScrollOffsetProvider = WebSearchScrollOffsetProvider._();
|
||||
|
||||
/// Persisted vertical scroll offset of the web-search results list. Kept
|
||||
/// alive (like [MetaSearchController]) so returning to the search screen
|
||||
/// after opening a result restores the user's place instead of jumping
|
||||
/// back to the top. Reset to 0 on every fresh submit and on reset().
|
||||
final class WebSearchScrollOffsetProvider
|
||||
extends $NotifierProvider<WebSearchScrollOffset, double> {
|
||||
/// Persisted vertical scroll offset of the web-search results list. Kept
|
||||
/// alive (like [MetaSearchController]) so returning to the search screen
|
||||
/// after opening a result restores the user's place instead of jumping
|
||||
/// back to the top. Reset to 0 on every fresh submit and on reset().
|
||||
WebSearchScrollOffsetProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'webSearchScrollOffsetProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$webSearchScrollOffsetHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
WebSearchScrollOffset create() => WebSearchScrollOffset();
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(double value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<double>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$webSearchScrollOffsetHash() =>
|
||||
r'8d622d93aba7712b2bf0e6da7cbde6c90ee00c07';
|
||||
|
||||
/// Persisted vertical scroll offset of the web-search results list. Kept
|
||||
/// alive (like [MetaSearchController]) so returning to the search screen
|
||||
/// after opening a result restores the user's place instead of jumping
|
||||
/// back to the top. Reset to 0 on every fresh submit and on reset().
|
||||
|
||||
abstract class _$WebSearchScrollOffset extends $Notifier<double> {
|
||||
double build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final ref = this.ref as $Ref<double, double>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<double, double>,
|
||||
double,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleCreate(ref, build);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import 'package:copy_with_extension/copy_with_extension.dart';
|
||||
import 'package:fast_equatable/fast_equatable.dart';
|
||||
|
||||
part 'captured_page_state.g.dart';
|
||||
|
||||
enum CapturedPageStatus { capturing, downloading, ready, downloadFailed }
|
||||
|
||||
@CopyWith()
|
||||
class CapturedPageState with FastEquatable {
|
||||
final Uri sourceUrl;
|
||||
final CapturedPageStatus status;
|
||||
final String? captureId;
|
||||
final Uri? finalUrl;
|
||||
final String? filename;
|
||||
|
||||
/// Capture engine used: `singlefile` or `shot-scraper`.
|
||||
final String? method;
|
||||
|
||||
/// Engine-specific selector — preset (singlefile) or mode (shot-scraper).
|
||||
final String? variant;
|
||||
|
||||
/// MIME type returned by the backend (e.g. `text/html`, `application/pdf`,
|
||||
/// `image/png`). Used to choose the right file extension and to pick the
|
||||
/// right viewer when opening the artifact locally.
|
||||
final String? contentType;
|
||||
|
||||
final int? byteLength;
|
||||
final String? localPath;
|
||||
final String? downloadToken;
|
||||
final String? errorMessage;
|
||||
|
||||
CapturedPageState({
|
||||
required this.sourceUrl,
|
||||
required this.status,
|
||||
this.captureId,
|
||||
this.finalUrl,
|
||||
this.filename,
|
||||
this.method,
|
||||
this.variant,
|
||||
this.contentType,
|
||||
this.byteLength,
|
||||
this.localPath,
|
||||
this.downloadToken,
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get hashParameters => [
|
||||
sourceUrl,
|
||||
status,
|
||||
captureId,
|
||||
finalUrl,
|
||||
filename,
|
||||
method,
|
||||
variant,
|
||||
contentType,
|
||||
byteLength,
|
||||
localPath,
|
||||
downloadToken,
|
||||
errorMessage,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'captured_page_state.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// CopyWithGenerator
|
||||
// **************************************************************************
|
||||
|
||||
abstract class _$CapturedPageStateCWProxy {
|
||||
CapturedPageState sourceUrl(Uri sourceUrl);
|
||||
|
||||
CapturedPageState status(CapturedPageStatus status);
|
||||
|
||||
CapturedPageState captureId(String? captureId);
|
||||
|
||||
CapturedPageState finalUrl(Uri? finalUrl);
|
||||
|
||||
CapturedPageState filename(String? filename);
|
||||
|
||||
CapturedPageState method(String? method);
|
||||
|
||||
CapturedPageState variant(String? variant);
|
||||
|
||||
CapturedPageState contentType(String? contentType);
|
||||
|
||||
CapturedPageState byteLength(int? byteLength);
|
||||
|
||||
CapturedPageState localPath(String? localPath);
|
||||
|
||||
CapturedPageState downloadToken(String? downloadToken);
|
||||
|
||||
CapturedPageState errorMessage(String? errorMessage);
|
||||
|
||||
/// Creates a new instance with the provided field values.
|
||||
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `CapturedPageState(...).copyWith.fieldName(value)`.
|
||||
///
|
||||
/// Example:
|
||||
/// ```dart
|
||||
/// CapturedPageState(...).copyWith(id: 12, name: "My name")
|
||||
/// ```
|
||||
CapturedPageState call({
|
||||
Uri sourceUrl,
|
||||
CapturedPageStatus status,
|
||||
String? captureId,
|
||||
Uri? finalUrl,
|
||||
String? filename,
|
||||
String? method,
|
||||
String? variant,
|
||||
String? contentType,
|
||||
int? byteLength,
|
||||
String? localPath,
|
||||
String? downloadToken,
|
||||
String? errorMessage,
|
||||
});
|
||||
}
|
||||
|
||||
/// Callable proxy for `copyWith` functionality.
|
||||
/// Use as `instanceOfCapturedPageState.copyWith(...)` or call `instanceOfCapturedPageState.copyWith.fieldName(value)` for a single field.
|
||||
class _$CapturedPageStateCWProxyImpl implements _$CapturedPageStateCWProxy {
|
||||
const _$CapturedPageStateCWProxyImpl(this._value);
|
||||
|
||||
final CapturedPageState _value;
|
||||
|
||||
@override
|
||||
CapturedPageState sourceUrl(Uri sourceUrl) => call(sourceUrl: sourceUrl);
|
||||
|
||||
@override
|
||||
CapturedPageState status(CapturedPageStatus status) => call(status: status);
|
||||
|
||||
@override
|
||||
CapturedPageState captureId(String? captureId) => call(captureId: captureId);
|
||||
|
||||
@override
|
||||
CapturedPageState finalUrl(Uri? finalUrl) => call(finalUrl: finalUrl);
|
||||
|
||||
@override
|
||||
CapturedPageState filename(String? filename) => call(filename: filename);
|
||||
|
||||
@override
|
||||
CapturedPageState method(String? method) => call(method: method);
|
||||
|
||||
@override
|
||||
CapturedPageState variant(String? variant) => call(variant: variant);
|
||||
|
||||
@override
|
||||
CapturedPageState contentType(String? contentType) =>
|
||||
call(contentType: contentType);
|
||||
|
||||
@override
|
||||
CapturedPageState byteLength(int? byteLength) => call(byteLength: byteLength);
|
||||
|
||||
@override
|
||||
CapturedPageState localPath(String? localPath) => call(localPath: localPath);
|
||||
|
||||
@override
|
||||
CapturedPageState downloadToken(String? downloadToken) =>
|
||||
call(downloadToken: downloadToken);
|
||||
|
||||
@override
|
||||
CapturedPageState errorMessage(String? errorMessage) =>
|
||||
call(errorMessage: errorMessage);
|
||||
|
||||
@override
|
||||
/// Creates a new instance with the provided field values.
|
||||
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `CapturedPageState(...).copyWith.fieldName(value)`.
|
||||
///
|
||||
/// Example:
|
||||
/// ```dart
|
||||
/// CapturedPageState(...).copyWith(id: 12, name: "My name")
|
||||
/// ```
|
||||
CapturedPageState call({
|
||||
Object? sourceUrl = const $CopyWithPlaceholder(),
|
||||
Object? status = const $CopyWithPlaceholder(),
|
||||
Object? captureId = const $CopyWithPlaceholder(),
|
||||
Object? finalUrl = const $CopyWithPlaceholder(),
|
||||
Object? filename = const $CopyWithPlaceholder(),
|
||||
Object? method = const $CopyWithPlaceholder(),
|
||||
Object? variant = const $CopyWithPlaceholder(),
|
||||
Object? contentType = const $CopyWithPlaceholder(),
|
||||
Object? byteLength = const $CopyWithPlaceholder(),
|
||||
Object? localPath = const $CopyWithPlaceholder(),
|
||||
Object? downloadToken = const $CopyWithPlaceholder(),
|
||||
Object? errorMessage = const $CopyWithPlaceholder(),
|
||||
}) {
|
||||
return CapturedPageState(
|
||||
sourceUrl: sourceUrl == const $CopyWithPlaceholder() || sourceUrl == null
|
||||
? _value.sourceUrl
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: sourceUrl as Uri,
|
||||
status: status == const $CopyWithPlaceholder() || status == null
|
||||
? _value.status
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: status as CapturedPageStatus,
|
||||
captureId: captureId == const $CopyWithPlaceholder()
|
||||
? _value.captureId
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: captureId as String?,
|
||||
finalUrl: finalUrl == const $CopyWithPlaceholder()
|
||||
? _value.finalUrl
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: finalUrl as Uri?,
|
||||
filename: filename == const $CopyWithPlaceholder()
|
||||
? _value.filename
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: filename as String?,
|
||||
method: method == const $CopyWithPlaceholder()
|
||||
? _value.method
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: method as String?,
|
||||
variant: variant == const $CopyWithPlaceholder()
|
||||
? _value.variant
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: variant as String?,
|
||||
contentType: contentType == const $CopyWithPlaceholder()
|
||||
? _value.contentType
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: contentType as String?,
|
||||
byteLength: byteLength == const $CopyWithPlaceholder()
|
||||
? _value.byteLength
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: byteLength as int?,
|
||||
localPath: localPath == const $CopyWithPlaceholder()
|
||||
? _value.localPath
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: localPath as String?,
|
||||
downloadToken: downloadToken == const $CopyWithPlaceholder()
|
||||
? _value.downloadToken
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: downloadToken as String?,
|
||||
errorMessage: errorMessage == const $CopyWithPlaceholder()
|
||||
? _value.errorMessage
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: errorMessage as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension $CapturedPageStateCopyWith on CapturedPageState {
|
||||
/// Returns a callable class used to build a new instance with modified fields.
|
||||
/// Example: `instanceOfCapturedPageState.copyWith(...)` or `instanceOfCapturedPageState.copyWith.fieldName(...)`.
|
||||
// ignore: library_private_types_in_public_api
|
||||
_$CapturedPageStateCWProxy get copyWith =>
|
||||
_$CapturedPageStateCWProxyImpl(this);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Capture pipeline selector. Each choice maps to a (method, variant) pair
|
||||
/// understood by the search backend's capture clients.
|
||||
enum FetchMethodChoice {
|
||||
/// Trafilatura — extracted reader-mode text + metadata. Not a "capture"
|
||||
/// per se; goes through the `fetchPage` command.
|
||||
trafilatura(method: null, variant: null),
|
||||
|
||||
/// SingleFile — self-contained HTML archive.
|
||||
singlefileHtml(method: 'singlefile', variant: 'balanced'),
|
||||
|
||||
/// shot-scraper — PDF rendering.
|
||||
shotScraperPdf(method: 'shot-scraper', variant: 'pdf'),
|
||||
|
||||
/// shot-scraper — PNG screenshot.
|
||||
shotScraperPng(method: 'shot-scraper', variant: 'png');
|
||||
|
||||
const FetchMethodChoice({required this.method, required this.variant});
|
||||
|
||||
final String? method;
|
||||
final String? variant;
|
||||
|
||||
static FetchMethodChoice? forCapture({
|
||||
required String method,
|
||||
required String variant,
|
||||
}) {
|
||||
for (final choice in FetchMethodChoice.values) {
|
||||
if (choice.method == method && choice.variant == variant) {
|
||||
return choice;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String get title => switch (this) {
|
||||
FetchMethodChoice.trafilatura => 'Extracted Preview',
|
||||
FetchMethodChoice.singlefileHtml => 'Full Page Capture',
|
||||
FetchMethodChoice.shotScraperPdf => 'PDF Snapshot',
|
||||
FetchMethodChoice.shotScraperPng => 'Image Snapshot',
|
||||
};
|
||||
|
||||
String get shortLabel => switch (this) {
|
||||
FetchMethodChoice.trafilatura => 'Preview',
|
||||
FetchMethodChoice.singlefileHtml => 'Archive',
|
||||
FetchMethodChoice.shotScraperPdf => 'PDF',
|
||||
FetchMethodChoice.shotScraperPng => 'Image',
|
||||
};
|
||||
|
||||
String get subtitle => switch (this) {
|
||||
FetchMethodChoice.trafilatura =>
|
||||
'Reader-optimized text and metadata for the in-app preview',
|
||||
FetchMethodChoice.singlefileHtml =>
|
||||
'Archive the full page with layout and assets for later use',
|
||||
FetchMethodChoice.shotScraperPdf =>
|
||||
'Render the page to a PDF for offline reading and sharing',
|
||||
FetchMethodChoice.shotScraperPng =>
|
||||
'Capture a full-page PNG screenshot of the rendered page',
|
||||
};
|
||||
|
||||
IconData get icon => switch (this) {
|
||||
FetchMethodChoice.trafilatura => Icons.description_outlined,
|
||||
FetchMethodChoice.singlefileHtml => Icons.archive_outlined,
|
||||
FetchMethodChoice.shotScraperPdf => Icons.picture_as_pdf_outlined,
|
||||
FetchMethodChoice.shotScraperPng => Icons.image_outlined,
|
||||
};
|
||||
}
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:search_backend/search_backend.dart';
|
||||
import 'package:search_client/search_client.dart';
|
||||
import 'package:weblibre/core/filesystem.dart';
|
||||
import 'package:weblibre/features/search_credits/domain/providers.dart';
|
||||
import 'package:weblibre/features/search_credits/domain/providers/proxy_client.dart';
|
||||
|
||||
part 'capture_artifact_downloader.g.dart';
|
||||
|
||||
class CaptureArtifactDownloadException implements Exception {
|
||||
final String message;
|
||||
final int? statusCode;
|
||||
|
||||
CaptureArtifactDownloadException(this.message, {this.statusCode});
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'CaptureArtifactDownloadException: $message'
|
||||
'${statusCode != null ? ' (status $statusCode)' : ''}';
|
||||
}
|
||||
|
||||
class CaptureArtifactDownloader {
|
||||
CaptureArtifactDownloader({
|
||||
required BackendEndpoints endpoints,
|
||||
required Directory storageDir,
|
||||
http.Client? client,
|
||||
}) : _endpoints = endpoints,
|
||||
_storageDir = storageDir,
|
||||
_client = client ?? http.Client();
|
||||
|
||||
final BackendEndpoints _endpoints;
|
||||
final Directory _storageDir;
|
||||
final http.Client _client;
|
||||
|
||||
/// Bound on the artifact download — covers connect + read combined. The
|
||||
/// download path runs through the proxy client (which may be Tor), so a
|
||||
/// stuck connection would otherwise leave the UI in `downloading` forever.
|
||||
static const _downloadTimeout = Duration(seconds: 60);
|
||||
|
||||
Future<String> download(CaptureArtifactReceipt receipt) async {
|
||||
await _storageDir.create(recursive: true);
|
||||
|
||||
final extension = captureFileExtension(receipt.contentType);
|
||||
if (extension == null) {
|
||||
// No on-disk extension we know how to serve back to Gecko later — the
|
||||
// loopback capture server only probes a fixed set of extensions and an
|
||||
// `.bin` artifact would be orphaned on disk. Surface the failure
|
||||
// upstream instead of silently writing a file that can never load.
|
||||
throw CaptureArtifactDownloadException(
|
||||
'unsupported capture content type: ${receipt.contentType}',
|
||||
);
|
||||
}
|
||||
final targetPath = p.join(
|
||||
_storageDir.path,
|
||||
'${receipt.captureId}$extension',
|
||||
);
|
||||
final tmpPath = '$targetPath.part';
|
||||
|
||||
final target = File(targetPath);
|
||||
if (await target.exists()) {
|
||||
return targetPath;
|
||||
}
|
||||
|
||||
final uri = _endpoints.captureDownload(receipt.captureId);
|
||||
|
||||
final request = http.Request('GET', uri);
|
||||
request.headers[HttpHeaders.authorizationHeader] =
|
||||
'Bearer ${receipt.downloadToken}';
|
||||
final streamed = await _client.send(request).timeout(_downloadTimeout);
|
||||
|
||||
if (streamed.statusCode != HttpStatus.ok) {
|
||||
throw CaptureArtifactDownloadException(
|
||||
'capture artifact download failed',
|
||||
statusCode: streamed.statusCode,
|
||||
);
|
||||
}
|
||||
|
||||
final tmp = File(tmpPath);
|
||||
final sink = tmp.openWrite();
|
||||
try {
|
||||
await streamed.stream.pipe(sink).timeout(_downloadTimeout);
|
||||
} catch (e) {
|
||||
await sink.close();
|
||||
if (await tmp.exists()) {
|
||||
await tmp.delete();
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
|
||||
await tmp.rename(targetPath);
|
||||
return targetPath;
|
||||
}
|
||||
}
|
||||
|
||||
/// Directory under the app temp area where capture artifacts are stored.
|
||||
Directory captureStorageDirectory() {
|
||||
return Directory(
|
||||
p.join(
|
||||
filesystem.tempDir.path,
|
||||
'web_search_captures',
|
||||
filesystem.selectedProfile.uuid,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Single source of truth for the capture artifact MIME ↔ extension mapping.
|
||||
///
|
||||
/// The capture server's loopback handler probes for exactly these extensions
|
||||
/// on disk (see [resolveCaptureExtensions]) and looks up the response
|
||||
/// `Content-Type` against this list (see [captureContentTypeForExtension]),
|
||||
/// so adding a new artifact format only requires editing this one table.
|
||||
const _captureArtifactTypes = <({String mime, String extension})>[
|
||||
(mime: 'text/html', extension: '.html'),
|
||||
(mime: 'application/pdf', extension: '.pdf'),
|
||||
(mime: 'image/png', extension: '.png'),
|
||||
(mime: 'image/jpeg', extension: '.jpg'),
|
||||
];
|
||||
|
||||
/// File extensions the capture server should probe when resolving a stored
|
||||
/// artifact (HTML + `.jpeg` alias for `image/jpeg`).
|
||||
const captureSupportedExtensions = <String>[
|
||||
'.html',
|
||||
'.pdf',
|
||||
'.png',
|
||||
'.jpg',
|
||||
'.jpeg',
|
||||
];
|
||||
|
||||
/// Maps a capture receipt's MIME [contentType] to the on-disk extension we
|
||||
/// store the artifact under. The extension is also what the loopback capture
|
||||
/// server uses to pick the right `Content-Type` when serving back to Gecko.
|
||||
///
|
||||
/// Returns `null` for an unknown content type — the caller (downloader)
|
||||
/// surfaces this as a download error rather than writing an orphan `.bin`
|
||||
/// the loopback server can't serve.
|
||||
String? captureFileExtension(String? contentType) {
|
||||
if (contentType == null) return null;
|
||||
final mime = contentType.split(';').first.trim().toLowerCase();
|
||||
for (final type in _captureArtifactTypes) {
|
||||
if (type.mime == mime) return type.extension;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// True if [contentType] designates an HTML artifact. The sandbox-capture
|
||||
/// flow (loopback redirects, retry loader, link-click interception) only
|
||||
/// applies to HTML — PDF / PNG artifacts are loaded directly in a regular
|
||||
/// tab so Gecko's built-in viewer renders them.
|
||||
bool isHtmlCaptureContentType(String? contentType) {
|
||||
if (contentType == null) return false;
|
||||
return contentType.split(';').first.trim().toLowerCase() == 'text/html';
|
||||
}
|
||||
|
||||
/// Snapshot of the host display, fed to the capture engines as
|
||||
/// `CaptureDimensions` so PDF/PNG renders match the user's viewport.
|
||||
///
|
||||
/// `mobile` is always `true` so captures emulate a mobile viewport.
|
||||
/// `colorScheme` follows the platform brightness so dark-mode sites render
|
||||
/// correctly.
|
||||
CaptureDimensions currentDisplayCaptureDimensions() {
|
||||
final view =
|
||||
PlatformDispatcher.instance.implicitView ??
|
||||
PlatformDispatcher.instance.views.first;
|
||||
final dpr = view.devicePixelRatio;
|
||||
final physical = view.physicalSize;
|
||||
final colorScheme =
|
||||
PlatformDispatcher.instance.platformBrightness == Brightness.dark
|
||||
? 'dark'
|
||||
: 'light';
|
||||
if (dpr <= 0 || physical.width <= 0 || physical.height <= 0) {
|
||||
return CaptureDimensions(mobile: true, colorScheme: colorScheme);
|
||||
}
|
||||
final logicalWidth = (physical.width / dpr).round();
|
||||
final logicalHeight = (physical.height / dpr).round();
|
||||
return CaptureDimensions(
|
||||
width: logicalWidth,
|
||||
height: logicalHeight,
|
||||
screenWidth: logicalWidth,
|
||||
screenHeight: logicalHeight,
|
||||
dpi: dpr,
|
||||
mobile: true,
|
||||
colorScheme: colorScheme,
|
||||
);
|
||||
}
|
||||
|
||||
/// Inverse of [captureFileExtension] — used by the loopback capture server
|
||||
/// to choose a `Content-Type` header from a stored artifact's extension.
|
||||
/// `.html` carries a charset; binary types don't.
|
||||
String? captureContentTypeForExtension(String extension) {
|
||||
final ext = extension.toLowerCase();
|
||||
if (ext == '.jpeg') return 'image/jpeg';
|
||||
for (final type in _captureArtifactTypes) {
|
||||
if (type.extension == ext) {
|
||||
return type.mime == 'text/html' ? 'text/html; charset=utf-8' : type.mime;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
CaptureArtifactDownloader captureArtifactDownloader(Ref ref) {
|
||||
return CaptureArtifactDownloader(
|
||||
endpoints: ref.watch(searchBackendEndpointsProvider),
|
||||
storageDir: captureStorageDirectory(),
|
||||
client: ref.watch(searchProxyHttpClientProvider),
|
||||
);
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'capture_artifact_downloader.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(captureArtifactDownloader)
|
||||
final captureArtifactDownloaderProvider = CaptureArtifactDownloaderProvider._();
|
||||
|
||||
final class CaptureArtifactDownloaderProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
CaptureArtifactDownloader,
|
||||
CaptureArtifactDownloader,
|
||||
CaptureArtifactDownloader
|
||||
>
|
||||
with $Provider<CaptureArtifactDownloader> {
|
||||
CaptureArtifactDownloaderProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'captureArtifactDownloaderProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$captureArtifactDownloaderHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<CaptureArtifactDownloader> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
CaptureArtifactDownloader create(Ref ref) {
|
||||
return captureArtifactDownloader(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(CaptureArtifactDownloader value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<CaptureArtifactDownloader>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$captureArtifactDownloaderHash() =>
|
||||
r'f746015082a68afc71e2af2e37ac83e3cc856891';
|
||||
@@ -0,0 +1,631 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:weblibre/features/web_search/domain/services/capture_artifact_downloader.dart';
|
||||
|
||||
part 'capture_server.g.dart';
|
||||
|
||||
// Extensions kept in lock-step with [captureFileExtension] in
|
||||
// capture_artifact_downloader.dart — the downloader writes the artifact under
|
||||
// one of these and this server probes for the same set.
|
||||
|
||||
class RetryRequest {
|
||||
final String tabId;
|
||||
final String captureId;
|
||||
const RetryRequest({required this.tabId, required this.captureId});
|
||||
}
|
||||
|
||||
/// Loopback HTTP server that exposes locally-stored capture HTML to the
|
||||
/// in-app GeckoView.
|
||||
///
|
||||
/// Gecko rejects `content://` (PDF-only) and `file://` (blocked scheme), so
|
||||
/// captures are served over `http://127.0.0.1:<port>/captures/<id>.html?t=<token>`.
|
||||
/// Every published capture gets a fresh random token — other apps binding
|
||||
/// to the same loopback cannot enumerate captures without guessing it.
|
||||
///
|
||||
/// The server also serves a zero-JS "Capturing…" loader page at
|
||||
/// `/loader?tab=<tabId>&capture=<captureId>[&err=1]`. Sandbox tab navigation
|
||||
/// is redirected to this loader while the capture is in flight; a
|
||||
/// `meta http-equiv="refresh"` tag loops until the capture is ready, at which
|
||||
/// point the loader responds with a `Refresh: 0; url=<captureUrl>` header so
|
||||
/// GeckoView transitions to the real capture without JavaScript.
|
||||
class _CaptureEntry {
|
||||
_CaptureEntry({required this.token});
|
||||
|
||||
final String token;
|
||||
// Cached path to the resolved artifact, populated by the first probe so
|
||||
// hot paths (loader long-poll wakeups) don't keep stat'ing the FS for
|
||||
// five extensions per check.
|
||||
File? cachedFile;
|
||||
}
|
||||
|
||||
class CaptureServer {
|
||||
CaptureServer({required Directory storageDir}) : _storageDir = storageDir;
|
||||
|
||||
final Directory _storageDir;
|
||||
final Map<String, _CaptureEntry> _entriesById = {};
|
||||
final Set<String> _failedIds = {};
|
||||
// Completer per captureId, completed by publish()/markFailed() so loader
|
||||
// long-polls wake up immediately instead of busy-looping a 200ms poll.
|
||||
// Recreated after each completion so a subsequent retry can wait again.
|
||||
final Map<String, Completer<void>> _waiters = {};
|
||||
final _random = Random.secure();
|
||||
final _retryController = StreamController<RetryRequest>.broadcast();
|
||||
|
||||
HttpServer? _server;
|
||||
Future<HttpServer>? _starting;
|
||||
|
||||
Completer<void> _waiterFor(String captureId) {
|
||||
return _waiters.putIfAbsent(captureId, Completer<void>.new);
|
||||
}
|
||||
|
||||
void _signalWaiter(String captureId) {
|
||||
final completer = _waiters.remove(captureId);
|
||||
if (completer != null && !completer.isCompleted) {
|
||||
completer.complete();
|
||||
}
|
||||
}
|
||||
|
||||
Stream<RetryRequest> get retryRequests => _retryController.stream;
|
||||
|
||||
Future<HttpServer> _ensureStarted() async {
|
||||
final existing = _server;
|
||||
if (existing != null) return existing;
|
||||
final pending = _starting;
|
||||
if (pending != null) return pending;
|
||||
|
||||
// Track success and failure: a rejected bind() must clear `_starting`,
|
||||
// otherwise the stale rejected future is handed back to every subsequent
|
||||
// caller and the server can never recover (e.g. transient EADDRINUSE).
|
||||
final future = HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||
_starting = future
|
||||
.then((server) {
|
||||
_server = server;
|
||||
unawaited(_serve(server));
|
||||
return server;
|
||||
})
|
||||
.whenComplete(() => _starting = null);
|
||||
return _starting!;
|
||||
}
|
||||
|
||||
Future<void> _serve(HttpServer server) async {
|
||||
await for (final request in server) {
|
||||
unawaited(_handle(request));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handle(HttpRequest request) async {
|
||||
try {
|
||||
final segments = request.uri.pathSegments;
|
||||
if (segments.isEmpty) {
|
||||
request.response.statusCode = HttpStatus.notFound;
|
||||
await request.response.close();
|
||||
return;
|
||||
}
|
||||
|
||||
switch (segments[0]) {
|
||||
case 'captures':
|
||||
if (request.method != 'GET' || segments.length != 2) {
|
||||
await _emptyResponse(request, HttpStatus.methodNotAllowed);
|
||||
return;
|
||||
}
|
||||
await _handleCapture(request, segments[1]);
|
||||
return;
|
||||
case 'loader':
|
||||
if (segments.length == 1) {
|
||||
if (request.method != 'GET') {
|
||||
await _emptyResponse(request, HttpStatus.methodNotAllowed);
|
||||
return;
|
||||
}
|
||||
await _handleLoader(request);
|
||||
return;
|
||||
}
|
||||
if (segments.length == 2 && segments[1] == 'wait') {
|
||||
if (request.method != 'GET') {
|
||||
await _emptyResponse(request, HttpStatus.methodNotAllowed);
|
||||
return;
|
||||
}
|
||||
await _handleLoaderWait(request);
|
||||
return;
|
||||
}
|
||||
if (segments.length == 2 && segments[1] == 'retry') {
|
||||
if (request.method != 'POST') {
|
||||
await _emptyResponse(request, HttpStatus.methodNotAllowed);
|
||||
return;
|
||||
}
|
||||
await _handleRetry(request);
|
||||
return;
|
||||
}
|
||||
await _emptyResponse(request, HttpStatus.notFound);
|
||||
return;
|
||||
default:
|
||||
await _emptyResponse(request, HttpStatus.notFound);
|
||||
return;
|
||||
}
|
||||
} catch (_) {
|
||||
try {
|
||||
await request.response.close();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleCapture(HttpRequest request, String lastSegment) async {
|
||||
final extIndex = lastSegment.lastIndexOf('.');
|
||||
final captureId = extIndex >= 0
|
||||
? lastSegment.substring(0, extIndex)
|
||||
: lastSegment;
|
||||
if (!_captureIdPattern.hasMatch(captureId)) {
|
||||
await _emptyResponse(request, HttpStatus.notFound);
|
||||
return;
|
||||
}
|
||||
|
||||
final entry = _entriesById[captureId];
|
||||
final token = request.uri.queryParameters['t'];
|
||||
if (entry == null || token == null || token != entry.token) {
|
||||
await _emptyResponse(request, HttpStatus.forbidden);
|
||||
return;
|
||||
}
|
||||
|
||||
final file = await _resolveCaptureFile(captureId);
|
||||
if (file == null) {
|
||||
await _emptyResponse(request, HttpStatus.notFound);
|
||||
return;
|
||||
}
|
||||
|
||||
final extension = p.extension(file.path).toLowerCase();
|
||||
final mime =
|
||||
captureContentTypeForExtension(extension) ?? 'application/octet-stream';
|
||||
final isHtml = extension == '.html';
|
||||
final length = await file.length();
|
||||
|
||||
request.response.statusCode = HttpStatus.ok;
|
||||
request.response.headers
|
||||
..set(HttpHeaders.contentTypeHeader, mime)
|
||||
..contentLength = length
|
||||
..set(HttpHeaders.cacheControlHeader, 'no-store')
|
||||
..set('X-Content-Type-Options', 'nosniff');
|
||||
if (isHtml) {
|
||||
// HTML is the only artifact type that can execute script or load
|
||||
// subresources — clamp it down. PDF/PNG bytes are inert; the browser's
|
||||
// built-in viewer handles them and CSP would just confuse it.
|
||||
request.response.headers.set('Content-Security-Policy', _captureCsp);
|
||||
}
|
||||
await request.response.addStream(file.openRead());
|
||||
await request.response.close();
|
||||
}
|
||||
|
||||
/// Resolves the on-disk artifact for [captureId] and caches the result on
|
||||
/// the entry so subsequent lookups skip the five-extension stat loop. The
|
||||
/// cache is re-validated on every lookup via a cheap `exists()` so a file
|
||||
/// removed out-of-band (cache cleanup, manual delete) is detected and the
|
||||
/// stat loop is re-run; without this, the server would happily hand
|
||||
/// `request.response.addStream(file.openRead())` a missing file and
|
||||
/// surface as a mid-stream error rather than the expected 404.
|
||||
Future<File?> _resolveCaptureFile(String captureId) async {
|
||||
final entry = _entriesById[captureId];
|
||||
if (entry == null) return null;
|
||||
final cached = entry.cachedFile;
|
||||
if (cached != null) {
|
||||
if (await cached.exists()) return cached;
|
||||
entry.cachedFile = null;
|
||||
}
|
||||
for (final ext in captureSupportedExtensions) {
|
||||
final file = File(p.join(_storageDir.path, '$captureId$ext'));
|
||||
if (await file.exists()) {
|
||||
entry.cachedFile = file;
|
||||
return file;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
File? _resolveCaptureFileSync(String captureId) {
|
||||
final entry = _entriesById[captureId];
|
||||
if (entry == null) return null;
|
||||
final cached = entry.cachedFile;
|
||||
if (cached != null) {
|
||||
if (cached.existsSync()) return cached;
|
||||
entry.cachedFile = null;
|
||||
}
|
||||
for (final ext in captureSupportedExtensions) {
|
||||
final file = File(p.join(_storageDir.path, '$captureId$ext'));
|
||||
if (file.existsSync()) {
|
||||
entry.cachedFile = file;
|
||||
return file;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> _handleLoader(HttpRequest request) async {
|
||||
final tabId = request.uri.queryParameters['tab'];
|
||||
final captureId = request.uri.queryParameters['capture'];
|
||||
final err = request.uri.queryParameters['err'] == '1';
|
||||
|
||||
if (tabId == null ||
|
||||
captureId == null ||
|
||||
!_captureIdPattern.hasMatch(captureId)) {
|
||||
await _emptyResponse(request, HttpStatus.notFound);
|
||||
return;
|
||||
}
|
||||
|
||||
// Render the loader shell immediately. Status transitions are driven
|
||||
// by an inline JS long-poll against /loader/wait — no meta-refresh,
|
||||
// no full-page reloads while the user is staring at the spinner.
|
||||
await _writeLoaderHtml(
|
||||
request,
|
||||
body: _loaderShellBody(initialError: err),
|
||||
tabId: tabId,
|
||||
captureId: captureId,
|
||||
);
|
||||
}
|
||||
|
||||
/// JSON long-poll endpoint consumed by the loader shell. Holds the
|
||||
/// connection open up to ~25s, returning as soon as the capture
|
||||
/// transitions to `ready` or `failed`. Idle clients reconnect on close,
|
||||
/// so we keep timeouts well under typical proxy buffering windows.
|
||||
///
|
||||
/// Wakes up event-driven via a per-id [Completer] signaled by
|
||||
/// [publish]/[markFailed]; the older revision busy-polled `isReady` every
|
||||
/// 200ms which burned CPU on long captures.
|
||||
Future<void> _handleLoaderWait(HttpRequest request) async {
|
||||
final captureId = request.uri.queryParameters['capture'];
|
||||
if (captureId == null || !_captureIdPattern.hasMatch(captureId)) {
|
||||
await _emptyResponse(request, HttpStatus.badRequest);
|
||||
return;
|
||||
}
|
||||
|
||||
String status;
|
||||
String? captureUrl;
|
||||
|
||||
if (_failedIds.contains(captureId)) {
|
||||
status = 'failed';
|
||||
} else if (isReady(captureId)) {
|
||||
status = 'ready';
|
||||
} else {
|
||||
// Race-free: register the waiter before re-checking ready/failed.
|
||||
// If publish() fires between the early returns above and here, the
|
||||
// waiter has already been completed via _signalWaiter.
|
||||
final completer = _waiterFor(captureId);
|
||||
try {
|
||||
await completer.future.timeout(const Duration(seconds: 25));
|
||||
} on TimeoutException {
|
||||
// Fall through — loader script reconnects on close.
|
||||
}
|
||||
if (_failedIds.contains(captureId)) {
|
||||
status = 'failed';
|
||||
} else if (isReady(captureId)) {
|
||||
status = 'ready';
|
||||
} else {
|
||||
status = 'pending';
|
||||
}
|
||||
}
|
||||
|
||||
if (status == 'ready') {
|
||||
final server = _server;
|
||||
final entry = _entriesById[captureId];
|
||||
if (server != null && entry != null) {
|
||||
captureUrl = _buildCaptureUrl(
|
||||
server.port,
|
||||
captureId,
|
||||
entry.token,
|
||||
).toString();
|
||||
}
|
||||
}
|
||||
|
||||
final body = jsonEncode({
|
||||
'status': status,
|
||||
if (captureUrl != null) 'url': captureUrl,
|
||||
});
|
||||
request.response.statusCode = HttpStatus.ok;
|
||||
request.response.headers
|
||||
..contentType = ContentType('application', 'json', charset: 'utf-8')
|
||||
..set(HttpHeaders.cacheControlHeader, 'no-store');
|
||||
request.response.write(body);
|
||||
await request.response.close();
|
||||
}
|
||||
|
||||
Future<void> _handleRetry(HttpRequest request) async {
|
||||
final tabId = request.uri.queryParameters['tab'];
|
||||
final captureId = request.uri.queryParameters['capture'];
|
||||
if (tabId == null ||
|
||||
captureId == null ||
|
||||
!_captureIdPattern.hasMatch(captureId)) {
|
||||
await _emptyResponse(request, HttpStatus.badRequest);
|
||||
return;
|
||||
}
|
||||
_retryController.add(RetryRequest(tabId: tabId, captureId: captureId));
|
||||
// The loader script in the *current* page picks up the new state via its
|
||||
// long-poll, so a Refresh header (or full reload) would just discard the
|
||||
// tab's in-flight fetch. Reply with an empty 204 — the client doesn't
|
||||
// need the body.
|
||||
await _emptyResponse(request, HttpStatus.noContent);
|
||||
}
|
||||
|
||||
Future<void> _writeLoaderHtml(
|
||||
HttpRequest request, {
|
||||
required String body,
|
||||
required String tabId,
|
||||
required String captureId,
|
||||
}) async {
|
||||
request.response.statusCode = HttpStatus.ok;
|
||||
request.response.headers
|
||||
..contentType = ContentType('text', 'html', charset: 'utf-8')
|
||||
..set(HttpHeaders.cacheControlHeader, 'no-store')
|
||||
..set('X-Content-Type-Options', 'nosniff')
|
||||
..set('Content-Security-Policy', _loaderCsp);
|
||||
final tabIdJson = jsonEncode(tabId);
|
||||
final captureIdJson = jsonEncode(captureId);
|
||||
final html =
|
||||
'<!doctype html><html><head><meta charset="utf-8">'
|
||||
'<meta name="viewport" content="width=device-width,initial-scale=1">'
|
||||
'<title>Capturing…</title><style>$_loaderStyles</style>'
|
||||
'</head><body>$body'
|
||||
'<script>window.__TAB_ID__=$tabIdJson;'
|
||||
'window.__CAPTURE_ID__=$captureIdJson;</script>'
|
||||
'<script>$_loaderScript</script>'
|
||||
'</body></html>';
|
||||
request.response.write(html);
|
||||
await request.response.close();
|
||||
}
|
||||
|
||||
Future<void> _emptyResponse(HttpRequest request, int status) async {
|
||||
request.response.statusCode = status;
|
||||
await request.response.close();
|
||||
}
|
||||
|
||||
/// Registers a capture and returns a `http://127.0.0.1:<port>/...` URL
|
||||
/// that can be loaded in a GeckoView tab.
|
||||
Future<Uri> publish(String captureId) async {
|
||||
final server = await _ensureStarted();
|
||||
_failedIds.remove(captureId);
|
||||
final entry = _entriesById.putIfAbsent(
|
||||
captureId,
|
||||
() => _CaptureEntry(token: _generateToken()),
|
||||
);
|
||||
// Wake any in-flight long-poll for this id — the artifact is now
|
||||
// resolvable, so the poll can return `ready` immediately.
|
||||
_signalWaiter(captureId);
|
||||
return _buildCaptureUrl(server.port, captureId, entry.token);
|
||||
}
|
||||
|
||||
/// Marks a capture as failed so any in-flight loader long-polls return
|
||||
/// immediately with `status: 'failed'`.
|
||||
void markFailed(String captureId) {
|
||||
_failedIds.add(captureId);
|
||||
_signalWaiter(captureId);
|
||||
}
|
||||
|
||||
/// Clears any prior failed flag for the given capture id (e.g. before a
|
||||
/// retry).
|
||||
void clearFailed(String captureId) {
|
||||
_failedIds.remove(captureId);
|
||||
}
|
||||
|
||||
/// Ensures the server is running and returns its port. Useful when callers
|
||||
/// need to build a loader URL before any capture has been published.
|
||||
Future<int> ensureStarted() async {
|
||||
final server = await _ensureStarted();
|
||||
return server.port;
|
||||
}
|
||||
|
||||
/// Builds the loader URL for the given tab + capture. Also ensures the
|
||||
/// server is running so callers can use the returned port stably.
|
||||
Future<Uri> loaderUrl({
|
||||
required String tabId,
|
||||
required String captureId,
|
||||
bool error = false,
|
||||
}) async {
|
||||
final port = await ensureStarted();
|
||||
return Uri(
|
||||
scheme: 'http',
|
||||
host: InternetAddress.loopbackIPv4.address,
|
||||
port: port,
|
||||
pathSegments: const ['loader'],
|
||||
queryParameters: {
|
||||
'tab': tabId,
|
||||
'capture': captureId,
|
||||
if (error) 'err': '1',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// True if [publish] has been called for [captureId] and the artifact file
|
||||
/// is present on disk. Used by the loader to decide between meta-refresh
|
||||
/// and redirect.
|
||||
bool isReady(String captureId) {
|
||||
final entry = _entriesById[captureId];
|
||||
if (entry == null) return false;
|
||||
return _resolveCaptureFileSync(captureId) != null;
|
||||
}
|
||||
|
||||
void revoke(String captureId) {
|
||||
_entriesById.remove(captureId);
|
||||
_failedIds.remove(captureId);
|
||||
_signalWaiter(captureId);
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
final server = _server;
|
||||
_server = null;
|
||||
_starting = null;
|
||||
_entriesById.clear();
|
||||
_failedIds.clear();
|
||||
// Release every pending long-poll so the request handler can finish.
|
||||
for (final completer in _waiters.values) {
|
||||
if (!completer.isCompleted) completer.complete();
|
||||
}
|
||||
_waiters.clear();
|
||||
await server?.close(force: true);
|
||||
await _retryController.close();
|
||||
}
|
||||
|
||||
Uri _buildCaptureUrl(int port, String captureId, String token) {
|
||||
// If the artifact is on disk, use its real extension so Gecko picks the
|
||||
// right viewer (e.g. PDF.js for `.pdf`). Pre-publish (no file yet) we
|
||||
// fall back to `.html`; the loader long-poll re-resolves the URL once
|
||||
// the artifact lands.
|
||||
final file = _resolveCaptureFileSync(captureId);
|
||||
final ext = file != null ? p.extension(file.path).toLowerCase() : '.html';
|
||||
return Uri(
|
||||
scheme: 'http',
|
||||
host: InternetAddress.loopbackIPv4.address,
|
||||
port: port,
|
||||
pathSegments: ['captures', '$captureId$ext'],
|
||||
queryParameters: {'t': token},
|
||||
);
|
||||
}
|
||||
|
||||
String _generateToken() {
|
||||
final bytes = List<int>.generate(24, (_) => _random.nextInt(256));
|
||||
return base64Url.encode(bytes).replaceAll('=', '');
|
||||
}
|
||||
|
||||
static final _captureIdPattern = RegExp(r'^[A-Za-z0-9_-]+$');
|
||||
|
||||
static const _captureCsp =
|
||||
"default-src 'none'; "
|
||||
"script-src 'none'; "
|
||||
"style-src 'unsafe-inline' data:; "
|
||||
"img-src data: blob:; "
|
||||
"font-src data:; "
|
||||
"frame-ancestors 'none'; "
|
||||
"base-uri 'none'; "
|
||||
"form-action 'none'";
|
||||
|
||||
// The loader is server-controlled HTML on the loopback origin only — it
|
||||
// never loads remote subresources. Inline script/style are required for
|
||||
// the long-poll JS and the spinner CSS.
|
||||
static const _loaderCsp =
|
||||
"default-src 'none'; "
|
||||
"script-src 'unsafe-inline'; "
|
||||
"style-src 'unsafe-inline'; "
|
||||
"connect-src 'self'; "
|
||||
"img-src data:; "
|
||||
"frame-ancestors 'none'; "
|
||||
"base-uri 'none'; "
|
||||
"form-action 'self'";
|
||||
|
||||
static const _loaderStyles =
|
||||
'html,body{height:100%}'
|
||||
'body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,'
|
||||
'sans-serif;display:flex;align-items:center;justify-content:center;'
|
||||
'min-height:100vh;margin:0;background:#0b0d10;color:#e6e8eb}'
|
||||
'main{text-align:center;max-width:480px;padding:32px;opacity:0;'
|
||||
'animation:fade .35s ease-out forwards}'
|
||||
'@keyframes fade{to{opacity:1}}'
|
||||
'.spinner{width:56px;height:56px;margin:0 auto 20px;border-radius:50%;'
|
||||
'border:3px solid rgba(255,255,255,.08);border-top-color:#7aa2f7;'
|
||||
'animation:spin 1s linear infinite}'
|
||||
'@keyframes spin{to{transform:rotate(360deg)}}'
|
||||
'h1{font-size:18px;font-weight:600;margin:0 0 8px;letter-spacing:.2px}'
|
||||
'p{margin:0;color:#9aa1a8;font-size:14px;line-height:1.5}'
|
||||
'.dots::after{display:inline-block;width:1.2em;text-align:left;'
|
||||
'animation:dots 1.4s steps(4,end) infinite;content:""}'
|
||||
'@keyframes dots{0%{content:""}25%{content:"."}50%{content:".."}'
|
||||
'75%{content:"..."}100%{content:""}}'
|
||||
'.error h1{color:#f7768e}'
|
||||
'.actions{margin-top:20px;display:flex;gap:8px;justify-content:center}'
|
||||
'.btn{padding:10px 16px;border:0;border-radius:10px;cursor:pointer;'
|
||||
'font:inherit;font-weight:600;background:#7aa2f7;color:#0b0d10}'
|
||||
'.btn.secondary{background:transparent;color:#9aa1a8;'
|
||||
'border:1px solid rgba(255,255,255,.12)}'
|
||||
'.hidden{display:none}'
|
||||
'@media(prefers-color-scheme:light){body{background:#f5f6f8;'
|
||||
'color:#1a1d22}.spinner{border-color:rgba(0,0,0,.08);'
|
||||
'border-top-color:#3b82f6}.btn{background:#3b82f6;color:white}'
|
||||
'p{color:#52606b}}';
|
||||
|
||||
// Inline script for the loader shell. Reads window.__TAB_ID__ and
|
||||
// window.__CAPTURE_ID__, long-polls /loader/wait, then either redirects
|
||||
// to the ready capture or flips to the error pane.
|
||||
//
|
||||
// Network-error retries are capped (MAX_NET_ERRORS) so a tab whose capture
|
||||
// service has gone away doesn't busy-loop forever — after the cap the
|
||||
// loader gives up and shows the error pane with a Retry button (which
|
||||
// resets the counter via showPending → poll()).
|
||||
static const _loaderScript = r'''
|
||||
(function(){
|
||||
var pending=document.getElementById('pending');
|
||||
var error=document.getElementById('error');
|
||||
var retryBtn=document.getElementById('retry');
|
||||
var tabId=window.__TAB_ID__;
|
||||
var captureId=window.__CAPTURE_ID__;
|
||||
var MAX_NET_ERRORS=15;
|
||||
var netErrors=0;
|
||||
function showError(){
|
||||
if(pending)pending.classList.add('hidden');
|
||||
if(error)error.classList.remove('hidden');
|
||||
}
|
||||
function showPending(){
|
||||
netErrors=0;
|
||||
if(error)error.classList.add('hidden');
|
||||
if(pending)pending.classList.remove('hidden');
|
||||
}
|
||||
async function poll(){
|
||||
try{
|
||||
var r=await fetch('/loader/wait?tab='+encodeURIComponent(tabId)+
|
||||
'&capture='+encodeURIComponent(captureId),{cache:'no-store'});
|
||||
if(!r.ok){
|
||||
if(++netErrors>=MAX_NET_ERRORS){showError();return}
|
||||
await new Promise(function(res){setTimeout(res,2000)});return poll();
|
||||
}
|
||||
netErrors=0;
|
||||
var data=await r.json();
|
||||
if(data.status==='ready'&&data.url){location.replace(data.url);return}
|
||||
if(data.status==='failed'){showError();return}
|
||||
// pending timeout — reconnect immediately.
|
||||
return poll();
|
||||
}catch(e){
|
||||
if(++netErrors>=MAX_NET_ERRORS){showError();return}
|
||||
await new Promise(function(res){setTimeout(res,2000)});
|
||||
return poll();
|
||||
}
|
||||
}
|
||||
if(retryBtn){
|
||||
retryBtn.addEventListener('click',function(){
|
||||
showPending();
|
||||
fetch('/loader/retry?tab='+encodeURIComponent(tabId)+
|
||||
'&capture='+encodeURIComponent(captureId),
|
||||
{method:'POST',cache:'no-store'}).catch(function(){});
|
||||
poll();
|
||||
});
|
||||
}
|
||||
if(error&&!error.classList.contains('hidden')){
|
||||
// Started in error mode — wait for user retry.
|
||||
return;
|
||||
}
|
||||
poll();
|
||||
})();
|
||||
''';
|
||||
|
||||
String _loaderShellBody({required bool initialError}) {
|
||||
final pendingClass = initialError ? 'hidden' : '';
|
||||
final errorClass = initialError ? 'error' : 'error hidden';
|
||||
return '<main id="pending" class="$pendingClass">'
|
||||
'<div class="spinner"></div>'
|
||||
'<h1>Capturing page<span class="dots"></span></h1>'
|
||||
'<p>Saving an offline copy. This usually takes a few seconds.</p>'
|
||||
'</main>'
|
||||
'<main id="error" class="$errorClass">'
|
||||
'<h1>Capture failed</h1>'
|
||||
'<p>The page could not be saved. Check the notification for details.</p>'
|
||||
'<div class="actions">'
|
||||
'<button id="retry" class="btn" type="button">Retry</button>'
|
||||
'</div>'
|
||||
'</main>';
|
||||
}
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
CaptureServer captureServer(Ref ref) {
|
||||
final server = CaptureServer(storageDir: captureStorageDirectory());
|
||||
ref.onDispose(() {
|
||||
unawaited(server.stop());
|
||||
});
|
||||
return server;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'capture_server.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(captureServer)
|
||||
final captureServerProvider = CaptureServerProvider._();
|
||||
|
||||
final class CaptureServerProvider
|
||||
extends $FunctionalProvider<CaptureServer, CaptureServer, CaptureServer>
|
||||
with $Provider<CaptureServer> {
|
||||
CaptureServerProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'captureServerProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$captureServerHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<CaptureServer> $createElement($ProviderPointer pointer) =>
|
||||
$ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
CaptureServer create(Ref ref) {
|
||||
return captureServer(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(CaptureServer value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<CaptureServer>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$captureServerHash() => r'ddbb9eff1b3d622a44a5ae9523e2f96a37b075fb';
|
||||
@@ -0,0 +1,57 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:weblibre/core/filesystem.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/database/definitions.drift.dart'
|
||||
show CaptureTabData;
|
||||
|
||||
/// Mirror of the capture_tab rows on disk under
|
||||
/// `<profileDir>/files/sandbox_captures.json`. Kotlin reads this file during
|
||||
/// pre-Gecko bootstrap, before tab restore can dispatch any load request.
|
||||
///
|
||||
/// The schema is intentionally tiny: only the fields Kotlin needs to build a
|
||||
/// placeholder [SandboxEntry] with `redirectUrl = "about:blank"`. Dart
|
||||
/// replaces those placeholders with real loopback URLs via
|
||||
/// [SandboxCaptureApi.resetAll] once [CaptureServer] is running.
|
||||
///
|
||||
/// The [_version] field is written at the top of the JSON envelope. Kotlin
|
||||
/// reads it and refuses to rehydrate any version higher than the one it
|
||||
/// supports — a forward-incompatible write from a newer Dart side leaves
|
||||
/// the registry empty for that cold start, which is the safe outcome
|
||||
/// (sandbox tabs reopen as about:blank rather than loading live URLs).
|
||||
/// Bump this in lockstep with the Kotlin `SUPPORTED_VERSION` constant.
|
||||
const _version = 1;
|
||||
|
||||
class SandboxCaptureStore {
|
||||
File _file() {
|
||||
return File(
|
||||
p.join(
|
||||
filesystem.selectedProfileDir.path,
|
||||
'files',
|
||||
'sandbox_captures.json',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> write(List<CaptureTabData> rows) async {
|
||||
final file = _file();
|
||||
await file.parent.create(recursive: true);
|
||||
final payload = jsonEncode({
|
||||
'version': _version,
|
||||
'entries': rows
|
||||
.map((r) {
|
||||
return {
|
||||
'tabId': r.tabId,
|
||||
'captureId': r.captureId,
|
||||
'sourceUrl': r.sourceUrl,
|
||||
'status': r.status,
|
||||
};
|
||||
})
|
||||
.toList(growable: false),
|
||||
});
|
||||
final tmp = File('${file.path}.part');
|
||||
await tmp.writeAsString(payload, flush: true);
|
||||
await tmp.rename(file.path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:weblibre/features/web_search/domain/controllers/search_controller.dart';
|
||||
import 'package:weblibre/features/web_search/domain/entities/captured_page_state.dart';
|
||||
import 'package:weblibre/features/web_search/domain/entities/fetch_method.dart';
|
||||
import 'package:weblibre/features/web_search/domain/services/capture_artifact_downloader.dart';
|
||||
|
||||
Future<void> showFetchMethodSheet(
|
||||
BuildContext context, {
|
||||
required Uri url,
|
||||
required Future<void> Function(Uri url) onPreview,
|
||||
required Future<void> Function(CapturedPageState captured) onOpenCapture,
|
||||
}) {
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
showDragHandle: true,
|
||||
builder: (context) => _FetchMethodSheet(
|
||||
url: url,
|
||||
onPreview: onPreview,
|
||||
onOpenCapture: onOpenCapture,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _FetchMethodSheet extends ConsumerWidget {
|
||||
final Uri url;
|
||||
final Future<void> Function(Uri url) onPreview;
|
||||
final Future<void> Function(CapturedPageState captured) onOpenCapture;
|
||||
|
||||
const _FetchMethodSheet({
|
||||
required this.url,
|
||||
required this.onPreview,
|
||||
required this.onOpenCapture,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final state = ref.watch(metaSearchControllerProvider);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Fetch Page Data', style: textTheme.titleLarge),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
url.toString(),
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
for (final choice in FetchMethodChoice.values)
|
||||
_MethodTile(
|
||||
url: url,
|
||||
choice: choice,
|
||||
state: state,
|
||||
onPreview: onPreview,
|
||||
onOpenCapture: onOpenCapture,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MethodTile extends ConsumerWidget {
|
||||
final Uri url;
|
||||
final FetchMethodChoice choice;
|
||||
final MetaSearchState state;
|
||||
final Future<void> Function(Uri url) onPreview;
|
||||
final Future<void> Function(CapturedPageState captured) onOpenCapture;
|
||||
|
||||
const _MethodTile({
|
||||
required this.url,
|
||||
required this.choice,
|
||||
required this.state,
|
||||
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 busy = state.isMethodBusy(url, choice);
|
||||
final ready = state.isMethodReady(url, choice);
|
||||
final captured = state.capturedPage(url, choice);
|
||||
final failed = captured?.status == CapturedPageStatus.downloadFailed;
|
||||
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
|
||||
leading: Icon(choice.icon, color: colorScheme.onSurfaceVariant),
|
||||
title: Text(choice.title),
|
||||
subtitle: Text(
|
||||
failed
|
||||
? (captured?.errorMessage ?? 'Download failed — tap to retry')
|
||||
: choice.subtitle,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: failed ? colorScheme.error : colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
trailing: _StatusIndicator(
|
||||
busy: busy,
|
||||
ready: ready,
|
||||
failed: failed,
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
onTap: busy ? null : () => _handleTap(context, ref, ready, failed, captured),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleTap(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
bool ready,
|
||||
bool failed,
|
||||
CapturedPageState? captured,
|
||||
) async {
|
||||
if (ready) {
|
||||
Navigator.of(context).pop();
|
||||
if (choice == FetchMethodChoice.trafilatura) {
|
||||
await onPreview(url);
|
||||
} else if (captured != null) {
|
||||
await onOpenCapture(captured);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
Navigator.of(context).pop();
|
||||
|
||||
if (failed && captured != null) {
|
||||
await ref
|
||||
.read(metaSearchControllerProvider.notifier)
|
||||
.retryCaptureDownload(url, choice);
|
||||
return;
|
||||
}
|
||||
|
||||
if (choice == FetchMethodChoice.trafilatura) {
|
||||
await ref.read(metaSearchControllerProvider.notifier).fetchPage(url);
|
||||
return;
|
||||
}
|
||||
|
||||
await ref
|
||||
.read(metaSearchControllerProvider.notifier)
|
||||
.capturePage(
|
||||
url,
|
||||
choice: choice,
|
||||
// Render at the device's actual viewport so PDF/PNG snapshots
|
||||
// match what the user sees; singlefile ignores these fields.
|
||||
dimensions: currentDisplayCaptureDimensions(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusIndicator extends StatelessWidget {
|
||||
final bool busy;
|
||||
final bool ready;
|
||||
final bool failed;
|
||||
final ColorScheme colorScheme;
|
||||
|
||||
const _StatusIndicator({
|
||||
required this.busy,
|
||||
required this.ready,
|
||||
required this.failed,
|
||||
required this.colorScheme,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (busy) {
|
||||
return const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
);
|
||||
}
|
||||
if (ready) {
|
||||
return Icon(Icons.check_circle, color: colorScheme.primary);
|
||||
}
|
||||
if (failed) {
|
||||
return Icon(Icons.refresh, color: colorScheme.error);
|
||||
}
|
||||
return Icon(
|
||||
Icons.download_rounded,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:weblibre/core/routing/routes.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/providers/tab_session.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
|
||||
import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_controller.dart';
|
||||
import 'package:weblibre/features/web_search/domain/services/capture_server.dart';
|
||||
|
||||
/// Parameters describing where a web-search-opened tab should land.
|
||||
///
|
||||
/// The search screen owns the user-visible selectors (tab type, container,
|
||||
/// parent for child tabs) and threads them through here so the result-card
|
||||
/// open path honours them — historically this path inherited the *currently
|
||||
/// selected* tab's mode/container instead, ignoring the search UI.
|
||||
class WebSearchOpenTarget {
|
||||
final TabMode tabMode;
|
||||
final TabContainerSelection containerSelection;
|
||||
final String? parentId;
|
||||
|
||||
const WebSearchOpenTarget({
|
||||
required this.tabMode,
|
||||
required this.containerSelection,
|
||||
this.parentId,
|
||||
});
|
||||
}
|
||||
|
||||
abstract interface class WebSearchTabOpener {
|
||||
Future<void> open(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
Uri uri, {
|
||||
required WebSearchOpenTarget target,
|
||||
});
|
||||
|
||||
Future<void> openCapture(
|
||||
BuildContext context,
|
||||
WidgetRef ref, {
|
||||
required String captureId,
|
||||
required Uri sourceUrl,
|
||||
required WebSearchOpenTarget target,
|
||||
String? contentType,
|
||||
String? method,
|
||||
String? variant,
|
||||
});
|
||||
}
|
||||
|
||||
final webSearchTabOpenerProvider = Provider<WebSearchTabOpener>(
|
||||
(ref) => const _DefaultWebSearchTabOpener(),
|
||||
);
|
||||
|
||||
final class _DefaultWebSearchTabOpener implements WebSearchTabOpener {
|
||||
const _DefaultWebSearchTabOpener();
|
||||
|
||||
@override
|
||||
Future<void> open(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
Uri uri, {
|
||||
required WebSearchOpenTarget target,
|
||||
}) async {
|
||||
await ref
|
||||
.read(tabRepositoryProvider.notifier)
|
||||
.addTab(
|
||||
url: uri,
|
||||
tabMode: target.tabMode,
|
||||
parentId: target.parentId,
|
||||
promptOnBackBehavior: ReturnToSearchTabBackPromptBehavior(
|
||||
tabType: target.tabMode.toTabType(),
|
||||
),
|
||||
selectTab: true,
|
||||
containerSelection: target.containerSelection,
|
||||
);
|
||||
|
||||
// Push (not go/replace) so the SearchRoute stays underneath. Pressing
|
||||
// back from the freshly opened tab pops back to the search results
|
||||
// instead of closing the tab. metaSearchControllerProvider is
|
||||
// keepAlive, so results survive the navigation either way.
|
||||
if (context.mounted) {
|
||||
const BrowserRoute().go(context);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> openCapture(
|
||||
BuildContext context,
|
||||
WidgetRef ref, {
|
||||
required String captureId,
|
||||
required Uri sourceUrl,
|
||||
required WebSearchOpenTarget target,
|
||||
String? contentType,
|
||||
String? method,
|
||||
String? variant,
|
||||
}) async {
|
||||
// Never navigate to the real sourceUrl — Gecko's app-link resolution
|
||||
// (e.g. github.com triggers the "Open in GitHub app?" dialog) fires
|
||||
// before our interceptor can redirect, and a cancel falls back to
|
||||
// loading the live site. Load the loopback capture URL directly.
|
||||
final captureUrl = await ref.read(captureServerProvider).publish(captureId);
|
||||
|
||||
final tabId = await ref
|
||||
.read(tabRepositoryProvider.notifier)
|
||||
.addTab(
|
||||
url: captureUrl,
|
||||
tabMode: target.tabMode,
|
||||
parentId: target.parentId,
|
||||
containerSelection: target.containerSelection,
|
||||
promptOnBackBehavior: ReturnToSearchTabBackPromptBehavior(
|
||||
tabType: target.tabMode.toTabType(),
|
||||
),
|
||||
selectTab: true,
|
||||
startLoading: false,
|
||||
);
|
||||
|
||||
// Always register the capture root — even for PDF / PNG. The capture_tab
|
||||
// DAO row is what (a) tells Kotlin's middleware to treat this tab as a
|
||||
// sandbox tab so out-of-origin link navigations get intercepted, and
|
||||
// (b) backs the address bar's source-URL display so the loopback URL
|
||||
// doesn't leak. PDF and PNG viewers don't follow links in practice, so
|
||||
// the extra interception is harmless.
|
||||
await ref
|
||||
.read(sandboxCaptureControllerProvider.notifier)
|
||||
.registerRootCapture(
|
||||
tabId: tabId,
|
||||
receipt: CapturedPageReceiptLike(
|
||||
captureId: captureId,
|
||||
sourceUrl: sourceUrl,
|
||||
),
|
||||
method: method,
|
||||
variant: variant,
|
||||
);
|
||||
|
||||
await ref
|
||||
.read(tabSessionProvider(tabId: tabId).notifier)
|
||||
.loadUrl(url: captureUrl);
|
||||
|
||||
if (context.mounted) {
|
||||
const BrowserRoute().go(context);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import 'package:fading_scroll/fading_scroll.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:weblibre/features/web_search/domain/controllers/search_controller.dart';
|
||||
import 'package:weblibre/features/web_search/presentation/open_in_new_tab.dart';
|
||||
import 'package:weblibre/features/web_search/presentation/widgets/search_result_metadata_chips.dart';
|
||||
import 'package:weblibre/presentation/widgets/failure_widget.dart';
|
||||
import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart';
|
||||
import 'package:weblibre/presentation/widgets/url_icon.dart';
|
||||
|
||||
class PagePreviewScreen extends ConsumerWidget {
|
||||
final Uri uri;
|
||||
final WebSearchOpenTarget Function() resolveOpenTarget;
|
||||
|
||||
const PagePreviewScreen({
|
||||
super.key,
|
||||
required this.uri,
|
||||
required this.resolveOpenTarget,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// The preview screen only depends on the document for this one URL and
|
||||
// the matching result row. Selecting both via a record means unrelated
|
||||
// controller updates (favicons, image streams, other URLs' fetches)
|
||||
// don't rebuild the (potentially-large) Markdown body below.
|
||||
final (document, result) = ref.watch(
|
||||
metaSearchControllerProvider.select(
|
||||
(s) => (
|
||||
s.documentsByUrl[uri],
|
||||
s.results.where((item) => item.url == uri).firstOrNull,
|
||||
),
|
||||
),
|
||||
);
|
||||
final title = result?.title ?? document?.metadata.title ?? uri.authority;
|
||||
|
||||
if (document == null) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(title)),
|
||||
body: const FailureWidget(
|
||||
title: 'Preview unavailable',
|
||||
exception:
|
||||
'Fetch the page from the result list before opening a preview.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(title),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Open in browser',
|
||||
onPressed: () async {
|
||||
await ref
|
||||
.read(webSearchTabOpenerProvider)
|
||||
.open(context, ref, uri, target: resolveOpenTarget());
|
||||
},
|
||||
icon: const Icon(Icons.open_in_new),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: FadingScroll(
|
||||
fadingSize: 25,
|
||||
builder: (context, controller) {
|
||||
return SingleChildScrollView(
|
||||
controller: controller,
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
UriBreadcrumb(
|
||||
uri: uri,
|
||||
icon: UrlIcon([uri], iconSize: 16, cacheOnly: true),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SearchResultMetadataChips(
|
||||
metadata: result?.metadata ?? const [],
|
||||
pageMetadata: document.metadata,
|
||||
),
|
||||
if (result?.metadata case final metadata?
|
||||
when metadata.isNotEmpty)
|
||||
SearchResultMetadataExpandable(metadata: metadata),
|
||||
const SizedBox(height: 16),
|
||||
MarkdownBody(selectable: true, data: document.content),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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