new home screen
This commit is contained in:
@@ -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:
|
||||
|
||||
+10
-16
@@ -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();
|
||||
|
||||
+290
@@ -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());
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
+68
@@ -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);
|
||||
}
|
||||
}
|
||||
+43
@@ -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',
|
||||
};
|
||||
}
|
||||
+42
@@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
+185
-247
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+28
@@ -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 =>
|
||||
|
||||
+111
-79
@@ -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()),
|
||||
|
||||
+129
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+37
-17
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+10
-8
@@ -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() {
|
||||
|
||||
+91
-48
@@ -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;
|
||||
}
|
||||
|
||||
+85
-34
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
+22
-8
@@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
+270
-303
@@ -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'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
+104
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
+127
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
+170
-38
@@ -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 (_) {}
|
||||
},
|
||||
),
|
||||
|
||||
+76
@@ -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;
|
||||
}
|
||||
+200
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+8
-7
@@ -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(
|
||||
|
||||
+21
-30
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
+56
-34
@@ -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) {
|
||||
|
||||
+23
@@ -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.
|
||||
|
||||
+102
-1
@@ -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._();
|
||||
|
||||
+27
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+34
-1
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
+6
@@ -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 {
|
||||
|
||||
+177
@@ -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),
|
||||
);
|
||||
+9
@@ -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,
|
||||
|
||||
+281
@@ -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}) {
|
||||
|
||||
+47
@@ -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;
|
||||
}
|
||||
+101
-27
@@ -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) {
|
||||
|
||||
+1
-1
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
+118
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
+4
-1
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
+248
@@ -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]);
|
||||
});
|
||||
});
|
||||
}
|
||||
+205
@@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user