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
+1
View File
@@ -18,6 +18,7 @@ targets:
quotes: lib/features/quotes/data/database/database.dart
sites: lib/features/popular_sites/data/database/database.dart
tabs: lib/features/geckoview/features/tabs/data/database/database.dart
top_site: lib/features/geckoview/features/top_sites/data/database/database.dart
user: lib/features/user/data/database/database.dart
web_feed: lib/features/web_feed/data/database/database.dart
sql:
@@ -117,12 +117,12 @@
"references": [],
"type": "table",
"data": {
"name": "top_site_seed_state",
"name": "hidden_top_site",
"was_declared_in_moor": true,
"columns": [
{
"name": "seed_id",
"getter_name": "seedId",
"name": "url",
"getter_name": "url",
"moor_type": "string",
"nullable": false,
"customConstraints": "PRIMARY KEY NOT NULL",
@@ -130,17 +130,11 @@
"default_client_dart": null,
"dsl_features": [
"primary-key"
]
},
{
"name": "applied_at",
"getter_name": "appliedAt",
"moor_type": "dateTime",
"nullable": false,
"customConstraints": "NOT NULL",
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
],
"type_converter": {
"dart_expr": "const UriConverter()",
"dart_type_name": "Uri"
}
}
],
"is_virtual": false,
@@ -169,11 +163,11 @@
]
},
{
"name": "top_site_seed_state",
"name": "hidden_top_site",
"sql": [
{
"dialect": "sqlite",
"sql": "CREATE TABLE IF NOT EXISTS \"top_site_seed_state\" (\"seed_id\" TEXT PRIMARY KEY NOT NULL, \"applied_at\" INTEGER NOT NULL);"
"sql": "CREATE TABLE IF NOT EXISTS \"hidden_top_site\" (\"url\" TEXT PRIMARY KEY NOT NULL);"
}
]
}
@@ -0,0 +1,210 @@
{
"_meta": {
"description": "This file contains a serialized version of schema entities for drift.",
"version": "1.3.0"
},
"options": {
"store_date_time_values_as_text": false
},
"entities": [
{
"id": 0,
"references": [],
"type": "table",
"data": {
"name": "top_site",
"was_declared_in_moor": true,
"columns": [
{
"name": "id",
"getter_name": "id",
"moor_type": "string",
"nullable": false,
"customConstraints": "PRIMARY KEY NOT NULL",
"default_dart": null,
"default_client_dart": null,
"dsl_features": [
"primary-key"
]
},
{
"name": "title",
"getter_name": "title",
"moor_type": "string",
"nullable": false,
"customConstraints": "NOT NULL",
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "url",
"getter_name": "url",
"moor_type": "string",
"nullable": false,
"customConstraints": "NOT NULL",
"default_dart": null,
"default_client_dart": null,
"dsl_features": [],
"type_converter": {
"dart_expr": "const UriConverter()",
"dart_type_name": "Uri"
}
},
{
"name": "source",
"getter_name": "source",
"moor_type": "int",
"nullable": false,
"customConstraints": "NOT NULL",
"default_dart": null,
"default_client_dart": null,
"dsl_features": [],
"type_converter": {
"dart_expr": "const EnumIndexConverter<StoredTopSiteSource>(StoredTopSiteSource.values)",
"dart_type_name": "StoredTopSiteSource"
}
},
{
"name": "order_key",
"getter_name": "orderKey",
"moor_type": "string",
"nullable": false,
"customConstraints": "NOT NULL",
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "created_at",
"getter_name": "createdAt",
"moor_type": "dateTime",
"nullable": false,
"customConstraints": "NOT NULL",
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
}
],
"is_virtual": false,
"without_rowid": false,
"constraints": [
"UNIQUE(url)"
],
"unique_keys": [
[
"url"
]
]
}
},
{
"id": 1,
"references": [
0
],
"type": "index",
"data": {
"on": 0,
"name": "idx_top_site_order_key",
"sql": "CREATE INDEX idx_top_site_order_key ON top_site(order_key);",
"unique": false,
"columns": []
}
},
{
"id": 2,
"references": [],
"type": "table",
"data": {
"name": "hidden_top_site",
"was_declared_in_moor": true,
"columns": [
{
"name": "url",
"getter_name": "url",
"moor_type": "string",
"nullable": false,
"customConstraints": "PRIMARY KEY NOT NULL",
"default_dart": null,
"default_client_dart": null,
"dsl_features": [
"primary-key"
],
"type_converter": {
"dart_expr": "const UriConverter()",
"dart_type_name": "Uri"
}
}
],
"is_virtual": false,
"without_rowid": false,
"constraints": []
}
},
{
"id": 3,
"references": [],
"type": "table",
"data": {
"name": "hidden_top_site_host",
"was_declared_in_moor": true,
"columns": [
{
"name": "host",
"getter_name": "host",
"moor_type": "string",
"nullable": false,
"customConstraints": "PRIMARY KEY NOT NULL",
"default_dart": null,
"default_client_dart": null,
"dsl_features": [
"primary-key"
]
}
],
"is_virtual": false,
"without_rowid": false,
"constraints": []
}
}
],
"fixed_sql": [
{
"name": "top_site",
"sql": [
{
"dialect": "sqlite",
"sql": "CREATE TABLE IF NOT EXISTS \"top_site\" (\"id\" TEXT PRIMARY KEY NOT NULL, \"title\" TEXT NOT NULL, \"url\" TEXT NOT NULL, \"source\" INTEGER NOT NULL, \"order_key\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, UNIQUE(url));"
}
]
},
{
"name": "idx_top_site_order_key",
"sql": [
{
"dialect": "sqlite",
"sql": "CREATE INDEX idx_top_site_order_key ON top_site (order_key)"
}
]
},
{
"name": "hidden_top_site",
"sql": [
{
"dialect": "sqlite",
"sql": "CREATE TABLE IF NOT EXISTS \"hidden_top_site\" (\"url\" TEXT PRIMARY KEY NOT NULL);"
}
]
},
{
"name": "hidden_top_site_host",
"sql": [
{
"dialect": "sqlite",
"sql": "CREATE TABLE IF NOT EXISTS \"hidden_top_site_host\" (\"host\" TEXT PRIMARY KEY NOT NULL);"
}
]
}
]
}
@@ -55,6 +55,7 @@ import 'package:weblibre/features/geckoview/features/history/presentation/screen
import 'package:weblibre/features/geckoview/features/open_link_tools/presentation/dialogs/open_shared_content.dart';
import 'package:weblibre/features/geckoview/features/open_link_tools/presentation/screens/unshortener_settings.dart';
import 'package:weblibre/features/geckoview/features/open_link_tools/presentation/screens/url_cleaner_settings.dart';
import 'package:weblibre/features/geckoview/features/search/domain/providers/search_modules_view.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/screens/search.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
import 'package:weblibre/features/geckoview/features/tabs/presentation/screens/container_draft_suggestions.dart';
@@ -82,7 +83,9 @@ import 'package:weblibre/features/settings/presentation/screens/experimental_set
import 'package:weblibre/features/settings/presentation/screens/extensions_settings.dart';
import 'package:weblibre/features/settings/presentation/screens/fingerprint_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/locale_settings.dart';
import 'package:weblibre/features/settings/presentation/screens/module_surface_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';
@@ -1674,6 +1674,21 @@ RouteBase get $settingsRoute => GoRouteData.$route(
name: 'UnshortenerSettingsRoute',
factory: $UnshortenerSettingsRoute._fromState,
),
GoRouteData.$route(
path: 'home',
name: 'HomeSettingsRoute',
factory: $HomeSettingsRoute._fromState,
),
GoRouteData.$route(
path: 'home_modules',
name: 'HomeModulesSettingsRoute',
factory: $HomeModulesSettingsRoute._fromState,
),
GoRouteData.$route(
path: 'new_tab_modules',
name: 'NewTabModulesSettingsRoute',
factory: $NewTabModulesSettingsRoute._fromState,
),
GoRouteData.$route(
path: 'contextual_toolbar',
name: 'ContextualToolbarSettingsRoute',
@@ -2295,6 +2310,69 @@ mixin $UnshortenerSettingsRoute on GoRouteData {
void replace(BuildContext context) => context.replace(location);
}
mixin $HomeSettingsRoute on GoRouteData {
static HomeSettingsRoute _fromState(GoRouterState state) =>
const HomeSettingsRoute();
@override
String get location => GoRouteData.$location('/settings/home');
@override
void go(BuildContext context) => context.go(location);
@override
Future<T?> push<T>(BuildContext context) => context.push<T>(location);
@override
void pushReplacement(BuildContext context) =>
context.pushReplacement(location);
@override
void replace(BuildContext context) => context.replace(location);
}
mixin $HomeModulesSettingsRoute on GoRouteData {
static HomeModulesSettingsRoute _fromState(GoRouterState state) =>
const HomeModulesSettingsRoute();
@override
String get location => GoRouteData.$location('/settings/home_modules');
@override
void go(BuildContext context) => context.go(location);
@override
Future<T?> push<T>(BuildContext context) => context.push<T>(location);
@override
void pushReplacement(BuildContext context) =>
context.pushReplacement(location);
@override
void replace(BuildContext context) => context.replace(location);
}
mixin $NewTabModulesSettingsRoute on GoRouteData {
static NewTabModulesSettingsRoute _fromState(GoRouterState state) =>
const NewTabModulesSettingsRoute();
@override
String get location => GoRouteData.$location('/settings/new_tab_modules');
@override
void go(BuildContext context) => context.go(location);
@override
Future<T?> push<T>(BuildContext context) => context.push<T>(location);
@override
void pushReplacement(BuildContext context) =>
context.pushReplacement(location);
@override
void replace(BuildContext context) => context.replace(location);
}
mixin $ContextualToolbarSettingsRoute on GoRouteData {
static ContextualToolbarSettingsRoute _fromState(GoRouterState state) =>
const ContextualToolbarSettingsRoute();
@@ -117,6 +117,15 @@ part of 'routes.dart';
name: 'UnshortenerSettingsRoute',
path: 'unshortener',
),
TypedGoRoute<HomeSettingsRoute>(name: 'HomeSettingsRoute', path: 'home'),
TypedGoRoute<HomeModulesSettingsRoute>(
name: 'HomeModulesSettingsRoute',
path: 'home_modules',
),
TypedGoRoute<NewTabModulesSettingsRoute>(
name: 'NewTabModulesSettingsRoute',
path: 'new_tab_modules',
),
TypedGoRoute<ContextualToolbarSettingsRoute>(
name: 'ContextualToolbarSettingsRoute',
path: 'contextual_toolbar',
@@ -359,6 +368,38 @@ class UnshortenerSettingsRoute extends GoRouteData
}
}
class HomeSettingsRoute extends GoRouteData with $HomeSettingsRoute {
const HomeSettingsRoute();
@override
Widget build(BuildContext context, GoRouterState state) {
return const HomeSettingsScreen();
}
}
class HomeModulesSettingsRoute extends GoRouteData
with $HomeModulesSettingsRoute {
const HomeModulesSettingsRoute();
@override
Widget build(BuildContext context, GoRouterState state) {
return const ModuleSurfaceSettingsScreen();
}
}
class NewTabModulesSettingsRoute extends GoRouteData
with $NewTabModulesSettingsRoute {
const NewTabModulesSettingsRoute();
@override
Widget build(BuildContext context, GoRouterState state) {
return const ModuleSurfaceSettingsScreen(
surface: ModuleSurface.newTab,
title: 'Customize New Tab',
);
}
}
class ContextualToolbarSettingsRoute extends GoRouteData
with $ContextualToolbarSettingsRoute {
const ContextualToolbarSettingsRoute();
@@ -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> {
@@ -18,7 +18,6 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:math' as math;
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_svg/svg.dart';
@@ -26,14 +25,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/design/app_colors.dart';
class BrowserPage extends ConsumerWidget {
final double bottomViewportInset;
final Widget child;
const BrowserPage({
super.key,
this.bottomViewportInset = 0,
required this.child,
});
const BrowserPage({super.key, required this.child});
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -41,9 +35,64 @@ class BrowserPage extends ConsumerWidget {
final colorScheme = theme.colorScheme;
final appColors = AppColors.of(context);
return DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
return Stack(
fit: StackFit.expand,
children: [
// The aura backdrop is static: it only changes when the theme does.
// Isolating it in a repaint boundary keeps it out of the scrolling
// content's repaints, and the picture itself is raster-cacheable.
RepaintBoundary(
child: CustomPaint(
painter: _AuraBackdropPainter(
colorScheme: colorScheme,
appColors: appColors,
),
isComplex: true,
willChange: false,
),
),
Positioned.fill(child: child),
],
);
}
}
/// The decorative background shared by the browser home and the onboarding
/// pages: a diagonal wash with three soft coloured orbs bleeding in from the
/// edges.
///
/// This used to be three solid circles under a full-viewport
/// `BackdropFilter(ImageFilter.blur(sigma: 72))`. That cost a save-layer plus a
/// multi-pass gaussian blur of the entire screen *on every frame* —
/// `BackdropFilter` re-reads and re-blurs its backdrop unconditionally and is
/// never raster-cached — to soften artwork that never moves. Above the GeckoView
/// platform view it was worse still, forcing the Android external view embedder
/// to split the frame into extra overlay surfaces.
///
/// A blurred disc is, to the eye, exactly a radial gradient, so the orbs are
/// drawn as gradients directly. No save-layers, no blur passes, and the whole
/// backdrop reduces to four shader-filled rects.
class _AuraBackdropPainter extends CustomPainter {
final ColorScheme colorScheme;
final AppColors appColors;
const _AuraBackdropPainter({
required this.colorScheme,
required this.appColors,
});
/// Sigma the orbs were previously blurred with. Retained as the falloff width
/// so the gradients match the look the blur produced.
static const _sigma = 72.0;
@override
void paint(Canvas canvas, Size size) {
final bounds = Offset.zero & size;
canvas.drawRect(
bounds,
Paint()
..shader = LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
@@ -60,55 +109,87 @@ class BrowserPage extends ConsumerWidget {
colorScheme.surfaceContainerHigh,
),
],
),
),
child: Stack(
fit: StackFit.expand,
children: [
Positioned(
top: -70,
left: -120,
child: _BackdropOrb(
width: 400,
height: 400,
color: appColors.auraPurple,
),
),
Positioned(
top: 220,
right: -150,
child: _BackdropOrb(
width: 340,
height: 340,
color: appColors.auraGold,
),
),
Positioned(
bottom: 18,
left: -8,
child: _BackdropOrb(
width: 320,
height: 320,
color: appColors.auraShadowHighlight,
),
),
Positioned.fill(
child: IgnorePointer(
child: ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 72, sigmaY: 72),
child: ColoredBox(
color: appColors.auraTint.withValues(alpha: 0.12),
),
),
),
),
),
Positioned.fill(child: child),
],
),
).createShader(bounds),
);
// Centres and radii are the previous `Positioned` orbs resolved against the
// viewport: 400² at (top: -70, left: -120), 340² at (top: 220, right: -150)
// and 320² at (bottom: 18, left: -8).
_paintOrb(canvas, bounds, const Offset(80, 130), 200, appColors.auraPurple);
_paintOrb(
canvas,
bounds,
Offset(size.width - 20, 390),
170,
appColors.auraGold,
);
_paintOrb(
canvas,
bounds,
Offset(152, size.height - 178),
160,
appColors.auraShadowHighlight,
);
canvas.drawRect(
bounds,
Paint()..color = appColors.auraTint.withValues(alpha: 0.12),
);
}
void _paintOrb(
Canvas canvas,
Rect bounds,
Offset center,
double radius,
Color color,
) {
// Blur energy is spent by 2σ past the edge, so that is where the gradient
// ends.
final gradientRadius = radius + 2 * _sigma;
// A gaussian-blurred disc holds an alpha of `1 - exp(-r²/2σ²)` at its
// centre and falls off across the edge along the blur's error function,
// which is ~0.98/0.84/0.5/0.16/0 at -2σ/-σ/0/+σ/+2σ relative to the edge.
// Sampling those five points reproduces the blur closely enough that the
// difference is invisible at this scale.
final centerAlpha =
1 - math.exp(-(radius * radius) / (2 * _sigma * _sigma));
const falloff = <double>[0.977, 0.841, 0.5, 0.159, 0.0];
final colors = <Color>[color.withValues(alpha: centerAlpha)];
final stops = <double>[0.0];
for (var i = 0; i < falloff.length; i++) {
final sampleRadius = radius + (i - 2) * _sigma;
if (sampleRadius <= 0) {
// The disc is smaller than the blur reaches inward; the samples that
// fall inside the centre are already covered by [centerAlpha].
continue;
}
colors.add(
// Clamped so the profile stays monotonically fading outward for small
// discs, where the edge samples would otherwise exceed the centre.
color.withValues(alpha: math.min(falloff[i], centerAlpha)),
);
stops.add(sampleRadius / gradientRadius);
}
canvas.drawRect(
bounds,
Paint()
..shader = RadialGradient(
colors: colors,
stops: stops,
).createShader(Rect.fromCircle(center: center, radius: gradientRadius)),
);
}
@override
bool shouldRepaint(_AuraBackdropPainter oldDelegate) =>
oldDelegate.colorScheme != colorScheme ||
oldDelegate.appColors != appColors;
}
class BrowserPageContent extends StatelessWidget {
@@ -161,16 +242,18 @@ class BrandHeader extends StatelessWidget {
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(32),
// Enough tint to read as brand colours: below roughly a quarter the
// blend lands on a neutral grey and the mark looks like a placeholder.
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Color.alphaBlend(
AppColors.brandPurple.withValues(alpha: 0.18),
AppColors.brandPurple.withValues(alpha: 0.28),
colorScheme.surfaceContainerHighest,
),
Color.alphaBlend(
AppColors.brandYellow.withValues(alpha: 0.12),
AppColors.brandYellow.withValues(alpha: 0.20),
colorScheme.surfaceContainer,
),
],
@@ -186,31 +269,11 @@ class BrandHeader extends StatelessWidget {
),
],
),
// 56 inside a 112 tile with 20 of padding: at 72 the mark exactly fills
// the content box and its arms touch the tile edge, which reads as a
// cropped image rather than a logo.
child: Center(
child: SvgPicture.asset('assets/icon/icon.svg', width: 72, height: 72),
),
);
}
}
class _BackdropOrb extends StatelessWidget {
final double width;
final double height;
final Color color;
const _BackdropOrb({
required this.width,
required this.height,
required this.color,
});
@override
Widget build(BuildContext context) {
return IgnorePointer(
child: Container(
width: width,
height: height,
decoration: BoxDecoration(shape: BoxShape.circle, color: color),
child: SvgPicture.asset('assets/icon/icon.svg', width: 56, height: 56),
),
);
}
@@ -31,6 +31,11 @@ class UrlListTile extends StatelessWidget {
final Color? containerColor;
final IconData? containerIcon;
final bool useCustomColor;
/// Off by default, as at every other [UriBreadcrumb] call site: under a title
/// that is usually the host itself, a leading `https ` is a crumb that never
/// varies. The insecure case is carried by the address bar's security icon,
/// not by these rows.
final bool showHttpScheme;
final VoidCallback? onTap;
@@ -43,7 +48,7 @@ class UrlListTile extends StatelessWidget {
this.containerColor,
this.containerIcon,
this.useCustomColor = false,
this.showHttpScheme = true,
this.showHttpScheme = false,
this.onTap,
});
@@ -0,0 +1,164 @@
/*
* 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:drift/drift.dart' show Value;
import 'package:drift/native.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:weblibre/data/database/functions/lexo_rank_functions.dart';
import 'package:weblibre/data/database/functions/url_functions.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/database/database.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_source.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
Future<void> _addContainer(TabDatabase db, String id) {
return db.containerDao.addContainer(
ContainerData(
id: id,
name: id,
color: Colors.blue,
orderKey: id,
metadata: ContainerMetadata.withDefaults(contextualIdentity: id),
),
);
}
/// Inserts [id] and stamps it, so "most recently used" ordering is explicit
/// rather than dependent on insertion timing.
Future<void> _addTab(
TabDatabase db,
String id, {
String? containerId,
required int minuteOfUse,
}) async {
await db.tabDao.insertTab(
id,
source: TabSource.manual,
parentId: const Value(null),
containerId: Value(containerId),
);
await db.tabDao.touchTab(
id,
timestamp: DateTime(2026, 8, 1, 12, minuteOfUse),
);
}
void main() {
late TabDatabase db;
setUp(() {
db = TabDatabase(
NativeDatabase.memory(
setup: (database) {
registerLexorankFunctions(database);
registerUrlFunctions(database);
},
),
);
});
tearDown(() async {
await db.close();
});
group('getTabsFifo', () {
test('returns the most recently used tab first', () async {
await _addTab(db, 'older', minuteOfUse: 1);
await _addTab(db, 'newer', minuteOfUse: 5);
final tabs = await db.tabDao.getTabsFifo(limit: 1).get();
expect(tabs.single.id, 'newer');
});
test('skips excluded tabs', () async {
// The regression this guards: tab rows are deleted only after the next
// selection is made, so the tab being closed is still here — and having
// just been active it sorts first, so an unfiltered query resumes the
// very tab that is about to disappear.
await _addTab(db, 'closing', minuteOfUse: 9);
await _addTab(db, 'survivor', minuteOfUse: 1);
final tabs = await db.tabDao
.getTabsFifo(limit: 1, excludedTabIds: {'closing'})
.get();
expect(tabs.single.id, 'survivor');
});
test('returns nothing when every candidate is excluded', () async {
await _addTab(db, 'closing', minuteOfUse: 1);
final tabs = await db.tabDao
.getTabsFifo(limit: 1, excludedTabIds: {'closing'})
.get();
expect(tabs, isEmpty);
});
});
group('getContainerTabsFifo', () {
test('stays within the requested container', () async {
await _addContainer(db, 'a');
await _addContainer(db, 'b');
await _addTab(db, 'other-container', containerId: 'b', minuteOfUse: 9);
await _addTab(db, 'wanted', containerId: 'a', minuteOfUse: 1);
final tabs = await db.tabDao.getContainerTabsFifo('a', limit: 1).get();
expect(tabs.single.id, 'wanted');
});
test('skips the closing tab within a container', () async {
await _addContainer(db, 'a');
await _addTab(db, 'closing', containerId: 'a', minuteOfUse: 9);
await _addTab(db, 'survivor', containerId: 'a', minuteOfUse: 1);
final tabs = await db.tabDao
.getContainerTabsFifo('a', limit: 1, excludedTabIds: {'closing'})
.get();
expect(tabs.single.id, 'survivor');
});
test('a null container means unassigned, not any container', () async {
await _addContainer(db, 'a');
await _addTab(db, 'in-container', containerId: 'a', minuteOfUse: 9);
await _addTab(db, 'unassigned', minuteOfUse: 1);
final tabs = await db.tabDao.getContainerTabsFifo(null, limit: 1).get();
expect(tabs.single.id, 'unassigned');
});
test('skips the closing tab in the unassigned container', () async {
// Closing the last unassigned tab must not resume that same tab, nor
// fall through into a container.
await _addContainer(db, 'a');
await _addTab(db, 'in-container', containerId: 'a', minuteOfUse: 5);
await _addTab(db, 'closing', minuteOfUse: 9);
final tabs = await db.tabDao
.getContainerTabsFifo(null, limit: 1, excludedTabIds: {'closing'})
.get();
expect(tabs, isEmpty);
});
});
}
@@ -5,6 +5,7 @@
import 'package:drift/drift.dart';
import 'package:drift/internal/migrations.dart';
import 'schema_v1.dart' as v1;
import 'schema_v2.dart' as v2;
class GeneratedHelper implements SchemaInstantiationHelper {
@override
@@ -12,10 +13,12 @@ class GeneratedHelper implements SchemaInstantiationHelper {
switch (version) {
case 1:
return v1.DatabaseAtV1(db);
case 2:
return v2.DatabaseAtV2(db);
default:
throw MissingSchemaException(version, versions);
}
}
static const versions = const [1];
static const versions = const [1, 2];
}
@@ -0,0 +1,500 @@
// dart format width=80
// GENERATED BY drift_dev, DO NOT MODIFY.
// ignore_for_file: type=lint,unused_import
//
import 'package:drift/drift.dart';
class TopSite extends Table with TableInfo<TopSite, TopSiteData> {
@override
final GeneratedDatabase attachedDatabase;
final String? _alias;
TopSite(this.attachedDatabase, [this._alias]);
late final GeneratedColumn<String> id = GeneratedColumn<String>(
'id',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
$customConstraints: 'PRIMARY KEY NOT NULL',
);
late final GeneratedColumn<String> title = GeneratedColumn<String>(
'title',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
$customConstraints: 'NOT NULL',
);
late final GeneratedColumn<String> url = GeneratedColumn<String>(
'url',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
$customConstraints: 'NOT NULL',
);
late final GeneratedColumn<int> source = GeneratedColumn<int>(
'source',
aliasedName,
false,
type: DriftSqlType.int,
requiredDuringInsert: true,
$customConstraints: 'NOT NULL',
);
late final GeneratedColumn<String> orderKey = GeneratedColumn<String>(
'order_key',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
$customConstraints: 'NOT NULL',
);
late final GeneratedColumn<int> createdAt = GeneratedColumn<int>(
'created_at',
aliasedName,
false,
type: DriftSqlType.int,
requiredDuringInsert: true,
$customConstraints: 'NOT NULL',
);
@override
List<GeneratedColumn> get $columns => [
id,
title,
url,
source,
orderKey,
createdAt,
];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'top_site';
@override
Set<GeneratedColumn> get $primaryKey => {id};
@override
List<Set<GeneratedColumn>> get uniqueKeys => [
{url},
];
@override
TopSiteData map(Map<String, dynamic> data, {String? tablePrefix}) {
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
return TopSiteData(
id: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}id'],
)!,
title: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}title'],
)!,
url: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}url'],
)!,
source: attachedDatabase.typeMapping.read(
DriftSqlType.int,
data['${effectivePrefix}source'],
)!,
orderKey: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}order_key'],
)!,
createdAt: attachedDatabase.typeMapping.read(
DriftSqlType.int,
data['${effectivePrefix}created_at'],
)!,
);
}
@override
TopSite createAlias(String alias) {
return TopSite(attachedDatabase, alias);
}
@override
List<String> get customConstraints => const ['UNIQUE(url)'];
@override
bool get dontWriteConstraints => true;
}
class TopSiteData extends DataClass implements Insertable<TopSiteData> {
final String id;
final String title;
final String url;
final int source;
final String orderKey;
final int createdAt;
const TopSiteData({
required this.id,
required this.title,
required this.url,
required this.source,
required this.orderKey,
required this.createdAt,
});
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
map['id'] = Variable<String>(id);
map['title'] = Variable<String>(title);
map['url'] = Variable<String>(url);
map['source'] = Variable<int>(source);
map['order_key'] = Variable<String>(orderKey);
map['created_at'] = Variable<int>(createdAt);
return map;
}
factory TopSiteData.fromJson(
Map<String, dynamic> json, {
ValueSerializer? serializer,
}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return TopSiteData(
id: serializer.fromJson<String>(json['id']),
title: serializer.fromJson<String>(json['title']),
url: serializer.fromJson<String>(json['url']),
source: serializer.fromJson<int>(json['source']),
orderKey: serializer.fromJson<String>(json['orderKey']),
createdAt: serializer.fromJson<int>(json['createdAt']),
);
}
@override
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'id': serializer.toJson<String>(id),
'title': serializer.toJson<String>(title),
'url': serializer.toJson<String>(url),
'source': serializer.toJson<int>(source),
'orderKey': serializer.toJson<String>(orderKey),
'createdAt': serializer.toJson<int>(createdAt),
};
}
TopSiteData copyWith({
String? id,
String? title,
String? url,
int? source,
String? orderKey,
int? createdAt,
}) => TopSiteData(
id: id ?? this.id,
title: title ?? this.title,
url: url ?? this.url,
source: source ?? this.source,
orderKey: orderKey ?? this.orderKey,
createdAt: createdAt ?? this.createdAt,
);
TopSiteData copyWithCompanion(TopSiteCompanion data) {
return TopSiteData(
id: data.id.present ? data.id.value : this.id,
title: data.title.present ? data.title.value : this.title,
url: data.url.present ? data.url.value : this.url,
source: data.source.present ? data.source.value : this.source,
orderKey: data.orderKey.present ? data.orderKey.value : this.orderKey,
createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt,
);
}
@override
String toString() {
return (StringBuffer('TopSiteData(')
..write('id: $id, ')
..write('title: $title, ')
..write('url: $url, ')
..write('source: $source, ')
..write('orderKey: $orderKey, ')
..write('createdAt: $createdAt')
..write(')'))
.toString();
}
@override
int get hashCode => Object.hash(id, title, url, source, orderKey, createdAt);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is TopSiteData &&
other.id == this.id &&
other.title == this.title &&
other.url == this.url &&
other.source == this.source &&
other.orderKey == this.orderKey &&
other.createdAt == this.createdAt);
}
class TopSiteCompanion extends UpdateCompanion<TopSiteData> {
final Value<String> id;
final Value<String> title;
final Value<String> url;
final Value<int> source;
final Value<String> orderKey;
final Value<int> createdAt;
final Value<int> rowid;
const TopSiteCompanion({
this.id = const Value.absent(),
this.title = const Value.absent(),
this.url = const Value.absent(),
this.source = const Value.absent(),
this.orderKey = const Value.absent(),
this.createdAt = const Value.absent(),
this.rowid = const Value.absent(),
});
TopSiteCompanion.insert({
required String id,
required String title,
required String url,
required int source,
required String orderKey,
required int createdAt,
this.rowid = const Value.absent(),
}) : id = Value(id),
title = Value(title),
url = Value(url),
source = Value(source),
orderKey = Value(orderKey),
createdAt = Value(createdAt);
static Insertable<TopSiteData> custom({
Expression<String>? id,
Expression<String>? title,
Expression<String>? url,
Expression<int>? source,
Expression<String>? orderKey,
Expression<int>? createdAt,
Expression<int>? rowid,
}) {
return RawValuesInsertable({
if (id != null) 'id': id,
if (title != null) 'title': title,
if (url != null) 'url': url,
if (source != null) 'source': source,
if (orderKey != null) 'order_key': orderKey,
if (createdAt != null) 'created_at': createdAt,
if (rowid != null) 'rowid': rowid,
});
}
TopSiteCompanion copyWith({
Value<String>? id,
Value<String>? title,
Value<String>? url,
Value<int>? source,
Value<String>? orderKey,
Value<int>? createdAt,
Value<int>? rowid,
}) {
return TopSiteCompanion(
id: id ?? this.id,
title: title ?? this.title,
url: url ?? this.url,
source: source ?? this.source,
orderKey: orderKey ?? this.orderKey,
createdAt: createdAt ?? this.createdAt,
rowid: rowid ?? this.rowid,
);
}
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
if (id.present) {
map['id'] = Variable<String>(id.value);
}
if (title.present) {
map['title'] = Variable<String>(title.value);
}
if (url.present) {
map['url'] = Variable<String>(url.value);
}
if (source.present) {
map['source'] = Variable<int>(source.value);
}
if (orderKey.present) {
map['order_key'] = Variable<String>(orderKey.value);
}
if (createdAt.present) {
map['created_at'] = Variable<int>(createdAt.value);
}
if (rowid.present) {
map['rowid'] = Variable<int>(rowid.value);
}
return map;
}
@override
String toString() {
return (StringBuffer('TopSiteCompanion(')
..write('id: $id, ')
..write('title: $title, ')
..write('url: $url, ')
..write('source: $source, ')
..write('orderKey: $orderKey, ')
..write('createdAt: $createdAt, ')
..write('rowid: $rowid')
..write(')'))
.toString();
}
}
class HiddenTopSite extends Table
with TableInfo<HiddenTopSite, HiddenTopSiteData> {
@override
final GeneratedDatabase attachedDatabase;
final String? _alias;
HiddenTopSite(this.attachedDatabase, [this._alias]);
late final GeneratedColumn<String> url = GeneratedColumn<String>(
'url',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
$customConstraints: 'PRIMARY KEY NOT NULL',
);
@override
List<GeneratedColumn> get $columns => [url];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'hidden_top_site';
@override
Set<GeneratedColumn> get $primaryKey => {url};
@override
HiddenTopSiteData map(Map<String, dynamic> data, {String? tablePrefix}) {
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
return HiddenTopSiteData(
url: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}url'],
)!,
);
}
@override
HiddenTopSite createAlias(String alias) {
return HiddenTopSite(attachedDatabase, alias);
}
@override
bool get dontWriteConstraints => true;
}
class HiddenTopSiteData extends DataClass
implements Insertable<HiddenTopSiteData> {
final String url;
const HiddenTopSiteData({required this.url});
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
map['url'] = Variable<String>(url);
return map;
}
factory HiddenTopSiteData.fromJson(
Map<String, dynamic> json, {
ValueSerializer? serializer,
}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return HiddenTopSiteData(url: serializer.fromJson<String>(json['url']));
}
@override
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{'url': serializer.toJson<String>(url)};
}
HiddenTopSiteData copyWith({String? url}) =>
HiddenTopSiteData(url: url ?? this.url);
HiddenTopSiteData copyWithCompanion(HiddenTopSiteCompanion data) {
return HiddenTopSiteData(url: data.url.present ? data.url.value : this.url);
}
@override
String toString() {
return (StringBuffer('HiddenTopSiteData(')
..write('url: $url')
..write(')'))
.toString();
}
@override
int get hashCode => url.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is HiddenTopSiteData && other.url == this.url);
}
class HiddenTopSiteCompanion extends UpdateCompanion<HiddenTopSiteData> {
final Value<String> url;
final Value<int> rowid;
const HiddenTopSiteCompanion({
this.url = const Value.absent(),
this.rowid = const Value.absent(),
});
HiddenTopSiteCompanion.insert({
required String url,
this.rowid = const Value.absent(),
}) : url = Value(url);
static Insertable<HiddenTopSiteData> custom({
Expression<String>? url,
Expression<int>? rowid,
}) {
return RawValuesInsertable({
if (url != null) 'url': url,
if (rowid != null) 'rowid': rowid,
});
}
HiddenTopSiteCompanion copyWith({Value<String>? url, Value<int>? rowid}) {
return HiddenTopSiteCompanion(
url: url ?? this.url,
rowid: rowid ?? this.rowid,
);
}
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
if (url.present) {
map['url'] = Variable<String>(url.value);
}
if (rowid.present) {
map['rowid'] = Variable<int>(rowid.value);
}
return map;
}
@override
String toString() {
return (StringBuffer('HiddenTopSiteCompanion(')
..write('url: $url, ')
..write('rowid: $rowid')
..write(')'))
.toString();
}
}
class DatabaseAtV1 extends GeneratedDatabase {
DatabaseAtV1(QueryExecutor e) : super(e);
late final TopSite topSite = TopSite(this);
late final Index idxTopSiteOrderKey = Index(
'idx_top_site_order_key',
'CREATE INDEX idx_top_site_order_key ON top_site (order_key)',
);
late final HiddenTopSite hiddenTopSite = HiddenTopSite(this);
@override
Iterable<TableInfo<Table, Object?>> get allTables =>
allSchemaEntities.whereType<TableInfo<Table, Object?>>();
@override
List<DatabaseSchemaEntity> get allSchemaEntities => [
topSite,
idxTopSiteOrderKey,
hiddenTopSite,
];
@override
int get schemaVersion => 1;
}
@@ -0,0 +1,649 @@
// dart format width=80
// GENERATED BY drift_dev, DO NOT MODIFY.
// ignore_for_file: type=lint,unused_import
//
import 'package:drift/drift.dart';
class TopSite extends Table with TableInfo<TopSite, TopSiteData> {
@override
final GeneratedDatabase attachedDatabase;
final String? _alias;
TopSite(this.attachedDatabase, [this._alias]);
late final GeneratedColumn<String> id = GeneratedColumn<String>(
'id',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
$customConstraints: 'PRIMARY KEY NOT NULL',
);
late final GeneratedColumn<String> title = GeneratedColumn<String>(
'title',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
$customConstraints: 'NOT NULL',
);
late final GeneratedColumn<String> url = GeneratedColumn<String>(
'url',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
$customConstraints: 'NOT NULL',
);
late final GeneratedColumn<int> source = GeneratedColumn<int>(
'source',
aliasedName,
false,
type: DriftSqlType.int,
requiredDuringInsert: true,
$customConstraints: 'NOT NULL',
);
late final GeneratedColumn<String> orderKey = GeneratedColumn<String>(
'order_key',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
$customConstraints: 'NOT NULL',
);
late final GeneratedColumn<int> createdAt = GeneratedColumn<int>(
'created_at',
aliasedName,
false,
type: DriftSqlType.int,
requiredDuringInsert: true,
$customConstraints: 'NOT NULL',
);
@override
List<GeneratedColumn> get $columns => [
id,
title,
url,
source,
orderKey,
createdAt,
];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'top_site';
@override
Set<GeneratedColumn> get $primaryKey => {id};
@override
List<Set<GeneratedColumn>> get uniqueKeys => [
{url},
];
@override
TopSiteData map(Map<String, dynamic> data, {String? tablePrefix}) {
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
return TopSiteData(
id: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}id'],
)!,
title: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}title'],
)!,
url: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}url'],
)!,
source: attachedDatabase.typeMapping.read(
DriftSqlType.int,
data['${effectivePrefix}source'],
)!,
orderKey: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}order_key'],
)!,
createdAt: attachedDatabase.typeMapping.read(
DriftSqlType.int,
data['${effectivePrefix}created_at'],
)!,
);
}
@override
TopSite createAlias(String alias) {
return TopSite(attachedDatabase, alias);
}
@override
List<String> get customConstraints => const ['UNIQUE(url)'];
@override
bool get dontWriteConstraints => true;
}
class TopSiteData extends DataClass implements Insertable<TopSiteData> {
final String id;
final String title;
final String url;
final int source;
final String orderKey;
final int createdAt;
const TopSiteData({
required this.id,
required this.title,
required this.url,
required this.source,
required this.orderKey,
required this.createdAt,
});
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
map['id'] = Variable<String>(id);
map['title'] = Variable<String>(title);
map['url'] = Variable<String>(url);
map['source'] = Variable<int>(source);
map['order_key'] = Variable<String>(orderKey);
map['created_at'] = Variable<int>(createdAt);
return map;
}
factory TopSiteData.fromJson(
Map<String, dynamic> json, {
ValueSerializer? serializer,
}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return TopSiteData(
id: serializer.fromJson<String>(json['id']),
title: serializer.fromJson<String>(json['title']),
url: serializer.fromJson<String>(json['url']),
source: serializer.fromJson<int>(json['source']),
orderKey: serializer.fromJson<String>(json['orderKey']),
createdAt: serializer.fromJson<int>(json['createdAt']),
);
}
@override
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'id': serializer.toJson<String>(id),
'title': serializer.toJson<String>(title),
'url': serializer.toJson<String>(url),
'source': serializer.toJson<int>(source),
'orderKey': serializer.toJson<String>(orderKey),
'createdAt': serializer.toJson<int>(createdAt),
};
}
TopSiteData copyWith({
String? id,
String? title,
String? url,
int? source,
String? orderKey,
int? createdAt,
}) => TopSiteData(
id: id ?? this.id,
title: title ?? this.title,
url: url ?? this.url,
source: source ?? this.source,
orderKey: orderKey ?? this.orderKey,
createdAt: createdAt ?? this.createdAt,
);
TopSiteData copyWithCompanion(TopSiteCompanion data) {
return TopSiteData(
id: data.id.present ? data.id.value : this.id,
title: data.title.present ? data.title.value : this.title,
url: data.url.present ? data.url.value : this.url,
source: data.source.present ? data.source.value : this.source,
orderKey: data.orderKey.present ? data.orderKey.value : this.orderKey,
createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt,
);
}
@override
String toString() {
return (StringBuffer('TopSiteData(')
..write('id: $id, ')
..write('title: $title, ')
..write('url: $url, ')
..write('source: $source, ')
..write('orderKey: $orderKey, ')
..write('createdAt: $createdAt')
..write(')'))
.toString();
}
@override
int get hashCode => Object.hash(id, title, url, source, orderKey, createdAt);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is TopSiteData &&
other.id == this.id &&
other.title == this.title &&
other.url == this.url &&
other.source == this.source &&
other.orderKey == this.orderKey &&
other.createdAt == this.createdAt);
}
class TopSiteCompanion extends UpdateCompanion<TopSiteData> {
final Value<String> id;
final Value<String> title;
final Value<String> url;
final Value<int> source;
final Value<String> orderKey;
final Value<int> createdAt;
final Value<int> rowid;
const TopSiteCompanion({
this.id = const Value.absent(),
this.title = const Value.absent(),
this.url = const Value.absent(),
this.source = const Value.absent(),
this.orderKey = const Value.absent(),
this.createdAt = const Value.absent(),
this.rowid = const Value.absent(),
});
TopSiteCompanion.insert({
required String id,
required String title,
required String url,
required int source,
required String orderKey,
required int createdAt,
this.rowid = const Value.absent(),
}) : id = Value(id),
title = Value(title),
url = Value(url),
source = Value(source),
orderKey = Value(orderKey),
createdAt = Value(createdAt);
static Insertable<TopSiteData> custom({
Expression<String>? id,
Expression<String>? title,
Expression<String>? url,
Expression<int>? source,
Expression<String>? orderKey,
Expression<int>? createdAt,
Expression<int>? rowid,
}) {
return RawValuesInsertable({
if (id != null) 'id': id,
if (title != null) 'title': title,
if (url != null) 'url': url,
if (source != null) 'source': source,
if (orderKey != null) 'order_key': orderKey,
if (createdAt != null) 'created_at': createdAt,
if (rowid != null) 'rowid': rowid,
});
}
TopSiteCompanion copyWith({
Value<String>? id,
Value<String>? title,
Value<String>? url,
Value<int>? source,
Value<String>? orderKey,
Value<int>? createdAt,
Value<int>? rowid,
}) {
return TopSiteCompanion(
id: id ?? this.id,
title: title ?? this.title,
url: url ?? this.url,
source: source ?? this.source,
orderKey: orderKey ?? this.orderKey,
createdAt: createdAt ?? this.createdAt,
rowid: rowid ?? this.rowid,
);
}
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
if (id.present) {
map['id'] = Variable<String>(id.value);
}
if (title.present) {
map['title'] = Variable<String>(title.value);
}
if (url.present) {
map['url'] = Variable<String>(url.value);
}
if (source.present) {
map['source'] = Variable<int>(source.value);
}
if (orderKey.present) {
map['order_key'] = Variable<String>(orderKey.value);
}
if (createdAt.present) {
map['created_at'] = Variable<int>(createdAt.value);
}
if (rowid.present) {
map['rowid'] = Variable<int>(rowid.value);
}
return map;
}
@override
String toString() {
return (StringBuffer('TopSiteCompanion(')
..write('id: $id, ')
..write('title: $title, ')
..write('url: $url, ')
..write('source: $source, ')
..write('orderKey: $orderKey, ')
..write('createdAt: $createdAt, ')
..write('rowid: $rowid')
..write(')'))
.toString();
}
}
class HiddenTopSite extends Table
with TableInfo<HiddenTopSite, HiddenTopSiteData> {
@override
final GeneratedDatabase attachedDatabase;
final String? _alias;
HiddenTopSite(this.attachedDatabase, [this._alias]);
late final GeneratedColumn<String> url = GeneratedColumn<String>(
'url',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
$customConstraints: 'PRIMARY KEY NOT NULL',
);
@override
List<GeneratedColumn> get $columns => [url];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'hidden_top_site';
@override
Set<GeneratedColumn> get $primaryKey => {url};
@override
HiddenTopSiteData map(Map<String, dynamic> data, {String? tablePrefix}) {
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
return HiddenTopSiteData(
url: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}url'],
)!,
);
}
@override
HiddenTopSite createAlias(String alias) {
return HiddenTopSite(attachedDatabase, alias);
}
@override
bool get dontWriteConstraints => true;
}
class HiddenTopSiteData extends DataClass
implements Insertable<HiddenTopSiteData> {
final String url;
const HiddenTopSiteData({required this.url});
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
map['url'] = Variable<String>(url);
return map;
}
factory HiddenTopSiteData.fromJson(
Map<String, dynamic> json, {
ValueSerializer? serializer,
}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return HiddenTopSiteData(url: serializer.fromJson<String>(json['url']));
}
@override
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{'url': serializer.toJson<String>(url)};
}
HiddenTopSiteData copyWith({String? url}) =>
HiddenTopSiteData(url: url ?? this.url);
HiddenTopSiteData copyWithCompanion(HiddenTopSiteCompanion data) {
return HiddenTopSiteData(url: data.url.present ? data.url.value : this.url);
}
@override
String toString() {
return (StringBuffer('HiddenTopSiteData(')
..write('url: $url')
..write(')'))
.toString();
}
@override
int get hashCode => url.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is HiddenTopSiteData && other.url == this.url);
}
class HiddenTopSiteCompanion extends UpdateCompanion<HiddenTopSiteData> {
final Value<String> url;
final Value<int> rowid;
const HiddenTopSiteCompanion({
this.url = const Value.absent(),
this.rowid = const Value.absent(),
});
HiddenTopSiteCompanion.insert({
required String url,
this.rowid = const Value.absent(),
}) : url = Value(url);
static Insertable<HiddenTopSiteData> custom({
Expression<String>? url,
Expression<int>? rowid,
}) {
return RawValuesInsertable({
if (url != null) 'url': url,
if (rowid != null) 'rowid': rowid,
});
}
HiddenTopSiteCompanion copyWith({Value<String>? url, Value<int>? rowid}) {
return HiddenTopSiteCompanion(
url: url ?? this.url,
rowid: rowid ?? this.rowid,
);
}
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
if (url.present) {
map['url'] = Variable<String>(url.value);
}
if (rowid.present) {
map['rowid'] = Variable<int>(rowid.value);
}
return map;
}
@override
String toString() {
return (StringBuffer('HiddenTopSiteCompanion(')
..write('url: $url, ')
..write('rowid: $rowid')
..write(')'))
.toString();
}
}
class HiddenTopSiteHost extends Table
with TableInfo<HiddenTopSiteHost, HiddenTopSiteHostData> {
@override
final GeneratedDatabase attachedDatabase;
final String? _alias;
HiddenTopSiteHost(this.attachedDatabase, [this._alias]);
late final GeneratedColumn<String> host = GeneratedColumn<String>(
'host',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
$customConstraints: 'PRIMARY KEY NOT NULL',
);
@override
List<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<GeneratedColumn> get $primaryKey => {host};
@override
HiddenTopSiteHostData map(Map<String, dynamic> data, {String? tablePrefix}) {
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
return HiddenTopSiteHostData(
host: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}host'],
)!,
);
}
@override
HiddenTopSiteHost createAlias(String alias) {
return HiddenTopSiteHost(attachedDatabase, alias);
}
@override
bool get dontWriteConstraints => true;
}
class HiddenTopSiteHostData extends DataClass
implements Insertable<HiddenTopSiteHostData> {
final String host;
const HiddenTopSiteHostData({required this.host});
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
map['host'] = Variable<String>(host);
return map;
}
factory HiddenTopSiteHostData.fromJson(
Map<String, dynamic> json, {
ValueSerializer? serializer,
}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return HiddenTopSiteHostData(
host: serializer.fromJson<String>(json['host']),
);
}
@override
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{'host': serializer.toJson<String>(host)};
}
HiddenTopSiteHostData copyWith({String? host}) =>
HiddenTopSiteHostData(host: host ?? this.host);
HiddenTopSiteHostData copyWithCompanion(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 HiddenTopSiteHostData && other.host == this.host);
}
class HiddenTopSiteHostCompanion
extends UpdateCompanion<HiddenTopSiteHostData> {
final Value<String> host;
final Value<int> rowid;
const HiddenTopSiteHostCompanion({
this.host = const Value.absent(),
this.rowid = const Value.absent(),
});
HiddenTopSiteHostCompanion.insert({
required String host,
this.rowid = const Value.absent(),
}) : host = Value(host);
static Insertable<HiddenTopSiteHostData> custom({
Expression<String>? host,
Expression<int>? rowid,
}) {
return RawValuesInsertable({
if (host != null) 'host': host,
if (rowid != null) 'rowid': rowid,
});
}
HiddenTopSiteHostCompanion copyWith({
Value<String>? host,
Value<int>? rowid,
}) {
return HiddenTopSiteHostCompanion(
host: host ?? this.host,
rowid: rowid ?? this.rowid,
);
}
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
if (host.present) {
map['host'] = Variable<String>(host.value);
}
if (rowid.present) {
map['rowid'] = Variable<int>(rowid.value);
}
return map;
}
@override
String toString() {
return (StringBuffer('HiddenTopSiteHostCompanion(')
..write('host: $host, ')
..write('rowid: $rowid')
..write(')'))
.toString();
}
}
class DatabaseAtV2 extends GeneratedDatabase {
DatabaseAtV2(QueryExecutor e) : super(e);
late final TopSite topSite = TopSite(this);
late final Index idxTopSiteOrderKey = Index(
'idx_top_site_order_key',
'CREATE INDEX idx_top_site_order_key ON top_site (order_key)',
);
late final HiddenTopSite hiddenTopSite = HiddenTopSite(this);
late final HiddenTopSiteHost hiddenTopSiteHost = HiddenTopSiteHost(this);
@override
Iterable<TableInfo<Table, Object?>> get allTables =>
allSchemaEntities.whereType<TableInfo<Table, Object?>>();
@override
List<DatabaseSchemaEntity> get allSchemaEntities => [
topSite,
idxTopSiteOrderKey,
hiddenTopSite,
hiddenTopSiteHost,
];
@override
int get schemaVersion => 2;
}
@@ -0,0 +1,76 @@
// dart format width=80
// ignore_for_file: unused_local_variable, unused_import
import 'package:drift/drift.dart';
import 'package:drift_dev/api/migrations_native.dart';
import 'package:weblibre/features/geckoview/features/top_sites/data/database/database.dart';
import 'package:flutter_test/flutter_test.dart';
import 'generated/schema.dart';
import 'generated/schema_v1.dart' as v1;
import 'generated/schema_v2.dart' as v2;
void main() {
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
late SchemaVerifier verifier;
setUpAll(() {
verifier = SchemaVerifier(GeneratedHelper());
});
group('simple database migrations', () {
// These simple tests verify all possible schema updates with a simple (no
// data) migration. This is a quick way to ensure that written database
// migrations properly alter the schema.
const versions = GeneratedHelper.versions;
for (final (i, fromVersion) in versions.indexed) {
group('from $fromVersion', () {
for (final toVersion in versions.skip(i + 1)) {
test('to $toVersion', () async {
final schema = await verifier.schemaAt(fromVersion);
final db = TopSiteDatabase(schema.newConnection());
await verifier.migrateAndValidate(db, toVersion);
await db.close();
});
}
});
}
});
// The following template shows how to write tests ensuring your migrations
// preserve existing data.
// Testing this can be useful for migrations that change existing columns
// (e.g. by alterating their type or constraints). Migrations that only add
// tables or columns typically don't need these advanced tests. For more
// information, see https://drift.simonbinder.eu/migrations/tests/#verifying-data-integrity
// TODO: This generated template shows how these tests could be written. Adopt
// it to your own needs when testing migrations with data integrity.
test('migration from v1 to v2 does not corrupt data', () async {
// Add data to insert into the old database, and the expected rows after the
// migration.
// TODO: Fill these lists
final oldTopSiteData = <v1.TopSiteData>[];
final expectedNewTopSiteData = <v2.TopSiteData>[];
final oldHiddenTopSiteData = <v1.HiddenTopSiteData>[];
final expectedNewHiddenTopSiteData = <v2.HiddenTopSiteData>[];
await verifier.testWithDataIntegrity(
oldVersion: 1,
newVersion: 2,
createOld: v1.DatabaseAtV1.new,
createNew: v2.DatabaseAtV2.new,
openTestedDatabase: TopSiteDatabase.new,
createItems: (batch, oldDb) {
batch.insertAll(oldDb.topSite, oldTopSiteData);
batch.insertAll(oldDb.hiddenTopSite, oldHiddenTopSiteData);
},
validateItems: (newDb) async {
expect(expectedNewTopSiteData, await newDb.select(newDb.topSite).get());
expect(
expectedNewHiddenTopSiteData,
await newDb.select(newDb.hiddenTopSite).get(),
);
},
);
});
}
@@ -1,159 +0,0 @@
// dart format width=80
// GENERATED BY drift_dev, DO NOT MODIFY.
// ignore_for_file: type=lint,unused_import
//
import 'package:drift/drift.dart';
class TopSite extends Table with TableInfo {
@override
final GeneratedDatabase attachedDatabase;
final String? _alias;
TopSite(this.attachedDatabase, [this._alias]);
late final GeneratedColumn<String> id = GeneratedColumn<String>(
'id',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
$customConstraints: 'PRIMARY KEY NOT NULL',
);
late final GeneratedColumn<String> title = GeneratedColumn<String>(
'title',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
$customConstraints: 'NOT NULL',
);
late final GeneratedColumn<String> url = GeneratedColumn<String>(
'url',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
$customConstraints: 'NOT NULL',
);
late final GeneratedColumn<int> source = GeneratedColumn<int>(
'source',
aliasedName,
false,
type: DriftSqlType.int,
requiredDuringInsert: true,
$customConstraints: 'NOT NULL',
);
late final GeneratedColumn<String> orderKey = GeneratedColumn<String>(
'order_key',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
$customConstraints: 'NOT NULL',
);
late final GeneratedColumn<int> createdAt = GeneratedColumn<int>(
'created_at',
aliasedName,
false,
type: DriftSqlType.int,
requiredDuringInsert: true,
$customConstraints: 'NOT NULL',
);
@override
List<GeneratedColumn> get $columns => [
id,
title,
url,
source,
orderKey,
createdAt,
];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'top_site';
@override
Set<GeneratedColumn> get $primaryKey => {id};
@override
List<Set<GeneratedColumn>> get uniqueKeys => [
{url},
];
@override
Never map(Map<String, dynamic> data, {String? tablePrefix}) {
throw UnsupportedError('TableInfo.map in schema verification code');
}
@override
TopSite createAlias(String alias) {
return TopSite(attachedDatabase, alias);
}
@override
List<String> get customConstraints => const ['UNIQUE(url)'];
@override
bool get dontWriteConstraints => true;
}
class TopSiteSeedState extends Table with TableInfo {
@override
final GeneratedDatabase attachedDatabase;
final String? _alias;
TopSiteSeedState(this.attachedDatabase, [this._alias]);
late final GeneratedColumn<String> seedId = GeneratedColumn<String>(
'seed_id',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
$customConstraints: 'PRIMARY KEY NOT NULL',
);
late final GeneratedColumn<int> appliedAt = GeneratedColumn<int>(
'applied_at',
aliasedName,
false,
type: DriftSqlType.int,
requiredDuringInsert: true,
$customConstraints: 'NOT NULL',
);
@override
List<GeneratedColumn> get $columns => [seedId, appliedAt];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'top_site_seed_state';
@override
Set<GeneratedColumn> get $primaryKey => {seedId};
@override
Never map(Map<String, dynamic> data, {String? tablePrefix}) {
throw UnsupportedError('TableInfo.map in schema verification code');
}
@override
TopSiteSeedState createAlias(String alias) {
return TopSiteSeedState(attachedDatabase, alias);
}
@override
bool get dontWriteConstraints => true;
}
class DatabaseAtV1 extends GeneratedDatabase {
DatabaseAtV1(QueryExecutor e) : super(e);
late final TopSite topSite = TopSite(this);
late final Index idxTopSiteOrderKey = Index(
'idx_top_site_order_key',
'CREATE INDEX idx_top_site_order_key ON top_site (order_key)',
);
late final TopSiteSeedState topSiteSeedState = TopSiteSeedState(this);
@override
Iterable<TableInfo<Table, Object?>> get allTables =>
allSchemaEntities.whereType<TableInfo<Table, Object?>>();
@override
List<DatabaseSchemaEntity> get allSchemaEntities => [
topSite,
idxTopSiteOrderKey,
topSiteSeedState,
];
@override
int get schemaVersion => 1;
}
@@ -1,61 +0,0 @@
/*
* 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/>.
*/
// dart format width=80
// ignore_for_file: unused_local_variable, unused_import
import 'package:drift/drift.dart';
import 'package:drift_dev/api/migrations_native.dart';
import 'package:weblibre/features/geckoview/features/top_sites/data/database/database.dart';
import 'package:flutter_test/flutter_test.dart';
import 'generated/schema.dart';
import 'generated/schema_v1.dart' as v1;
void main() {
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
late SchemaVerifier verifier;
setUpAll(() {
verifier = SchemaVerifier(GeneratedHelper());
});
group('simple database migrations', () {
const versions = GeneratedHelper.versions;
for (final (i, fromVersion) in versions.indexed) {
group('from $fromVersion', () {
for (final toVersion in versions.skip(i + 1)) {
test('to $toVersion', () async {
final schema = await verifier.schemaAt(fromVersion);
final db = TopSiteDatabase(schema.newConnection());
await verifier.migrateAndValidate(db, toVersion);
await db.close();
});
}
});
}
});
test('v1 schema creation works', () async {
final schema = await verifier.schemaAt(1);
final db = TopSiteDatabase(schema.newConnection());
await verifier.migrateAndValidate(db, 1);
await db.close();
});
}
@@ -0,0 +1,217 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/controllers/home_target_controller.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/entities/home_target.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
import 'package:weblibre/features/user/data/models/general_settings.dart';
ContainerData _container(String id) =>
ContainerData(id: id, color: const Color(0xFF000000), orderKey: 'a');
void main() {
group('resolveHomeTargetContainer', () {
final selected = _container('selected');
final scoped = _container('scoped');
test('unscoped follows the selected container', () {
expect(
resolveHomeTargetContainer(
scopeToContainer: false,
scopedContainer: null,
selectedContainer: selected,
),
isA<SpecificContainerTabSelection>().having(
(s) => s.container.id,
'container',
'selected',
),
);
});
test('unscoped with no selection is unassigned', () {
expect(
resolveHomeTargetContainer(
scopeToContainer: false,
scopedContainer: null,
selectedContainer: null,
),
isA<UnassignedContainerTabSelection>(),
);
});
test('scoped uses its own container, not the selected one', () {
expect(
resolveHomeTargetContainer(
scopeToContainer: true,
scopedContainer: scoped,
selectedContainer: selected,
),
isA<SpecificContainerTabSelection>().having(
(s) => s.container.id,
'container',
'scoped',
),
);
});
test('scoped to the unassigned container stays unassigned', () {
// The case a plain null-check gets wrong: closing the last unassigned tab
// scopes to "unassigned", which is a real container, not the absence of
// a scope — falling back to the selected container would move the user.
expect(
resolveHomeTargetContainer(
scopeToContainer: true,
scopedContainer: null,
selectedContainer: selected,
),
isA<UnassignedContainerTabSelection>(),
);
});
});
group('default', () {
test('is home, so startup is unchanged for existing users', () {
// Any other default would alter startup behaviour for everyone on
// upgrade. Changing this needs a deliberate decision, not a drive-by.
expect(GeneralSettings.withDefaults().homeTarget, HomeTarget.home);
expect(GeneralSettings.withDefaults().homeTargetUrl, isNull);
expect(GeneralSettings.withDefaults().homeTargetOnLastTabClosed, isFalse);
});
});
group('resolveHomeTarget', () {
test('home stays home', () {
expect(
resolveHomeTarget(target: HomeTarget.home, customUrl: null),
HomeTarget.home,
);
});
test(
'resume is returned; the caller decides if there is anything to resume',
() {
expect(
resolveHomeTarget(target: HomeTarget.resumeLastTab, customUrl: null),
HomeTarget.resumeLastTab,
);
},
);
test('a configured address is used', () {
expect(
resolveHomeTarget(
target: HomeTarget.customUrl,
customUrl: 'https://example.com',
),
HomeTarget.customUrl,
);
});
test('an unset address falls back to home', () {
expect(
resolveHomeTarget(target: HomeTarget.customUrl, customUrl: null),
HomeTarget.home,
);
expect(
resolveHomeTarget(target: HomeTarget.customUrl, customUrl: ' '),
HomeTarget.home,
);
});
test('an unparseable address falls back to home', () {
expect(
resolveHomeTarget(
target: HomeTarget.customUrl,
customUrl: 'not a url at all',
),
HomeTarget.home,
);
});
group('custom-URL reopen loop', () {
test('closing the configured page does not reopen it', () {
expect(
resolveHomeTarget(
target: HomeTarget.customUrl,
customUrl: 'https://example.com/start',
closingTabUrl: Uri.parse('https://example.com/start'),
),
HomeTarget.home,
);
});
test('the URL guard ignores scheme and host case', () {
expect(
resolveHomeTarget(
target: HomeTarget.customUrl,
customUrl: 'https://Example.com/start',
closingTabUrl: Uri.parse('http://example.com/start'),
),
HomeTarget.home,
);
});
test('closing a different page still opens the configured one', () {
expect(
resolveHomeTarget(
target: HomeTarget.customUrl,
customUrl: 'https://example.com/start',
closingTabUrl: Uri.parse('https://example.com/other'),
),
HomeTarget.customUrl,
);
});
test('reopening within the guard window is suppressed', () {
final now = DateTime(2026, 8, 1, 12);
expect(
resolveHomeTarget(
target: HomeTarget.customUrl,
customUrl: 'https://example.com',
lastCustomUrlOpenedAt: now.subtract(const Duration(seconds: 1)),
now: now,
),
HomeTarget.home,
reason:
'a redirect away from the configured page would otherwise '
'defeat the URL guard and loop',
);
});
test('reopening after the window is allowed', () {
final now = DateTime(2026, 8, 1, 12);
expect(
resolveHomeTarget(
target: HomeTarget.customUrl,
customUrl: 'https://example.com',
lastCustomUrlOpenedAt: now.subtract(const Duration(seconds: 30)),
now: now,
),
HomeTarget.customUrl,
);
});
});
});
}
@@ -0,0 +1,248 @@
/*
* 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:convert';
import 'package:flutter_test/flutter_test.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';
ModuleOrderEntry _entry(SearchModuleType type, {bool visible = true}) =>
ModuleOrderEntry(type: type, visible: visible);
List<SearchModuleType> _types(List<ModuleOrderEntry> entries) =>
entries.map((e) => e.type).toList();
void main() {
group('mergeModuleOrderWithDefaults', () {
test('uses the defaults verbatim when nothing is persisted', () {
const defaults = <ModuleSurfaceDefault>[
(type: SearchModuleType.recentSearches, visible: true),
(type: SearchModuleType.topSites, visible: true),
];
final merged = mergeModuleOrderWithDefaults(null, defaults);
expect(_types(merged), defaults.map((d) => d.type).toList());
expect(merged.every((e) => e.visible), isTrue);
});
test('preserves a reordered persisted list', () {
const defaults = <ModuleSurfaceDefault>[
(type: SearchModuleType.recentSearches, visible: true),
(type: SearchModuleType.frequentBangs, visible: true),
(type: SearchModuleType.topSites, visible: true),
];
final persisted = [
_entry(SearchModuleType.topSites),
_entry(SearchModuleType.recentSearches),
_entry(SearchModuleType.frequentBangs),
];
final merged = mergeModuleOrderWithDefaults(persisted, defaults);
expect(_types(merged), _types(persisted));
});
test('preserves persisted visibility', () {
const defaults = <ModuleSurfaceDefault>[
(type: SearchModuleType.recentSearches, visible: true),
(type: SearchModuleType.topSites, visible: true),
];
final persisted = [
_entry(SearchModuleType.recentSearches, visible: false),
_entry(SearchModuleType.topSites),
];
final merged = mergeModuleOrderWithDefaults(persisted, defaults);
expect(merged[0].visible, isFalse);
expect(merged[1].visible, isTrue);
});
test('drops persisted modules that are no longer offered', () {
const defaults = <ModuleSurfaceDefault>[
(type: SearchModuleType.topSites, visible: true),
];
final persisted = [
_entry(SearchModuleType.recentSearches),
_entry(SearchModuleType.topSites),
];
final merged = mergeModuleOrderWithDefaults(persisted, defaults);
expect(_types(merged), [SearchModuleType.topSites]);
});
test('inserts a new default at its position, not at the tail', () {
const defaults = <ModuleSurfaceDefault>[
(type: SearchModuleType.recentSearches, visible: true),
(
type: SearchModuleType.frequentBangs,
visible: true,
), // newly introduced, in the middle
(type: SearchModuleType.topSites, visible: true),
];
final persisted = [
_entry(SearchModuleType.recentSearches),
_entry(SearchModuleType.topSites),
];
final merged = mergeModuleOrderWithDefaults(persisted, defaults);
expect(_types(merged), [
SearchModuleType.recentSearches,
SearchModuleType.frequentBangs,
SearchModuleType.topSites,
]);
});
test('a new default keeps its own visibility instead of forcing on', () {
// This is what lets a module be offered on a surface without switching it
// on for everyone who already customised that surface.
const defaults = <ModuleSurfaceDefault>[
(type: SearchModuleType.topSites, visible: true),
(type: SearchModuleType.quote, visible: false),
];
final persisted = [_entry(SearchModuleType.topSites)];
final merged = mergeModuleOrderWithDefaults(persisted, defaults);
expect(
merged.firstWhere((e) => e.type == SearchModuleType.quote).visible,
isFalse,
);
});
test('clamps the insert position when the persisted list is shorter', () {
const defaults = <ModuleSurfaceDefault>[
(type: SearchModuleType.recentSearches, visible: true),
(type: SearchModuleType.frequentBangs, visible: true),
(type: SearchModuleType.topSites, visible: true),
(
type: SearchModuleType.containers,
visible: true,
), // index 3, beyond the persisted length
];
final persisted = [_entry(SearchModuleType.recentSearches)];
final merged = mergeModuleOrderWithDefaults(persisted, defaults);
expect(
merged.map((e) => e.type).toSet(),
defaults.map((d) => d.type).toSet(),
);
expect(merged, hasLength(defaults.length));
});
test('is idempotent', () {
const defaults = <ModuleSurfaceDefault>[
(type: SearchModuleType.recentSearches, visible: true),
(type: SearchModuleType.frequentBangs, visible: true),
(type: SearchModuleType.topSites, visible: true),
];
final persisted = [
_entry(SearchModuleType.topSites, visible: false),
_entry(SearchModuleType.recentSearches),
];
final once = mergeModuleOrderWithDefaults(persisted, defaults);
final twice = mergeModuleOrderWithDefaults(once, defaults);
expect(twice, once);
});
});
group('persisted payload compatibility', () {
// The storage key and the on-disk shape are a compatibility contract: the
// empty-state order has shipped to users under this exact key, encoded by
// ModuleOrderEntry.toJson. Changing either silently resets their layout.
test('the empty-state order keeps its shipped storage key', () {
expect(ModuleSurface.newTab.key, 'EmptyStateModuleOrder');
});
test('a real shipped payload round-trips unchanged', () {
// Captured from the shape SearchModuleOrder.build writes today: a user
// who moved Shortcuts to the top and hid History Highlights.
const payload =
'[{"type":"topSites","visible":true},'
'{"type":"recentSearches","visible":true},'
'{"type":"frequentBangs","visible":true},'
'{"type":"recentArticles","visible":true},'
'{"type":"recentTabs","visible":true},'
'{"type":"recentHistory","visible":true},'
'{"type":"historyHighlights","visible":false},'
'{"type":"containers","visible":true}]';
final decoded = (jsonDecode(payload) as List<dynamic>)
.cast<Map<String, dynamic>>()
.map(ModuleOrderEntry.fromJson)
.toList();
final merged = mergeModuleOrderWithDefaults(
decoded,
ModuleSurface.newTab.defaultModules,
);
// Everything the user saved survives, in their order, untouched...
expect(
merged.where((e) => decoded.any((d) => d.type == e.type)).toList(),
decoded,
reason: 'a saved layout must survive the surface rename untouched',
);
expect(_types(merged).first, SearchModuleType.topSites);
expect(
merged
.firstWhere((e) => e.type == SearchModuleType.historyHighlights)
.visible,
isFalse,
);
// ...and modules added since then appear without switching themselves on.
final added = merged.where((e) => !decoded.any((d) => d.type == e.type));
expect(
added.every((e) => !e.visible),
isTrue,
reason: 'a module added to a shipped surface must default to off',
);
});
test('unparseable entries are skipped rather than poisoning the list', () {
// Mirrors the try/catch in SearchModuleOrder.build's decode: an entry
// naming a module that no longer exists must not discard the whole order.
const payload =
'[{"type":"topSites","visible":true},'
'{"type":"aModuleThatWasRemoved","visible":true}]';
final decoded = (jsonDecode(payload) as List<dynamic>)
.cast<Map<String, dynamic>>()
.map((e) {
try {
return ModuleOrderEntry.fromJson(e);
} catch (_) {
return null;
}
})
.whereType<ModuleOrderEntry>()
.toList();
expect(_types(decoded), [SearchModuleType.topSites]);
});
});
}
@@ -0,0 +1,205 @@
/*
* 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_mozilla_components/flutter_mozilla_components.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:weblibre/extensions/uri.dart';
import 'package:weblibre/features/geckoview/features/top_sites/domain/entities/top_site_host.dart';
import 'package:weblibre/features/geckoview/features/top_sites/domain/repositories/top_site_repository.dart';
TopFrecentSiteInfo _site(String url, {String? title}) =>
TopFrecentSiteInfo(url: url, title: title);
void main() {
group('canonicalTopSiteHost', () {
test('lowercases the host', () {
expect(
canonicalTopSiteHost(Uri.parse('https://EXAMPLE.com/x')),
'example.com',
);
});
test('strips a leading www.', () {
expect(
canonicalTopSiteHost(Uri.parse('https://www.example.com')),
'example.com',
);
});
test('strips only one leading www.', () {
expect(
canonicalTopSiteHost(Uri.parse('https://www.www.example.com')),
'www.example.com',
);
});
test('drops the port', () {
expect(
canonicalTopSiteHost(Uri.parse('https://example.com:8443/x')),
'example.com',
);
});
test('keeps subdomains distinct', () {
expect(
canonicalTopSiteHost(Uri.parse('https://app.discord.com')),
isNot(canonicalTopSiteHost(Uri.parse('https://discord.com'))),
);
});
test('handles IP literals', () {
expect(
canonicalTopSiteHost(Uri.parse('http://127.0.0.1:8080')),
'127.0.0.1',
);
});
test('returns empty for authority-less URLs so it never matches', () {
expect(canonicalTopSiteHost(Uri.parse('about:blank')), isEmpty);
expect(canonicalTopSiteHost(Uri.parse('data:text/plain,hi')), isEmpty);
});
});
group('filterFrecentTopSites', () {
test('maps frecent sites to history-sourced shortcuts', () {
final items = filterFrecentTopSites(
sites: [_site('https://example.com', title: 'Example')],
limit: 5,
excludeUrls: const {},
excludeHosts: const {},
);
expect(items, hasLength(1));
expect(items.single.title, 'Example');
expect(items.single.url, Uri.parse('https://example.com'));
});
test('falls back to the host when a site has no title', () {
final items = filterFrecentTopSites(
sites: [_site('https://example.com/page')],
limit: 5,
excludeUrls: const {},
excludeHosts: const {},
);
expect(items.single.title, 'example.com');
});
test('a hidden URL suppresses the matching history entry', () {
// Regression test: the hidden list was only ever applied to the bundled
// defaults, so removing a frecency-ranked shortcut looked like it worked
// and then the site reappeared on the next refresh.
final items = filterFrecentTopSites(
sites: [_site('https://example.com'), _site('https://other.com')],
limit: 5,
excludeUrls: {Uri.parse('https://example.com').normalized.toString()},
excludeHosts: const {},
);
expect(items.map((i) => i.url.host), ['other.com']);
});
test('a hidden host suppresses every URL on it (issue #267)', () {
// The reported case: a PWA occupying 19 of 25 slots with distinct URLs.
final items = filterFrecentTopSites(
sites: [
for (var i = 0; i < 19; i++) _site('https://discord.com/channels/$i'),
_site('https://example.com'),
_site('https://other.com'),
],
limit: 25,
excludeUrls: const {},
excludeHosts: {'discord.com'},
);
expect(
items.any((i) => i.url.host == 'discord.com'),
isFalse,
reason: 'hiding the domain must clear every one of its URLs',
);
expect(items.map((i) => i.url.host), ['example.com', 'other.com']);
});
test('host exclusion ignores www. and case', () {
final items = filterFrecentTopSites(
sites: [_site('https://WWW.Discord.com/app')],
limit: 5,
excludeUrls: const {},
excludeHosts: {'discord.com'},
);
expect(items, isEmpty);
});
test('still fills up to the limit once exclusions are applied', () {
final items = filterFrecentTopSites(
sites: [
for (var i = 0; i < 10; i++) _site('https://blocked.com/$i'),
for (var i = 0; i < 5; i++) _site('https://site$i.com'),
],
limit: 3,
excludeUrls: const {},
excludeHosts: {'blocked.com'},
);
expect(items, hasLength(3));
});
test('never returns more than the limit', () {
final items = filterFrecentTopSites(
sites: [for (var i = 0; i < 20; i++) _site('https://site$i.com')],
limit: 4,
excludeUrls: const {},
excludeHosts: const {},
);
expect(items, hasLength(4));
});
test('a pinned site is unaffected by its host being hidden', () {
// Regression guard for the fix to addPinnedSite: pinned entries are
// returned ahead of these filters, so pinning one URL never needs to
// lift a domain-wide hide — doing so would restore every other page on
// that domain the user had just removed.
final items = filterFrecentTopSites(
sites: [_site('https://discord.com/a'), _site('https://discord.com/b')],
limit: 25,
excludeUrls: const {},
excludeHosts: {'discord.com'},
);
expect(
items,
isEmpty,
reason: 'the host stays hidden for everything that is not pinned',
);
});
test('skips unparseable URLs instead of throwing', () {
final items = filterFrecentTopSites(
sites: [_site('::::not a url'), _site('https://example.com')],
limit: 5,
excludeUrls: const {},
excludeHosts: const {},
);
expect(items.map((i) => i.url.host), ['example.com']);
});
});
}
@@ -0,0 +1,98 @@
/*
* 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:drift/drift.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:weblibre/features/user/data/models/general_settings.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
void main() {
group('GeneralSettings deserialization coverage', () {
// The failure this guards against is silent: a field added to
// GeneralSettings without a matching read in the deserializer saves to the
// database correctly and then reverts to its default on the next launch,
// because nothing ever reads it back out.
test('every serialized field is read back by the deserializer', () {
final serializedKeys = GeneralSettings.withDefaults()
.toJson()
.keys
.toSet();
final readKeys = {
...generalSettingColumnTypes.keys,
...generalSettingJsonKeys,
};
expect(
serializedKeys.difference(readKeys),
isEmpty,
reason:
'These GeneralSettings fields are written but never read back. '
'Add each one to generalSettingColumnTypes (with its DriftSqlType) '
'or, for JSON documents, to generalSettingJsonKeys.',
);
});
test('a key is never both a plain column and a JSON document', () {
expect(
generalSettingColumnTypes.keys.toSet().intersection(
generalSettingJsonKeys,
),
isEmpty,
);
});
test('JSON-backed settings are absent from the plain column types', () {
// They are read as strings and decoded, so listing them in the column map
// as well would hand fromJson the raw encoded string.
for (final key in generalSettingJsonKeys) {
expect(generalSettingColumnTypes.containsKey(key), isFalse);
}
});
test('legacy keys are retained so fromJson migrations keep working', () {
// These no longer exist on GeneralSettings but are still consumed by the
// migrations in GeneralSettings.fromJson, so they must stay readable.
for (final legacyKey in const [
'newTabPosition',
'tabBarShowQuickTabSwitcherBar',
'quickTabSwitcherMode',
]) {
expect(
generalSettingColumnTypes.containsKey(legacyKey),
isTrue,
reason: '$legacyKey is a legacy key consumed by a fromJson migration',
);
}
});
test('column types are limited to the kinds the setting table stores', () {
const supported = {
DriftSqlType.string,
DriftSqlType.bool,
DriftSqlType.int,
DriftSqlType.double,
};
for (final MapEntry(key: key, value: type)
in generalSettingColumnTypes.entries) {
expect(supported, contains(type), reason: '$key has type $type');
}
});
});
}