new home screen

This commit is contained in:
Fabian Freund
2026-08-04 16:03:30 +02:00
parent 3def036b3b
commit dbfcd1daaa
62 changed files with 6302 additions and 1496 deletions
@@ -38,6 +38,7 @@ import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_detail_state.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_list.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/controllers/home_target_controller.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/services/browser_data.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/database/database.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/entities/isolation_context.dart';
@@ -213,6 +214,8 @@ class TabRepository extends _$TabRepository {
}
if (selectTab && ref.mounted) {
_clearForceBrowserHome();
final selectedContainerNotifier = ref.read(
selectedContainerProvider.notifier,
);
@@ -269,7 +272,7 @@ class TabRepository extends _$TabRepository {
SpecificContainerTabSelection(:final container) => container,
};
return await db.transaction(() async {
final createdTabIds = await db.transaction(() async {
final createdTabIds = await _tabsService.addMultipleTabs(
tabs: tabs,
selectTabId: selectTabId,
@@ -320,6 +323,12 @@ class TabRepository extends _$TabRepository {
return createdTabIds;
});
if (selectTabId != null && ref.mounted) {
_clearForceBrowserHome();
}
return createdTabIds;
}
Future<String> duplicateTab({
@@ -363,7 +372,7 @@ class TabRepository extends _$TabRepository {
.getSingleOrNull() ??
selectTabId;
return await tabDao.upsertTabTransactional(
final newTabId = await tabDao.upsertTabTransactional(
() {
return _tabsService.duplicateTab(
selectTabId: selectTabId,
@@ -376,6 +385,12 @@ class TabRepository extends _$TabRepository {
containerId: Value(containerData?.id),
tabMode: Value(duplicateTabMode),
);
if (selectTab && ref.mounted) {
_clearForceBrowserHome();
}
return newTabId;
}
Future<bool> selectPreviouslyOpenedTab(String tabId) async {
@@ -392,11 +407,11 @@ class TabRepository extends _$TabRepository {
return false;
}
Future<bool> resumeLatestTab() async {
Future<bool> resumeLatestTab({Set<String> excludedTabIds = const {}}) async {
final latestTab = await ref
.read(tabDatabaseProvider)
.tabDao
.getTabsFifo(limit: 1)
.getTabsFifo(limit: 1, excludedTabIds: excludedTabIds)
.getSingleOrNull();
if (!ref.mounted || latestTab == null) {
@@ -406,11 +421,18 @@ class TabRepository extends _$TabRepository {
return selectTab(latestTab.id);
}
Future<bool> resumeLatestContainerTab(String? containerId) async {
Future<bool> resumeLatestContainerTab(
String? containerId, {
Set<String> excludedTabIds = const {},
}) async {
final latestTab = await ref
.read(tabDatabaseProvider)
.tabDao
.getContainerTabsFifo(containerId, limit: 1)
.getContainerTabsFifo(
containerId,
limit: 1,
excludedTabIds: excludedTabIds,
)
.getSingleOrNull();
if (!ref.mounted || latestTab == null) {
@@ -464,6 +486,7 @@ class TabRepository extends _$TabRepository {
if (!ref.read(browserRestoreCompleteProvider) &&
!ref.read(tabStatesProvider).containsKey(tabId)) {
ref.read(pendingTabSelectionProvider.notifier).queue(tabId);
_clearForceBrowserHome();
return true;
}
@@ -487,10 +510,36 @@ class TabRepository extends _$TabRepository {
}
}
_clearForceBrowserHome();
await _tabsService.selectTab(tabId: tabId);
return true;
}
/// Cancels a pending "stay on home", because something is about to be shown.
///
/// Done explicitly at each selection rather than by listening to the selected
/// tab: the engine selects tabs on its own (restore, session recovery) and
/// such a listener would immediately undo the flag the home target had just
/// set. Call it only once the selection is certain — a proxy healthcheck can
/// still refuse it, and discarding the flag then would drop the user off home
/// without putting anything in its place.
void _clearForceBrowserHome() {
ref.read(forceBrowserHomeProvider.notifier).clear();
}
/// Selects [tabId] on behalf of the engine's own follow-up logic, i.e. not
/// because the user asked for this particular tab.
///
/// Still counts as leaving home: a neighbour is now on screen, so a
/// "stay on home" left over from an earlier close no longer describes
/// anything. Safe against the home target's own flag, because the branch of
/// [_selectNextTab] that sets it is reached only when nothing was selected
/// here.
Future<void> _selectTabAfterClose(String tabId) async {
_clearForceBrowserHome();
await _tabsService.selectTab(tabId: tabId);
}
Future<String?> _adjacentVisibleTabByOrder(
String tabId, {
required String? containerId,
@@ -588,7 +637,7 @@ class TabRepository extends _$TabRepository {
if (tabState?.parentId != null) {
final parentId = tabState!.parentId!;
if (!excludedTabIds.contains(parentId)) {
return _tabsService.selectTab(tabId: parentId);
return _selectTabAfterClose(parentId);
}
}
@@ -601,7 +650,7 @@ class TabRepository extends _$TabRepository {
if (previousTabId != null) {
if (sameContainerTabs.any((tab) => tab == previousTabId)) {
return _tabsService.selectTab(tabId: previousTabId);
return _selectTabAfterClose(previousTabId);
}
}
@@ -614,11 +663,33 @@ class TabRepository extends _$TabRepository {
);
if (orderedNeighborTabId != null) {
return _tabsService.selectTab(tabId: orderedNeighborTabId);
return _selectTabAfterClose(orderedNeighborTabId);
}
if (!ref.mounted) return;
// Out of candidates in this container. By default the search widens to
// unassigned tabs and then to other containers, which drags the user out
// of the container they were working in; the home target keeps them here.
if (ref
.read(generalSettingsWithDefaultsProvider)
.homeTargetOnLastTabClosed) {
await ref
.read(homeTargetControllerProvider.notifier)
.applyTarget(
// currentContainerId is null for the unassigned container, which is
// still a scope to stay inside — hence the explicit flag.
scopeToContainer: true,
containerId: currentContainerId,
closingTabUrl: tabState?.url,
// Tab rows outlive this call — they are deleted only after the next
// selection is made — so without this the resume would pick the
// very tab being closed, which sorts first as the active one.
excludedTabIds: {...excludedTabIds, tabId},
);
return;
}
final unassignedTabs = await ref
.read(containerRepositoryProvider.notifier)
.getContainerTabIds(null)
@@ -629,7 +700,7 @@ class TabRepository extends _$TabRepository {
);
if (unassignedTabs.isNotEmpty) {
return _tabsService.selectTab(tabId: unassignedTabs.first);
return _selectTabAfterClose(unassignedTabs.first);
}
if (!ref.mounted) return;
@@ -654,7 +725,7 @@ class TabRepository extends _$TabRepository {
);
if (nextContainerTabs.isNotEmpty) {
return _tabsService.selectTab(tabId: nextContainerTabs!.first);
return _selectTabAfterClose(nextContainerTabs!.first);
}
}
@@ -41,7 +41,7 @@ final class TabRepositoryProvider
}
}
String _$tabRepositoryHash() => r'8abe7686d937434e2970675c143f3891ec34cce2';
String _$tabRepositoryHash() => r'c94ccf85da3fee36ebac3521f51f265a5f8d34d3';
abstract class _$TabRepository extends $Notifier<void> {
void build();
@@ -0,0 +1,290 @@
/*
* 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 'dart:async';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/core/logger.dart';
import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.dart';
import 'package:weblibre/features/geckoview/domain/providers.dart';
import 'package:weblibre/features/geckoview/domain/providers/restore_complete.dart';
import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart';
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/entities/home_target.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selected_container.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
import 'package:weblibre/utils/uri_parser.dart' as uri_parser;
part 'home_target_controller.g.dart';
/// A custom-URL target reopened within this window of the last one is treated
/// as a loop and suppressed.
const _customUrlLoopWindow = Duration(seconds: 2);
/// How long the startup check waits for the restored selection to arrive before
/// concluding that there is none. Covers the native selected-tab debounce (50ms)
/// plus the channel hop, with room to spare.
const _restoredSelectionWindow = Duration(milliseconds: 300);
/// What [HomeTargetController] should actually do, given the configuration and
/// the current state.
///
/// Pure so the fallbacks and the loop guards can be tested without a browser.
HomeTarget resolveHomeTarget({
required HomeTarget target,
required String? customUrl,
DateTime? lastCustomUrlOpenedAt,
DateTime? now,
Uri? closingTabUrl,
}) {
switch (target) {
case HomeTarget.home:
return HomeTarget.home;
case HomeTarget.resumeLastTab:
// Whether there is anything to resume is only known once the repository
// has looked; the caller falls back to home when it reports none.
return HomeTarget.resumeLastTab;
case HomeTarget.customUrl:
final parsed = uri_parser.tryParseUrl(customUrl ?? '');
if (parsed == null) {
return HomeTarget.home;
}
// Closing the custom-URL tab must not immediately reopen it. Two guards,
// because either alone is escapable: the URL check misses redirects away
// from the configured address, and the time check misses a slow user.
if (closingTabUrl != null && _sameTarget(closingTabUrl, parsed)) {
return HomeTarget.home;
}
if (lastCustomUrlOpenedAt != null) {
final elapsed = (now ?? DateTime.now()).difference(
lastCustomUrlOpenedAt,
);
if (elapsed < _customUrlLoopWindow) {
return HomeTarget.home;
}
}
return HomeTarget.customUrl;
}
}
/// Which container a home target opens its tab in.
///
/// Pure because this is exactly where the scope distinction is easy to get
/// wrong: under [scopeToContainer] a null [scopedContainer] means the
/// *unassigned* container and must stay unassigned, rather than silently
/// falling back to whichever container happens to be selected.
TabContainerSelection resolveHomeTargetContainer({
required bool scopeToContainer,
required ContainerData? scopedContainer,
required ContainerData? selectedContainer,
}) {
final container = scopeToContainer ? scopedContainer : selectedContainer;
return container == null
? const TabContainerSelection.unassigned()
: TabContainerSelection.specific(container);
}
bool _sameTarget(Uri a, Uri b) =>
a.host.toLowerCase() == b.host.toLowerCase() && a.path == b.path;
/// Applies the configured [HomeTarget] when the browser has nothing to show.
@Riverpod(keepAlive: true)
class HomeTargetController extends _$HomeTargetController {
DateTime? _lastCustomUrlOpenedAt;
var _startupHandled = false;
/// Runs the configured target.
///
/// With [scopeToContainer] the target is confined to [containerId], so
/// closing the last tab in a container keeps the user there. A null
/// [containerId] under that flag means the *unassigned* container, which is a
/// real scope — not the absence of one. Without the flag (cold start) the
/// target is unscoped and follows the selected container.
///
/// [closingTabUrl] is the tab that triggered this, used to break the
/// custom-URL reopen loop. [excludedTabIds] are tabs that are being closed
/// but not yet deleted, which a resume must not select.
Future<void> applyTarget({
bool scopeToContainer = false,
String? containerId,
Uri? closingTabUrl,
Set<String> excludedTabIds = const {},
}) async {
final settings = ref.read(generalSettingsWithDefaultsProvider);
final tabs = ref.read(tabRepositoryProvider.notifier);
final resolved = resolveHomeTarget(
target: settings.homeTarget,
customUrl: settings.homeTargetUrl,
lastCustomUrlOpenedAt: _lastCustomUrlOpenedAt,
closingTabUrl: closingTabUrl,
);
switch (resolved) {
case HomeTarget.home:
ref.read(forceBrowserHomeProvider.notifier).request();
case HomeTarget.resumeLastTab:
// Scoped resume goes through the container query even for a null
// container: that selects the newest *unassigned* tab, where the
// unscoped call would happily jump into some other container.
final resumed = scopeToContainer
? await tabs.resumeLatestContainerTab(
containerId,
excludedTabIds: excludedTabIds,
)
: await tabs.resumeLatestTab(excludedTabIds: excludedTabIds);
// Nothing to resume: home beats leaving a blank viewport.
if (!resumed && ref.mounted) {
ref.read(forceBrowserHomeProvider.notifier).request();
}
case HomeTarget.customUrl:
final url = uri_parser.tryParseUrl(settings.homeTargetUrl ?? '');
if (url == null) {
ref.read(forceBrowserHomeProvider.notifier).request();
return;
}
_lastCustomUrlOpenedAt = DateTime.now();
final scopedContainer = (scopeToContainer && containerId != null)
? await ref
.read(containerRepositoryProvider.notifier)
.getContainerData(containerId)
: null;
if (!ref.mounted) return;
await tabs.addTab(
url: url,
tabMode: TabMode.regular,
selectTab: true,
containerSelection: resolveHomeTargetContainer(
scopeToContainer: scopeToContainer,
scopedContainer: scopedContainer,
selectedContainer: ref.read(selectedContainerDataProvider).value,
),
);
}
}
/// Runs the configured target at cold start, unless the engine restored a
/// selection of its own — the user is then already looking at a page.
Future<void> _applyStartupTarget() async {
if (await _hasRestoredSelection()) return;
if (!ref.mounted) return;
await applyTarget();
}
/// Whether the restored session came with a selected tab.
///
/// The answer cannot be read off [selectedTabProvider] the moment restore
/// completes: the two facts travel over independent native flows, and only
/// the selected-tab one is debounced (~50ms, so that it lands after the
/// tab-added and tab-list events). Restore-complete therefore reliably
/// *overtakes* the selection it implies, and reading at that instant reports
/// no tab for a session that has one. Acting on that latches the home surface
/// over the restored tab, where it stays until the user picks a tab by hand.
///
/// So the absence is waited on rather than read. [GeckoTabService.syncEvents]
/// nudges native into pushing the current selection undebounced, but its
/// reply is deliberately not the signal: native replies once it has *sent*
/// the event, which says nothing about the event having arrived here — it
/// travels on its own channel, and Flutter orders messages within a channel,
/// not across them. The event itself is the signal; the nudge only shortens
/// the wait for it.
Future<bool> _hasRestoredSelection() async {
if (ref.read(selectedTabProvider) != null) return true;
final completer = Completer<bool>();
// A ValueStream, so a selection that arrived before this subscription is
// replayed into it — the gap between the read above and here cannot swallow
// the event.
final subscription = ref
.read(eventServiceProvider)
.selectedTabEvents
.listen((tabId) {
if (tabId != null && !completer.isCompleted) {
completer.complete(true);
}
});
// Bounds the wait for a session that genuinely restored nothing. Paid only
// in that case, and against the home surface — which is already on screen
// while no tab is selected, so the delay costs a target that opens or
// resumes a tab slightly later, not a visible stall.
final timeout = Timer(_restoredSelectionWindow, () {
if (!completer.isCompleted) {
completer.complete(false);
}
});
unawaited(
GeckoTabService().syncEvents(onSelectedTabChange: true).catchError((
Object error,
StackTrace stackTrace,
) {
// Non-fatal: the debounced push still arrives within the window.
logger.w(
'Failed to request the selected tab for the startup home target',
error: error,
stackTrace: stackTrace,
);
}),
);
try {
return await completer.future;
} finally {
timeout.cancel();
await subscription.cancel();
}
}
@override
void build() {
ref.listen(
// This controller is created lazily by the browser view. Restore can
// already be complete by then, and a plain listen would sit waiting for
// an edge that has been and gone, silently skipping the startup target.
fireImmediately: true,
browserRestoreCompleteProvider,
(previous, next) {
if (!next || _startupHandled) return;
_startupHandled = true;
unawaited(_applyStartupTarget());
},
);
}
}
@@ -0,0 +1,68 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'home_target_controller.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Applies the configured [HomeTarget] when the browser has nothing to show.
@ProviderFor(HomeTargetController)
final homeTargetControllerProvider = HomeTargetControllerProvider._();
/// Applies the configured [HomeTarget] when the browser has nothing to show.
final class HomeTargetControllerProvider
extends $NotifierProvider<HomeTargetController, void> {
/// Applies the configured [HomeTarget] when the browser has nothing to show.
HomeTargetControllerProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'homeTargetControllerProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$homeTargetControllerHash();
@$internal
@override
HomeTargetController create() => HomeTargetController();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(void value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<void>(value),
);
}
}
String _$homeTargetControllerHash() =>
r'1bb3a879ee569261b1dfcc4c0d8b28ef0cb7f7d3';
/// Applies the configured [HomeTarget] when the browser has nothing to show.
abstract class _$HomeTargetController extends $Notifier<void> {
void build();
@$mustCallSuper
@override
WhenComplete runBuild() {
final ref = this.ref as $Ref<void, void>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<void, void>,
void,
Object?,
Object?
>;
return element.handleCreate(ref, build);
}
}
@@ -0,0 +1,43 @@
/*
* 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/>.
*/
/// What the browser lands on when there is no tab to show.
enum HomeTarget {
/// Show the home surface. The default, and what the browser has always done.
home,
/// Reopen the most recently used tab, scoped to the selected container.
resumeLastTab,
/// Open a configured address.
customUrl;
String get label => switch (this) {
home => 'Home page',
resumeLastTab => 'Last opened tab',
customUrl => 'Custom address',
};
String get description => switch (this) {
home => 'Show shortcuts and the sections you have chosen',
resumeLastTab => 'Pick up where you left off',
customUrl => 'Open a specific page',
};
}
@@ -63,6 +63,7 @@ import 'package:weblibre/features/geckoview/features/readerview/presentation/con
import 'package:weblibre/features/geckoview/features/search/domain/providers/search_autofocus.dart';
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/geckoview/features/tabs/domain/providers/selected_container.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart';
import 'package:weblibre/features/proxy/data/proxy_connection.dart';
@@ -133,6 +134,21 @@ class _AnimatedToolbar extends HookWidget {
}
/// Manages scroll-based auto-hide logic and returns the toolbar widget.
/// Whether the main toolbar row should be dropped for the browser home surface.
///
/// Held back when there is no contextual toolbar: the tab count and the
/// navigation menu relocate there when it exists, and the main row is their only
/// other home — dropping it without one would leave no way to reach the menu.
///
/// A function rather than an inline condition because it is evaluated in two
/// places — where the bar is built and where its height is measured for the
/// browser viewport inset. If those two disagree the browser is inset for a row
/// that is not drawn.
bool _suppressMainToolbarForHome({
required bool showBrowserHome,
required bool showContextualToolbar,
}) => showBrowserHome && showContextualToolbar;
/// Animation is handled by the parent _AnimatedToolbar wrapper.
class _TabBar extends HookConsumerWidget {
final bool showMainToolbar;
@@ -228,6 +244,11 @@ class _TabBar extends HookConsumerWidget {
},
);
final suppressMainToolbar = _suppressMainToolbarForHome(
showBrowserHome: ref.watch(shouldShowBrowserHomeProvider),
showContextualToolbar: showContextualToolbar,
);
// Return the toolbar widget - parent handles animation.
// Rail positions are rendered by a dedicated Stack layer, not _TabBar, but
// are handled here for exhaustiveness/correctness.
@@ -238,6 +259,7 @@ class _TabBar extends HookConsumerWidget {
quickTabSwitcherRowCount: quickTabSwitcherRowCount,
isSmallWebMode: isSmallWebMode,
enableGestures: enableGestures,
suppressMainToolbar: suppressMainToolbar,
),
TabBarPosition.bottom => BrowserBottomAppBar(
displayedSheet: displayedSheet,
@@ -245,12 +267,14 @@ class _TabBar extends HookConsumerWidget {
showContextualToolbar: showContextualToolbar,
quickTabSwitcherRowCount: quickTabSwitcherRowCount,
isSmallWebMode: isSmallWebMode,
suppressMainToolbar: suppressMainToolbar,
),
TabBarPosition.left || TabBarPosition.right => BrowserSideRail(
position: tabBarPosition,
showContextualToolbar: showContextualToolbar,
quickTabSwitcherRowCount: quickTabSwitcherRowCount,
isSmallWebMode: isSmallWebMode,
suppressMainToolbar: suppressMainToolbar,
),
};
}
@@ -673,6 +697,11 @@ class _SideRailToolbarLayer extends StatelessWidget {
final int quickTabSwitcherRowCount;
final String? selectedTabId;
/// Resolved by the caller, as for the horizontal bars: the rail is built here
/// rather than by [_TabBar], so without this the home surface would keep the
/// main toolbar row only in the rail positions.
final bool suppressMainToolbar;
const _SideRailToolbarLayer({
required this.sheetDisplayed,
required this.tabInFullScreen,
@@ -680,6 +709,7 @@ class _SideRailToolbarLayer extends StatelessWidget {
required this.showContextualToolbar,
required this.quickTabSwitcherRowCount,
required this.selectedTabId,
required this.suppressMainToolbar,
});
@override
@@ -694,6 +724,7 @@ class _SideRailToolbarLayer extends StatelessWidget {
showContextualToolbar: showContextualToolbar,
quickTabSwitcherRowCount: quickTabSwitcherRowCount,
isSmallWebMode: false,
suppressMainToolbar: suppressMainToolbar,
),
);
}
@@ -1186,6 +1217,13 @@ class BrowserScreen extends HookConsumerWidget {
final relativeSafeArea = MediaQuery.of(context).relativeSafeArea();
final bottomSafeArea = MediaQuery.of(context).padding.bottom;
// Must match what _TabBar resolves, or the browser is inset for a toolbar
// row that is not drawn.
final suppressMainToolbarForHome = _suppressMainToolbarForHome(
showBrowserHome: ref.watch(shouldShowBrowserHomeProvider),
showContextualToolbar: showContextualToolbar,
);
// Calculate bottom toolbar size for FAB and sheet positioning
final Size bottomAppBarContentSize;
// The same size computed as if no sheet were displayed. `ViewTabsSheet`
@@ -1216,6 +1254,7 @@ class BrowserScreen extends HookConsumerWidget {
quickTabSwitcherRowCount: quickTabSwitcherRowCount,
isSmallWebMode: false,
displayedSheet: displayedSheet,
suppressMainToolbar: suppressMainToolbarForHome,
).preferredSize;
viewportBottomAppBarContentSize = displayedSheet == null
? bottomAppBarContentSize
@@ -1225,6 +1264,7 @@ class BrowserScreen extends HookConsumerWidget {
quickTabSwitcherRowCount: quickTabSwitcherRowCount,
isSmallWebMode: false,
displayedSheet: null,
suppressMainToolbar: suppressMainToolbarForHome,
).preferredSize;
}
// Total height includes safe area padding
@@ -1260,6 +1300,7 @@ class BrowserScreen extends HookConsumerWidget {
quickTabSwitcherRowCount: quickTabSwitcherRowCount,
isSmallWebMode: isSmallWebActive,
enableGestures: !isSmallWebActive,
suppressMainToolbar: suppressMainToolbarForHome,
).preferredSize;
final topAppBarTotalHeight = topAppBarContentSize.height + topSafeArea;
@@ -1563,6 +1604,7 @@ class BrowserScreen extends HookConsumerWidget {
showContextualToolbar: showContextualToolbar,
quickTabSwitcherRowCount: quickTabSwitcherRowCount,
selectedTabId: selectedTabId,
suppressMainToolbar: suppressMainToolbarForHome,
),
),
@@ -17,230 +17,219 @@
* 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 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:flutter_svg/svg.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/design/app_colors.dart';
import 'package:sliver_tools/sliver_tools.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/account/presentation/widgets/supporter_home_banner.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_list.dart';
import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.dart';
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/providers/browser_viewport_toolbar_insets.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/home/home_search_pill.dart';
import 'package:weblibre/features/geckoview/features/search/domain/providers/search_modules_view.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/module_surface_scope.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/module_surface_slivers.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selected_container.dart';
import 'package:weblibre/features/geckoview/features/tabs/utils/container_colors.dart';
import 'package:weblibre/features/quotes/data/database/definitions.drift.dart';
import 'package:weblibre/features/quotes/domain/providers.dart';
import 'package:weblibre/features/proxy/presentation/controllers/ensure_proxy_started.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
import 'package:weblibre/presentation/widgets/browser_page.dart';
/// The home surface creates tabs of the user's configured default type; the
/// child type is meaningless here because there is no tab to be a child of.
TabMode _tabModeFor(TabType tabType) => switch (tabType) {
TabType.regular || TabType.child => TabMode.regular,
TabType.private => TabMode.private,
TabType.isolated => TabMode.newIsolated(),
};
/// The browser home: what fills the viewport when no tab is selected, or when
/// the selected tab belongs to a different container than the selected one.
///
/// Renders the same configurable module list as the new-tab page, under
/// [ModuleSurface.home] so the two keep separate layouts. Everything below the
/// header is user-arrangeable; only the brand/container header, the search pill
/// and the supporter banner are fixed chrome.
///
/// Each piece owns its own provider subscriptions rather than watching
/// everything at the root, so a settings write or a toolbar-inset animation
/// does not rebuild the module list underneath.
class BrowserHome extends ConsumerWidget {
const BrowserHome({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final settings = ref.watch(generalSettingsWithDefaultsProvider);
final quoteAsync = ref.watch(randomQuoteProvider);
final hasTabs = ref.watch(
tabListProvider.select((tabs) => tabs.value.isNotEmpty),
);
final viewportToolbarInsets = ref.watch(
browserViewportToolbarInsetsControllerProvider,
);
final containerData = ref.watch(
selectedContainerDataProvider.select((value) => value.value),
);
final hasContainerTabs = ref.watch(
selectedContainerTabCountProvider.select(
(data) => switch (data) {
AsyncData(:final value) => value > 0,
_ => false,
},
),
);
final pixelRatio = MediaQuery.devicePixelRatioOf(context);
final bottomViewportInset =
viewportToolbarInsets.effectiveBottomInsetPx / pixelRatio;
Future<void> openNewTab() {
return SearchRoute(
tabType: settings.effectiveDefaultCreateTabType,
).push(context);
final tabType = ref
.read(generalSettingsWithDefaultsProvider)
.effectiveDefaultCreateTabType;
return SearchRoute(tabType: tabType).push(context);
}
Future<void> viewTabs() {
return const TabViewRoute().push(context);
}
Future<void> viewTabs() => const TabViewRoute().push(context);
Future<void> resumeLatestTab() async {
await ref.read(tabRepositoryProvider.notifier).resumeLatestTab();
}
Future<void> resumeLatestContainerTab() async {
Future<void> resumeLastTab() async {
final containerId = ref.read(selectedContainerProvider);
await ref
.read(tabRepositoryProvider.notifier)
.resumeLatestContainerTab(containerId);
final repository = ref.read(tabRepositoryProvider.notifier);
// Resume within the container in scope; falling back to the global
// "latest tab" would silently jump the user into another container.
if (containerId != null) {
await repository.resumeLatestContainerTab(containerId);
} else {
await repository.resumeLatestTab();
}
}
final callbacks = ModuleSurfaceCallbacks(
onUriSelected: (uri) async {
final container = ref.read(selectedContainerDataProvider).value;
await ref
.read(tabRepositoryProvider.notifier)
.addTab(
url: uri,
tabMode: _tabModeFor(
ref
.read(generalSettingsWithDefaultsProvider)
.effectiveDefaultCreateTabType,
),
selectTab: true,
containerSelection: container == null
? const TabContainerSelection.unassigned()
: TabContainerSelection.specific(container),
);
},
onTabSelected: (tabId) async {
await ref.read(tabRepositoryProvider.notifier).selectTab(tabId);
},
onArticleSelected: (article) {
unawaited(FeedArticleRoute(articleId: article.id).push(context));
},
onContainerSelected: (container) async {
final result = await ref
.read(selectedContainerProvider.notifier)
.setContainerId(container.id);
if (!context.mounted) return;
if (result == SetContainerResult.success) {
await ensureProxyStartedForContainer(context, ref, container);
}
},
onNewTab: openNewTab,
onViewTabs: viewTabs,
onResumeLastTab: resumeLastTab,
);
return BrowserPage(
child: BrowserPageContent(
bottomViewportInset: bottomViewportInset,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
if (containerData != null)
_ContainerHeader(container: containerData)
else
BrandHeader(colorScheme: colorScheme),
const SizedBox(height: 24),
if (!hasTabs) ...[
Text(
'WebLibre is ready',
textAlign: TextAlign.center,
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 10),
Text(
'A browser that respects you. Open a tab and experience the web, libre.',
textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
height: 1.45,
),
),
const SizedBox(height: 28),
] else if (containerData != null) ...[
Text(
containerData.name?.isNotEmpty == true
? containerData.name!
: 'Container',
textAlign: TextAlign.center,
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 10),
Text(
hasContainerTabs
? 'No matching tab selected'
: 'No open tabs in this container',
textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
height: 1.45,
),
),
const SizedBox(height: 28),
],
Wrap(
alignment: WrapAlignment.center,
spacing: 12,
runSpacing: 12,
children: [
if (hasTabs)
OutlinedButton.icon(
onPressed: viewTabs,
icon: const Icon(Icons.tab_rounded),
label: const Text('View tabs'),
// The viewport, not just its first sliver, has to clear the status bar:
// BrowserSystemBars fills that inset with an opaque strip painted over
// this surface, and the pinned pill below would scroll underneath it and
// disappear. Bottom stays excluded — [_HomeBottomInsetSpacer] owns it,
// because that inset animates with the toolbar.
child: SafeArea(
bottom: false,
child: RepaintBoundary(
child: ModuleSurfaceScope(
surface: ModuleSurface.home,
// Unpinned: the sections here are short, and the pinned search pill
// above already holds the top of the viewport. Backing each header
// so it could pin would lay opaque bands across the aura gradient.
pinnedHeaderBackgroundColor: null,
child: CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
const SliverToBoxAdapter(child: SizedBox(height: 20)),
const SliverToBoxAdapter(child: _HomeHeader()),
// Pinned, because the browser toolbar's address field is
// suppressed while home is showing: this is the only way into
// search, so it has to survive scrolling. Pinning it above the
// section headers also gives them something to slide under.
const SliverPinnedHeader(child: HomeSearchPill()),
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: SupporterHomeBanner(),
),
FilledButton.icon(
onPressed: openNewTab,
icon: const Icon(Icons.add_rounded),
label: const Text('New tab'),
),
if (hasContainerTabs)
FilledButton.tonalIcon(
onPressed: resumeLatestContainerTab,
icon: const Icon(Icons.history_rounded),
label: const Text('Resume last tab'),
)
else if (hasTabs && containerData == null)
FilledButton.tonalIcon(
onPressed: resumeLatestTab,
icon: const Icon(Icons.history_rounded),
label: const Text('Resume last tab'),
),
ModuleSurfaceSliverList(
surface: ModuleSurface.home,
callbacks: callbacks,
),
const _HomeBottomInsetSpacer(),
],
),
const SizedBox(height: 24),
const SupporterHomeBanner(),
Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: colorScheme.surfaceContainer.withValues(alpha: 0.9),
borderRadius: BorderRadius.circular(28),
border: Border.all(
color: Color.alphaBlend(
AppColors.brandGrey.withValues(alpha: 0.18),
colorScheme.outlineVariant,
),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(12),
),
child: const Icon(MdiIcons.formatQuoteOpen, size: 18),
),
const SizedBox(width: 12),
Expanded(
child: Text(
'A thought for the road',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
IconButton.filledTonal(
tooltip: 'Refresh quote',
onPressed: () {
ref.invalidate(randomQuoteProvider);
},
icon: const Icon(Icons.refresh_rounded),
),
],
),
const SizedBox(height: 16),
switch (quoteAsync) {
AsyncData(:final value) => _QuoteBlock(quote: value),
AsyncError() => Text(
'Open a new tab and make this space your own.',
style: theme.textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
height: 1.5,
),
),
_ => const LinearProgressIndicator(minHeight: 3),
},
],
),
),
],
),
),
),
);
}
}
/// Brand mark, or the selected container's identity when there is one.
class _HomeHeader extends ConsumerWidget {
const _HomeHeader();
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final container = ref.watch(
selectedContainerDataProvider.select((value) => value.value),
);
return Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
child: Column(
children: [
if (container != null)
_ContainerHeader(container: container)
else
BrandHeader(colorScheme: theme.colorScheme),
if (container != null) ...[
const SizedBox(height: 12),
Text(
container.name?.isNotEmpty == true
? container.name!
: 'Container',
textAlign: TextAlign.center,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
],
],
),
);
}
}
/// Trailing space so the last module clears the bottom app bar.
///
/// A spacer rather than a [SliverPadding] around the list: the inset animates
/// with the toolbar, and padding would relayout every module on each frame.
/// Owning the watch here also keeps those frames off the module list entirely.
class _HomeBottomInsetSpacer extends ConsumerWidget {
const _HomeBottomInsetSpacer();
@override
Widget build(BuildContext context, WidgetRef ref) {
final insetPx = ref.watch(
browserViewportToolbarInsetsControllerProvider.select(
(state) => state.effectiveBottomInsetPx,
),
);
final inset = insetPx / MediaQuery.devicePixelRatioOf(context);
return SliverToBoxAdapter(child: SizedBox(height: 24 + inset));
}
}
class _ContainerHeader extends StatelessWidget {
final ContainerData container;
@@ -249,19 +238,18 @@ class _ContainerHeader extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final containerColor = container.color;
final containerPalette = ContainerColors.palette(
context,
containerColor,
container.color,
useCustomColor: container.metadata.useCustomColor,
);
return Container(
width: 112,
height: 112,
padding: const EdgeInsets.all(20),
width: 96,
height: 96,
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(32),
borderRadius: BorderRadius.circular(28),
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
@@ -279,61 +267,11 @@ class _ContainerHeader extends StatelessWidget {
),
],
),
// See [BrandHeader]: the mark needs room inside the tile, and 60 in a
// 96 tile with 18 of padding leaves it none.
child: Center(
child: SvgPicture.asset('assets/icon/icon.svg', width: 72, height: 72),
child: SvgPicture.asset('assets/icon/icon.svg', width: 48, height: 48),
),
);
}
}
class _QuoteBlock extends StatelessWidget {
final Quote? quote;
const _QuoteBlock({required this.quote});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
if (quote == null) {
return Text(
'Open a new tab and make this space your own.',
style: theme.textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
height: 1.5,
),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'"${quote!.quote}"',
style: theme.textTheme.bodyLarge?.copyWith(
height: 1.55,
color: colorScheme.onSurface,
),
),
const SizedBox(height: 12),
Text(
'- ${quote!.author}',
style: theme.textTheme.titleSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
if (quote!.source case final String source when source.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
source,
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
],
);
}
}
@@ -74,6 +74,7 @@ class BrowserTopAppBar extends StatelessWidget {
final int quickTabSwitcherRowCount;
final bool isSmallWebMode;
final bool enableGestures;
final bool suppressMainToolbar;
late final BrowserTabBar _tabBar;
late final _size = Size.fromHeight(_tabBar.getToolbarHeight());
@@ -85,6 +86,7 @@ class BrowserTopAppBar extends StatelessWidget {
required this.quickTabSwitcherRowCount,
required this.isSmallWebMode,
this.enableGestures = true,
this.suppressMainToolbar = false,
}) {
_tabBar = BrowserTabBar(
showMainToolbar: showMainToolbar,
@@ -95,6 +97,7 @@ class BrowserTopAppBar extends StatelessWidget {
enableGestures: enableGestures,
hideMainToolbarButtonsDuplicatedInContextualToolbar:
showContextualToolbar,
suppressMainToolbar: suppressMainToolbar,
);
}
@@ -115,6 +118,7 @@ class BrowserBottomAppBar extends StatelessWidget {
final bool isSmallWebMode;
final Sheet? displayedSheet;
final bool enableGestures;
final bool suppressMainToolbar;
late final BrowserTabBar _tabBar;
late final _size = Size.fromHeight(_tabBar.getToolbarHeight());
@@ -127,6 +131,7 @@ class BrowserBottomAppBar extends StatelessWidget {
required this.quickTabSwitcherRowCount,
required this.isSmallWebMode,
this.enableGestures = true,
this.suppressMainToolbar = false,
}) {
_tabBar = BrowserTabBar(
displayedSheet: displayedSheet,
@@ -137,6 +142,7 @@ class BrowserBottomAppBar extends StatelessWidget {
enableGestures: enableGestures,
hideMainToolbarButtonsDuplicatedInContextualToolbar:
showContextualToolbar,
suppressMainToolbar: suppressMainToolbar,
);
}
@@ -173,6 +179,8 @@ class BrowserSideRail extends ConsumerWidget {
/// [TabBarPosition.right]).
final TabBarPosition position;
final bool suppressMainToolbar;
late final BrowserTabBar _tabBar;
late final _size = Size.fromWidth(_tabBar.getToolbarWidth());
@@ -182,6 +190,7 @@ class BrowserSideRail extends ConsumerWidget {
required this.quickTabSwitcherRowCount,
required this.isSmallWebMode,
required this.position,
this.suppressMainToolbar = false,
}) {
_tabBar = BrowserTabBar(
displayedSheet: null,
@@ -192,6 +201,7 @@ class BrowserSideRail extends ConsumerWidget {
enableGestures: true,
hideMainToolbarButtonsDuplicatedInContextualToolbar:
showContextualToolbar,
suppressMainToolbar: suppressMainToolbar,
);
}
@@ -251,6 +261,22 @@ class BrowserTabBar extends HookConsumerWidget {
final bool isSmallWebMode;
final bool enableGestures;
/// Drops the main toolbar row entirely — not just its contents.
///
/// Set on the home surface, where this row has nothing left to say: its
/// address field is replaced by the home surface's own pinned search pill,
/// and what sits beside it — the pinned add-ons, the reader button — acts on
/// a page that is not open. Blanking only the title would strand the add-ons
/// at the right of an empty strip and still reserve [kToolbarHeight] here.
///
/// The caller resolves this rather than deriving it from
/// `shouldShowBrowserHomeProvider`, for two reasons: [getToolbarHeight] runs
/// outside the widget tree (from the wrappers' constructors, to size the bar
/// before it is built), and the decision also depends on whether a contextual
/// toolbar exists to take over the tab count and navigation menu — which the
/// wrappers rewrite before it reaches this widget.
final bool suppressMainToolbar;
const BrowserTabBar({
super.key,
required this.showMainToolbar,
@@ -260,6 +286,7 @@ class BrowserTabBar extends HookConsumerWidget {
required this.isSmallWebMode,
required this.enableGestures,
this.hideMainToolbarButtonsDuplicatedInContextualToolbar = false,
this.suppressMainToolbar = false,
});
static const contextualToolabarHeight = 54.0;
@@ -275,6 +302,7 @@ class BrowserTabBar extends HookConsumerWidget {
bool get displayAppBar =>
showMainToolbar &&
!suppressMainToolbar &&
(!showContextualToolbar || displayedSheet is! ViewTabsSheet);
bool get displayQuickTabSwitcher =>
@@ -43,6 +43,7 @@ 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/providers/web_extensions_state.dart';
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/controllers/home_target_controller.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/providers.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/providers/intent.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/providers/lifecycle.dart';
@@ -190,6 +191,10 @@ class _BrowserViewState extends ConsumerState<BrowserView>
final showHome = ref.watch(shouldShowBrowserHomeProvider);
// Instantiate the home-target controller so its cold-start listener is
// alive. Its state is void, so watching costs nothing.
ref.watch(homeTargetControllerProvider);
final topRoute = ref.watch(currentTopRouteProvider);
final androidInfoAsync = ref.watch(androidDeviceInfoProvider);
final unmountGeckoViewOffRoute = ref.watch(
@@ -246,98 +251,125 @@ class _BrowserViewState extends ConsumerState<BrowserView>
}
: null,
child: Stack(
// Expand rather than the default loose fit: every layer here is meant
// to fill the viewport, and the enclosing stack passes loose
// constraints down. Under a loose fit the stack would size itself from
// its only non-positioned child — the engine — and collapse to nothing
// whenever that child is offstage, taking the [Positioned.fill] home
// surface and gesture overlay down with it.
fit: StackFit.expand,
children: [
Visibility(
visible: isGeckoViewVisible,
child: GeckoView(
preInitializationStep: () async {
await ref
.read(eventServiceProvider)
.viewReadyStateEvents
.firstWhere((state) => state == true)
.timeout(
const Duration(seconds: 3),
onTimeout: () {
logger.e(
'Browser fragement not reported ready, trying to intitialize anyways',
);
return true;
},
);
},
postInitializationStep: () async {
await widget.postInitializationStep?.call();
// Two separate concerns, deliberately not folded into one flag:
//
// [Offstage] — the home surface covers the whole viewport, so the
// engine contributes no visible pixels while it is up. Painting it
// anyway pushes a platform-view layer, which puts every frame through
// [AndroidExternalViewEmbedder] hybrid composition: the frame is split
// into a platform-view surface plus Flutter overlay surfaces and
// submitted with a platform-thread round-trip, pinning the raster
// thread for tens of milliseconds. Offstage still lays the view out
// and keeps the element (and with it the view controller and the
// native fragment) alive, so there is no teardown, reload or flicker
// — it just stops painting, and the home composites as a single
// surface.
//
// [Visibility] — the off-route unmount, which deliberately *does*
// destroy the platform view (see [isGeckoViewVisible] above), so it
// keeps the default maintainState: false.
Offstage(
offstage: showHome,
child: Visibility(
visible: isGeckoViewVisible,
child: GeckoView(
preInitializationStep: () async {
await ref
.read(eventServiceProvider)
.viewReadyStateEvents
.firstWhere((state) => state == true)
.timeout(
const Duration(seconds: 3),
onTimeout: () {
logger.e(
'Browser fragement not reported ready, trying to intitialize anyways',
);
return true;
},
);
},
postInitializationStep: () async {
await widget.postInitializationStep?.call();
if (!_initializationCompleter.isCompleted) {
_initializationCompleter.complete();
if (!_initializationCompleter.isCompleted) {
_initializationCompleter.complete();
const quickActions = QuickActions();
const quickActions = QuickActions();
//Debounce: https://github.com/flutter/flutter/issues/131121
DateTime? lastAction;
await quickActions.initialize((type) async {
if (lastAction == null ||
DateTime.now().difference(lastAction!) >
const Duration(seconds: 5)) {
if (type == 'new_tab') {
lastAction = DateTime.now();
//Debounce: https://github.com/flutter/flutter/issues/131121
DateTime? lastAction;
await quickActions.initialize((type) async {
if (lastAction == null ||
DateTime.now().difference(lastAction!) >
const Duration(seconds: 5)) {
if (type == 'new_tab') {
lastAction = DateTime.now();
final router = await ref.read(routerProvider.future);
const route = SearchRoute(tabType: TabType.regular);
final router = await ref.read(routerProvider.future);
const route = SearchRoute(tabType: TabType.regular);
await router.push(route.location);
} else if (type == 'new_private_tab') {
lastAction = DateTime.now();
await router.push(route.location);
} else if (type == 'new_private_tab') {
lastAction = DateTime.now();
final router = await ref.read(routerProvider.future);
const route = SearchRoute(tabType: TabType.private);
final router = await ref.read(routerProvider.future);
const route = SearchRoute(tabType: TabType.private);
await router.push(route.location);
} else if (type == 'new_isolated_tab') {
final settings = ref.read(
generalSettingsWithDefaultsProvider,
);
if (!settings.showIsolatedTabUi) {
return;
await router.push(route.location);
} else if (type == 'new_isolated_tab') {
final settings = ref.read(
generalSettingsWithDefaultsProvider,
);
if (!settings.showIsolatedTabUi) {
return;
}
lastAction = DateTime.now();
final router = await ref.read(routerProvider.future);
const route = SearchRoute(tabType: TabType.isolated);
await router.push(route.location);
} else {
throw UnimplementedError(
'Unknown quick action shortcut type',
);
}
lastAction = DateTime.now();
final router = await ref.read(routerProvider.future);
const route = SearchRoute(tabType: TabType.isolated);
await router.push(route.location);
} else {
throw UnimplementedError(
'Unknown quick action shortcut type',
);
}
}
});
});
final settings = ref.read(
generalSettingsWithDefaultsProvider,
);
await quickActions.setShortcutItems([
const ShortcutItem(
type: 'new_tab',
localizedTitle: 'New Tab',
icon: 'mdi_icon_tab',
),
const ShortcutItem(
type: 'new_private_tab',
localizedTitle: 'New Private Tab',
icon: 'mdi_icon_domino_mask',
),
if (settings.showIsolatedTabUi)
final settings = ref.read(
generalSettingsWithDefaultsProvider,
);
await quickActions.setShortcutItems([
const ShortcutItem(
type: 'new_isolated_tab',
localizedTitle: 'New Isolated Tab',
icon: 'mdi_icon_snowflake',
type: 'new_tab',
localizedTitle: 'New Tab',
icon: 'mdi_icon_tab',
),
]);
}
},
const ShortcutItem(
type: 'new_private_tab',
localizedTitle: 'New Private Tab',
icon: 'mdi_icon_domino_mask',
),
if (settings.showIsolatedTabUi)
const ShortcutItem(
type: 'new_isolated_tab',
localizedTitle: 'New Isolated Tab',
icon: 'mdi_icon_snowflake',
),
]);
}
},
),
),
),
if (showHome) const Positioned.fill(child: BrowserHome()),
@@ -0,0 +1,129 @@
/*
* 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 'dart:async';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
import 'package:weblibre/presentation/widgets/qr_scanner_button.dart';
import 'package:weblibre/presentation/widgets/speech_to_text_button.dart';
/// The home surface's entry into search.
///
/// Deliberately not a real text field: it pushes the search screen, which owns
/// the actual input, its autofocus and its keyboard-inset handling. A second
/// live field here would compete with all three. Its only job is to make the
/// home surface read as the same page as the new-tab screen.
///
/// The QR and voice buttons are the same widgets [SearchField] mounts, but they
/// cannot write into a controller here because there is no field to write to —
/// they hand their result to the search screen as its initial text instead.
/// Neither auto-submits: speech recognition misfires, and a scanned code is
/// untrusted input that should not navigate on its own.
///
/// Rendered pinned by the home surface, so it is opaque and carries a shadow:
/// the module list scrolls underneath it.
class HomeSearchPill extends ConsumerWidget {
const HomeSearchPill({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
// Selector, not the whole settings object: this rebuilds on every
// settings write otherwise, and it sits above the module list.
final defaultTabType = ref.watch(
generalSettingsWithDefaultsProvider.select(
(settings) => settings.effectiveDefaultCreateTabType,
),
);
void openSearch([String? initialText]) {
unawaited(
SearchRoute(
tabType: defaultTabType,
// The route encodes this into a path segment, so an empty string
// would leave a trailing slash that no longer matches the pattern.
searchText: (initialText == null || initialText.isEmpty)
? SearchRoute.emptySearchText
: initialText,
).push(context),
);
}
return Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 12),
child: Material(
color: colorScheme.surfaceContainerHigh,
surfaceTintColor: Colors.transparent,
shadowColor: colorScheme.shadow,
elevation: 3,
borderRadius: BorderRadius.circular(28),
clipBehavior: Clip.antiAlias,
child: Row(
children: [
Expanded(
child: InkWell(
onTap: openSearch,
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 8, 16),
child: Row(
children: [
Icon(Icons.search, color: colorScheme.onSurfaceVariant),
const SizedBox(width: 12),
Expanded(
child: Text(
'Search or enter URL',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
],
),
),
),
),
QrScannerButton(
onScanResult: (scanResult) {
final code = scanResult?.code;
if (code == null || !context.mounted) return;
openSearch(code);
},
),
SpeechToTextButton(
onTextReceived: (text) {
if (!context.mounted) return;
openSearch(text);
},
),
const SizedBox(width: 4),
],
),
),
);
}
}
@@ -45,28 +45,45 @@ class ModuleOrderEntry with FastEquatable {
List<Object?> get hashParameters => [type, visible];
}
List<ModuleOrderEntry> _mergeWithDefaults(
/// Reconciles a persisted module order with the surface's current defaults.
///
/// Persisted entries whose module no longer exists on the surface are dropped,
/// and modules that were added since the order was saved are inserted at their
/// position in [defaults] rather than appended, so a new module lands where it
/// was designed to sit instead of at the bottom of the user's list.
///
/// Pure and exported so the reconciliation can be tested directly — it runs on
/// every read of a persisted order, and a regression here silently rewrites
/// user configuration.
List<ModuleOrderEntry> mergeModuleOrderWithDefaults(
List<ModuleOrderEntry>? persisted,
List<SearchModuleType> defaults,
List<ModuleSurfaceDefault> defaults,
) {
List<ModuleOrderEntry> fromDefaults() => defaults
.map((d) => ModuleOrderEntry(type: d.type, visible: d.visible))
.toList();
if (persisted == null) {
return defaults
.map((type) => ModuleOrderEntry(type: type, visible: true))
.toList();
return fromDefaults();
}
final defaultSet = defaults.toSet();
final offered = {for (final d in defaults) d.type: d};
// Keep persisted entries that are still valid
final result = persisted.where((e) => defaultSet.contains(e.type)).toList();
final result = persisted.where((e) => offered.containsKey(e.type)).toList();
// Insert any new defaults at their position from the defaults list so newly
// introduced modules land where they're meant to (e.g. at the top), instead
// of trailing the user's persisted order.
// of trailing the user's persisted order. They keep the default's own
// visibility, so a module can be offered without being switched on for
// everyone who already customised this surface.
final persistedTypes = result.map((e) => e.type).toSet();
for (var i = 0; i < defaults.length; i++) {
final type = defaults[i];
if (!persistedTypes.contains(type)) {
final entry = defaults[i];
if (!persistedTypes.contains(entry.type)) {
final insertAt = i.clamp(0, result.length);
result.insert(insertAt, ModuleOrderEntry(type: type, visible: true));
result.insert(
insertAt,
ModuleOrderEntry(type: entry.type, visible: entry.visible),
);
}
}
return result;
@@ -91,11 +108,16 @@ class SearchModuleOrder extends _$SearchModuleOrder {
];
}
/// Discards the user's layout for this surface and returns to its defaults.
void resetToDefaults() {
state = mergeModuleOrderWithDefaults(null, surface.defaultModules);
}
@override
List<ModuleOrderEntry> build(SearchModuleGroup group) {
List<ModuleOrderEntry> build(ModuleSurface surface) {
persist(
ref.watch(riverpodDatabaseStorageProvider),
key: group.key,
key: surface.key,
options: const StorageOptions(cacheTime: StorageCacheTime.unsafe_forever),
encode: (state) => jsonEncode(state.map((e) => e.toJson()).toList()),
decode: (encoded) {
@@ -111,13 +133,11 @@ class SearchModuleOrder extends _$SearchModuleOrder {
.whereType<ModuleOrderEntry>()
.toList();
// Merge with defaults to pick up newly added or remove deleted modules
return _mergeWithDefaults(decoded, group.defaultModules);
return mergeModuleOrderWithDefaults(decoded, surface.defaultModules);
},
);
return stateOrNull ??
group.defaultModules
.map((type) => ModuleOrderEntry(type: type, visible: true))
.toList();
mergeModuleOrderWithDefaults(null, surface.defaultModules);
}
}
@@ -36,6 +36,8 @@ const _$SearchModuleTypeEnumMap = {
SearchModuleType.recentTabs: 'recentTabs',
SearchModuleType.containers: 'containers',
SearchModuleType.frequentBangs: 'frequentBangs',
SearchModuleType.quote: 'quote',
SearchModuleType.quickActions: 'quickActions',
};
// **************************************************************************
@@ -52,7 +54,7 @@ final class SearchModuleOrderProvider
extends $NotifierProvider<SearchModuleOrder, List<ModuleOrderEntry>> {
SearchModuleOrderProvider._({
required SearchModuleOrderFamily super.from,
required SearchModuleGroup super.argument,
required ModuleSurface super.argument,
}) : super(
retry: null,
name: r'searchModuleOrderProvider',
@@ -94,7 +96,7 @@ final class SearchModuleOrderProvider
}
}
String _$searchModuleOrderHash() => r'153cebf32e0bf7b42c4eaaa113b0ca5f36851b5f';
String _$searchModuleOrderHash() => r'ef43bc259db7a07ca1accab9d7ae803c376e76b4';
final class SearchModuleOrderFamily extends $Family
with
@@ -103,7 +105,7 @@ final class SearchModuleOrderFamily extends $Family
List<ModuleOrderEntry>,
List<ModuleOrderEntry>,
List<ModuleOrderEntry>,
SearchModuleGroup
ModuleSurface
> {
SearchModuleOrderFamily._()
: super(
@@ -114,18 +116,18 @@ final class SearchModuleOrderFamily extends $Family
isAutoDispose: false,
);
SearchModuleOrderProvider call(SearchModuleGroup group) =>
SearchModuleOrderProvider._(argument: group, from: this);
SearchModuleOrderProvider call(ModuleSurface surface) =>
SearchModuleOrderProvider._(argument: surface, from: this);
@override
String toString() => r'searchModuleOrderProvider';
}
abstract class _$SearchModuleOrder extends $Notifier<List<ModuleOrderEntry>> {
late final _$args = ref.$arg as SearchModuleGroup;
SearchModuleGroup get group => _$args;
late final _$args = ref.$arg as ModuleSurface;
ModuleSurface get surface => _$args;
List<ModuleOrderEntry> build(SearchModuleGroup group);
List<ModuleOrderEntry> build(ModuleSurface surface);
@$mustCallSuper
@override
WhenComplete runBuild() {
@@ -62,7 +62,16 @@ enum SearchModuleType {
recentArticles,
recentTabs,
containers,
frequentBangs;
frequentBangs,
/// The daily quote card. Carries no list of its own, so it neither paginates
/// nor reports a count; the header's trailing slot holds the reroll button.
quote,
/// New tab / View tabs / Resume last tab. These act on the browser shell
/// around the surface, so they are only offered on [ModuleSurface.home] —
/// on the new-tab page "New tab" is the page you are already looking at.
quickActions;
String get label => switch (this) {
recentSearches => 'Recent Searches',
@@ -82,61 +91,83 @@ enum SearchModuleType {
recentTabs => 'Recent Tabs',
containers => 'Containers',
frequentBangs => 'Frequent Bangs',
quote => 'Quote',
quickActions => 'Quick Actions',
};
}
enum SearchModuleGroup {
emptyState(
key: 'EmptyStateModuleOrder',
/// One module slot on a surface: which module, and whether it starts enabled.
typedef ModuleSurfaceDefault = ({SearchModuleType type, bool visible});
/// An independently-configured module list.
///
/// Each surface persists its own order and visibility under [key] while sharing
/// one module catalogue ([SearchModuleType]), one section chrome
/// ([SearchModuleSection]) and one customization UI — the same split
/// `ToolbarConfigLocation` uses for the two toolbars.
///
/// A module may appear on several surfaces, so the surface cannot be derived
/// from the module. It is supplied by the host instead, via `ModuleSurfaceScope`.
enum ModuleSurface {
/// The browser home shown when no tab is selected.
home(
key: 'HomeModuleOrder',
defaultModules: [
SearchModuleType.recentSearches,
SearchModuleType.frequentBangs,
SearchModuleType.topSites,
SearchModuleType.recentArticles,
SearchModuleType.recentTabs,
SearchModuleType.recentHistory,
SearchModuleType.historyHighlights,
SearchModuleType.containers,
(type: SearchModuleType.quickActions, visible: true),
(type: SearchModuleType.topSites, visible: true),
(type: SearchModuleType.recentTabs, visible: true),
(type: SearchModuleType.quote, visible: true),
(type: SearchModuleType.recentHistory, visible: false),
(type: SearchModuleType.historyHighlights, visible: false),
(type: SearchModuleType.recentArticles, visible: false),
(type: SearchModuleType.containers, visible: false),
],
),
/// The new-tab page: the search screen before anything has been typed.
///
/// [key] is a compatibility contract — this order has shipped to users under
/// that exact string, and renaming it resets every existing layout.
newTab(
key: 'EmptyStateModuleOrder',
defaultModules: [
(type: SearchModuleType.recentSearches, visible: true),
(type: SearchModuleType.frequentBangs, visible: true),
(type: SearchModuleType.topSites, visible: true),
(type: SearchModuleType.recentArticles, visible: true),
(type: SearchModuleType.recentTabs, visible: true),
(type: SearchModuleType.recentHistory, visible: true),
(type: SearchModuleType.historyHighlights, visible: true),
(type: SearchModuleType.containers, visible: true),
// Offered but off, so adding it leaves existing new-tab pages untouched.
(type: SearchModuleType.quote, visible: false),
],
),
/// The search screen once a query has been entered.
search(
key: 'SearchModuleOrder',
defaultModules: [
SearchModuleType.searchProviders,
SearchModuleType.searchSuggestions,
SearchModuleType.tabs,
SearchModuleType.bookmarks,
SearchModuleType.articles,
SearchModuleType.combinedHistory,
SearchModuleType.popularSites,
(type: SearchModuleType.searchProviders, visible: true),
(type: SearchModuleType.searchSuggestions, visible: true),
(type: SearchModuleType.tabs, visible: true),
(type: SearchModuleType.bookmarks, visible: true),
(type: SearchModuleType.articles, visible: true),
(type: SearchModuleType.combinedHistory, visible: true),
(type: SearchModuleType.popularSites, visible: true),
],
);
const SearchModuleGroup({required this.key, required this.defaultModules});
final String key;
final List<SearchModuleType> defaultModules;
}
const ModuleSurface({required this.key, required this.defaultModules});
extension SearchModuleTypeGroup on SearchModuleType {
SearchModuleGroup get group => switch (this) {
SearchModuleType.recentSearches ||
SearchModuleType.topSites ||
SearchModuleType.recentArticles ||
SearchModuleType.recentTabs ||
SearchModuleType.recentHistory ||
SearchModuleType.historyHighlights ||
SearchModuleType.containers ||
SearchModuleType.frequentBangs => SearchModuleGroup.emptyState,
SearchModuleType.searchProviders ||
SearchModuleType.searchSuggestions ||
SearchModuleType.tabs ||
SearchModuleType.bookmarks ||
SearchModuleType.articles ||
SearchModuleType.history ||
SearchModuleType.localHistory ||
SearchModuleType.combinedHistory ||
SearchModuleType.popularSites => SearchModuleGroup.search,
};
/// Storage key for this surface's persisted order. Never change a shipped one.
final String key;
final List<ModuleSurfaceDefault> defaultModules;
/// Whether [module] is offered on this surface at all.
bool offers(SearchModuleType module) =>
defaultModules.any((d) => d.type == module);
}
enum SearchModuleDisplayState { preview, expanded, collapsed }
@@ -167,18 +198,30 @@ class SearchModuleDisplayStateController
};
}
/// Keyed by surface as well as module: the same module can be on screen on
/// two surfaces at once (home stays mounted underneath the pushed search
/// screen), and collapsing it in one place must not collapse it in the other.
@override
SearchModuleDisplayState build(SearchModuleType module) {
SearchModuleDisplayState build(
ModuleSurface surface,
SearchModuleType module,
) {
return SearchModuleDisplayState.preview;
}
}
@Riverpod()
class SearchReorderMode extends _$SearchReorderMode {
// ignore: use_setters_to_change_properties
void activate(SearchModuleGroup group) => state = group;
void deactivate() => state = null;
void activate() => state = true;
void deactivate() => state = false;
/// Keyed by surface, like [SearchModuleDisplayStateController] — and here the
/// key also bounds the state's lifetime. The browser home stays mounted
/// underneath the pushed search screen and would keep a single shared
/// instance alive, so a reorder started on the search screen and left by the
/// system back gesture (rather than "Done") would survive the pop and still
/// be active the next time that screen opened. Per surface, the search
/// screen's own instance is disposed with the screen.
@override
SearchModuleGroup? build() => null;
bool build(ModuleSurface surface) => false;
}
@@ -21,7 +21,7 @@ final class SearchModuleDisplayStateControllerProvider
> {
SearchModuleDisplayStateControllerProvider._({
required SearchModuleDisplayStateControllerFamily super.from,
required SearchModuleType super.argument,
required (ModuleSurface, SearchModuleType) super.argument,
}) : super(
retry: null,
name: r'searchModuleDisplayStateControllerProvider',
@@ -38,7 +38,7 @@ final class SearchModuleDisplayStateControllerProvider
String toString() {
return r'searchModuleDisplayStateControllerProvider'
''
'($argument)';
'$argument';
}
@$internal
@@ -67,7 +67,7 @@ final class SearchModuleDisplayStateControllerProvider
}
String _$searchModuleDisplayStateControllerHash() =>
r'c3f1c93b618eec76e86c1c9cf0bf8fbdfdcb1430';
r'6e17f17c4dee1ad560560b81e9c4c9cade8aeec4';
final class SearchModuleDisplayStateControllerFamily extends $Family
with
@@ -76,7 +76,7 @@ final class SearchModuleDisplayStateControllerFamily extends $Family
SearchModuleDisplayState,
SearchModuleDisplayState,
SearchModuleDisplayState,
SearchModuleType
(ModuleSurface, SearchModuleType)
> {
SearchModuleDisplayStateControllerFamily._()
: super(
@@ -87,11 +87,13 @@ final class SearchModuleDisplayStateControllerFamily extends $Family
isAutoDispose: true,
);
SearchModuleDisplayStateControllerProvider call(SearchModuleType module) =>
SearchModuleDisplayStateControllerProvider._(
argument: module,
from: this,
);
SearchModuleDisplayStateControllerProvider call(
ModuleSurface surface,
SearchModuleType module,
) => SearchModuleDisplayStateControllerProvider._(
argument: (surface, module),
from: this,
);
@override
String toString() => r'searchModuleDisplayStateControllerProvider';
@@ -99,10 +101,14 @@ final class SearchModuleDisplayStateControllerFamily extends $Family
abstract class _$SearchModuleDisplayStateController
extends $Notifier<SearchModuleDisplayState> {
late final _$args = ref.$arg as SearchModuleType;
SearchModuleType get module => _$args;
late final _$args = ref.$arg as (ModuleSurface, SearchModuleType);
ModuleSurface get surface => _$args.$1;
SearchModuleType get module => _$args.$2;
SearchModuleDisplayState build(SearchModuleType module);
SearchModuleDisplayState build(
ModuleSurface surface,
SearchModuleType module,
);
@$mustCallSuper
@override
WhenComplete runBuild() {
@@ -116,58 +122,103 @@ abstract class _$SearchModuleDisplayStateController
Object?,
Object?
>;
return element.handleCreate(ref, () => build(_$args));
return element.handleCreate(ref, () => build(_$args.$1, _$args.$2));
}
}
@ProviderFor(SearchReorderMode)
final searchReorderModeProvider = SearchReorderModeProvider._();
final searchReorderModeProvider = SearchReorderModeFamily._();
final class SearchReorderModeProvider
extends $NotifierProvider<SearchReorderMode, SearchModuleGroup?> {
SearchReorderModeProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'searchReorderModeProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
extends $NotifierProvider<SearchReorderMode, bool> {
SearchReorderModeProvider._({
required SearchReorderModeFamily super.from,
required ModuleSurface super.argument,
}) : super(
retry: null,
name: r'searchReorderModeProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$searchReorderModeHash();
@override
String toString() {
return r'searchReorderModeProvider'
''
'($argument)';
}
@$internal
@override
SearchReorderMode create() => SearchReorderMode();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(SearchModuleGroup? value) {
Override overrideWithValue(bool value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<SearchModuleGroup?>(value),
providerOverride: $SyncValueProvider<bool>(value),
);
}
@override
bool operator ==(Object other) {
return other is SearchReorderModeProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$searchReorderModeHash() => r'eda188e53e5b5f1a331ce94c3cb8808c79e358d3';
String _$searchReorderModeHash() => r'2fda7e61b9c67e04e39254733e0ba957ff5de35a';
abstract class _$SearchReorderMode extends $Notifier<SearchModuleGroup?> {
SearchModuleGroup? build();
final class SearchReorderModeFamily extends $Family
with
$ClassFamilyOverride<
SearchReorderMode,
bool,
bool,
bool,
ModuleSurface
> {
SearchReorderModeFamily._()
: super(
retry: null,
name: r'searchReorderModeProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
SearchReorderModeProvider call(ModuleSurface surface) =>
SearchReorderModeProvider._(argument: surface, from: this);
@override
String toString() => r'searchReorderModeProvider';
}
abstract class _$SearchReorderMode extends $Notifier<bool> {
late final _$args = ref.$arg as ModuleSurface;
ModuleSurface get surface => _$args;
bool build(ModuleSurface surface);
@$mustCallSuper
@override
WhenComplete runBuild() {
final ref = this.ref as $Ref<SearchModuleGroup?, SearchModuleGroup?>;
final ref = this.ref as $Ref<bool, bool>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<SearchModuleGroup?, SearchModuleGroup?>,
SearchModuleGroup?,
AnyNotifier<bool, bool>,
bool,
Object?,
Object?
>;
return element.handleCreate(ref, build);
return element.handleCreate(ref, () => build(_$args));
}
}
@@ -20,25 +20,37 @@
import 'package:flutter/material.dart';
import 'package:weblibre/utils/uri_parser.dart' as uri_parser;
/// Edits an existing shortcut, or — with both initial values omitted — creates
/// one from scratch.
Future<({String title, Uri url})?> showEditTopSiteDialog(
BuildContext context, {
required String initialTitle,
required Uri initialUrl,
String initialTitle = '',
Uri? initialUrl,
String dialogTitle = 'Edit Shortcut',
String confirmLabel = 'Save',
}) {
return showDialog<({String title, Uri url})>(
context: context,
builder: (context) =>
_EditTopSiteDialog(initialTitle: initialTitle, initialUrl: initialUrl),
builder: (context) => _EditTopSiteDialog(
initialTitle: initialTitle,
initialUrl: initialUrl,
dialogTitle: dialogTitle,
confirmLabel: confirmLabel,
),
);
}
class _EditTopSiteDialog extends StatefulWidget {
final String initialTitle;
final Uri initialUrl;
final Uri? initialUrl;
final String dialogTitle;
final String confirmLabel;
const _EditTopSiteDialog({
required this.initialTitle,
required this.initialUrl,
required this.dialogTitle,
required this.confirmLabel,
});
@override
@@ -54,7 +66,9 @@ class _EditTopSiteDialogState extends State<_EditTopSiteDialog> {
void initState() {
super.initState();
_titleController = TextEditingController(text: widget.initialTitle);
_urlController = TextEditingController(text: widget.initialUrl.toString());
_urlController = TextEditingController(
text: widget.initialUrl?.toString() ?? '',
);
}
@override
@@ -67,7 +81,7 @@ class _EditTopSiteDialogState extends State<_EditTopSiteDialog> {
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Edit Shortcut'),
title: Text(widget.dialogTitle),
content: Form(
key: _formKey,
child: Column(
@@ -125,7 +139,7 @@ class _EditTopSiteDialogState extends State<_EditTopSiteDialog> {
));
}
},
child: const Text('Save'),
child: Text(widget.confirmLabel),
),
],
);
@@ -44,14 +44,8 @@ import 'package:weblibre/features/geckoview/features/search/domain/providers/sea
import 'package:weblibre/features/geckoview/features/search/domain/providers/search_modules_view.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/animated_tab_type_switcher.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/clipboard_fill.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/empty_state/containers_section.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/empty_state/frequent_bangs_section.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/empty_state/history_highlights_section.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/empty_state/recent_feed_articles_section.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/empty_state/recent_history_section.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/empty_state/recent_searches_section.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/empty_state/recent_tabs_section.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/empty_state/top_sites_section.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/module_surface_scope.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/module_surface_slivers.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_field.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_module_reorder_view.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/bookmark_search.dart';
@@ -529,61 +523,46 @@ class SearchScreen extends HookConsumerWidget {
return () => scrollController.removeListener(listener);
}, [scrollController]);
final reorderGroup = ref.watch(searchReorderModeProvider);
// The screen is two surfaces in one: before anything is typed it is the
// new-tab page, afterwards it is the search results page. They are mutually
// exclusive, so a single scope covers both.
final activeSurface = showNoInputSections
? ModuleSurface.newTab
: ModuleSurface.search;
final emptyStateOrder = ref.watch(
searchModuleOrderProvider(SearchModuleGroup.emptyState),
final reorderActive = ref.watch(searchReorderModeProvider(activeSurface));
final moduleCallbacks = ModuleSurfaceCallbacks(
onUriSelected: openUriInTab,
searchTextController: searchTextController,
submitSearch: submitSearch,
onArticleSelected: (article) {
FeedArticleRoute(articleId: article.id).pushReplacement(context);
},
onTabSelected: (tabId) async {
await ref.read(tabRepositoryProvider.notifier).selectTab(tabId);
if (context.mounted) {
ref.read(bottomSheetControllerProvider.notifier).requestDismiss();
const BrowserRoute().go(context);
}
},
onContainerSelected: (container) async {
final result = await ref
.read(selectedContainerProvider.notifier)
.setContainerId(container.id);
if (!context.mounted) return;
if (result == SetContainerResult.success) {
await ensureProxyStartedForContainer(context, ref, container);
}
if (context.mounted && result == SetContainerResult.success) {
const TabViewRoute().go(context);
}
},
);
final searchOrder = ref.watch(
searchModuleOrderProvider(SearchModuleGroup.search),
);
final emptyStateWidgets = <SearchModuleType, Widget>{
SearchModuleType.recentSearches: RecentSearchesSection(
searchTextController: searchTextController,
submitSearch: submitSearch,
),
SearchModuleType.frequentBangs: const FrequentBangsSection(),
SearchModuleType.topSites: TopSitesSection(onUriSelected: openUriInTab),
SearchModuleType.recentArticles: RecentFeedArticlesSection(
onArticleSelected: (article) {
FeedArticleRoute(articleId: article.id).pushReplacement(context);
},
),
SearchModuleType.recentTabs: RecentTabsSection(
onTabSelected: (tabId) async {
await ref.read(tabRepositoryProvider.notifier).selectTab(tabId);
if (context.mounted) {
ref.read(bottomSheetControllerProvider.notifier).requestDismiss();
const BrowserRoute().go(context);
}
},
),
SearchModuleType.recentHistory: RecentHistorySection(
onUriSelected: openUriInTab,
),
SearchModuleType.historyHighlights: HistoryHighlightsSection(
onUriSelected: openUriInTab,
),
SearchModuleType.containers: ContainersSection(
onContainerSelected: (container) async {
final result = await ref
.read(selectedContainerProvider.notifier)
.setContainerId(container.id);
if (!context.mounted) return;
if (result == SetContainerResult.success) {
await ensureProxyStartedForContainer(context, ref, container);
}
if (context.mounted && result == SetContainerResult.success) {
const TabViewRoute().go(context);
}
},
),
};
final searchWidgets = <SearchModuleType, Widget>{
SearchModuleType.searchProviders: SearchProvidersSection(
@@ -620,6 +599,10 @@ class SearchScreen extends HookConsumerWidget {
),
};
final searchOrder = ref.watch(
searchModuleOrderProvider(ModuleSurface.search),
);
bool canShowSearchModule(SearchModuleType type) {
if (!isUrlInput.value) {
return true;
@@ -642,266 +625,250 @@ class SearchScreen extends HookConsumerWidget {
body: SafeArea(
child: Form(
key: formKey,
child: CustomScrollView(
controller: scrollController,
slivers: [
SliverAppBar(
floating: true,
pinned: true,
automaticallyImplyLeading: false,
leading: showCloseButton
? IconButton(
tooltip: 'Close',
icon: const Icon(Icons.close),
onPressed: () => context.pop(),
)
: null,
backgroundColor: colorScheme.surface,
scrolledUnderElevation: 0,
shadowColor: Colors.transparent,
surfaceTintColor: Colors.transparent,
// Collapse the toolbar in edit mode (no tab-type switcher), but
// keep it when the close button needs somewhere to render.
toolbarHeight: (isEditMode && !showCloseButton)
? 0
: kToolbarHeight,
titleSpacing: 0.0,
title: isEditMode
? null
: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Builder(
builder: (context) {
final tabTypeSwitcher = Focus(
canRequestFocus: false,
child: AnimatedTabTypeSwitcher(
selected: selectedTabType.value,
onChanged: (value) {
selectedTabType.value = value;
// Restore focus to search field after segment change
WidgetsBinding.instance.addPostFrameCallback((
_,
) {
searchFocusNode.requestFocus();
});
},
showChildOption: createChildTabsOption,
showIsolatedOption: settings.showIsolatedTabUi,
selectedBackgroundColor: switch (selectedTabType
.value) {
TabType.regular => null,
TabType.private =>
appColors.privateSelectionOverlay,
TabType.isolated =>
appColors.isolatedSelectionOverlay,
TabType.child => switch (currentTabTabType) {
TabType.private =>
appColors.privateSelectionOverlay,
TabType.isolated =>
appColors.isolatedSelectionOverlay,
_ => null,
child: ModuleSurfaceScope(
surface: activeSurface,
pinnedHeaderBackgroundColor: Theme.of(context).canvasColor,
child: CustomScrollView(
controller: scrollController,
slivers: [
SliverAppBar(
floating: true,
pinned: true,
automaticallyImplyLeading: false,
leading: showCloseButton
? IconButton(
tooltip: 'Close',
icon: const Icon(Icons.close),
onPressed: () => context.pop(),
)
: null,
backgroundColor: colorScheme.surface,
scrolledUnderElevation: 0,
shadowColor: Colors.transparent,
surfaceTintColor: Colors.transparent,
// Collapse the toolbar in edit mode (no tab-type switcher), but
// keep it when the close button needs somewhere to render.
toolbarHeight: (isEditMode && !showCloseButton)
? 0
: kToolbarHeight,
titleSpacing: 0.0,
title: isEditMode
? null
: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Builder(
builder: (context) {
final tabTypeSwitcher = Focus(
canRequestFocus: false,
child: AnimatedTabTypeSwitcher(
selected: selectedTabType.value,
onChanged: (value) {
selectedTabType.value = value;
// Restore focus to search field after segment change
WidgetsBinding.instance
.addPostFrameCallback((_) {
searchFocusNode.requestFocus();
});
},
},
),
);
if (!settings.showContainerUi) {
return Center(
child: Transform.scale(
scale: 1.08,
child: tabTypeSwitcher,
showChildOption: createChildTabsOption,
showIsolatedOption:
settings.showIsolatedTabUi,
selectedBackgroundColor:
switch (selectedTabType.value) {
TabType.regular => null,
TabType.private =>
appColors.privateSelectionOverlay,
TabType.isolated =>
appColors.isolatedSelectionOverlay,
TabType.child =>
switch (currentTabTabType) {
TabType.private =>
appColors.privateSelectionOverlay,
TabType.isolated =>
appColors
.isolatedSelectionOverlay,
_ => null,
},
},
),
);
}
return Row(
children: [
Expanded(
flex: 4,
child: Align(
alignment: Alignment.centerLeft,
if (!settings.showContainerUi) {
return Center(
child: Transform.scale(
scale: 1.08,
child: tabTypeSwitcher,
),
),
const SizedBox(width: 8),
Flexible(
flex: 2,
child: Align(
alignment: Alignment.centerRight,
child: CompactContainerSelector(
selectedContainer: selectedContainer,
emphasizeSelection: false,
);
}
return Row(
children: [
Expanded(
flex: 4,
child: Align(
alignment: Alignment.centerLeft,
child: tabTypeSwitcher,
),
),
),
],
);
},
),
),
bottom: PreferredSize(
preferredSize: Size.fromHeight(preferredHeight.value),
child: SearchField(
textFieldKey: textFieldKey,
showBangIcon: showBangIcon,
textEditingController: searchTextController,
focusNode: searchFocusNode,
maxLines: isEditMode ? 3 : 1,
privateMode: privateTabMode,
label: const Text('Search or enter URL'),
unfocusOnTapOutside: false,
onClearPressed: () {
final url = revertUrl.value;
if (url != null &&
searchTextController.text ==
reverseMatchedQuery.value) {
// First press after a reverse-match swap: restore the
// original URL and drop the auto-selected bang. The
// user can press again to actually clear.
searchTextController.value = TextEditingValue(
text: url,
selection: TextSelection(
baseOffset: 0,
extentOffset: url.length,
),
);
revertUrl.value = null;
reverseMatchedQuery.value = null;
ref
.read(selectedBangTriggerProvider().notifier)
.clearTrigger();
} else {
revertUrl.value = null;
reverseMatchedQuery.value = null;
searchTextController.clear();
}
},
onSubmitted: (value) async {
if (value.isEmpty) return;
switch (classifyAddressBarInput(value)) {
case NavigateInputClassification(:final uri):
await openUriInTab(uri);
case SearchInputClassification(:final query):
// Read from both providers - use site if set, otherwise global
final siteBang = isEditMode
? ref.read(
selectedBangDataProvider(
domain: existingTabState.url.host,
const SizedBox(width: 8),
Flexible(
flex: 2,
child: Align(
alignment: Alignment.centerRight,
child: CompactContainerSelector(
selectedContainer: selectedContainer,
emphasizeSelection: false,
),
),
),
)
: null;
final globalBang = ref.read(
selectedBangDataProvider(),
],
);
},
),
),
bottom: PreferredSize(
preferredSize: Size.fromHeight(preferredHeight.value),
child: SearchField(
textFieldKey: textFieldKey,
showBangIcon: showBangIcon,
textEditingController: searchTextController,
focusNode: searchFocusNode,
maxLines: isEditMode ? 3 : 1,
privateMode: privateTabMode,
label: const Text('Search or enter URL'),
unfocusOnTapOutside: false,
onClearPressed: () {
final url = revertUrl.value;
if (url != null &&
searchTextController.text ==
reverseMatchedQuery.value) {
// First press after a reverse-match swap: restore the
// original URL and drop the auto-selected bang. The
// user can press again to actually clear.
searchTextController.value = TextEditingValue(
text: url,
selection: TextSelection(
baseOffset: 0,
extentOffset: url.length,
),
);
final bang =
siteBang ??
globalBang ??
await ref.read(defaultSearchBangProvider.future);
revertUrl.value = null;
reverseMatchedQuery.value = null;
ref
.read(selectedBangTriggerProvider().notifier)
.clearTrigger();
} else {
revertUrl.value = null;
reverseMatchedQuery.value = null;
searchTextController.clear();
}
},
onSubmitted: (value) async {
if (value.isEmpty) return;
if (bang == null) return;
final uri = await resolveSearchUri(bang, query);
if (uri == null) {
// Web search dispatched in-app; reset edit state.
isEditingAfterSearch.value = false;
return;
}
await openUriInTab(uri);
case InvalidInputClassification():
if (context.mounted) {
ui_helper.showErrorMessage(
context,
'Invalid address',
switch (classifyAddressBarInput(value)) {
case NavigateInputClassification(:final uri):
await openUriInTab(uri);
case SearchInputClassification(:final query):
// Read from both providers - use site if set, otherwise global
final siteBang = isEditMode
? ref.read(
selectedBangDataProvider(
domain: existingTabState.url.host,
),
)
: null;
final globalBang = ref.read(
selectedBangDataProvider(),
);
}
}
},
activeBang: activeBang,
showSuggestions: true,
),
),
),
SliverToBoxAdapter(
child: ClipboardFillLink(controller: searchTextController),
),
if (isWebSearchBang(activeBang))
const SliverPadding(
padding: EdgeInsets.fromLTRB(0, 8, 0, 4),
sliver: SliverToBoxAdapter(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_WebSearchOptionsRow(),
WebSearchTorBootstrapProgress(),
],
final bang =
siteBang ??
globalBang ??
await ref.read(
defaultSearchBangProvider.future,
);
if (bang == null) return;
final uri = await resolveSearchUri(bang, query);
if (uri == null) {
// Web search dispatched in-app; reset edit state.
isEditingAfterSearch.value = false;
return;
}
await openUriInTab(uri);
case InvalidInputClassification():
if (context.mounted) {
ui_helper.showErrorMessage(
context,
'Invalid address',
);
}
}
},
activeBang: activeBang,
showSuggestions: true,
),
),
),
if (reorderGroup != null)
SearchModuleReorderView(group: reorderGroup)
else if (isWebSearchBang(activeBang) &&
ref.watch(
metaSearchControllerProvider.select(
(s) =>
s.status != WebSearchStatus.idle ||
s.query.isNotEmpty,
SliverToBoxAdapter(
child: ClipboardFillLink(controller: searchTextController),
),
if (isWebSearchBang(activeBang))
const SliverPadding(
padding: EdgeInsets.fromLTRB(0, 8, 0, 4),
sliver: SliverToBoxAdapter(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_WebSearchOptionsRow(),
WebSearchTorBootstrapProgress(),
],
),
),
)) ...[
// Once a web search has been dispatched, the screen shows
// the fetched results only — search suggestions and search
// providers belong to the normal search page, not the
// results view.
WebSearchResultsSection(
resolveOpenTarget: () => WebSearchOpenTarget(
tabMode: effectiveTabMode,
containerSelection: selectedContainer == null
? const TabContainerSelection.unassigned()
: TabContainerSelection.specific(selectedContainer),
parentId: (selectedTabType.value == TabType.child)
? ref.read(selectedTabProvider)
: null,
),
),
] else if (showNoInputSections) ...[
for (final entry in emptyStateOrder)
if (emptyStateWidgets.containsKey(entry.type))
emptyStateWidgets[entry.type]!,
const _CustomizeSectionsButton(
group: SearchModuleGroup.emptyState,
),
] else ...[
for (final entry in searchOrder)
if (searchWidgets.containsKey(entry.type) &&
canShowSearchModule(entry.type))
searchWidgets[entry.type]!,
const _CustomizeSectionsButton(group: SearchModuleGroup.search),
if (reorderActive)
SearchModuleReorderView(surface: activeSurface)
else if (isWebSearchBang(activeBang) &&
ref.watch(
metaSearchControllerProvider.select(
(s) =>
s.status != WebSearchStatus.idle ||
s.query.isNotEmpty,
),
)) ...[
// Once a web search has been dispatched, the screen shows
// the fetched results only — search suggestions and search
// providers belong to the normal search page, not the
// results view.
WebSearchResultsSection(
resolveOpenTarget: () => WebSearchOpenTarget(
tabMode: effectiveTabMode,
containerSelection: selectedContainer == null
? const TabContainerSelection.unassigned()
: TabContainerSelection.specific(selectedContainer),
parentId: (selectedTabType.value == TabType.child)
? ref.read(selectedTabProvider)
: null,
),
),
] else if (showNoInputSections)
ModuleSurfaceSliverList(
surface: ModuleSurface.newTab,
callbacks: moduleCallbacks,
)
else ...[
for (final entry in searchOrder)
if (searchWidgets.containsKey(entry.type) &&
entry.visible &&
canShowSearchModule(entry.type))
searchWidgets[entry.type]!,
const CustomizeSectionsButton(surface: ModuleSurface.search),
],
],
],
),
),
),
);
}
}
class _CustomizeSectionsButton extends ConsumerWidget {
final SearchModuleGroup group;
const _CustomizeSectionsButton({required this.group});
@override
Widget build(BuildContext context, WidgetRef ref) {
return SliverToBoxAdapter(
child: Center(
child: Padding(
padding: const EdgeInsets.only(top: 24),
child: TextButton.icon(
onPressed: () =>
ref.read(searchReorderModeProvider.notifier).activate(group),
icon: const Icon(Icons.tune, size: 18),
label: const Text('Customize sections'),
),
),
),
),
@@ -0,0 +1,104 @@
/*
* 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/features/geckoview/domain/providers/tab_list.dart';
import 'package:weblibre/features/geckoview/features/search/domain/providers/search_modules_view.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/search_module_section.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selected_container.dart';
/// New tab / View tabs / Resume last tab.
///
/// Which buttons appear depends on what there is to act on: with no tabs at all
/// only "New tab" is meaningful, and "Resume last tab" resumes within the
/// selected container when there is one.
class QuickActionsSection extends ConsumerWidget {
final VoidCallback onNewTab;
final VoidCallback onViewTabs;
final VoidCallback onResumeLastTab;
const QuickActionsSection({
super.key,
required this.onNewTab,
required this.onViewTabs,
required this.onResumeLastTab,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final hasTabs = ref.watch(
tabListProvider.select((tabs) => tabs.value.isNotEmpty),
);
final hasContainer = ref.watch(
selectedContainerDataProvider.select((value) => value.value != null),
);
final hasContainerTabs = ref.watch(
selectedContainerTabCountProvider.select(
(data) => switch (data) {
AsyncData(:final value) => value > 0,
_ => false,
},
),
);
// Resuming is offered for the container in scope, or globally when no
// container is selected — never across a container boundary, which would
// silently move the user somewhere else.
final canResume = hasContainer ? hasContainerTabs : hasTabs;
return SearchModuleSection(
title: 'Quick Actions',
moduleType: SearchModuleType.quickActions,
totalCount: 0,
showPagination: false,
contentSliverBuilder: ({required isCollapsed, required visibleCount}) => [
if (!isCollapsed)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
child: Wrap(
spacing: 12,
runSpacing: 12,
children: [
FilledButton.icon(
onPressed: onNewTab,
icon: const Icon(Icons.add_rounded),
label: const Text('New tab'),
),
if (hasTabs)
OutlinedButton.icon(
onPressed: onViewTabs,
icon: const Icon(Icons.tab_rounded),
label: const Text('View tabs'),
),
if (canResume)
FilledButton.tonalIcon(
onPressed: onResumeLastTab,
icon: const Icon(Icons.history_rounded),
label: const Text('Resume last tab'),
),
],
),
),
),
],
);
}
}
@@ -0,0 +1,127 @@
/*
* 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/features/geckoview/features/search/domain/providers/search_modules_view.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/search_module_section.dart';
import 'package:weblibre/features/quotes/data/database/definitions.drift.dart';
import 'package:weblibre/features/quotes/domain/providers.dart';
/// The daily quote card.
///
/// Was hardcoded into the browser home; it is a module so it can be switched
/// off, which is the single most-requested change to that page.
class QuoteSection extends ConsumerWidget {
const QuoteSection({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final quoteAsync = ref.watch(randomQuoteProvider);
return SearchModuleSection(
title: 'A thought for the road',
moduleType: SearchModuleType.quote,
// A single card rather than a list: nothing to count or paginate.
totalCount: 0,
showPagination: false,
headerTrailing: IconButton(
tooltip: 'Refresh quote',
onPressed: () => ref.invalidate(randomQuoteProvider),
icon: const Icon(Icons.refresh_rounded),
),
contentSliverBuilder: ({required isCollapsed, required visibleCount}) => [
if (!isCollapsed)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 12),
child: switch (quoteAsync) {
AsyncData(:final value) => _QuoteBlock(quote: value),
AsyncError() => const _QuotePlaceholder(),
_ => const LinearProgressIndicator(minHeight: 3),
},
),
),
],
);
}
}
class _QuotePlaceholder extends StatelessWidget {
const _QuotePlaceholder();
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Text(
'Open a new tab and make this space your own.',
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
height: 1.5,
),
);
}
}
class _QuoteBlock extends StatelessWidget {
final Quote? quote;
const _QuoteBlock({required this.quote});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
if (quote == null) {
return const _QuotePlaceholder();
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'"${quote!.quote}"',
style: theme.textTheme.bodyLarge?.copyWith(
height: 1.55,
color: colorScheme.onSurface,
),
),
const SizedBox(height: 12),
Text(
'- ${quote!.author}',
style: theme.textTheme.titleSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
if (quote!.source case final String source when source.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
source,
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
],
);
}
}
@@ -27,7 +27,9 @@ import 'package:flutter_reorderable_grid_view/widgets/reorderable_builder.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/features/geckoview/features/search/domain/providers/search_modules_view.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/dialogs/edit_top_site_dialog.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/module_surface_scope.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/search_module_section.dart';
import 'package:weblibre/features/geckoview/features/top_sites/domain/entities/top_site_host.dart';
import 'package:weblibre/features/geckoview/features/top_sites/domain/entities/top_site_item.dart';
import 'package:weblibre/features/geckoview/features/top_sites/domain/entities/top_site_source.dart';
import 'package:weblibre/features/geckoview/features/top_sites/domain/providers.dart';
@@ -121,21 +123,30 @@ class TopSitesSection extends HookConsumerWidget {
).select((value) => value.value ?? []),
);
if (topSites.isEmpty) {
return const SliverToBoxAdapter(child: SizedBox.shrink());
}
final reorderMode = useState(false);
final reorderBusy = useState(false);
final persistedItems = topSites.where((s) => s.isPersisted).toList();
final historyItems = topSites.where((s) => !s.isPersisted).toList();
// Home leads with the user's own shortcuts, so its preview is sized to the
// curated tiles rather than to a fixed count: every pinned and default site
// is on screen from the start, and frecency suggestions — which pad the
// list out to [_topSitesMaxLimit] — stay behind "Show all N". The cap only
// bites for someone who has pinned more than a grid's worth.
//
// The other surfaces sit above a search field where the grid is one module
// among many, and keep the short fixed preview.
final isHome = ModuleSurfaceScope.surfaceOf(context) == ModuleSurface.home;
final previewLimit = isHome
? persistedItems.length.clamp(0, _topSitesMaxLimit)
: _topSitesPreviewLimit;
return SearchModuleSection(
title: 'Shortcuts',
moduleType: SearchModuleType.topSites,
totalCount: topSites.length,
previewLimit: _topSitesPreviewLimit,
previewLimit: previewLimit,
headerTrailing: persistedItems.length >= 2
? IconButton.filledTonal(
icon: const Icon(Icons.swap_vert),
@@ -159,22 +170,35 @@ class TopSitesSection extends HookConsumerWidget {
)
: null,
contentSliverBuilder:
({required bool isCollapsed, required int visibleCount}) => [
if (!isCollapsed)
if (reorderMode.value)
_ReorderableTopSitesGrid(
persistedItems: persistedItems,
historyItems: historyItems,
reorderBusy: reorderBusy,
onUriSelected: onUriSelected,
)
else
_TopSitesGrid(
items: topSites,
visibleCount: visibleCount,
onUriSelected: onUriSelected,
),
],
({required bool isCollapsed, required int visibleCount}) {
// Suggestions are the tail of the list, so they are on screen
// exactly when the visible window reaches past the curated tiles.
// Reorder mode lays the two groups out itself and has to be told.
final showSuggestions = visibleCount > persistedItems.length;
return [
if (!isCollapsed)
if (reorderMode.value)
_ReorderableTopSitesGrid(
persistedItems: persistedItems,
historyItems: showSuggestions
? historyItems
: const <TopSiteItem>[],
reorderBusy: reorderBusy,
onUriSelected: onUriSelected,
)
else
_TopSitesGrid(
items: topSites,
visibleCount: visibleCount,
onUriSelected: onUriSelected,
// Counted over curated tiles only: the cap is on how many
// shortcuts you may keep, and gating on the padded length
// hid the "+" as soon as suggestions filled the list out.
showAddTile: persistedItems.length < _topSitesMaxLimit,
),
];
},
);
}
}
@@ -183,11 +207,13 @@ class _TopSitesGrid extends ConsumerWidget {
final List<TopSiteItem> items;
final int visibleCount;
final void Function(Uri uri) onUriSelected;
final bool showAddTile;
const _TopSitesGrid({
required this.items,
required this.visibleCount,
required this.onUriSelected,
this.showAddTile = false,
});
@override
@@ -198,15 +224,23 @@ class _TopSitesGrid extends ConsumerWidget {
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0),
sliver: SliverGrid.builder(
gridDelegate: const _TopSitesGridDelegate(),
itemCount: displayItems.length,
itemCount: displayItems.length + (showAddTile ? 1 : 0),
itemBuilder: (context, index) {
if (index == displayItems.length) {
return _AddShortcutTile(onPressed: () => _addItem(context, ref));
}
final item = displayItems[index];
return _TopSiteGridTile(
item: item,
onTap: () => onUriSelected(item.url),
onPin: () => _pinItem(context, ref, item),
onEdit: () => _editItem(context, ref, item),
onPin: item.isPersisted ? null : () => _pinItem(context, ref, item),
onEdit: item.isPersisted
? () => _editItem(context, ref, item)
: null,
onRemove: () => _removeItem(context, ref, item),
onRemoveDomain: () =>
_removeItem(context, ref, item, wholeDomain: true),
);
},
),
@@ -214,6 +248,32 @@ class _TopSitesGrid extends ConsumerWidget {
}
}
/// Trailing "+" cell. Creating a shortcut previously required visiting the site
/// and pinning it from the browser menu; there was no way to just type one in.
class _AddShortcutTile extends StatelessWidget {
final VoidCallback onPressed;
const _AddShortcutTile({required this.onPressed});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Material(
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.4),
borderRadius: _TopSiteGridTile._borderRadius,
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onPressed,
child: Tooltip(
message: 'Add shortcut',
child: Icon(Icons.add, color: colorScheme.onSurfaceVariant),
),
),
);
}
}
class _ReorderableTopSitesGrid extends HookConsumerWidget {
final List<TopSiteItem> persistedItems;
final List<TopSiteItem> historyItems;
@@ -231,6 +291,22 @@ class _ReorderableTopSitesGrid extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final localItems = useKeyedState(persistedItems, [persistedItems]);
// Attached to the inner grid below, and handed to ReorderableBuilder so it
// reads its scroll position from there rather than from the enclosing
// CustomScrollView.
//
// The package records each tile's position as `localPosition +
// scrollOffset` when the tile is first built, but during a drag it tests
// collisions against `pointerLocalPosition + (scrollOffset -
// scrollOffsetAtDragStart)`. Those two agree only if the scroll offset was
// zero when the tiles were created. Left to find the outer scrollable, that
// holds only when the surface happens to be scrolled to the top — and
// reaching this module's reorder toggle usually means it is not, so every
// tile ends up displaced by the scroll amount and the drop lands on the
// wrong cell. The inner grid never scrolls, so sourcing the offset from it
// pins it at zero and both sides reduce to plain local coordinates.
final gridScrollController = useScrollController();
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0),
@@ -238,6 +314,7 @@ class _ReorderableTopSitesGrid extends HookConsumerWidget {
builder: (context, constraints) {
final layout = _resolveGridLayout(constraints.maxWidth);
return ReorderableBuilder.builder(
scrollController: gridScrollController,
itemCount: localItems.value.length,
onReorderPositions: (positions) async {
if (reorderBusy.value || positions.isEmpty) return;
@@ -337,6 +414,12 @@ class _ReorderableTopSitesGrid extends HookConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
GridView.builder(
// Never scrolls (the outer surface does), so this
// controller's offset stays at zero — which is exactly
// what ReorderableBuilder needs to read. Only this grid
// gets it: a controller attached to two positions throws
// when the package asks for `position`.
controller: gridScrollController,
padding: EdgeInsets.zero,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
@@ -403,6 +486,7 @@ class _TopSiteGridTile extends StatefulWidget {
final VoidCallback? onPin;
final VoidCallback? onEdit;
final VoidCallback? onRemove;
final VoidCallback? onRemoveDomain;
final bool showDragHandle;
const _TopSiteGridTile({
@@ -411,6 +495,7 @@ class _TopSiteGridTile extends StatefulWidget {
this.onPin,
this.onEdit,
this.onRemove,
this.onRemoveDomain,
this.showDragHandle = false,
});
@@ -425,9 +510,10 @@ class _TopSiteGridTileState extends State<_TopSiteGridTile> {
final _menuController = MenuController();
bool get _hasMenu =>
(widget.item.isPersisted &&
(widget.onEdit != null || widget.onRemove != null)) ||
(!widget.item.isPersisted && widget.onPin != null);
widget.onPin != null ||
widget.onEdit != null ||
widget.onRemove != null ||
widget.onRemoveDomain != null;
@override
Widget build(BuildContext context) {
@@ -437,15 +523,26 @@ class _TopSiteGridTileState extends State<_TopSiteGridTile> {
return MenuAnchor(
controller: _menuController,
menuChildren: [
if (!widget.item.isPersisted && widget.onPin != null)
if (widget.onPin != null)
MenuItemButton(onPressed: widget.onPin, child: const Text('Pin')),
if (widget.item.isPersisted && widget.onEdit != null)
if (widget.onEdit != null)
MenuItemButton(onPressed: widget.onEdit, child: const Text('Edit')),
if (widget.item.isPersisted && widget.onRemove != null)
// Offered for history-derived tiles too. Without it a frequently
// visited site — a PWA especially — could occupy most of the grid
// with no way to get rid of it.
if (widget.onRemove != null)
MenuItemButton(
onPressed: widget.onRemove,
child: const Text('Remove'),
),
if (widget.onRemoveDomain != null &&
canonicalTopSiteHost(widget.item.url).isNotEmpty)
MenuItemButton(
onPressed: widget.onRemoveDomain,
child: Text(
'Hide all from ${canonicalTopSiteHost(widget.item.url)}',
),
),
],
child: Material(
color: colorScheme.surfaceContainerHigh,
@@ -636,7 +733,7 @@ Future<void> _editItem(
// If the URL changed, hide the original so it doesn't reappear
// from the const defaults list.
if (item.url != result.url) {
await repo.hideDefaultSite(item.url);
await repo.hideSite(item.url);
}
await repo.updateSite(id: id, title: result.title, url: result.url);
@@ -650,29 +747,64 @@ Future<void> _editItem(
}
}
Future<void> _addItem(BuildContext context, WidgetRef ref) async {
final result = await showEditTopSiteDialog(
context,
dialogTitle: 'Add shortcut',
confirmLabel: 'Add',
);
if (result == null || !context.mounted) return;
try {
await ref
.read(topSiteRepositoryProvider.notifier)
.addPinnedSite(title: result.title, url: result.url);
if (context.mounted) {
ui_helper.showInfoMessage(context, 'Added "${result.title}"');
}
} catch (e) {
if (context.mounted) {
ui_helper.showErrorMessage(context, 'Failed to add shortcut');
}
}
}
Future<void> _removeItem(
BuildContext context,
WidgetRef ref,
TopSiteItem item,
) async {
TopSiteItem item, {
bool wholeDomain = false,
}) async {
final repo = ref.read(topSiteRepositoryProvider.notifier);
final wasPersisted = item.id != null;
try {
if (item.id != null) {
if (wasPersisted) {
await repo.removeSite(item.id!);
}
// Hide the URL so it doesn't reappear from the const defaults list
await repo.hideDefaultSite(item.url);
// Hide it so it doesn't come back from the bundled defaults or from
// frecency-ranked history.
await repo.hideSite(item.url, wholeDomain: wholeDomain);
if (context.mounted) {
ui_helper.showInfoMessage(
context,
'Removed "${item.title}"',
wholeDomain
? 'Hid all shortcuts from ${canonicalTopSiteHost(item.url)}'
: 'Removed "${item.title}"',
action: SnackBarAction(
label: 'Undo',
onPressed: () async {
try {
await repo.addPinnedSite(title: item.title, url: item.url);
// Lift the suppression first, then restore the pin only if the
// shortcut was one. An unpinned history entry comes back on its
// own once it is no longer hidden; re-pinning it would silently
// promote it to something the user never created.
await repo.unhideSite(item.url, wholeDomain: wholeDomain);
if (wasPersisted) {
await repo.addPinnedSite(title: item.title, url: item.url);
}
} catch (_) {}
},
),
@@ -0,0 +1,76 @@
/*
* 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:weblibre/features/geckoview/features/search/domain/providers/search_modules_view.dart';
/// Marks which [ModuleSurface] the modules below it belong to.
///
/// The same module can appear on more than one surface, so a module cannot name
/// its own surface — the host does, once, above its scroll view. Every
/// `SearchModuleSection` reads it from here to find the order it should honour,
/// the reorder mode it should respond to, and the backdrop its pinned header
/// should sit on.
///
/// Inherited lookups walk the element tree, which includes sliver elements, so
/// sections nested inside `MultiSliver`s resolve this correctly.
class ModuleSurfaceScope extends InheritedWidget {
final ModuleSurface surface;
/// Painted behind the section headers, which pin to the top of the viewport
/// when this is set. Null leaves them unpinned and unpainted.
///
/// The two are one setting because they are one decision: a pinned header
/// has content scrolling underneath it and therefore *must* be opaque, while
/// an unpinned header never covers anything and so needs no backdrop at all.
///
/// The search screen pins on `canvasColor`: its result lists are long, and
/// the header tells you which module you are looking at. The browser home
/// does not pin. Its sections are short, and on the `BrowserPage` aura
/// gradient an opaque band per header stacks into a set of slabs cutting
/// across the backdrop — with several short or collapsed modules in a row,
/// the bands land next to each other and the surface reads as stripes.
final Color? pinnedHeaderBackgroundColor;
const ModuleSurfaceScope({
super.key,
required this.surface,
required this.pinnedHeaderBackgroundColor,
required super.child,
});
static ModuleSurfaceScope of(BuildContext context) {
final scope = context
.dependOnInheritedWidgetOfExactType<ModuleSurfaceScope>();
assert(
scope != null,
'No ModuleSurfaceScope found. Surface modules must be hosted under one '
'so they know which configuration to follow.',
);
return scope!;
}
/// The surface modules below [context] belong to.
static ModuleSurface surfaceOf(BuildContext context) => of(context).surface;
@override
bool updateShouldNotify(ModuleSurfaceScope oldWidget) =>
surface != oldWidget.surface ||
pinnedHeaderBackgroundColor != oldWidget.pinnedHeaderBackgroundColor;
}
@@ -0,0 +1,200 @@
/*
* 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/features/geckoview/features/search/domain/providers/search_module_order.dart';
import 'package:weblibre/features/geckoview/features/search/domain/providers/search_modules_view.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/empty_state/containers_section.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/empty_state/frequent_bangs_section.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/empty_state/history_highlights_section.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/empty_state/quick_actions_section.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/empty_state/quote_section.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/empty_state/recent_feed_articles_section.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/empty_state/recent_history_section.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/empty_state/recent_searches_section.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/empty_state/recent_tabs_section.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/empty_state/top_sites_section.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_module_reorder_view.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
import 'package:weblibre/features/web_feed/data/models/feed_article.dart';
/// How a host opens the things its modules surface.
///
/// The two hosts reach the same content by different routes: the search screen
/// may be editing an existing tab, has a bottom sheet to dismiss and has to
/// navigate back to the browser; the browser home is already there and only
/// needs to select. Keeping that in the host rather than in each module is what
/// lets both share one set of section widgets.
class ModuleSurfaceCallbacks {
final void Function(Uri uri) onUriSelected;
final void Function(String tabId) onTabSelected;
final void Function(FeedArticle article) onArticleSelected;
final void Function(ContainerDataWithCount container) onContainerSelected;
/// Present only on surfaces that own a live text field. Modules that write
/// into the query box are not offered where these are null.
final TextEditingController? searchTextController;
final Future<void> Function(String query)? submitSearch;
/// Present only on [ModuleSurface.home], which is embedded in the browser
/// shell and can act on it.
final VoidCallback? onNewTab;
final VoidCallback? onViewTabs;
final VoidCallback? onResumeLastTab;
const ModuleSurfaceCallbacks({
required this.onUriSelected,
required this.onTabSelected,
required this.onArticleSelected,
required this.onContainerSelected,
this.searchTextController,
this.submitSearch,
this.onNewTab,
this.onViewTabs,
this.onResumeLastTab,
});
}
/// Builders rather than widgets: a module that is switched off is never
/// constructed, so it never subscribes to its providers and never queries the
/// database. Building the widgets eagerly would make hidden modules cost the
/// same as visible ones.
Map<SearchModuleType, Widget Function()> buildSurfaceModuleBuilders({
required ModuleSurface surface,
required ModuleSurfaceCallbacks callbacks,
}) {
return {
if (callbacks.searchTextController != null &&
callbacks.submitSearch != null)
SearchModuleType.recentSearches: () => RecentSearchesSection(
searchTextController: callbacks.searchTextController!,
submitSearch: callbacks.submitSearch!,
),
SearchModuleType.frequentBangs: () => const FrequentBangsSection(),
SearchModuleType.topSites: () =>
TopSitesSection(onUriSelected: callbacks.onUriSelected),
SearchModuleType.recentArticles: () => RecentFeedArticlesSection(
onArticleSelected: callbacks.onArticleSelected,
),
SearchModuleType.recentTabs: () =>
RecentTabsSection(onTabSelected: callbacks.onTabSelected),
SearchModuleType.recentHistory: () =>
RecentHistorySection(onUriSelected: callbacks.onUriSelected),
SearchModuleType.historyHighlights: () =>
HistoryHighlightsSection(onUriSelected: callbacks.onUriSelected),
SearchModuleType.containers: () =>
ContainersSection(onContainerSelected: callbacks.onContainerSelected),
SearchModuleType.quote: () => const QuoteSection(),
if (callbacks.onNewTab != null &&
callbacks.onViewTabs != null &&
callbacks.onResumeLastTab != null)
SearchModuleType.quickActions: () => QuickActionsSection(
onNewTab: callbacks.onNewTab!,
onViewTabs: callbacks.onViewTabs!,
onResumeLastTab: callbacks.onResumeLastTab!,
),
};
}
/// Renders [surface]'s modules in the user's saved order, followed by the entry
/// point into the customization UI.
///
/// While reorder mode targets this surface the module list is replaced by the
/// reorder view. The check is per-surface: home stays mounted underneath the
/// pushed search screen, and without it, starting a reorder on one would put
/// the other into reorder mode too.
class ModuleSurfaceSliverList extends ConsumerWidget {
final ModuleSurface surface;
final ModuleSurfaceCallbacks callbacks;
/// Lets a host suppress modules that do not apply to the current input, e.g.
/// hiding search providers once the text parses as a URL.
final bool Function(SearchModuleType type)? moduleFilter;
const ModuleSurfaceSliverList({
super.key,
required this.surface,
required this.callbacks,
this.moduleFilter,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
if (ref.watch(searchReorderModeProvider(surface))) {
return SearchModuleReorderView(surface: surface);
}
final order = ref.watch(searchModuleOrderProvider(surface));
final builders = buildSurfaceModuleBuilders(
surface: surface,
callbacks: callbacks,
);
return SliverMainAxisGroup(
slivers: [
for (final entry in order)
if (entry.visible &&
builders.containsKey(entry.type) &&
(moduleFilter?.call(entry.type) ?? true))
builders[entry.type]!(),
CustomizeSectionsButton(surface: surface),
],
);
}
}
/// Always-present entry into the reorder UI, so a surface whose modules are all
/// hidden or empty is still configurable.
class CustomizeSectionsButton extends ConsumerWidget {
final ModuleSurface surface;
const CustomizeSectionsButton({super.key, required this.surface});
@override
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
// Low emphasis on purpose: this is a settings affordance sitting at the end
// of the user's content, not an action the surface is asking for. It stays
// visible rather than moving into settings because the header long-press is
// the only other route to reorder mode, and nothing advertises it.
return SliverToBoxAdapter(
child: Center(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: TextButton.icon(
onPressed: () => ref
.read(searchReorderModeProvider(surface).notifier)
.activate(),
style: TextButton.styleFrom(
foregroundColor: colorScheme.onSurfaceVariant,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
icon: const Icon(Icons.tune, size: 18),
label: Text(
'Customize sections',
style: Theme.of(context).textTheme.labelLarge,
),
),
),
),
);
}
}
@@ -23,13 +23,13 @@ import 'package:weblibre/features/geckoview/features/search/domain/providers/sea
import 'package:weblibre/features/geckoview/features/search/domain/providers/search_modules_view.dart';
class SearchModuleReorderView extends ConsumerWidget {
final SearchModuleGroup group;
final ModuleSurface surface;
const SearchModuleReorderView({super.key, required this.group});
const SearchModuleReorderView({super.key, required this.surface});
@override
Widget build(BuildContext context, WidgetRef ref) {
final entries = ref.watch(searchModuleOrderProvider(group));
final entries = ref.watch(searchModuleOrderProvider(surface));
final colorScheme = Theme.of(context).colorScheme;
return SliverMainAxisGroup(
@@ -46,8 +46,9 @@ class SearchModuleReorderView extends ConsumerWidget {
),
),
TextButton(
onPressed: () =>
ref.read(searchReorderModeProvider.notifier).deactivate(),
onPressed: () => ref
.read(searchReorderModeProvider(surface).notifier)
.deactivate(),
child: const Text('Done'),
),
],
@@ -58,7 +59,7 @@ class SearchModuleReorderView extends ConsumerWidget {
itemCount: entries.length,
onReorderItem: (oldIndex, newIndex) {
ref
.read(searchModuleOrderProvider(group).notifier)
.read(searchModuleOrderProvider(surface).notifier)
.reorder(oldIndex, newIndex);
},
itemBuilder: (context, index) {
@@ -75,7 +76,7 @@ class SearchModuleReorderView extends ConsumerWidget {
: colorScheme.onSurfaceVariant,
),
onPressed: () => ref
.read(searchModuleOrderProvider(group).notifier)
.read(searchModuleOrderProvider(surface).notifier)
.toggleVisibility(entry.type),
),
title: Text(
@@ -77,11 +77,15 @@ class SearchModuleHeader extends StatelessWidget {
onLongPress: onLongPress,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
// Tighter when collapsed: a run of collapsed sections is
// otherwise a stack of full-height bands with nothing in them.
padding: EdgeInsets.symmetric(
vertical: isCollapsed ? 4.0 : 8.0,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(width: 8),
const SizedBox(width: 4),
AnimatedRotation(
turns: isCollapsed ? -0.25 : 0,
duration: disableAnimations
@@ -96,7 +100,11 @@ class SearchModuleHeader extends StatelessWidget {
const SizedBox(width: 8),
Text(
title.toUpperCase(),
style: Theme.of(context).textTheme.labelSmall,
style: Theme.of(context).textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w700,
letterSpacing: 0.8,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
@@ -105,39 +113,22 @@ class SearchModuleHeader extends StatelessWidget {
),
if (headerTrailing != null) headerTrailing!,
if (showTrailing)
// Borderless: an outlined pill next to an 11px label reads as the
// most important thing in the row, which it is not.
TextButton(
onPressed: onToggleExpansion,
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(999),
side: BorderSide(
color: Theme.of(context).colorScheme.outline,
width: 0.5,
),
),
foregroundColor: Theme.of(context).colorScheme.primary,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
isExpanded ? 'Show less' : 'Show all $totalCount',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(width: 4),
Icon(
isExpanded ? Icons.expand_less : Icons.expand_more,
size: 16,
color: Theme.of(context).colorScheme.primary,
),
],
child: Text(
isExpanded ? 'Show less' : 'Show all $totalCount',
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
),
],
@@ -22,6 +22,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:sliver_tools/sliver_tools.dart';
import 'package:weblibre/features/geckoview/features/search/domain/providers/search_module_order.dart';
import 'package:weblibre/features/geckoview/features/search/domain/providers/search_modules_view.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/module_surface_scope.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/search_module_header.dart';
const previewItemsPerModule = 3;
@@ -75,6 +76,11 @@ class SearchModuleSection extends ConsumerWidget {
})
contentSliverBuilder;
/// Overrides the surface this section configures itself from. Normally left
/// null so it is inherited from the enclosing [ModuleSurfaceScope]; set it in
/// tests that render a section without a host.
final ModuleSurface? surface;
const SearchModuleSection({
super.key,
required this.title,
@@ -85,11 +91,15 @@ class SearchModuleSection extends ConsumerWidget {
this.previewLimit = previewItemsPerModule,
this.hideWhenEmpty = false,
this.showPagination = true,
this.surface,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final moduleOrder = ref.watch(searchModuleOrderProvider(moduleType.group));
final scope = this.surface == null ? ModuleSurfaceScope.of(context) : null;
final surface = this.surface ?? scope!.surface;
final moduleOrder = ref.watch(searchModuleOrderProvider(surface));
final isVisible = moduleOrder.any((e) => e.type == moduleType && e.visible);
if (!isVisible) {
return MultiSliver(children: const []);
@@ -100,7 +110,7 @@ class SearchModuleSection extends ConsumerWidget {
}
final displayState = ref.watch(
searchModuleDisplayStateControllerProvider(moduleType),
searchModuleDisplayStateControllerProvider(surface, moduleType),
);
final isCollapsed = displayState == SearchModuleDisplayState.collapsed;
@@ -112,40 +122,52 @@ class SearchModuleSection extends ConsumerWidget {
? 0
: (showAllItems ? totalCount : previewLimit);
// A section rendered without a host (tests) behaves like the search
// screen, which is the surface that has one.
final pinnedBackground = scope == null
? Theme.of(context).canvasColor
: scope.pinnedHeaderBackgroundColor;
final header = SearchModuleHeader(
title: title,
totalCount: totalCount,
displayState: displayState,
headerTrailing: isCollapsed ? null : headerTrailing,
previewLimit: previewLimit,
showPagination: showPagination,
onToggleCollapse: () => ref
.read(
searchModuleDisplayStateControllerProvider(
surface,
moduleType,
).notifier,
)
.toggleCollapse(),
onToggleExpansion: () => ref
.read(
searchModuleDisplayStateControllerProvider(
surface,
moduleType,
).notifier,
)
.toggleExpansion(),
onLongPress: () =>
ref.read(searchReorderModeProvider(surface).notifier).activate(),
);
return MultiSliver(
pushPinnedChildren: true,
pushPinnedChildren: pinnedBackground != null,
children: [
const SliverToBoxAdapter(child: Divider()),
SliverPinnedHeader(
child: ColoredBox(
color: Theme.of(context).canvasColor,
child: SearchModuleHeader(
title: title,
totalCount: totalCount,
displayState: displayState,
headerTrailing: isCollapsed ? null : headerTrailing,
previewLimit: previewLimit,
showPagination: showPagination,
onToggleCollapse: () => ref
.read(
searchModuleDisplayStateControllerProvider(
moduleType,
).notifier,
)
.toggleCollapse(),
onToggleExpansion: () => ref
.read(
searchModuleDisplayStateControllerProvider(
moduleType,
).notifier,
)
.toggleExpansion(),
onLongPress: () => ref
.read(searchReorderModeProvider.notifier)
.activate(moduleType.group),
),
),
),
// Sections are separated by space rather than a rule. A divider drawn
// directly above a header that carries its own backdrop produces two
// edges where the eye expects one.
const SliverToBoxAdapter(child: SizedBox(height: 8)),
if (pinnedBackground != null)
SliverPinnedHeader(
child: ColoredBox(color: pinnedBackground, child: header),
)
else
SliverToBoxAdapter(child: header),
...contentSliverBuilder(
isCollapsed: isCollapsed,
visibleCount: visibleCount,
@@ -112,17 +112,34 @@ class TabDao extends DatabaseAccessor<TabDatabase> with $TabDaoMixin {
return query.map((row) => row.read(db.tab.id)!);
}
Selectable<TabData> getTabsFifo({int limit = 25}) {
return select(db.tab)
/// Most recently used tabs first.
///
/// [excludedTabIds] skips tabs that are on their way out: tab rows are only
/// deleted after the next selection has been made, so a tab being closed is
/// still present here — and, having just been active, sorts first.
Selectable<TabData> getTabsFifo({
int limit = 25,
Set<String> excludedTabIds = const {},
}) {
final query = select(db.tab)
..limit(limit)
..orderBy([(t) => OrderingTerm.desc(t.timestamp)]);
if (excludedTabIds.isNotEmpty) {
query.where((t) => t.id.isNotIn(excludedTabIds));
}
return query;
}
/// As [getTabsFifo], restricted to one container. A null [containerId] is the
/// unassigned container, not "any container".
Selectable<TabData> getContainerTabsFifo(
String? containerId, {
int limit = 25,
Set<String> excludedTabIds = const {},
}) {
return select(db.tab)
final query = select(db.tab)
..where(
(t) => containerId != null
? t.containerId.equals(containerId)
@@ -130,6 +147,12 @@ class TabDao extends DatabaseAccessor<TabDatabase> with $TabDaoMixin {
)
..limit(limit)
..orderBy([(t) => OrderingTerm.desc(t.timestamp)]);
if (excludedTabIds.isNotEmpty) {
query.where((t) => t.id.isNotIn(excludedTabIds));
}
return query;
}
SingleOrNullSelectable<String?> getTabContainerId(String tabId) {
@@ -172,10 +172,31 @@ Stream<ContainerData?> selectedContainerData(Ref ref) {
return Stream.value(null);
}
/// Forces the home surface on regardless of what is selected.
///
/// The home-target setting needs a way to say "stay on home" that survives the
/// engine auto-selecting a tab underneath — for instance when the last tab in a
/// container is closed. Keeping it as a separate flag leaves
/// [shouldShowBrowserHome] a pure predicate, and avoids pinning the selected
/// container, which [SelectedContainer]'s own tab listener would immediately
/// undo.
///
/// Cleared by [TabRepository.selectTab] and by creating a tab, i.e. by the user
/// deliberately going somewhere.
@Riverpod(keepAlive: true)
class ForceBrowserHome extends _$ForceBrowserHome {
void request() => state = true;
void clear() => state = false;
@override
bool build() => false;
}
/// Whether the browser home screen should be displayed instead of the
/// active tab's content.
///
/// Returns `true` when any of the following hold:
/// 0. [ForceBrowserHome] is set, i.e. the home target asked to stay here.
/// 1. No tab is selected at all (app just started or all tabs closed).
/// 2. The selected tab belongs to a different container than the currently
/// selected container this implies the user manually switched
@@ -187,6 +208,8 @@ Stream<ContainerData?> selectedContainerData(Ref ref) {
/// (if any) necessarily belongs to a different container.
@Riverpod()
bool shouldShowBrowserHome(Ref ref) {
if (ref.watch(forceBrowserHomeProvider)) return true;
final selectedTab = ref.watch(selectedTabProvider);
// No tab selected → always show home.
@@ -101,10 +101,109 @@ final class SelectedContainerDataProvider
String _$selectedContainerDataHash() =>
r'1ec86a82e1fc4823a867285f05036c903633a165';
/// Forces the home surface on regardless of what is selected.
///
/// The home-target setting needs a way to say "stay on home" that survives the
/// engine auto-selecting a tab underneath — for instance when the last tab in a
/// container is closed. Keeping it as a separate flag leaves
/// [shouldShowBrowserHome] a pure predicate, and avoids pinning the selected
/// container, which [SelectedContainer]'s own tab listener would immediately
/// undo.
///
/// Cleared by [TabRepository.selectTab] and by creating a tab, i.e. by the user
/// deliberately going somewhere.
@ProviderFor(ForceBrowserHome)
final forceBrowserHomeProvider = ForceBrowserHomeProvider._();
/// Forces the home surface on regardless of what is selected.
///
/// The home-target setting needs a way to say "stay on home" that survives the
/// engine auto-selecting a tab underneath — for instance when the last tab in a
/// container is closed. Keeping it as a separate flag leaves
/// [shouldShowBrowserHome] a pure predicate, and avoids pinning the selected
/// container, which [SelectedContainer]'s own tab listener would immediately
/// undo.
///
/// Cleared by [TabRepository.selectTab] and by creating a tab, i.e. by the user
/// deliberately going somewhere.
final class ForceBrowserHomeProvider
extends $NotifierProvider<ForceBrowserHome, bool> {
/// Forces the home surface on regardless of what is selected.
///
/// The home-target setting needs a way to say "stay on home" that survives the
/// engine auto-selecting a tab underneath — for instance when the last tab in a
/// container is closed. Keeping it as a separate flag leaves
/// [shouldShowBrowserHome] a pure predicate, and avoids pinning the selected
/// container, which [SelectedContainer]'s own tab listener would immediately
/// undo.
///
/// Cleared by [TabRepository.selectTab] and by creating a tab, i.e. by the user
/// deliberately going somewhere.
ForceBrowserHomeProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'forceBrowserHomeProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$forceBrowserHomeHash();
@$internal
@override
ForceBrowserHome create() => ForceBrowserHome();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(bool value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<bool>(value),
);
}
}
String _$forceBrowserHomeHash() => r'345e17f502b0c438117a0fe9d3f22c929a02c5db';
/// Forces the home surface on regardless of what is selected.
///
/// The home-target setting needs a way to say "stay on home" that survives the
/// engine auto-selecting a tab underneath — for instance when the last tab in a
/// container is closed. Keeping it as a separate flag leaves
/// [shouldShowBrowserHome] a pure predicate, and avoids pinning the selected
/// container, which [SelectedContainer]'s own tab listener would immediately
/// undo.
///
/// Cleared by [TabRepository.selectTab] and by creating a tab, i.e. by the user
/// deliberately going somewhere.
abstract class _$ForceBrowserHome extends $Notifier<bool> {
bool build();
@$mustCallSuper
@override
WhenComplete runBuild() {
final ref = this.ref as $Ref<bool, bool>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<bool, bool>,
bool,
Object?,
Object?
>;
return element.handleCreate(ref, build);
}
}
/// Whether the browser home screen should be displayed instead of the
/// active tab's content.
///
/// Returns `true` when any of the following hold:
/// 0. [ForceBrowserHome] is set, i.e. the home target asked to stay here.
/// 1. No tab is selected at all (app just started or all tabs closed).
/// 2. The selected tab belongs to a different container than the currently
/// selected container this implies the user manually switched
@@ -122,6 +221,7 @@ final shouldShowBrowserHomeProvider = ShouldShowBrowserHomeProvider._();
/// active tab's content.
///
/// Returns `true` when any of the following hold:
/// 0. [ForceBrowserHome] is set, i.e. the home target asked to stay here.
/// 1. No tab is selected at all (app just started or all tabs closed).
/// 2. The selected tab belongs to a different container than the currently
/// selected container this implies the user manually switched
@@ -139,6 +239,7 @@ final class ShouldShowBrowserHomeProvider
/// active tab's content.
///
/// Returns `true` when any of the following hold:
/// 0. [ForceBrowserHome] is set, i.e. the home target asked to stay here.
/// 1. No tab is selected at all (app just started or all tabs closed).
/// 2. The selected tab belongs to a different container than the currently
/// selected container this implies the user manually switched
@@ -182,7 +283,7 @@ final class ShouldShowBrowserHomeProvider
}
String _$shouldShowBrowserHomeHash() =>
r'644344c9abe06e0273dad584e75a53dc417781ad';
r'a2f6c3acac8a640b3a4d9a9468a729802fb6c4f5';
@ProviderFor(selectedContainerTabCount)
final selectedContainerTabCountProvider = SelectedContainerTabCountProvider._();
@@ -51,4 +51,31 @@ class HiddenTopSiteDao extends DatabaseAccessor<TopSiteDatabase>
..where((t) => t.url.equalsValue(url.normalized)))
.go();
}
Future<Set<String>> getHiddenHosts() async {
final rows = await db.hiddenTopSiteHost.select().get();
return rows.map((r) => r.host).toSet();
}
Stream<Set<String>> watchHiddenHosts() {
return db.hiddenTopSiteHost.select().watch().map(
(rows) => rows.map((r) => r.host).toSet(),
);
}
Future<void> hideHost(String host) {
if (host.isEmpty) {
return Future.value();
}
return db.hiddenTopSiteHost.insertOne(
HiddenTopSiteHostCompanion.insert(host: host),
mode: InsertMode.insertOrIgnore,
);
}
Future<void> unhideHost(String host) {
return (db.hiddenTopSiteHost.delete()..where((t) => t.host.equals(host)))
.go();
}
}
@@ -18,11 +18,13 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:drift/drift.dart';
import 'package:drift/internal/versioned_schema.dart';
import 'package:drift_dev/api/migrations_native.dart';
import 'package:flutter/foundation.dart';
import 'package:weblibre/features/geckoview/features/top_sites/data/database/daos/hidden_top_site.dart';
import 'package:weblibre/features/geckoview/features/top_sites/data/database/daos/top_site.dart';
import 'package:weblibre/features/geckoview/features/top_sites/data/database/database.drift.dart';
import 'package:weblibre/features/geckoview/features/top_sites/data/database/database.steps.dart';
@DriftDatabase(
include: {'definitions.drift'},
@@ -30,7 +32,7 @@ import 'package:weblibre/features/geckoview/features/top_sites/data/database/dat
)
class TopSiteDatabase extends $TopSiteDatabase {
@override
final int schemaVersion = 1;
final int schemaVersion = 2;
@override
MigrationStrategy get migration => MigrationStrategy(
@@ -41,7 +43,38 @@ class TopSiteDatabase extends $TopSiteDatabase {
await customStatement('PRAGMA foreign_keys = ON;');
},
onUpgrade: (m, from, to) async {
// Following the advice from https://drift.simonbinder.eu/Migrations/api/#general-tips
await customStatement('PRAGMA foreign_keys = OFF');
await transaction(
() => VersionedSchema.runMigrationSteps(
migrator: m,
from: from,
to: to,
steps: _upgrade,
),
);
if (kDebugMode) {
final wrongForeignKeys = await customSelect(
'PRAGMA foreign_key_check',
).get();
assert(
wrongForeignKeys.isEmpty,
'${wrongForeignKeys.map((e) => e.data)}',
);
}
await customStatement('PRAGMA foreign_keys = ON');
},
);
TopSiteDatabase(super.e);
static final _upgrade = migrationSteps(
from1To2: (m, schema) async {
await m.createTable(schema.hiddenTopSiteHost);
},
);
}
@@ -17,6 +17,9 @@ abstract class $TopSiteDatabase extends i0.GeneratedDatabase {
$TopSiteDatabaseManager get managers => $TopSiteDatabaseManager(this);
late final i1.TopSite topSite = i1.TopSite(this);
late final i1.HiddenTopSite hiddenTopSite = i1.HiddenTopSite(this);
late final i1.HiddenTopSiteHost hiddenTopSiteHost = i1.HiddenTopSiteHost(
this,
);
late final i2.TopSiteDao topSiteDao = i2.TopSiteDao(
this as i3.TopSiteDatabase,
);
@@ -34,6 +37,7 @@ abstract class $TopSiteDatabase extends i0.GeneratedDatabase {
topSite,
i1.idxTopSiteOrderKey,
hiddenTopSite,
hiddenTopSiteHost,
];
}
@@ -44,6 +48,8 @@ class $TopSiteDatabaseManager {
i1.$TopSiteTableManager(_db, _db.topSite);
i1.$HiddenTopSiteTableManager get hiddenTopSite =>
i1.$HiddenTopSiteTableManager(_db, _db.hiddenTopSite);
i1.$HiddenTopSiteHostTableManager get hiddenTopSiteHost =>
i1.$HiddenTopSiteHostTableManager(_db, _db.hiddenTopSiteHost);
}
extension DefineFunctions on i6.CommonDatabase {
@@ -0,0 +1,177 @@
// dart format width=80
import 'package:drift/internal/versioned_schema.dart' as i0;
import 'package:drift/drift.dart' as i1;
import 'package:drift/drift.dart'; // GENERATED BY drift_dev, DO NOT MODIFY.
// ignore_for_file: type=lint,unused_import
//
final class Schema2 extends i0.VersionedSchema {
Schema2({required super.database}) : super(version: 2);
@override
late final List<i1.DatabaseSchemaEntity> entities = [
topSite,
idxTopSiteOrderKey,
hiddenTopSite,
hiddenTopSiteHost,
];
late final Shape0 topSite = Shape0(
source: i0.VersionedTable(
entityName: 'top_site',
withoutRowId: false,
isStrict: false,
tableConstraints: ['UNIQUE(url)'],
columns: [
_column_0,
_column_1,
_column_2,
_column_3,
_column_4,
_column_5,
],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxTopSiteOrderKey = i1.Index(
'idx_top_site_order_key',
'CREATE INDEX idx_top_site_order_key ON top_site (order_key)',
);
late final Shape1 hiddenTopSite = Shape1(
source: i0.VersionedTable(
entityName: 'hidden_top_site',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [_column_6],
attachedDatabase: database,
),
alias: null,
);
late final Shape2 hiddenTopSiteHost = Shape2(
source: i0.VersionedTable(
entityName: 'hidden_top_site_host',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [_column_7],
attachedDatabase: database,
),
alias: null,
);
}
class Shape0 extends i0.VersionedTable {
Shape0({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<String> get id =>
columnsByName['id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get title =>
columnsByName['title']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get url =>
columnsByName['url']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get source =>
columnsByName['source']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get orderKey =>
columnsByName['order_key']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get createdAt =>
columnsByName['created_at']! as i1.GeneratedColumn<int>;
}
i1.GeneratedColumn<String> _column_0(String aliasedName) =>
i1.GeneratedColumn<String>(
'id',
aliasedName,
false,
type: i1.DriftSqlType.string,
$customConstraints: 'PRIMARY KEY NOT NULL',
);
i1.GeneratedColumn<String> _column_1(String aliasedName) =>
i1.GeneratedColumn<String>(
'title',
aliasedName,
false,
type: i1.DriftSqlType.string,
$customConstraints: 'NOT NULL',
);
i1.GeneratedColumn<String> _column_2(String aliasedName) =>
i1.GeneratedColumn<String>(
'url',
aliasedName,
false,
type: i1.DriftSqlType.string,
$customConstraints: 'NOT NULL',
);
i1.GeneratedColumn<int> _column_3(String aliasedName) =>
i1.GeneratedColumn<int>(
'source',
aliasedName,
false,
type: i1.DriftSqlType.int,
$customConstraints: 'NOT NULL',
);
i1.GeneratedColumn<String> _column_4(String aliasedName) =>
i1.GeneratedColumn<String>(
'order_key',
aliasedName,
false,
type: i1.DriftSqlType.string,
$customConstraints: 'NOT NULL',
);
i1.GeneratedColumn<int> _column_5(String aliasedName) =>
i1.GeneratedColumn<int>(
'created_at',
aliasedName,
false,
type: i1.DriftSqlType.int,
$customConstraints: 'NOT NULL',
);
class Shape1 extends i0.VersionedTable {
Shape1({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<String> get url =>
columnsByName['url']! as i1.GeneratedColumn<String>;
}
i1.GeneratedColumn<String> _column_6(String aliasedName) =>
i1.GeneratedColumn<String>(
'url',
aliasedName,
false,
type: i1.DriftSqlType.string,
$customConstraints: 'PRIMARY KEY NOT NULL',
);
class Shape2 extends i0.VersionedTable {
Shape2({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<String> get host =>
columnsByName['host']! as i1.GeneratedColumn<String>;
}
i1.GeneratedColumn<String> _column_7(String aliasedName) =>
i1.GeneratedColumn<String>(
'host',
aliasedName,
false,
type: i1.DriftSqlType.string,
$customConstraints: 'PRIMARY KEY NOT NULL',
);
i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
}) {
return (currentVersion, database) async {
switch (currentVersion) {
case 1:
final schema = Schema2(database: database);
final migrator = i1.Migrator(database, schema);
await from1To2(migrator, schema);
return 2;
default:
throw ArgumentError.value('Unknown migration from $currentVersion');
}
};
}
i1.OnUpgrade stepByStep({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
}) => i0.VersionedSchema.stepByStepHelper(
step: migrationSteps(from1To2: from1To2),
);
@@ -17,6 +17,15 @@ CREATE TABLE hidden_top_site (
url TEXT PRIMARY KEY NOT NULL MAPPED BY `const UriConverter()`
);
-- Domain-wide suppression, kept separate from hidden_top_site because the two
-- mean different things: hidden_top_site suppresses one exact URL (which the
-- edit flow relies on to stop an edited default reappearing), while this hides
-- every frecency result on a host. A single PWA can otherwise flood the grid
-- with dozens of distinct URLs that each need hiding individually.
CREATE TABLE hidden_top_site_host (
host TEXT PRIMARY KEY NOT NULL
);
leadingOrderKey(:bucket AS INTEGER):
SELECT lexo_rank_previous(
:bucket,
@@ -358,6 +358,137 @@ typedef $HiddenTopSiteProcessedTableManager =
i1.HiddenTopSiteData,
i0.PrefetchHooks Function()
>;
typedef $HiddenTopSiteHostCreateCompanionBuilder =
i1.HiddenTopSiteHostCompanion Function({
required String host,
i0.Value<int> rowid,
});
typedef $HiddenTopSiteHostUpdateCompanionBuilder =
i1.HiddenTopSiteHostCompanion Function({
i0.Value<String> host,
i0.Value<int> rowid,
});
class $HiddenTopSiteHostFilterComposer
extends i0.Composer<i0.GeneratedDatabase, i1.HiddenTopSiteHost> {
$HiddenTopSiteHostFilterComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
i0.ColumnFilters<String> get host => $composableBuilder(
column: $table.host,
builder: (column) => i0.ColumnFilters(column),
);
}
class $HiddenTopSiteHostOrderingComposer
extends i0.Composer<i0.GeneratedDatabase, i1.HiddenTopSiteHost> {
$HiddenTopSiteHostOrderingComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
i0.ColumnOrderings<String> get host => $composableBuilder(
column: $table.host,
builder: (column) => i0.ColumnOrderings(column),
);
}
class $HiddenTopSiteHostAnnotationComposer
extends i0.Composer<i0.GeneratedDatabase, i1.HiddenTopSiteHost> {
$HiddenTopSiteHostAnnotationComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
i0.GeneratedColumn<String> get host =>
$composableBuilder(column: $table.host, builder: (column) => column);
}
class $HiddenTopSiteHostTableManager
extends
i0.RootTableManager<
i0.GeneratedDatabase,
i1.HiddenTopSiteHost,
i1.HiddenTopSiteHostData,
i1.$HiddenTopSiteHostFilterComposer,
i1.$HiddenTopSiteHostOrderingComposer,
i1.$HiddenTopSiteHostAnnotationComposer,
$HiddenTopSiteHostCreateCompanionBuilder,
$HiddenTopSiteHostUpdateCompanionBuilder,
(
i1.HiddenTopSiteHostData,
i0.BaseReferences<
i0.GeneratedDatabase,
i1.HiddenTopSiteHost,
i1.HiddenTopSiteHostData
>,
),
i1.HiddenTopSiteHostData,
i0.PrefetchHooks Function()
> {
$HiddenTopSiteHostTableManager(
i0.GeneratedDatabase db,
i1.HiddenTopSiteHost table,
) : super(
i0.TableManagerState(
db: db,
table: table,
createFilteringComposer: () =>
i1.$HiddenTopSiteHostFilterComposer($db: db, $table: table),
createOrderingComposer: () =>
i1.$HiddenTopSiteHostOrderingComposer($db: db, $table: table),
createComputedFieldComposer: () =>
i1.$HiddenTopSiteHostAnnotationComposer($db: db, $table: table),
updateCompanionCallback:
({
i0.Value<String> host = const i0.Value.absent(),
i0.Value<int> rowid = const i0.Value.absent(),
}) => i1.HiddenTopSiteHostCompanion(host: host, rowid: rowid),
createCompanionCallback:
({
required String host,
i0.Value<int> rowid = const i0.Value.absent(),
}) => i1.HiddenTopSiteHostCompanion.insert(
host: host,
rowid: rowid,
),
withReferenceMapper: (p0) => p0
.map((e) => (e.readTable(table), i0.BaseReferences(db, table, e)))
.toList(),
prefetchHooksCallback: null,
),
);
}
typedef $HiddenTopSiteHostProcessedTableManager =
i0.ProcessedTableManager<
i0.GeneratedDatabase,
i1.HiddenTopSiteHost,
i1.HiddenTopSiteHostData,
i1.$HiddenTopSiteHostFilterComposer,
i1.$HiddenTopSiteHostOrderingComposer,
i1.$HiddenTopSiteHostAnnotationComposer,
$HiddenTopSiteHostCreateCompanionBuilder,
$HiddenTopSiteHostUpdateCompanionBuilder,
(
i1.HiddenTopSiteHostData,
i0.BaseReferences<
i0.GeneratedDatabase,
i1.HiddenTopSiteHost,
i1.HiddenTopSiteHostData
>,
),
i1.HiddenTopSiteHostData,
i0.PrefetchHooks Function()
>;
class TopSite extends i0.Table with i0.TableInfo<TopSite, i1.TopSiteData> {
@override
@@ -878,6 +1009,156 @@ class HiddenTopSiteCompanion extends i0.UpdateCompanion<i1.HiddenTopSiteData> {
}
}
class HiddenTopSiteHost extends i0.Table
with i0.TableInfo<HiddenTopSiteHost, i1.HiddenTopSiteHostData> {
@override
final i0.GeneratedDatabase attachedDatabase;
final String? _alias;
HiddenTopSiteHost(this.attachedDatabase, [this._alias]);
late final i0.GeneratedColumn<String> host = i0.GeneratedColumn<String>(
'host',
aliasedName,
false,
type: i0.DriftSqlType.string,
requiredDuringInsert: true,
$customConstraints: 'PRIMARY KEY NOT NULL',
);
@override
List<i0.GeneratedColumn> get $columns => [host];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'hidden_top_site_host';
@override
Set<i0.GeneratedColumn> get $primaryKey => {host};
@override
i1.HiddenTopSiteHostData map(
Map<String, dynamic> data, {
String? tablePrefix,
}) {
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
return i1.HiddenTopSiteHostData(
host: attachedDatabase.typeMapping.read(
i0.DriftSqlType.string,
data['${effectivePrefix}host'],
)!,
);
}
@override
HiddenTopSiteHost createAlias(String alias) {
return HiddenTopSiteHost(attachedDatabase, alias);
}
@override
bool get dontWriteConstraints => true;
}
class HiddenTopSiteHostData extends i0.DataClass
implements i0.Insertable<i1.HiddenTopSiteHostData> {
final String host;
const HiddenTopSiteHostData({required this.host});
@override
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
final map = <String, i0.Expression>{};
map['host'] = i0.Variable<String>(host);
return map;
}
factory HiddenTopSiteHostData.fromJson(
Map<String, dynamic> json, {
i0.ValueSerializer? serializer,
}) {
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
return HiddenTopSiteHostData(
host: serializer.fromJson<String>(json['host']),
);
}
@override
Map<String, dynamic> toJson({i0.ValueSerializer? serializer}) {
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{'host': serializer.toJson<String>(host)};
}
i1.HiddenTopSiteHostData copyWith({String? host}) =>
i1.HiddenTopSiteHostData(host: host ?? this.host);
HiddenTopSiteHostData copyWithCompanion(i1.HiddenTopSiteHostCompanion data) {
return HiddenTopSiteHostData(
host: data.host.present ? data.host.value : this.host,
);
}
@override
String toString() {
return (StringBuffer('HiddenTopSiteHostData(')
..write('host: $host')
..write(')'))
.toString();
}
@override
int get hashCode => host.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is i1.HiddenTopSiteHostData && other.host == this.host);
}
class HiddenTopSiteHostCompanion
extends i0.UpdateCompanion<i1.HiddenTopSiteHostData> {
final i0.Value<String> host;
final i0.Value<int> rowid;
const HiddenTopSiteHostCompanion({
this.host = const i0.Value.absent(),
this.rowid = const i0.Value.absent(),
});
HiddenTopSiteHostCompanion.insert({
required String host,
this.rowid = const i0.Value.absent(),
}) : host = i0.Value(host);
static i0.Insertable<i1.HiddenTopSiteHostData> custom({
i0.Expression<String>? host,
i0.Expression<int>? rowid,
}) {
return i0.RawValuesInsertable({
if (host != null) 'host': host,
if (rowid != null) 'rowid': rowid,
});
}
i1.HiddenTopSiteHostCompanion copyWith({
i0.Value<String>? host,
i0.Value<int>? rowid,
}) {
return i1.HiddenTopSiteHostCompanion(
host: host ?? this.host,
rowid: rowid ?? this.rowid,
);
}
@override
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
final map = <String, i0.Expression>{};
if (host.present) {
map['host'] = i0.Variable<String>(host.value);
}
if (rowid.present) {
map['rowid'] = i0.Variable<int>(rowid.value);
}
return map;
}
@override
String toString() {
return (StringBuffer('HiddenTopSiteHostCompanion(')
..write('host: $host, ')
..write('rowid: $rowid')
..write(')'))
.toString();
}
}
class DefinitionsDrift extends i4.ModularAccessor {
DefinitionsDrift(i0.GeneratedDatabase db) : super(db);
i0.Selectable<String> leadingOrderKey({required int bucket}) {
@@ -0,0 +1,47 @@
/*
* 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/>.
*/
/// Normalizes a URL's host for shortcut blacklist matching.
///
/// Lowercases, drops the port and strips a single leading `www.`, so hiding
/// `https://www.Example.com:443/x` also hides `http://example.com/y`.
///
/// Deliberately *not* registrable-domain (eTLD+1) matching: that needs the
/// public suffix list, and collapsing `foo.github.io` into `github.io` would
/// hide unrelated sites. Subdomains stay distinct — hiding `discord.com` does
/// not hide `app.discord.com`.
///
/// `Uri.host` is already empty for URLs without an authority (`about:blank`,
/// `data:`), which yields an empty string here and therefore never matches.
String canonicalTopSiteHost(Uri url) {
final host = url.host.toLowerCase();
if (host.isEmpty) {
return '';
}
const wwwPrefix = 'www.';
// Only strip when something remains, so a literal host of "www." is kept
// rather than collapsing to the empty string that matches nothing.
if (host.startsWith(wwwPrefix) && host.length > wwwPrefix.length) {
return host.substring(wwwPrefix.length);
}
return host;
}
@@ -18,7 +18,9 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:rxdart/rxdart.dart';
import 'package:weblibre/core/uuid.dart';
@@ -27,6 +29,7 @@ import 'package:weblibre/features/geckoview/features/history/domain/repositories
import 'package:weblibre/features/geckoview/features/top_sites/data/database/definitions.drift.dart';
import 'package:weblibre/features/geckoview/features/top_sites/data/entities/stored_top_site_source.dart';
import 'package:weblibre/features/geckoview/features/top_sites/data/providers.dart';
import 'package:weblibre/features/geckoview/features/top_sites/domain/entities/top_site_host.dart';
import 'package:weblibre/features/geckoview/features/top_sites/domain/entities/top_site_item.dart';
import 'package:weblibre/features/geckoview/features/top_sites/domain/entities/top_site_source.dart';
import 'package:weblibre/features/geckoview/features/top_sites/domain/providers.dart';
@@ -34,17 +37,56 @@ import 'package:weblibre/utils/uri_parser.dart' as uri_parser;
part 'top_site_repository.g.dart';
/// Turns frecency-ranked history rows into shortcut tiles, dropping anything
/// the user has hidden.
///
/// Pure and exported so the exclusion rules can be tested directly: this is
/// what decides whether "remove this shortcut" actually sticks.
List<TopSiteItem> filterFrecentTopSites({
required List<TopFrecentSiteInfo> sites,
required int limit,
required Set<String> excludeUrls,
required Set<String> excludeHosts,
}) {
final items = <TopSiteItem>[];
for (final site in sites) {
if (items.length >= limit) break;
final uri = Uri.tryParse(site.url);
if (uri == null) continue;
if (excludeUrls.contains(uri.normalized.toString())) continue;
if (excludeHosts.contains(canonicalTopSiteHost(uri))) continue;
final title = (site.title?.trim().isNotEmpty == true)
? site.title!.trim()
: uri.host;
items.add(
TopSiteItem(title: title, url: uri, source: TopSiteSource.history),
);
}
return items;
}
@Riverpod(keepAlive: true)
class TopSiteRepository extends _$TopSiteRepository {
Stream<List<TopSiteItem>> watchTopSites({int limit = 8}) {
final db = ref.read(topSiteDatabaseProvider);
return CombineLatestStream.combine2(
return CombineLatestStream.combine3(
db.topSiteDao.selectAllTopSites().watch(),
db.hiddenTopSiteDao.watchHiddenUrls(),
(List<TopSiteData> rows, Set<String> hiddenUrls) => (rows, hiddenUrls),
db.hiddenTopSiteDao.watchHiddenHosts(),
(
List<TopSiteData> rows,
Set<String> hiddenUrls,
Set<String> hiddenHosts,
) => (rows, hiddenUrls, hiddenHosts),
).asyncMap((record) async {
final (rows, hiddenUrls) = record;
final (rows, hiddenUrls, hiddenHosts) = record;
final persistedItems = rows.map(_mapRow).toList();
final persistedUrls = persistedItems
.map((s) => s.url.normalized.toString())
@@ -66,10 +108,15 @@ class TopSiteRepository extends _$TopSiteRepository {
final excludeUrls = {
...persistedUrls,
...defaultItems.map((s) => s.url.normalized.toString()),
// Hiding a site has to suppress it wherever it comes from. Without
// this, removing a frecency-ranked shortcut appeared to work and then
// the site came straight back on the next refresh.
...hiddenUrls,
};
final historyItems = await _getHistoryItems(
limit: remaining,
excludeUrls: excludeUrls,
excludeHosts: hiddenHosts,
);
return [...combined, ...historyItems];
@@ -80,6 +127,7 @@ class TopSiteRepository extends _$TopSiteRepository {
final db = ref.read(topSiteDatabaseProvider);
final rows = await db.topSiteDao.getAllTopSites();
final hiddenUrls = await db.hiddenTopSiteDao.getHiddenUrls();
final hiddenHosts = await db.hiddenTopSiteDao.getHiddenHosts();
final persistedItems = rows.map(_mapRow).toList();
final persistedUrls = persistedItems
@@ -102,10 +150,12 @@ class TopSiteRepository extends _$TopSiteRepository {
final excludeUrls = {
...persistedUrls,
...defaultItems.map((s) => s.url.normalized.toString()),
...hiddenUrls,
};
final historyItems = await _getHistoryItems(
limit: remaining,
excludeUrls: excludeUrls,
excludeHosts: hiddenHosts,
);
return [...combined, ...historyItems];
@@ -173,7 +223,12 @@ class TopSiteRepository extends _$TopSiteRepository {
_validateUrl(url);
final db = ref.read(topSiteDatabaseProvider);
// If it was a hidden default, unhide it
// If it was a hidden default, unhide it.
//
// Deliberately does not lift a domain-wide hide: pinned sites are returned
// ahead of the hidden filters anyway, so pinning one URL already works on a
// blacklisted host — and un-hiding the host here would silently restore
// every *other* page on that domain the user had just got rid of.
await db.hiddenTopSiteDao.unhideUrl(url);
// Check if URL already exists
@@ -219,8 +274,29 @@ class TopSiteRepository extends _$TopSiteRepository {
return ref.read(topSiteDatabaseProvider).topSiteDao.deleteSite(id);
}
Future<void> hideDefaultSite(Uri url) {
return ref.read(topSiteDatabaseProvider).hiddenTopSiteDao.hideUrl(url);
/// Suppresses [url] so it stops coming back — from the bundled defaults, and
/// from frecency-ranked history.
///
/// With [wholeDomain] the whole host is hidden instead, which is the only
/// practical way to get rid of a site that generates many distinct URLs.
Future<void> hideSite(Uri url, {bool wholeDomain = false}) async {
final dao = ref.read(topSiteDatabaseProvider).hiddenTopSiteDao;
await dao.hideUrl(url);
if (wholeDomain) {
await dao.hideHost(canonicalTopSiteHost(url));
}
}
/// Reverses [hideSite]. Undo has to lift both suppressions, or the shortcut
/// silently fails to come back.
Future<void> unhideSite(Uri url, {bool wholeDomain = false}) async {
final dao = ref.read(topSiteDatabaseProvider).hiddenTopSiteDao;
await dao.unhideUrl(url);
if (wholeDomain) {
await dao.unhideHost(canonicalTopSiteHost(url));
}
}
Future<bool> isPinnedTopSiteUrl(Uri url) async {
@@ -318,30 +394,28 @@ class TopSiteRepository extends _$TopSiteRepository {
Future<List<TopSiteItem>> _getHistoryItems({
required int limit,
required Set<String> excludeUrls,
required Set<String> excludeHosts,
}) async {
final frecentSites = await ref
.read(historyRepositoryProvider.notifier)
.getTopFrecentSites(limit: limit + excludeUrls.length);
final items = <TopSiteItem>[];
for (final site in frecentSites) {
if (items.length >= limit) break;
final uri = Uri.tryParse(site.url);
if (uri == null) continue;
if (excludeUrls.contains(uri.normalized.toString())) continue;
final title = (site.title?.trim().isNotEmpty == true)
? site.title!.trim()
: uri.host;
items.add(
TopSiteItem(title: title, url: uri, source: TopSiteSource.history),
);
if (limit <= 0) {
return const [];
}
return items;
// Over-fetch, because filtering happens after the query. One exclusion can
// eliminate many rows — a PWA on a hidden host may own dozens of distinct
// URLs — so scaling by the exclusion count alone under-fetches and leaves
// the grid short. Capped so a large blacklist can't pull an unbounded read.
final fetchLimit = math.min(200, (limit + excludeUrls.length + 1) * 4);
final frecentSites = await ref
.read(historyRepositoryProvider.notifier)
.getTopFrecentSites(limit: fetchLimit);
return filterFrecentTopSites(
sites: frecentSites,
limit: limit,
excludeUrls: excludeUrls,
excludeHosts: excludeHosts,
);
}
TopSiteItem _mapRow(TopSiteData row) {
@@ -41,7 +41,7 @@ final class TopSiteRepositoryProvider
}
}
String _$topSiteRepositoryHash() => r'43c0495dfb3044dc9bb2f420524b45afb5735a0b';
String _$topSiteRepositoryHash() => r'3907d90d379190fe3fc3e8897b08c254642239cf';
abstract class _$TopSiteRepository extends $Notifier<void> {
void build();
@@ -0,0 +1,234 @@
/*
* 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 'dart:async';
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:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/entities/home_target.dart';
import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart';
import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart';
import 'package:weblibre/features/user/data/models/general_settings.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
import 'package:weblibre/utils/uri_parser.dart' as uri_parser;
const List<SettingsSectionDefinition> homeSettingsSections = [
SettingsSectionDefinition(
title: 'Startup',
keywords: ['startup', 'home', 'resume', 'last tab', 'custom url'],
entries: [
SettingsEntryDefinition(
title: 'When there is no tab to show',
subtitle: 'On startup, and after closing the last tab',
keywords: ['startup', 'resume', 'last tab', 'custom url', 'homepage'],
child: _HomeTargetTile(),
),
SettingsEntryDefinition(
title: 'Apply when the last tab closes',
subtitle: 'Otherwise a tab from another container is opened instead',
keywords: ['close', 'last tab', 'container'],
child: _HomeTargetOnLastTabClosedTile(),
),
],
),
SettingsSectionDefinition(
title: 'Layout',
keywords: ['home', 'new tab', 'sections', 'modules', 'layout'],
entries: [
SettingsEntryDefinition(
title: 'Customize home sections',
subtitle: 'Choose and order what the home page shows',
keywords: [
'home',
'sections',
'shortcuts',
'quote',
'quick actions',
'reorder',
],
child: _CustomizeHomeSectionsTile(),
),
SettingsEntryDefinition(
title: 'Customize new tab sections',
subtitle: 'Choose and order what the new tab page shows',
keywords: ['new tab', 'sections', 'shortcuts', 'reorder'],
child: _CustomizeNewTabSectionsTile(),
),
],
),
];
class HomeSettingsScreen extends StatelessWidget {
const HomeSettingsScreen({super.key});
@override
Widget build(BuildContext context) {
return const SettingsDetailScaffold(
title: 'Home & New Tab',
subtitle: 'What the home and new tab pages show',
icon: MdiIcons.homeOutline,
sections: homeSettingsSections,
);
}
}
class _HomeTargetTile extends HookConsumerWidget {
const _HomeTargetTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(generalSettingsWithDefaultsProvider);
Future<void> save(GeneralSettings Function(GeneralSettings) update) {
return ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(update);
}
final urlController = useTextEditingController(
text: settings.homeTargetUrl ?? '',
);
// Persist on focus loss as well as on submit. Settings screens have no
// save button, so a user who types an address and taps back would
// otherwise lose it silently.
Future<void> saveUrlIfChanged() async {
final text = urlController.text.trim();
if (text == (settings.homeTargetUrl ?? '')) return;
if (text.isNotEmpty && uri_parser.tryParseUrl(text) == null) return;
await save((s) => s.copyWith.homeTargetUrl(text));
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
RadioGroup<HomeTarget>(
groupValue: settings.homeTarget,
onChanged: (value) async {
if (value != null) {
await save((s) => s.copyWith.homeTarget(value));
}
},
child: Column(
children: [
for (final target in HomeTarget.values)
RadioListTile<HomeTarget>(
value: target,
title: Text(target.label),
subtitle: Text(target.description),
),
],
),
),
if (settings.homeTarget == HomeTarget.customUrl)
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Focus(
onFocusChange: (hasFocus) {
if (!hasFocus) unawaited(saveUrlIfChanged());
},
child: TextFormField(
controller: urlController,
autovalidateMode: AutovalidateMode.onUserInteraction,
keyboardType: TextInputType.url,
decoration: const InputDecoration(
labelText: 'Address',
hintText: 'https://example.com',
border: OutlineInputBorder(),
),
validator: (value) {
final text = value?.trim() ?? '';
if (text.isEmpty) {
return 'Enter an address, or the home page is shown instead';
}
if (uri_parser.tryParseUrl(text) == null) {
return 'Not a valid address';
}
return null;
},
onFieldSubmitted: (_) => unawaited(saveUrlIfChanged()),
),
),
),
],
);
}
}
class _HomeTargetOnLastTabClosedTile extends ConsumerWidget {
const _HomeTargetOnLastTabClosedTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final enabled = ref.watch(
generalSettingsWithDefaultsProvider.select(
(s) => s.homeTargetOnLastTabClosed,
),
);
return SwitchListTile.adaptive(
value: enabled,
title: const Text('Apply when the last tab closes'),
subtitle: const Text(
'Closing the last tab in a container stays there instead of opening a '
'tab from somewhere else',
),
secondary: const Icon(Icons.tab_unselected),
onChanged: (value) async {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save((s) => s.copyWith.homeTargetOnLastTabClosed(value));
},
);
}
}
class _CustomizeHomeSectionsTile extends ConsumerWidget {
const _CustomizeHomeSectionsTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
return ListTile(
leading: const Icon(MdiIcons.homeOutline),
title: const Text('Customize home sections'),
subtitle: const Text('Choose and order what the home page shows'),
trailing: const Icon(Icons.chevron_right),
onTap: () => const HomeModulesSettingsRoute().push(context),
);
}
}
class _CustomizeNewTabSectionsTile extends ConsumerWidget {
const _CustomizeNewTabSectionsTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
return ListTile(
leading: const Icon(MdiIcons.tabPlus),
title: const Text('Customize new tab sections'),
subtitle: const Text('Choose and order what the new tab page shows'),
trailing: const Icon(Icons.chevron_right),
onTap: () => const NewTabModulesSettingsRoute().push(context),
);
}
}
@@ -0,0 +1,118 @@
/*
* 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/features/geckoview/features/search/domain/providers/search_module_order.dart';
import 'package:weblibre/features/geckoview/features/search/domain/providers/search_modules_view.dart';
import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart';
/// Reorders and toggles the sections of one [ModuleSurface].
///
/// One screen serves every surface — the surface only decides which saved list
/// is edited — mirroring how `ContextualToolbarSettingsScreen` serves both
/// toolbars.
class ModuleSurfaceSettingsScreen extends HookConsumerWidget {
final ModuleSurface surface;
final String title;
const ModuleSurfaceSettingsScreen({
super.key,
this.surface = ModuleSurface.home,
this.title = 'Customize Home',
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final entries = ref.watch(searchModuleOrderProvider(surface));
final notifier = ref.read(searchModuleOrderProvider(surface).notifier);
final colorScheme = Theme.of(context).colorScheme;
return SettingsCustomScrollScaffold(
title: title,
actions: [
MenuAnchor(
menuChildren: [
MenuItemButton(
onPressed: notifier.resetToDefaults,
child: const Text('Reset to Defaults'),
),
],
builder: (context, controller, child) => IconButton(
icon: const Icon(Icons.more_vert),
onPressed: () =>
controller.isOpen ? controller.close() : controller.open(),
),
),
],
slivers: [
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Text(
'Drag to reorder. Switch a section off to hide it here without '
'affecting the other page.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
),
SliverReorderableList(
itemCount: entries.length,
onReorderItem: notifier.reorder,
itemBuilder: (context, index) {
final entry = entries[index];
return Material(
key: ValueKey(entry.type),
color: Colors.transparent,
child: ListTile(
title: Text(
entry.type.label,
style: TextStyle(
color: entry.visible ? null : colorScheme.onSurfaceVariant,
),
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Switch.adaptive(
value: entry.visible,
onChanged: (_) => notifier.toggleVisibility(entry.type),
),
const SizedBox(width: 8),
ReorderableDragStartListener(
index: index,
child: Icon(
Icons.drag_handle,
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
);
},
),
const SliverToBoxAdapter(child: SizedBox(height: 24)),
],
);
}
}
@@ -28,6 +28,7 @@ import 'package:weblibre/features/settings/presentation/screens/advanced_setting
import 'package:weblibre/features/settings/presentation/screens/browsing_settings.dart';
import 'package:weblibre/features/settings/presentation/screens/extensions_settings.dart';
import 'package:weblibre/features/settings/presentation/screens/general_settings.dart';
import 'package:weblibre/features/settings/presentation/screens/home_settings.dart';
import 'package:weblibre/features/settings/presentation/screens/privacy_security_settings.dart';
import 'package:weblibre/features/settings/presentation/screens/proxy_settings.dart';
import 'package:weblibre/features/settings/presentation/screens/search_settings.dart';
@@ -113,6 +114,22 @@ _CategoryGroups _buildCategories() {
sections: browsingSettingsSections,
onTap: (context) => BrowsingSettingsRoute().push(context),
),
_SettingsCategoryDefinition(
title: 'Home & New Tab',
subtitle: 'What the home and new tab pages show',
icon: MdiIcons.homeOutline,
keywords: const [
'home',
'new tab',
'start page',
'sections',
'shortcuts',
'top sites',
'quote',
],
sections: homeSettingsSections,
onTap: (context) => const HomeSettingsRoute().push(context),
),
_SettingsCategoryDefinition(
title: 'Gestures',
subtitle: 'Stroke gestures for browser actions',
@@ -28,6 +28,7 @@ import 'package:weblibre/features/app_links/domain/entities/app_link_rule.dart';
import 'package:weblibre/features/app_links/domain/entities/context_app_link_policy.dart';
import 'package:weblibre/features/bangs/data/models/bang_group.dart';
import 'package:weblibre/features/bangs/data/models/bang_key.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/entities/home_target.dart';
import 'package:weblibre/features/intent_gatekeeper/domain/entities/intent_source_policy.dart';
import 'package:weblibre/features/search/domain/entities/abstract/i_search_suggestion_provider.dart';
@@ -157,6 +158,18 @@ class GeneralSettings with FastEquatable {
/// be dismissed without a system back button or back gesture (e.g. on e-ink
/// devices). Defaults to false. Only shown when the route can be popped.
final bool showSearchCloseButton;
/// What to land on when there is no tab to show — at cold start, and when
/// the last tab in scope is closed if [homeTargetOnLastTabClosed] is set.
final HomeTarget homeTarget;
/// Address opened when [homeTarget] is [HomeTarget.customUrl]. An unset or
/// unparseable value falls back to the home surface.
final String? homeTargetUrl;
/// Also apply [homeTarget] when the last tab in the current container is
/// closed, instead of falling through to a tab from somewhere else.
final bool homeTargetOnLastTabClosed;
@JsonKey(name: 'defaultCreateTabType')
final TabType storedDefaultCreateTabType;
final TabDirection tabListDirection;
@@ -291,6 +304,9 @@ class GeneralSettings with FastEquatable {
required this.showContainerUi,
required this.showIsolatedTabUi,
required this.showSearchCloseButton,
required this.homeTarget,
required this.homeTargetUrl,
required this.homeTargetOnLastTabClosed,
required this.storedDefaultCreateTabType,
required this.tabListDirection,
required this.tabBarDirection,
@@ -364,6 +380,9 @@ class GeneralSettings with FastEquatable {
bool? showContainerUi,
bool? showIsolatedTabUi,
bool? showSearchCloseButton,
HomeTarget? homeTarget,
this.homeTargetUrl,
bool? homeTargetOnLastTabClosed,
TabType? storedDefaultCreateTabType,
TabDirection? tabListDirection,
TabDirection? tabBarDirection,
@@ -434,6 +453,10 @@ class GeneralSettings with FastEquatable {
showContainerUi = showContainerUi ?? true,
showIsolatedTabUi = showIsolatedTabUi ?? true,
showSearchCloseButton = showSearchCloseButton ?? false,
// Defaults to `home`, which is exactly what the browser did before this
// setting existed. Anything else would change startup for every user.
homeTarget = homeTarget ?? HomeTarget.home,
homeTargetOnLastTabClosed = homeTargetOnLastTabClosed ?? false,
storedDefaultCreateTabType =
storedDefaultCreateTabType ?? TabType.regular,
tabListDirection = tabListDirection ?? TabDirection.newestFirst,
@@ -611,6 +634,9 @@ class GeneralSettings with FastEquatable {
showContainerUi,
showIsolatedTabUi,
showSearchCloseButton,
homeTarget,
homeTargetUrl,
homeTargetOnLastTabClosed,
storedDefaultCreateTabType,
tabListDirection,
tabBarDirection,
@@ -43,6 +43,12 @@ abstract class _$GeneralSettingsCWProxy {
GeneralSettings showSearchCloseButton(bool showSearchCloseButton);
GeneralSettings homeTarget(HomeTarget homeTarget);
GeneralSettings homeTargetUrl(String? homeTargetUrl);
GeneralSettings homeTargetOnLastTabClosed(bool homeTargetOnLastTabClosed);
GeneralSettings storedDefaultCreateTabType(
TabType storedDefaultCreateTabType,
);
@@ -193,6 +199,9 @@ abstract class _$GeneralSettingsCWProxy {
bool showContainerUi,
bool showIsolatedTabUi,
bool showSearchCloseButton,
HomeTarget homeTarget,
String? homeTargetUrl,
bool homeTargetOnLastTabClosed,
TabType storedDefaultCreateTabType,
TabDirection tabListDirection,
TabDirection tabBarDirection,
@@ -323,6 +332,18 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
GeneralSettings showSearchCloseButton(bool showSearchCloseButton) =>
call(showSearchCloseButton: showSearchCloseButton);
@override
GeneralSettings homeTarget(HomeTarget homeTarget) =>
call(homeTarget: homeTarget);
@override
GeneralSettings homeTargetUrl(String? homeTargetUrl) =>
call(homeTargetUrl: homeTargetUrl);
@override
GeneralSettings homeTargetOnLastTabClosed(bool homeTargetOnLastTabClosed) =>
call(homeTargetOnLastTabClosed: homeTargetOnLastTabClosed);
@override
GeneralSettings storedDefaultCreateTabType(
TabType storedDefaultCreateTabType,
@@ -582,6 +603,9 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
Object? showContainerUi = const $CopyWithPlaceholder(),
Object? showIsolatedTabUi = const $CopyWithPlaceholder(),
Object? showSearchCloseButton = const $CopyWithPlaceholder(),
Object? homeTarget = const $CopyWithPlaceholder(),
Object? homeTargetUrl = const $CopyWithPlaceholder(),
Object? homeTargetOnLastTabClosed = const $CopyWithPlaceholder(),
Object? storedDefaultCreateTabType = const $CopyWithPlaceholder(),
Object? tabListDirection = const $CopyWithPlaceholder(),
Object? tabBarDirection = const $CopyWithPlaceholder(),
@@ -731,6 +755,21 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
? _value.showSearchCloseButton
// ignore: cast_nullable_to_non_nullable
: showSearchCloseButton as bool,
homeTarget:
homeTarget == const $CopyWithPlaceholder() || homeTarget == null
? _value.homeTarget
// ignore: cast_nullable_to_non_nullable
: homeTarget as HomeTarget,
homeTargetUrl: homeTargetUrl == const $CopyWithPlaceholder()
? _value.homeTargetUrl
// ignore: cast_nullable_to_non_nullable
: homeTargetUrl as String?,
homeTargetOnLastTabClosed:
homeTargetOnLastTabClosed == const $CopyWithPlaceholder() ||
homeTargetOnLastTabClosed == null
? _value.homeTargetOnLastTabClosed
// ignore: cast_nullable_to_non_nullable
: homeTargetOnLastTabClosed as bool,
storedDefaultCreateTabType:
storedDefaultCreateTabType == const $CopyWithPlaceholder() ||
storedDefaultCreateTabType == null
@@ -1095,6 +1134,9 @@ GeneralSettings _$GeneralSettingsFromJson(
showContainerUi: json['showContainerUi'] as bool?,
showIsolatedTabUi: json['showIsolatedTabUi'] as bool?,
showSearchCloseButton: json['showSearchCloseButton'] as bool?,
homeTarget: $enumDecodeNullable(_$HomeTargetEnumMap, json['homeTarget']),
homeTargetUrl: json['homeTargetUrl'] as String?,
homeTargetOnLastTabClosed: json['homeTargetOnLastTabClosed'] as bool?,
storedDefaultCreateTabType: $enumDecodeNullable(
_$TabTypeEnumMap,
json['defaultCreateTabType'],
@@ -1234,6 +1276,9 @@ Map<String, dynamic> _$GeneralSettingsToJson(
'showContainerUi': instance.showContainerUi,
'showIsolatedTabUi': instance.showIsolatedTabUi,
'showSearchCloseButton': instance.showSearchCloseButton,
'homeTarget': _$HomeTargetEnumMap[instance.homeTarget]!,
'homeTargetUrl': instance.homeTargetUrl,
'homeTargetOnLastTabClosed': instance.homeTargetOnLastTabClosed,
'defaultCreateTabType':
_$TabTypeEnumMap[instance.storedDefaultCreateTabType]!,
'tabListDirection': _$TabDirectionEnumMap[instance.tabListDirection]!,
@@ -1331,6 +1376,12 @@ const _$SearchSuggestionProvidersEnumMap = {
SearchSuggestionProviders.qwant: 'qwant',
};
const _$HomeTargetEnumMap = {
HomeTarget.home: 'home',
HomeTarget.resumeLastTab: 'resumeLastTab',
HomeTarget.customUrl: 'customUrl',
};
const _$TabTypeEnumMap = {
TabType.regular: 'regular',
TabType.private: 'private',
@@ -21,6 +21,7 @@ import 'dart:async';
import 'dart:convert';
import 'package:drift/drift.dart';
import 'package:flutter/foundation.dart';
import 'package:nullability/nullability.dart';
import 'package:riverpod/riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
@@ -32,6 +33,106 @@ part 'general_settings.g.dart';
typedef UpdateGeneralSettingsFunc =
GeneralSettings Function(GeneralSettings currentSettings);
/// Column type for every persisted `general` setting, keyed by its JSON name.
///
/// Also carries legacy keys that no longer exist on [GeneralSettings] but are
/// still read so the migrations in `GeneralSettings.fromJson` keep working.
///
/// **Every field on [GeneralSettings] must appear here or in
/// [generalSettingJsonKeys].** A missing entry means the setting writes fine
/// but silently reverts to its default on the next launch, because it is never
/// read back out of the database. `general_settings_deserialize_test.dart`
/// guards this.
@visibleForTesting
const generalSettingColumnTypes = <String, DriftSqlType>{
'themeMode': DriftSqlType.string,
'uiScaleFactor': DriftSqlType.double,
'disableAnimations': DriftSqlType.bool,
'refreshRateMode': DriftSqlType.string,
'showModalBarrier': DriftSqlType.bool,
'enableReadability': DriftSqlType.bool,
'enforceReadability': DriftSqlType.bool,
'screenshotProtectionEnabled': DriftSqlType.bool,
'defaultSearchProvider': DriftSqlType.string,
'defaultSearchSuggestionsProvider': DriftSqlType.string,
'createChildTabsOption': DriftSqlType.bool,
'enableLocalAiFeatures': DriftSqlType.bool,
'showContainerUi': DriftSqlType.bool,
'showIsolatedTabUi': DriftSqlType.bool,
'defaultCreateTabType': DriftSqlType.string,
// Legacy: superseded by tabListDirection/tabBarDirection.
'newTabPosition': DriftSqlType.string,
'tabListDirection': DriftSqlType.string,
'tabBarDirection': DriftSqlType.string,
'tabIntentOpenSetting': DriftSqlType.string,
'bookmarkOpenSetting': DriftSqlType.string,
'autoHideTabBar': DriftSqlType.bool,
'tabBarSwipeAction': DriftSqlType.string,
'historyAutoCleanInterval': DriftSqlType.int,
'tabViewBottomSheet': DriftSqlType.bool,
'tabBarShowContextualBar': DriftSqlType.bool,
// Legacy: folded into tabBarStackingMode.
'tabBarShowQuickTabSwitcherBar': DriftSqlType.bool,
'tabBarPosition': DriftSqlType.string,
'tabBarLayout': DriftSqlType.string,
// Legacy: folded into tabBarStackingMode.
'quickTabSwitcherMode': DriftSqlType.string,
'tabBarStackingMode': DriftSqlType.string,
'pullToRefreshEnabled': DriftSqlType.bool,
'useExternalDownloadManager': DriftSqlType.bool,
'doubleBackCloseTab': DriftSqlType.bool,
'unassignedTabsAutoCleanInterval': DriftSqlType.int,
'maxSearchHistoryEntries': DriftSqlType.int,
'allowClipboardAccess': DriftSqlType.bool,
'tabListShowFavicons': DriftSqlType.bool,
'quickTabSwitcherShowTitles': DriftSqlType.bool,
'quickTabSwitcherHierarchyGlyphs': DriftSqlType.int,
'quickTabSwitcherShowHistorySuggestions': DriftSqlType.bool,
'quickTabSwitcherTitleWidth': DriftSqlType.double,
'quickTabSwitcherShowCloseButtonOnAllTabs': DriftSqlType.bool,
'syncServerOverride': DriftSqlType.string,
'syncTokenServerOverride': DriftSqlType.string,
'urlCleanerEnabled': DriftSqlType.bool,
'urlCleanerAutoApply': DriftSqlType.bool,
'urlCleanerAllowReferralMarketing': DriftSqlType.bool,
'urlCleanerCatalogUrl': DriftSqlType.string,
'urlCleanerHashUrl': DriftSqlType.string,
'urlCleanerAutoUpdate': DriftSqlType.bool,
'urlCleanerLastCheckEpochMs': DriftSqlType.int,
'urlCleanerLastUpdateWasAuto': DriftSqlType.bool,
'smallWebTabType': DriftSqlType.string,
'tabBarLongPressUrlCopy': DriftSqlType.bool,
'unshortenerEnabled': DriftSqlType.bool,
'unshortenerToken': DriftSqlType.string,
'allowNonManifestPwaInstall': DriftSqlType.bool,
'blockExternalAppsEnabled': DriftSqlType.bool,
'customTabsEnabled': DriftSqlType.bool,
'appLinksMode': DriftSqlType.string,
'appLinkMarketplaceFallback': DriftSqlType.bool,
'enableLocalSearchIndex': DriftSqlType.bool,
'indexPrivateTabs': DriftSqlType.bool,
'acceptSuggestionOnSubmit': DriftSqlType.bool,
'pureBlack': DriftSqlType.bool,
'showSearchCloseButton': DriftSqlType.bool,
'homeTarget': DriftSqlType.string,
'homeTargetUrl': DriftSqlType.string,
'homeTargetOnLastTabClosed': DriftSqlType.bool,
'globalDesktopMode': DriftSqlType.bool,
'unmountGeckoViewOffRoute': DriftSqlType.bool,
};
/// Settings stored as a JSON document in a TEXT column. Their value has to be
/// decoded before it reaches `GeneralSettings.fromJson`, which expects the
/// already-parsed list/map.
@visibleForTesting
const generalSettingJsonKeys = <String>{
'deleteBrowsingDataOnQuit',
'externalAppIntentPolicies',
'appLinkRules',
'appLinkContextOverrides',
'desktopModeSites',
};
@Riverpod(keepAlive: true)
class GeneralSettingsRepository extends _$GeneralSettingsRepository {
final _partitionKey = 'general';
@@ -41,284 +142,16 @@ class GeneralSettingsRepository extends _$GeneralSettingsRepository {
) {
final settings = Map.fromEntries(entries);
final db = ref.read(userDatabaseProvider);
final typeMapping = ref.read(userDatabaseProvider).typeMapping;
return GeneralSettings.fromJson({
'themeMode': settings['themeMode']?.readAs(
DriftSqlType.string,
db.typeMapping,
),
'uiScaleFactor': settings['uiScaleFactor']?.readAs(
DriftSqlType.double,
db.typeMapping,
),
'disableAnimations': settings['disableAnimations']?.readAs(
DriftSqlType.bool,
db.typeMapping,
),
'refreshRateMode': settings['refreshRateMode']?.readAs(
DriftSqlType.string,
db.typeMapping,
),
'showModalBarrier': settings['showModalBarrier']?.readAs(
DriftSqlType.bool,
db.typeMapping,
),
'enableReadability': settings['enableReadability']?.readAs(
DriftSqlType.bool,
db.typeMapping,
),
'enforceReadability': settings['enforceReadability']?.readAs(
DriftSqlType.bool,
db.typeMapping,
),
'deleteBrowsingDataOnQuit': settings['deleteBrowsingDataOnQuit']
?.readAs(DriftSqlType.string, db.typeMapping)
.mapNotNull(jsonDecode),
'screenshotProtectionEnabled': settings['screenshotProtectionEnabled']
?.readAs(DriftSqlType.bool, db.typeMapping),
'defaultSearchProvider': settings['defaultSearchProvider']?.readAs(
DriftSqlType.string,
db.typeMapping,
),
'defaultSearchSuggestionsProvider':
settings['defaultSearchSuggestionsProvider']?.readAs(
DriftSqlType.string,
db.typeMapping,
),
'createChildTabsOption': settings['createChildTabsOption']?.readAs(
DriftSqlType.bool,
db.typeMapping,
),
'enableLocalAiFeatures': settings['enableLocalAiFeatures']?.readAs(
DriftSqlType.bool,
db.typeMapping,
),
'showContainerUi': settings['showContainerUi']?.readAs(
DriftSqlType.bool,
db.typeMapping,
),
'showIsolatedTabUi': settings['showIsolatedTabUi']?.readAs(
DriftSqlType.bool,
db.typeMapping,
),
'defaultCreateTabType': settings['defaultCreateTabType']?.readAs(
DriftSqlType.string,
db.typeMapping,
),
'newTabPosition': settings['newTabPosition']?.readAs(
DriftSqlType.string,
db.typeMapping,
),
'tabListDirection': settings['tabListDirection']?.readAs(
DriftSqlType.string,
db.typeMapping,
),
'tabBarDirection': settings['tabBarDirection']?.readAs(
DriftSqlType.string,
db.typeMapping,
),
'tabIntentOpenSetting': settings['tabIntentOpenSetting']?.readAs(
DriftSqlType.string,
db.typeMapping,
),
'bookmarkOpenSetting': settings['bookmarkOpenSetting']?.readAs(
DriftSqlType.string,
db.typeMapping,
),
'autoHideTabBar': settings['autoHideTabBar']?.readAs(
DriftSqlType.bool,
db.typeMapping,
),
'tabBarSwipeAction': settings['tabBarSwipeAction']?.readAs(
DriftSqlType.string,
db.typeMapping,
),
'historyAutoCleanInterval': settings['historyAutoCleanInterval']?.readAs(
DriftSqlType.int,
db.typeMapping,
),
'tabViewBottomSheet': settings['tabViewBottomSheet']?.readAs(
DriftSqlType.bool,
db.typeMapping,
),
'tabBarShowContextualBar': settings['tabBarShowContextualBar']?.readAs(
DriftSqlType.bool,
db.typeMapping,
),
'tabBarShowQuickTabSwitcherBar': settings['tabBarShowQuickTabSwitcherBar']
?.readAs(DriftSqlType.bool, db.typeMapping),
'tabBarPosition': settings['tabBarPosition']?.readAs(
DriftSqlType.string,
db.typeMapping,
),
'tabBarLayout': settings['tabBarLayout']?.readAs(
DriftSqlType.string,
db.typeMapping,
),
'quickTabSwitcherMode': settings['quickTabSwitcherMode']?.readAs(
DriftSqlType.string,
db.typeMapping,
),
'tabBarStackingMode': settings['tabBarStackingMode']?.readAs(
DriftSqlType.string,
db.typeMapping,
),
'pullToRefreshEnabled': settings['pullToRefreshEnabled']?.readAs(
DriftSqlType.bool,
db.typeMapping,
),
'useExternalDownloadManager': settings['useExternalDownloadManager']
?.readAs(DriftSqlType.bool, db.typeMapping),
'doubleBackCloseTab': settings['doubleBackCloseTab']?.readAs(
DriftSqlType.bool,
db.typeMapping,
),
'unassignedTabsAutoCleanInterval':
settings['unassignedTabsAutoCleanInterval']?.readAs(
DriftSqlType.int,
db.typeMapping,
),
'maxSearchHistoryEntries': settings['maxSearchHistoryEntries']?.readAs(
DriftSqlType.int,
db.typeMapping,
),
'allowClipboardAccess': settings['allowClipboardAccess']?.readAs(
DriftSqlType.bool,
db.typeMapping,
),
'tabListShowFavicons': settings['tabListShowFavicons']?.readAs(
DriftSqlType.bool,
db.typeMapping,
),
'quickTabSwitcherShowTitles': settings['quickTabSwitcherShowTitles']
?.readAs(DriftSqlType.bool, db.typeMapping),
'quickTabSwitcherHierarchyGlyphs':
settings['quickTabSwitcherHierarchyGlyphs']?.readAs(
DriftSqlType.int,
db.typeMapping,
),
'quickTabSwitcherShowHistorySuggestions':
settings['quickTabSwitcherShowHistorySuggestions']?.readAs(
DriftSqlType.bool,
db.typeMapping,
),
'quickTabSwitcherTitleWidth': settings['quickTabSwitcherTitleWidth']
?.readAs(DriftSqlType.double, db.typeMapping),
'quickTabSwitcherShowCloseButtonOnAllTabs':
settings['quickTabSwitcherShowCloseButtonOnAllTabs']?.readAs(
DriftSqlType.bool,
db.typeMapping,
),
'syncServerOverride': settings['syncServerOverride']?.readAs(
DriftSqlType.string,
db.typeMapping,
),
'syncTokenServerOverride': settings['syncTokenServerOverride']?.readAs(
DriftSqlType.string,
db.typeMapping,
),
'urlCleanerEnabled': settings['urlCleanerEnabled']?.readAs(
DriftSqlType.bool,
db.typeMapping,
),
'urlCleanerAutoApply': settings['urlCleanerAutoApply']?.readAs(
DriftSqlType.bool,
db.typeMapping,
),
'urlCleanerAllowReferralMarketing':
settings['urlCleanerAllowReferralMarketing']?.readAs(
DriftSqlType.bool,
db.typeMapping,
),
'urlCleanerCatalogUrl': settings['urlCleanerCatalogUrl']?.readAs(
DriftSqlType.string,
db.typeMapping,
),
'urlCleanerHashUrl': settings['urlCleanerHashUrl']?.readAs(
DriftSqlType.string,
db.typeMapping,
),
'urlCleanerAutoUpdate': settings['urlCleanerAutoUpdate']?.readAs(
DriftSqlType.bool,
db.typeMapping,
),
'urlCleanerLastCheckEpochMs': settings['urlCleanerLastCheckEpochMs']
?.readAs(DriftSqlType.int, db.typeMapping),
'urlCleanerLastUpdateWasAuto': settings['urlCleanerLastUpdateWasAuto']
?.readAs(DriftSqlType.bool, db.typeMapping),
'smallWebTabType': settings['smallWebTabType']?.readAs(
DriftSqlType.string,
db.typeMapping,
),
'tabBarLongPressUrlCopy': settings['tabBarLongPressUrlCopy']?.readAs(
DriftSqlType.bool,
db.typeMapping,
),
'unshortenerEnabled': settings['unshortenerEnabled']?.readAs(
DriftSqlType.bool,
db.typeMapping,
),
'unshortenerToken': settings['unshortenerToken']?.readAs(
DriftSqlType.string,
db.typeMapping,
),
'allowNonManifestPwaInstall': settings['allowNonManifestPwaInstall']
?.readAs(DriftSqlType.bool, db.typeMapping),
'blockExternalAppsEnabled': settings['blockExternalAppsEnabled']?.readAs(
DriftSqlType.bool,
db.typeMapping,
),
'externalAppIntentPolicies': settings['externalAppIntentPolicies']
?.readAs(DriftSqlType.string, db.typeMapping)
.mapNotNull(jsonDecode),
'customTabsEnabled': settings['customTabsEnabled']?.readAs(
DriftSqlType.bool,
db.typeMapping,
),
'appLinksMode': settings['appLinksMode']?.readAs(
DriftSqlType.string,
db.typeMapping,
),
'appLinkRules': settings['appLinkRules']
?.readAs(DriftSqlType.string, db.typeMapping)
.mapNotNull(jsonDecode),
'appLinkContextOverrides': settings['appLinkContextOverrides']
?.readAs(DriftSqlType.string, db.typeMapping)
.mapNotNull(jsonDecode),
'appLinkMarketplaceFallback': settings['appLinkMarketplaceFallback']
?.readAs(DriftSqlType.bool, db.typeMapping),
'enableLocalSearchIndex': settings['enableLocalSearchIndex']?.readAs(
DriftSqlType.bool,
db.typeMapping,
),
'indexPrivateTabs': settings['indexPrivateTabs']?.readAs(
DriftSqlType.bool,
db.typeMapping,
),
'acceptSuggestionOnSubmit': settings['acceptSuggestionOnSubmit']?.readAs(
DriftSqlType.bool,
db.typeMapping,
),
'pureBlack': settings['pureBlack']?.readAs(
DriftSqlType.bool,
db.typeMapping,
),
'showSearchCloseButton': settings['showSearchCloseButton']?.readAs(
DriftSqlType.bool,
db.typeMapping,
),
'globalDesktopMode': settings['globalDesktopMode']?.readAs(
DriftSqlType.bool,
db.typeMapping,
),
'desktopModeSites': settings['desktopModeSites']
?.readAs(DriftSqlType.string, db.typeMapping)
.mapNotNull(jsonDecode),
'unmountGeckoViewOffRoute': settings['unmountGeckoViewOffRoute']?.readAs(
DriftSqlType.bool,
db.typeMapping,
),
for (final MapEntry(key: key, value: type)
in generalSettingColumnTypes.entries)
key: settings[key]?.readAs(type, typeMapping),
for (final key in generalSettingJsonKeys)
key: settings[key]
?.readAs(DriftSqlType.string, typeMapping)
.mapNotNull(jsonDecode),
});
}
@@ -35,7 +35,7 @@ final class GeneralSettingsRepositoryProvider
}
String _$generalSettingsRepositoryHash() =>
r'3c458f146b63ae219a55a5f0488a667b70c44f4b';
r'37cfacab1b4a9d67e185df4232d8e57349396296';
abstract class _$GeneralSettingsRepository
extends $StreamNotifier<GeneralSettings> {