From dbfcd1daaaf651a8e34d32485739c105668dd704 Mon Sep 17 00:00:00 2001 From: Fabian Freund Date: Tue, 4 Aug 2026 16:03:30 +0200 Subject: [PATCH] new home screen --- apps/weblibre/build.yaml | 1 + .../top_site}/drift_schema_v1.json | 26 +- .../top_site/drift_schema_v2.json | 210 ++++++ apps/weblibre/lib/core/routing/routes.dart | 3 + apps/weblibre/lib/core/routing/routes.g.dart | 78 +++ .../lib/core/routing/routes.settings.dart | 41 ++ .../geckoview/domain/repositories/tab.dart | 93 ++- .../geckoview/domain/repositories/tab.g.dart | 2 +- .../controllers/home_target_controller.dart | 290 ++++++++ .../controllers/home_target_controller.g.dart | 68 ++ .../browser/domain/entities/home_target.dart | 43 ++ .../browser/presentation/screens/browser.dart | 42 ++ .../presentation/widgets/browser_home.dart | 432 +++++------- .../browser_modules/bottom_app_bar.dart | 28 + .../widgets/browser_modules/browser_view.dart | 190 ++--- .../widgets/home/home_search_pill.dart | 129 ++++ .../domain/providers/search_module_order.dart | 54 +- .../providers/search_module_order.g.dart | 18 +- .../domain/providers/search_modules_view.dart | 139 ++-- .../providers/search_modules_view.g.dart | 119 +++- .../dialogs/edit_top_site_dialog.dart | 30 +- .../search/presentation/screens/search.dart | 573 ++++++++-------- .../empty_state/quick_actions_section.dart | 104 +++ .../widgets/empty_state/quote_section.dart | 127 ++++ .../empty_state/top_sites_section.dart | 208 +++++- .../widgets/module_surface_scope.dart | 76 ++ .../widgets/module_surface_slivers.dart | 200 ++++++ .../widgets/search_module_reorder_view.dart | 15 +- .../search_modules/search_module_header.dart | 51 +- .../search_modules/search_module_section.dart | 90 ++- .../features/tabs/data/database/daos/tab.dart | 29 +- .../domain/providers/selected_container.dart | 23 + .../providers/selected_container.g.dart | 103 ++- .../data/database/daos/hidden_top_site.dart | 27 + .../top_sites/data/database/database.dart | 35 +- .../data/database/database.drift.dart | 6 + .../data/database/database.steps.dart | 177 +++++ .../top_sites/data/database/definitions.drift | 9 + .../data/database/definitions.drift.dart | 281 ++++++++ .../domain/entities/top_site_host.dart | 47 ++ .../repositories/top_site_repository.dart | 128 +++- .../repositories/top_site_repository.g.dart | 2 +- .../presentation/screens/home_settings.dart | 234 +++++++ .../screens/module_surface_settings.dart | 118 ++++ .../presentation/screens/settings.dart | 17 + .../user/data/models/general_settings.dart | 26 + .../user/data/models/general_settings.g.dart | 51 ++ .../domain/repositories/general_settings.dart | 385 +++-------- .../repositories/general_settings.g.dart | 2 +- .../presentation/widgets/browser_page.dart | 229 +++--- .../presentation/widgets/url_list_tile.dart | 7 +- .../test/drift/tabs/resume_fifo_test.dart | 164 +++++ .../generated/schema.dart | 5 +- .../drift/top_site/generated/schema_v1.dart | 500 ++++++++++++++ .../drift/top_site/generated/schema_v2.dart | 649 ++++++++++++++++++ .../test/drift/top_site/migration_test.dart | 76 ++ .../drift/top_sites/generated/schema_v1.dart | 159 ----- .../test/drift/top_sites/migration_test.dart | 61 -- .../features/browser/home_target_test.dart | 217 ++++++ .../domain/search_module_order_test.dart | 248 +++++++ .../top_sites/top_site_filtering_test.dart | 205 ++++++ .../general_settings_deserialize_test.dart | 98 +++ 62 files changed, 6302 insertions(+), 1496 deletions(-) rename apps/weblibre/{test/drift/top_sites/generated => drift_schemas/top_site}/drift_schema_v1.json (87%) create mode 100644 apps/weblibre/drift_schemas/top_site/drift_schema_v2.json create mode 100644 apps/weblibre/lib/features/geckoview/features/browser/domain/controllers/home_target_controller.dart create mode 100644 apps/weblibre/lib/features/geckoview/features/browser/domain/controllers/home_target_controller.g.dart create mode 100644 apps/weblibre/lib/features/geckoview/features/browser/domain/entities/home_target.dart create mode 100644 apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/home/home_search_pill.dart create mode 100644 apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/empty_state/quick_actions_section.dart create mode 100644 apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/empty_state/quote_section.dart create mode 100644 apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/module_surface_scope.dart create mode 100644 apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/module_surface_slivers.dart create mode 100644 apps/weblibre/lib/features/geckoview/features/top_sites/data/database/database.steps.dart create mode 100644 apps/weblibre/lib/features/geckoview/features/top_sites/domain/entities/top_site_host.dart create mode 100644 apps/weblibre/lib/features/settings/presentation/screens/home_settings.dart create mode 100644 apps/weblibre/lib/features/settings/presentation/screens/module_surface_settings.dart create mode 100644 apps/weblibre/test/drift/tabs/resume_fifo_test.dart rename apps/weblibre/test/drift/{top_sites => top_site}/generated/schema.dart (81%) create mode 100644 apps/weblibre/test/drift/top_site/generated/schema_v1.dart create mode 100644 apps/weblibre/test/drift/top_site/generated/schema_v2.dart create mode 100644 apps/weblibre/test/drift/top_site/migration_test.dart delete mode 100644 apps/weblibre/test/drift/top_sites/generated/schema_v1.dart delete mode 100644 apps/weblibre/test/drift/top_sites/migration_test.dart create mode 100644 apps/weblibre/test/features/geckoview/features/browser/home_target_test.dart create mode 100644 apps/weblibre/test/features/geckoview/features/search/domain/search_module_order_test.dart create mode 100644 apps/weblibre/test/features/geckoview/features/top_sites/top_site_filtering_test.dart create mode 100644 apps/weblibre/test/features/user/general_settings_deserialize_test.dart diff --git a/apps/weblibre/build.yaml b/apps/weblibre/build.yaml index 9cbfbce2..9d5c17c4 100644 --- a/apps/weblibre/build.yaml +++ b/apps/weblibre/build.yaml @@ -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: diff --git a/apps/weblibre/test/drift/top_sites/generated/drift_schema_v1.json b/apps/weblibre/drift_schemas/top_site/drift_schema_v1.json similarity index 87% rename from apps/weblibre/test/drift/top_sites/generated/drift_schema_v1.json rename to apps/weblibre/drift_schemas/top_site/drift_schema_v1.json index 76c64126..e5a47449 100644 --- a/apps/weblibre/test/drift/top_sites/generated/drift_schema_v1.json +++ b/apps/weblibre/drift_schemas/top_site/drift_schema_v1.json @@ -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);" } ] } diff --git a/apps/weblibre/drift_schemas/top_site/drift_schema_v2.json b/apps/weblibre/drift_schemas/top_site/drift_schema_v2.json new file mode 100644 index 00000000..5189860b --- /dev/null +++ b/apps/weblibre/drift_schemas/top_site/drift_schema_v2.json @@ -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.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);" + } + ] + } + ] +} \ No newline at end of file diff --git a/apps/weblibre/lib/core/routing/routes.dart b/apps/weblibre/lib/core/routing/routes.dart index 71c454ad..c69e3289 100644 --- a/apps/weblibre/lib/core/routing/routes.dart +++ b/apps/weblibre/lib/core/routing/routes.dart @@ -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'; diff --git a/apps/weblibre/lib/core/routing/routes.g.dart b/apps/weblibre/lib/core/routing/routes.g.dart index 05d1b8c6..b5fc0d61 100644 --- a/apps/weblibre/lib/core/routing/routes.g.dart +++ b/apps/weblibre/lib/core/routing/routes.g.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 push(BuildContext context) => context.push(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 push(BuildContext context) => context.push(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 push(BuildContext context) => context.push(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(); diff --git a/apps/weblibre/lib/core/routing/routes.settings.dart b/apps/weblibre/lib/core/routing/routes.settings.dart index 4d926c46..e909a959 100644 --- a/apps/weblibre/lib/core/routing/routes.settings.dart +++ b/apps/weblibre/lib/core/routing/routes.settings.dart @@ -117,6 +117,15 @@ part of 'routes.dart'; name: 'UnshortenerSettingsRoute', path: 'unshortener', ), + TypedGoRoute(name: 'HomeSettingsRoute', path: 'home'), + TypedGoRoute( + name: 'HomeModulesSettingsRoute', + path: 'home_modules', + ), + TypedGoRoute( + name: 'NewTabModulesSettingsRoute', + path: 'new_tab_modules', + ), TypedGoRoute( 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(); diff --git a/apps/weblibre/lib/features/geckoview/domain/repositories/tab.dart b/apps/weblibre/lib/features/geckoview/domain/repositories/tab.dart index fda194eb..79490027 100644 --- a/apps/weblibre/lib/features/geckoview/domain/repositories/tab.dart +++ b/apps/weblibre/lib/features/geckoview/domain/repositories/tab.dart @@ -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 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 selectPreviouslyOpenedTab(String tabId) async { @@ -392,11 +407,11 @@ class TabRepository extends _$TabRepository { return false; } - Future resumeLatestTab() async { + Future resumeLatestTab({Set 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 resumeLatestContainerTab(String? containerId) async { + Future resumeLatestContainerTab( + String? containerId, { + Set 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 _selectTabAfterClose(String tabId) async { + _clearForceBrowserHome(); + await _tabsService.selectTab(tabId: tabId); + } + Future _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); } } diff --git a/apps/weblibre/lib/features/geckoview/domain/repositories/tab.g.dart b/apps/weblibre/lib/features/geckoview/domain/repositories/tab.g.dart index 4b69d487..4bd00c28 100644 --- a/apps/weblibre/lib/features/geckoview/domain/repositories/tab.g.dart +++ b/apps/weblibre/lib/features/geckoview/domain/repositories/tab.g.dart @@ -41,7 +41,7 @@ final class TabRepositoryProvider } } -String _$tabRepositoryHash() => r'8abe7686d937434e2970675c143f3891ec34cce2'; +String _$tabRepositoryHash() => r'c94ccf85da3fee36ebac3521f51f265a5f8d34d3'; abstract class _$TabRepository extends $Notifier { void build(); diff --git a/apps/weblibre/lib/features/geckoview/features/browser/domain/controllers/home_target_controller.dart b/apps/weblibre/lib/features/geckoview/features/browser/domain/controllers/home_target_controller.dart new file mode 100644 index 00000000..9b4d8698 --- /dev/null +++ b/apps/weblibre/lib/features/geckoview/features/browser/domain/controllers/home_target_controller.dart @@ -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 . + */ +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 applyTarget({ + bool scopeToContainer = false, + String? containerId, + Uri? closingTabUrl, + Set 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 _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 _hasRestoredSelection() async { + if (ref.read(selectedTabProvider) != null) return true; + + final completer = Completer(); + + // 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()); + }, + ); + } +} diff --git a/apps/weblibre/lib/features/geckoview/features/browser/domain/controllers/home_target_controller.g.dart b/apps/weblibre/lib/features/geckoview/features/browser/domain/controllers/home_target_controller.g.dart new file mode 100644 index 00000000..1175e043 --- /dev/null +++ b/apps/weblibre/lib/features/geckoview/features/browser/domain/controllers/home_target_controller.g.dart @@ -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 { + /// 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(value), + ); + } +} + +String _$homeTargetControllerHash() => + r'1bb3a879ee569261b1dfcc4c0d8b28ef0cb7f7d3'; + +/// Applies the configured [HomeTarget] when the browser has nothing to show. + +abstract class _$HomeTargetController extends $Notifier { + void build(); + @$mustCallSuper + @override + WhenComplete runBuild() { + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + void, + Object?, + Object? + >; + return element.handleCreate(ref, build); + } +} diff --git a/apps/weblibre/lib/features/geckoview/features/browser/domain/entities/home_target.dart b/apps/weblibre/lib/features/geckoview/features/browser/domain/entities/home_target.dart new file mode 100644 index 00000000..39cf3ce8 --- /dev/null +++ b/apps/weblibre/lib/features/geckoview/features/browser/domain/entities/home_target.dart @@ -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 . + */ + +/// 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', + }; +} diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/screens/browser.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/screens/browser.dart index 0d2d0985..696cdf82 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/screens/browser.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/screens/browser.dart @@ -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, ), ), diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_home.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_home.dart index 158298f1..b6ae30ba 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_home.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_home.dart @@ -17,230 +17,219 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ +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 openNewTab() { - return SearchRoute( - tabType: settings.effectiveDefaultCreateTabType, - ).push(context); + final tabType = ref + .read(generalSettingsWithDefaultsProvider) + .effectiveDefaultCreateTabType; + return SearchRoute(tabType: tabType).push(context); } - Future viewTabs() { - return const TabViewRoute().push(context); - } + Future viewTabs() => const TabViewRoute().push(context); - Future resumeLatestTab() async { - await ref.read(tabRepositoryProvider.notifier).resumeLatestTab(); - } - - Future resumeLatestContainerTab() async { + Future 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, - ), - ), - ], - ], - ); - } -} diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart index 1fcf724f..cdf9a8e0 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart @@ -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 => diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart index 76d2f4b8..98b3bfb1 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart @@ -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 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 } : 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()), diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/home/home_search_pill.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/home/home_search_pill.dart new file mode 100644 index 00000000..aa001c5d --- /dev/null +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/home/home_search_pill.dart @@ -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 . + */ +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), + ], + ), + ), + ); + } +} diff --git a/apps/weblibre/lib/features/geckoview/features/search/domain/providers/search_module_order.dart b/apps/weblibre/lib/features/geckoview/features/search/domain/providers/search_module_order.dart index e7a3dd3d..8978a631 100644 --- a/apps/weblibre/lib/features/geckoview/features/search/domain/providers/search_module_order.dart +++ b/apps/weblibre/lib/features/geckoview/features/search/domain/providers/search_module_order.dart @@ -45,28 +45,45 @@ class ModuleOrderEntry with FastEquatable { List get hashParameters => [type, visible]; } -List _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 mergeModuleOrderWithDefaults( List? persisted, - List defaults, + List defaults, ) { + List 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 build(SearchModuleGroup group) { + List 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() .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); } } diff --git a/apps/weblibre/lib/features/geckoview/features/search/domain/providers/search_module_order.g.dart b/apps/weblibre/lib/features/geckoview/features/search/domain/providers/search_module_order.g.dart index a7b2bf8f..14aa17c4 100644 --- a/apps/weblibre/lib/features/geckoview/features/search/domain/providers/search_module_order.g.dart +++ b/apps/weblibre/lib/features/geckoview/features/search/domain/providers/search_module_order.g.dart @@ -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> { 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, List, List, - 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> { - late final _$args = ref.$arg as SearchModuleGroup; - SearchModuleGroup get group => _$args; + late final _$args = ref.$arg as ModuleSurface; + ModuleSurface get surface => _$args; - List build(SearchModuleGroup group); + List build(ModuleSurface surface); @$mustCallSuper @override WhenComplete runBuild() { diff --git a/apps/weblibre/lib/features/geckoview/features/search/domain/providers/search_modules_view.dart b/apps/weblibre/lib/features/geckoview/features/search/domain/providers/search_modules_view.dart index b8c14e97..94428abb 100644 --- a/apps/weblibre/lib/features/geckoview/features/search/domain/providers/search_modules_view.dart +++ b/apps/weblibre/lib/features/geckoview/features/search/domain/providers/search_modules_view.dart @@ -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 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 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; } diff --git a/apps/weblibre/lib/features/geckoview/features/search/domain/providers/search_modules_view.g.dart b/apps/weblibre/lib/features/geckoview/features/search/domain/providers/search_modules_view.g.dart index ddff8b24..52f6f5ea 100644 --- a/apps/weblibre/lib/features/geckoview/features/search/domain/providers/search_modules_view.g.dart +++ b/apps/weblibre/lib/features/geckoview/features/search/domain/providers/search_modules_view.g.dart @@ -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 { - 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 { - SearchReorderModeProvider._() - : super( - from: null, - argument: null, - retry: null, - name: r'searchReorderModeProvider', - isAutoDispose: true, - dependencies: null, - $allTransitiveDependencies: null, - ); + extends $NotifierProvider { + 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(value), + providerOverride: $SyncValueProvider(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? 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 { + 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; + final ref = this.ref as $Ref; final element = ref.element as $ClassProviderElement< - AnyNotifier, - SearchModuleGroup?, + AnyNotifier, + bool, Object?, Object? >; - return element.handleCreate(ref, build); + return element.handleCreate(ref, () => build(_$args)); } } diff --git a/apps/weblibre/lib/features/geckoview/features/search/presentation/dialogs/edit_top_site_dialog.dart b/apps/weblibre/lib/features/geckoview/features/search/presentation/dialogs/edit_top_site_dialog.dart index abe73a66..1fb3af61 100644 --- a/apps/weblibre/lib/features/geckoview/features/search/presentation/dialogs/edit_top_site_dialog.dart +++ b/apps/weblibre/lib/features/geckoview/features/search/presentation/dialogs/edit_top_site_dialog.dart @@ -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), ), ], ); diff --git a/apps/weblibre/lib/features/geckoview/features/search/presentation/screens/search.dart b/apps/weblibre/lib/features/geckoview/features/search/presentation/screens/search.dart index bbd2d8e6..ef2790bd 100644 --- a/apps/weblibre/lib/features/geckoview/features/search/presentation/screens/search.dart +++ b/apps/weblibre/lib/features/geckoview/features/search/presentation/screens/search.dart @@ -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.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.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'), + ), ), ), ), diff --git a/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/empty_state/quick_actions_section.dart b/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/empty_state/quick_actions_section.dart new file mode 100644 index 00000000..3442fbae --- /dev/null +++ b/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/empty_state/quick_actions_section.dart @@ -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 . + */ +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'), + ), + ], + ), + ), + ), + ], + ); + } +} diff --git a/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/empty_state/quote_section.dart b/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/empty_state/quote_section.dart new file mode 100644 index 00000000..83e39084 --- /dev/null +++ b/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/empty_state/quote_section.dart @@ -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 . + */ +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, + ), + ), + ], + ], + ); + } +} diff --git a/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/empty_state/top_sites_section.dart b/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/empty_state/top_sites_section.dart index 660bd155..90989808 100644 --- a/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/empty_state/top_sites_section.dart +++ b/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/empty_state/top_sites_section.dart @@ -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 [], + 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 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 persistedItems; final List 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 _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 _editItem( } } +Future _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 _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 (_) {} }, ), diff --git a/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/module_surface_scope.dart b/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/module_surface_scope.dart new file mode 100644 index 00000000..ab425577 --- /dev/null +++ b/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/module_surface_scope.dart @@ -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 . + */ +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(); + 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; +} diff --git a/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/module_surface_slivers.dart b/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/module_surface_slivers.dart new file mode 100644 index 00000000..f514a210 --- /dev/null +++ b/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/module_surface_slivers.dart @@ -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 . + */ +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 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 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, + ), + ), + ), + ), + ); + } +} diff --git a/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/search_module_reorder_view.dart b/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/search_module_reorder_view.dart index 922fe396..48f46e44 100644 --- a/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/search_module_reorder_view.dart +++ b/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/search_module_reorder_view.dart @@ -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( diff --git a/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/search_modules/search_module_header.dart b/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/search_modules/search_module_header.dart index f30f3aac..1d6856fb 100644 --- a/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/search_modules/search_module_header.dart +++ b/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/search_modules/search_module_header.dart @@ -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, + ), ), ), ], diff --git a/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/search_modules/search_module_section.dart b/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/search_modules/search_module_section.dart index 30bae316..01e78027 100644 --- a/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/search_modules/search_module_section.dart +++ b/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/search_modules/search_module_section.dart @@ -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, diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/data/database/daos/tab.dart b/apps/weblibre/lib/features/geckoview/features/tabs/data/database/daos/tab.dart index cec5a62e..98b816d2 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/data/database/daos/tab.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/data/database/daos/tab.dart @@ -112,17 +112,34 @@ class TabDao extends DatabaseAccessor with $TabDaoMixin { return query.map((row) => row.read(db.tab.id)!); } - Selectable 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 getTabsFifo({ + int limit = 25, + Set 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 getContainerTabsFifo( String? containerId, { int limit = 25, + Set 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 with $TabDaoMixin { ) ..limit(limit) ..orderBy([(t) => OrderingTerm.desc(t.timestamp)]); + + if (excludedTabIds.isNotEmpty) { + query.where((t) => t.id.isNotIn(excludedTabIds)); + } + + return query; } SingleOrNullSelectable getTabContainerId(String tabId) { diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/domain/providers/selected_container.dart b/apps/weblibre/lib/features/geckoview/features/tabs/domain/providers/selected_container.dart index c76dc80e..1c35709b 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/domain/providers/selected_container.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/domain/providers/selected_container.dart @@ -172,10 +172,31 @@ Stream 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 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. diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/domain/providers/selected_container.g.dart b/apps/weblibre/lib/features/geckoview/features/tabs/domain/providers/selected_container.g.dart index e40c570b..310ea353 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/domain/providers/selected_container.g.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/domain/providers/selected_container.g.dart @@ -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 { + /// 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(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 build(); + @$mustCallSuper + @override + WhenComplete runBuild() { + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + 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._(); diff --git a/apps/weblibre/lib/features/geckoview/features/top_sites/data/database/daos/hidden_top_site.dart b/apps/weblibre/lib/features/geckoview/features/top_sites/data/database/daos/hidden_top_site.dart index 0aefb60d..74373d58 100644 --- a/apps/weblibre/lib/features/geckoview/features/top_sites/data/database/daos/hidden_top_site.dart +++ b/apps/weblibre/lib/features/geckoview/features/top_sites/data/database/daos/hidden_top_site.dart @@ -51,4 +51,31 @@ class HiddenTopSiteDao extends DatabaseAccessor ..where((t) => t.url.equalsValue(url.normalized))) .go(); } + + Future> getHiddenHosts() async { + final rows = await db.hiddenTopSiteHost.select().get(); + return rows.map((r) => r.host).toSet(); + } + + Stream> watchHiddenHosts() { + return db.hiddenTopSiteHost.select().watch().map( + (rows) => rows.map((r) => r.host).toSet(), + ); + } + + Future hideHost(String host) { + if (host.isEmpty) { + return Future.value(); + } + + return db.hiddenTopSiteHost.insertOne( + HiddenTopSiteHostCompanion.insert(host: host), + mode: InsertMode.insertOrIgnore, + ); + } + + Future unhideHost(String host) { + return (db.hiddenTopSiteHost.delete()..where((t) => t.host.equals(host))) + .go(); + } } diff --git a/apps/weblibre/lib/features/geckoview/features/top_sites/data/database/database.dart b/apps/weblibre/lib/features/geckoview/features/top_sites/data/database/database.dart index 9d5992b7..aaa898fe 100644 --- a/apps/weblibre/lib/features/geckoview/features/top_sites/data/database/database.dart +++ b/apps/weblibre/lib/features/geckoview/features/top_sites/data/database/database.dart @@ -18,11 +18,13 @@ * along with this program. If not, see . */ 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); + }, + ); } diff --git a/apps/weblibre/lib/features/geckoview/features/top_sites/data/database/database.drift.dart b/apps/weblibre/lib/features/geckoview/features/top_sites/data/database/database.drift.dart index f0c17509..123a0f98 100644 --- a/apps/weblibre/lib/features/geckoview/features/top_sites/data/database/database.drift.dart +++ b/apps/weblibre/lib/features/geckoview/features/top_sites/data/database/database.drift.dart @@ -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 { diff --git a/apps/weblibre/lib/features/geckoview/features/top_sites/data/database/database.steps.dart b/apps/weblibre/lib/features/geckoview/features/top_sites/data/database/database.steps.dart new file mode 100644 index 00000000..65b3d94e --- /dev/null +++ b/apps/weblibre/lib/features/geckoview/features/top_sites/data/database/database.steps.dart @@ -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 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 get id => + columnsByName['id']! as i1.GeneratedColumn; + i1.GeneratedColumn get title => + columnsByName['title']! as i1.GeneratedColumn; + i1.GeneratedColumn get url => + columnsByName['url']! as i1.GeneratedColumn; + i1.GeneratedColumn get source => + columnsByName['source']! as i1.GeneratedColumn; + i1.GeneratedColumn get orderKey => + columnsByName['order_key']! as i1.GeneratedColumn; + i1.GeneratedColumn get createdAt => + columnsByName['created_at']! as i1.GeneratedColumn; +} + +i1.GeneratedColumn _column_0(String aliasedName) => + i1.GeneratedColumn( + 'id', + aliasedName, + false, + type: i1.DriftSqlType.string, + $customConstraints: 'PRIMARY KEY NOT NULL', + ); +i1.GeneratedColumn _column_1(String aliasedName) => + i1.GeneratedColumn( + 'title', + aliasedName, + false, + type: i1.DriftSqlType.string, + $customConstraints: 'NOT NULL', + ); +i1.GeneratedColumn _column_2(String aliasedName) => + i1.GeneratedColumn( + 'url', + aliasedName, + false, + type: i1.DriftSqlType.string, + $customConstraints: 'NOT NULL', + ); +i1.GeneratedColumn _column_3(String aliasedName) => + i1.GeneratedColumn( + 'source', + aliasedName, + false, + type: i1.DriftSqlType.int, + $customConstraints: 'NOT NULL', + ); +i1.GeneratedColumn _column_4(String aliasedName) => + i1.GeneratedColumn( + 'order_key', + aliasedName, + false, + type: i1.DriftSqlType.string, + $customConstraints: 'NOT NULL', + ); +i1.GeneratedColumn _column_5(String aliasedName) => + i1.GeneratedColumn( + '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 get url => + columnsByName['url']! as i1.GeneratedColumn; +} + +i1.GeneratedColumn _column_6(String aliasedName) => + i1.GeneratedColumn( + '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 get host => + columnsByName['host']! as i1.GeneratedColumn; +} + +i1.GeneratedColumn _column_7(String aliasedName) => + i1.GeneratedColumn( + 'host', + aliasedName, + false, + type: i1.DriftSqlType.string, + $customConstraints: 'PRIMARY KEY NOT NULL', + ); +i0.MigrationStepWithVersion migrationSteps({ + required Future 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 Function(i1.Migrator m, Schema2 schema) from1To2, +}) => i0.VersionedSchema.stepByStepHelper( + step: migrationSteps(from1To2: from1To2), +); diff --git a/apps/weblibre/lib/features/geckoview/features/top_sites/data/database/definitions.drift b/apps/weblibre/lib/features/geckoview/features/top_sites/data/database/definitions.drift index f98320b4..d6c2716d 100644 --- a/apps/weblibre/lib/features/geckoview/features/top_sites/data/database/definitions.drift +++ b/apps/weblibre/lib/features/geckoview/features/top_sites/data/database/definitions.drift @@ -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, diff --git a/apps/weblibre/lib/features/geckoview/features/top_sites/data/database/definitions.drift.dart b/apps/weblibre/lib/features/geckoview/features/top_sites/data/database/definitions.drift.dart index 4f211208..621d147a 100644 --- a/apps/weblibre/lib/features/geckoview/features/top_sites/data/database/definitions.drift.dart +++ b/apps/weblibre/lib/features/geckoview/features/top_sites/data/database/definitions.drift.dart @@ -358,6 +358,137 @@ typedef $HiddenTopSiteProcessedTableManager = i1.HiddenTopSiteData, i0.PrefetchHooks Function() >; +typedef $HiddenTopSiteHostCreateCompanionBuilder = + i1.HiddenTopSiteHostCompanion Function({ + required String host, + i0.Value rowid, + }); +typedef $HiddenTopSiteHostUpdateCompanionBuilder = + i1.HiddenTopSiteHostCompanion Function({ + i0.Value host, + i0.Value rowid, + }); + +class $HiddenTopSiteHostFilterComposer + extends i0.Composer { + $HiddenTopSiteHostFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i0.ColumnFilters get host => $composableBuilder( + column: $table.host, + builder: (column) => i0.ColumnFilters(column), + ); +} + +class $HiddenTopSiteHostOrderingComposer + extends i0.Composer { + $HiddenTopSiteHostOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i0.ColumnOrderings get host => $composableBuilder( + column: $table.host, + builder: (column) => i0.ColumnOrderings(column), + ); +} + +class $HiddenTopSiteHostAnnotationComposer + extends i0.Composer { + $HiddenTopSiteHostAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i0.GeneratedColumn 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 host = const i0.Value.absent(), + i0.Value rowid = const i0.Value.absent(), + }) => i1.HiddenTopSiteHostCompanion(host: host, rowid: rowid), + createCompanionCallback: + ({ + required String host, + i0.Value 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 { @override @@ -878,6 +1009,156 @@ class HiddenTopSiteCompanion extends i0.UpdateCompanion { } } +class HiddenTopSiteHost extends i0.Table + with i0.TableInfo { + @override + final i0.GeneratedDatabase attachedDatabase; + final String? _alias; + HiddenTopSiteHost(this.attachedDatabase, [this._alias]); + late final i0.GeneratedColumn host = i0.GeneratedColumn( + 'host', + aliasedName, + false, + type: i0.DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'PRIMARY KEY NOT NULL', + ); + @override + List get $columns => [host]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'hidden_top_site_host'; + @override + Set get $primaryKey => {host}; + @override + i1.HiddenTopSiteHostData map( + Map 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 { + final String host; + const HiddenTopSiteHostData({required this.host}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['host'] = i0.Variable(host); + return map; + } + + factory HiddenTopSiteHostData.fromJson( + Map json, { + i0.ValueSerializer? serializer, + }) { + serializer ??= i0.driftRuntimeOptions.defaultSerializer; + return HiddenTopSiteHostData( + host: serializer.fromJson(json['host']), + ); + } + @override + Map toJson({i0.ValueSerializer? serializer}) { + serializer ??= i0.driftRuntimeOptions.defaultSerializer; + return {'host': serializer.toJson(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 { + final i0.Value host; + final i0.Value 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 custom({ + i0.Expression? host, + i0.Expression? rowid, + }) { + return i0.RawValuesInsertable({ + if (host != null) 'host': host, + if (rowid != null) 'rowid': rowid, + }); + } + + i1.HiddenTopSiteHostCompanion copyWith({ + i0.Value? host, + i0.Value? rowid, + }) { + return i1.HiddenTopSiteHostCompanion( + host: host ?? this.host, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (host.present) { + map['host'] = i0.Variable(host.value); + } + if (rowid.present) { + map['rowid'] = i0.Variable(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 leadingOrderKey({required int bucket}) { diff --git a/apps/weblibre/lib/features/geckoview/features/top_sites/domain/entities/top_site_host.dart b/apps/weblibre/lib/features/geckoview/features/top_sites/domain/entities/top_site_host.dart new file mode 100644 index 00000000..c573f857 --- /dev/null +++ b/apps/weblibre/lib/features/geckoview/features/top_sites/domain/entities/top_site_host.dart @@ -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 . + */ + +/// 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; +} diff --git a/apps/weblibre/lib/features/geckoview/features/top_sites/domain/repositories/top_site_repository.dart b/apps/weblibre/lib/features/geckoview/features/top_sites/domain/repositories/top_site_repository.dart index 0f6268c8..28a9d49a 100644 --- a/apps/weblibre/lib/features/geckoview/features/top_sites/domain/repositories/top_site_repository.dart +++ b/apps/weblibre/lib/features/geckoview/features/top_sites/domain/repositories/top_site_repository.dart @@ -18,7 +18,9 @@ * along with this program. If not, see . */ 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 filterFrecentTopSites({ + required List sites, + required int limit, + required Set excludeUrls, + required Set excludeHosts, +}) { + final items = []; + + 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> watchTopSites({int limit = 8}) { final db = ref.read(topSiteDatabaseProvider); - return CombineLatestStream.combine2( + return CombineLatestStream.combine3( db.topSiteDao.selectAllTopSites().watch(), db.hiddenTopSiteDao.watchHiddenUrls(), - (List rows, Set hiddenUrls) => (rows, hiddenUrls), + db.hiddenTopSiteDao.watchHiddenHosts(), + ( + List rows, + Set hiddenUrls, + Set 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 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 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 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 isPinnedTopSiteUrl(Uri url) async { @@ -318,30 +394,28 @@ class TopSiteRepository extends _$TopSiteRepository { Future> _getHistoryItems({ required int limit, required Set excludeUrls, + required Set excludeHosts, }) async { - final frecentSites = await ref - .read(historyRepositoryProvider.notifier) - .getTopFrecentSites(limit: limit + excludeUrls.length); - - final items = []; - 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) { diff --git a/apps/weblibre/lib/features/geckoview/features/top_sites/domain/repositories/top_site_repository.g.dart b/apps/weblibre/lib/features/geckoview/features/top_sites/domain/repositories/top_site_repository.g.dart index d06d3d33..1a635522 100644 --- a/apps/weblibre/lib/features/geckoview/features/top_sites/domain/repositories/top_site_repository.g.dart +++ b/apps/weblibre/lib/features/geckoview/features/top_sites/domain/repositories/top_site_repository.g.dart @@ -41,7 +41,7 @@ final class TopSiteRepositoryProvider } } -String _$topSiteRepositoryHash() => r'43c0495dfb3044dc9bb2f420524b45afb5735a0b'; +String _$topSiteRepositoryHash() => r'3907d90d379190fe3fc3e8897b08c254642239cf'; abstract class _$TopSiteRepository extends $Notifier { void build(); diff --git a/apps/weblibre/lib/features/settings/presentation/screens/home_settings.dart b/apps/weblibre/lib/features/settings/presentation/screens/home_settings.dart new file mode 100644 index 00000000..8d5712b6 --- /dev/null +++ b/apps/weblibre/lib/features/settings/presentation/screens/home_settings.dart @@ -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 . + */ +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 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 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 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( + 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( + 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), + ); + } +} diff --git a/apps/weblibre/lib/features/settings/presentation/screens/module_surface_settings.dart b/apps/weblibre/lib/features/settings/presentation/screens/module_surface_settings.dart new file mode 100644 index 00000000..ec5b8fea --- /dev/null +++ b/apps/weblibre/lib/features/settings/presentation/screens/module_surface_settings.dart @@ -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 . + */ +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)), + ], + ); + } +} diff --git a/apps/weblibre/lib/features/settings/presentation/screens/settings.dart b/apps/weblibre/lib/features/settings/presentation/screens/settings.dart index 7603cf82..1dcd8c98 100644 --- a/apps/weblibre/lib/features/settings/presentation/screens/settings.dart +++ b/apps/weblibre/lib/features/settings/presentation/screens/settings.dart @@ -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', diff --git a/apps/weblibre/lib/features/user/data/models/general_settings.dart b/apps/weblibre/lib/features/user/data/models/general_settings.dart index 5e5e2b01..675830d4 100644 --- a/apps/weblibre/lib/features/user/data/models/general_settings.dart +++ b/apps/weblibre/lib/features/user/data/models/general_settings.dart @@ -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, diff --git a/apps/weblibre/lib/features/user/data/models/general_settings.g.dart b/apps/weblibre/lib/features/user/data/models/general_settings.g.dart index c0ff576b..47dea43c 100644 --- a/apps/weblibre/lib/features/user/data/models/general_settings.g.dart +++ b/apps/weblibre/lib/features/user/data/models/general_settings.g.dart @@ -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 _$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', diff --git a/apps/weblibre/lib/features/user/domain/repositories/general_settings.dart b/apps/weblibre/lib/features/user/domain/repositories/general_settings.dart index 45bdd8f7..6240b57f 100644 --- a/apps/weblibre/lib/features/user/domain/repositories/general_settings.dart +++ b/apps/weblibre/lib/features/user/domain/repositories/general_settings.dart @@ -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 = { + '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 = { + '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), }); } diff --git a/apps/weblibre/lib/features/user/domain/repositories/general_settings.g.dart b/apps/weblibre/lib/features/user/domain/repositories/general_settings.g.dart index baed8122..3dfd7b6c 100644 --- a/apps/weblibre/lib/features/user/domain/repositories/general_settings.g.dart +++ b/apps/weblibre/lib/features/user/domain/repositories/general_settings.g.dart @@ -35,7 +35,7 @@ final class GeneralSettingsRepositoryProvider } String _$generalSettingsRepositoryHash() => - r'3c458f146b63ae219a55a5f0488a667b70c44f4b'; + r'37cfacab1b4a9d67e185df4232d8e57349396296'; abstract class _$GeneralSettingsRepository extends $StreamNotifier { diff --git a/apps/weblibre/lib/presentation/widgets/browser_page.dart b/apps/weblibre/lib/presentation/widgets/browser_page.dart index 1dd09a6b..2d655ce2 100644 --- a/apps/weblibre/lib/presentation/widgets/browser_page.dart +++ b/apps/weblibre/lib/presentation/widgets/browser_page.dart @@ -18,7 +18,6 @@ * along with this program. If not, see . */ 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 = [0.977, 0.841, 0.5, 0.159, 0.0]; + + final colors = [color.withValues(alpha: centerAlpha)]; + final stops = [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), ), ); } diff --git a/apps/weblibre/lib/presentation/widgets/url_list_tile.dart b/apps/weblibre/lib/presentation/widgets/url_list_tile.dart index 9992127b..45906c74 100644 --- a/apps/weblibre/lib/presentation/widgets/url_list_tile.dart +++ b/apps/weblibre/lib/presentation/widgets/url_list_tile.dart @@ -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, }); diff --git a/apps/weblibre/test/drift/tabs/resume_fifo_test.dart b/apps/weblibre/test/drift/tabs/resume_fifo_test.dart new file mode 100644 index 00000000..3b6fa4cb --- /dev/null +++ b/apps/weblibre/test/drift/tabs/resume_fifo_test.dart @@ -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 . + */ +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 _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 _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); + }); + }); +} diff --git a/apps/weblibre/test/drift/top_sites/generated/schema.dart b/apps/weblibre/test/drift/top_site/generated/schema.dart similarity index 81% rename from apps/weblibre/test/drift/top_sites/generated/schema.dart rename to apps/weblibre/test/drift/top_site/generated/schema.dart index 8869e40e..f5e870e5 100644 --- a/apps/weblibre/test/drift/top_sites/generated/schema.dart +++ b/apps/weblibre/test/drift/top_site/generated/schema.dart @@ -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]; } diff --git a/apps/weblibre/test/drift/top_site/generated/schema_v1.dart b/apps/weblibre/test/drift/top_site/generated/schema_v1.dart new file mode 100644 index 00000000..d37e9aeb --- /dev/null +++ b/apps/weblibre/test/drift/top_site/generated/schema_v1.dart @@ -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 { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + TopSite(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'PRIMARY KEY NOT NULL', + ); + late final GeneratedColumn title = GeneratedColumn( + 'title', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn url = GeneratedColumn( + 'url', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn source = GeneratedColumn( + 'source', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn orderKey = GeneratedColumn( + 'order_key', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + @override + List 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 get $primaryKey => {id}; + @override + List> get uniqueKeys => [ + {url}, + ]; + @override + TopSiteData map(Map 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 get customConstraints => const ['UNIQUE(url)']; + @override + bool get dontWriteConstraints => true; +} + +class TopSiteData extends DataClass implements Insertable { + 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 toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['title'] = Variable(title); + map['url'] = Variable(url); + map['source'] = Variable(source); + map['order_key'] = Variable(orderKey); + map['created_at'] = Variable(createdAt); + return map; + } + + factory TopSiteData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return TopSiteData( + id: serializer.fromJson(json['id']), + title: serializer.fromJson(json['title']), + url: serializer.fromJson(json['url']), + source: serializer.fromJson(json['source']), + orderKey: serializer.fromJson(json['orderKey']), + createdAt: serializer.fromJson(json['createdAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'title': serializer.toJson(title), + 'url': serializer.toJson(url), + 'source': serializer.toJson(source), + 'orderKey': serializer.toJson(orderKey), + 'createdAt': serializer.toJson(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 { + final Value id; + final Value title; + final Value url; + final Value source; + final Value orderKey; + final Value createdAt; + final Value 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 custom({ + Expression? id, + Expression? title, + Expression? url, + Expression? source, + Expression? orderKey, + Expression? createdAt, + Expression? 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? id, + Value? title, + Value? url, + Value? source, + Value? orderKey, + Value? createdAt, + Value? 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 toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (title.present) { + map['title'] = Variable(title.value); + } + if (url.present) { + map['url'] = Variable(url.value); + } + if (source.present) { + map['source'] = Variable(source.value); + } + if (orderKey.present) { + map['order_key'] = Variable(orderKey.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (rowid.present) { + map['rowid'] = Variable(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 { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + HiddenTopSite(this.attachedDatabase, [this._alias]); + late final GeneratedColumn url = GeneratedColumn( + 'url', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'PRIMARY KEY NOT NULL', + ); + @override + List get $columns => [url]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'hidden_top_site'; + @override + Set get $primaryKey => {url}; + @override + HiddenTopSiteData map(Map 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 { + final String url; + const HiddenTopSiteData({required this.url}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['url'] = Variable(url); + return map; + } + + factory HiddenTopSiteData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return HiddenTopSiteData(url: serializer.fromJson(json['url'])); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return {'url': serializer.toJson(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 { + final Value url; + final Value 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 custom({ + Expression? url, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (url != null) 'url': url, + if (rowid != null) 'rowid': rowid, + }); + } + + HiddenTopSiteCompanion copyWith({Value? url, Value? rowid}) { + return HiddenTopSiteCompanion( + url: url ?? this.url, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (url.present) { + map['url'] = Variable(url.value); + } + if (rowid.present) { + map['rowid'] = Variable(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> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + topSite, + idxTopSiteOrderKey, + hiddenTopSite, + ]; + @override + int get schemaVersion => 1; +} diff --git a/apps/weblibre/test/drift/top_site/generated/schema_v2.dart b/apps/weblibre/test/drift/top_site/generated/schema_v2.dart new file mode 100644 index 00000000..b31458cb --- /dev/null +++ b/apps/weblibre/test/drift/top_site/generated/schema_v2.dart @@ -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 { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + TopSite(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'PRIMARY KEY NOT NULL', + ); + late final GeneratedColumn title = GeneratedColumn( + 'title', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn url = GeneratedColumn( + 'url', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn source = GeneratedColumn( + 'source', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn orderKey = GeneratedColumn( + 'order_key', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + @override + List 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 get $primaryKey => {id}; + @override + List> get uniqueKeys => [ + {url}, + ]; + @override + TopSiteData map(Map 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 get customConstraints => const ['UNIQUE(url)']; + @override + bool get dontWriteConstraints => true; +} + +class TopSiteData extends DataClass implements Insertable { + 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 toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['title'] = Variable(title); + map['url'] = Variable(url); + map['source'] = Variable(source); + map['order_key'] = Variable(orderKey); + map['created_at'] = Variable(createdAt); + return map; + } + + factory TopSiteData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return TopSiteData( + id: serializer.fromJson(json['id']), + title: serializer.fromJson(json['title']), + url: serializer.fromJson(json['url']), + source: serializer.fromJson(json['source']), + orderKey: serializer.fromJson(json['orderKey']), + createdAt: serializer.fromJson(json['createdAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'title': serializer.toJson(title), + 'url': serializer.toJson(url), + 'source': serializer.toJson(source), + 'orderKey': serializer.toJson(orderKey), + 'createdAt': serializer.toJson(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 { + final Value id; + final Value title; + final Value url; + final Value source; + final Value orderKey; + final Value createdAt; + final Value 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 custom({ + Expression? id, + Expression? title, + Expression? url, + Expression? source, + Expression? orderKey, + Expression? createdAt, + Expression? 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? id, + Value? title, + Value? url, + Value? source, + Value? orderKey, + Value? createdAt, + Value? 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 toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (title.present) { + map['title'] = Variable(title.value); + } + if (url.present) { + map['url'] = Variable(url.value); + } + if (source.present) { + map['source'] = Variable(source.value); + } + if (orderKey.present) { + map['order_key'] = Variable(orderKey.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (rowid.present) { + map['rowid'] = Variable(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 { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + HiddenTopSite(this.attachedDatabase, [this._alias]); + late final GeneratedColumn url = GeneratedColumn( + 'url', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'PRIMARY KEY NOT NULL', + ); + @override + List get $columns => [url]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'hidden_top_site'; + @override + Set get $primaryKey => {url}; + @override + HiddenTopSiteData map(Map 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 { + final String url; + const HiddenTopSiteData({required this.url}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['url'] = Variable(url); + return map; + } + + factory HiddenTopSiteData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return HiddenTopSiteData(url: serializer.fromJson(json['url'])); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return {'url': serializer.toJson(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 { + final Value url; + final Value 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 custom({ + Expression? url, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (url != null) 'url': url, + if (rowid != null) 'rowid': rowid, + }); + } + + HiddenTopSiteCompanion copyWith({Value? url, Value? rowid}) { + return HiddenTopSiteCompanion( + url: url ?? this.url, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (url.present) { + map['url'] = Variable(url.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('HiddenTopSiteCompanion(') + ..write('url: $url, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class HiddenTopSiteHost extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + HiddenTopSiteHost(this.attachedDatabase, [this._alias]); + late final GeneratedColumn host = GeneratedColumn( + 'host', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'PRIMARY KEY NOT NULL', + ); + @override + List get $columns => [host]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'hidden_top_site_host'; + @override + Set get $primaryKey => {host}; + @override + HiddenTopSiteHostData map(Map 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 { + final String host; + const HiddenTopSiteHostData({required this.host}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['host'] = Variable(host); + return map; + } + + factory HiddenTopSiteHostData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return HiddenTopSiteHostData( + host: serializer.fromJson(json['host']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return {'host': serializer.toJson(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 { + final Value host; + final Value 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 custom({ + Expression? host, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (host != null) 'host': host, + if (rowid != null) 'rowid': rowid, + }); + } + + HiddenTopSiteHostCompanion copyWith({ + Value? host, + Value? rowid, + }) { + return HiddenTopSiteHostCompanion( + host: host ?? this.host, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (host.present) { + map['host'] = Variable(host.value); + } + if (rowid.present) { + map['rowid'] = Variable(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> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + topSite, + idxTopSiteOrderKey, + hiddenTopSite, + hiddenTopSiteHost, + ]; + @override + int get schemaVersion => 2; +} diff --git a/apps/weblibre/test/drift/top_site/migration_test.dart b/apps/weblibre/test/drift/top_site/migration_test.dart new file mode 100644 index 00000000..dbb59806 --- /dev/null +++ b/apps/weblibre/test/drift/top_site/migration_test.dart @@ -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 = []; + final expectedNewTopSiteData = []; + + final oldHiddenTopSiteData = []; + final expectedNewHiddenTopSiteData = []; + + 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(), + ); + }, + ); + }); +} diff --git a/apps/weblibre/test/drift/top_sites/generated/schema_v1.dart b/apps/weblibre/test/drift/top_sites/generated/schema_v1.dart deleted file mode 100644 index aa33e35f..00000000 --- a/apps/weblibre/test/drift/top_sites/generated/schema_v1.dart +++ /dev/null @@ -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 id = GeneratedColumn( - 'id', - aliasedName, - false, - type: DriftSqlType.string, - requiredDuringInsert: true, - $customConstraints: 'PRIMARY KEY NOT NULL', - ); - late final GeneratedColumn title = GeneratedColumn( - 'title', - aliasedName, - false, - type: DriftSqlType.string, - requiredDuringInsert: true, - $customConstraints: 'NOT NULL', - ); - late final GeneratedColumn url = GeneratedColumn( - 'url', - aliasedName, - false, - type: DriftSqlType.string, - requiredDuringInsert: true, - $customConstraints: 'NOT NULL', - ); - late final GeneratedColumn source = GeneratedColumn( - 'source', - aliasedName, - false, - type: DriftSqlType.int, - requiredDuringInsert: true, - $customConstraints: 'NOT NULL', - ); - late final GeneratedColumn orderKey = GeneratedColumn( - 'order_key', - aliasedName, - false, - type: DriftSqlType.string, - requiredDuringInsert: true, - $customConstraints: 'NOT NULL', - ); - late final GeneratedColumn createdAt = GeneratedColumn( - 'created_at', - aliasedName, - false, - type: DriftSqlType.int, - requiredDuringInsert: true, - $customConstraints: 'NOT NULL', - ); - @override - List 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 get $primaryKey => {id}; - @override - List> get uniqueKeys => [ - {url}, - ]; - @override - Never map(Map data, {String? tablePrefix}) { - throw UnsupportedError('TableInfo.map in schema verification code'); - } - - @override - TopSite createAlias(String alias) { - return TopSite(attachedDatabase, alias); - } - - @override - List 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 seedId = GeneratedColumn( - 'seed_id', - aliasedName, - false, - type: DriftSqlType.string, - requiredDuringInsert: true, - $customConstraints: 'PRIMARY KEY NOT NULL', - ); - late final GeneratedColumn appliedAt = GeneratedColumn( - 'applied_at', - aliasedName, - false, - type: DriftSqlType.int, - requiredDuringInsert: true, - $customConstraints: 'NOT NULL', - ); - @override - List 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 get $primaryKey => {seedId}; - @override - Never map(Map 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> get allTables => - allSchemaEntities.whereType>(); - @override - List get allSchemaEntities => [ - topSite, - idxTopSiteOrderKey, - topSiteSeedState, - ]; - @override - int get schemaVersion => 1; -} diff --git a/apps/weblibre/test/drift/top_sites/migration_test.dart b/apps/weblibre/test/drift/top_sites/migration_test.dart deleted file mode 100644 index c9131d6c..00000000 --- a/apps/weblibre/test/drift/top_sites/migration_test.dart +++ /dev/null @@ -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 . - */ - -// 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(); - }); -} diff --git a/apps/weblibre/test/features/geckoview/features/browser/home_target_test.dart b/apps/weblibre/test/features/geckoview/features/browser/home_target_test.dart new file mode 100644 index 00000000..76010ae1 --- /dev/null +++ b/apps/weblibre/test/features/geckoview/features/browser/home_target_test.dart @@ -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 . + */ +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().having( + (s) => s.container.id, + 'container', + 'selected', + ), + ); + }); + + test('unscoped with no selection is unassigned', () { + expect( + resolveHomeTargetContainer( + scopeToContainer: false, + scopedContainer: null, + selectedContainer: null, + ), + isA(), + ); + }); + + test('scoped uses its own container, not the selected one', () { + expect( + resolveHomeTargetContainer( + scopeToContainer: true, + scopedContainer: scoped, + selectedContainer: selected, + ), + isA().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(), + ); + }); + }); + + 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, + ); + }); + }); + }); +} diff --git a/apps/weblibre/test/features/geckoview/features/search/domain/search_module_order_test.dart b/apps/weblibre/test/features/geckoview/features/search/domain/search_module_order_test.dart new file mode 100644 index 00000000..07bda000 --- /dev/null +++ b/apps/weblibre/test/features/geckoview/features/search/domain/search_module_order_test.dart @@ -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 . + */ +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 _types(List entries) => + entries.map((e) => e.type).toList(); + +void main() { + group('mergeModuleOrderWithDefaults', () { + test('uses the defaults verbatim when nothing is persisted', () { + const defaults = [ + (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 = [ + (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 = [ + (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 = [ + (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 = [ + (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 = [ + (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 = [ + (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 = [ + (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) + .cast>() + .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) + .cast>() + .map((e) { + try { + return ModuleOrderEntry.fromJson(e); + } catch (_) { + return null; + } + }) + .whereType() + .toList(); + + expect(_types(decoded), [SearchModuleType.topSites]); + }); + }); +} diff --git a/apps/weblibre/test/features/geckoview/features/top_sites/top_site_filtering_test.dart b/apps/weblibre/test/features/geckoview/features/top_sites/top_site_filtering_test.dart new file mode 100644 index 00000000..c73a09a1 --- /dev/null +++ b/apps/weblibre/test/features/geckoview/features/top_sites/top_site_filtering_test.dart @@ -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 . + */ +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']); + }); + }); +} diff --git a/apps/weblibre/test/features/user/general_settings_deserialize_test.dart b/apps/weblibre/test/features/user/general_settings_deserialize_test.dart new file mode 100644 index 00000000..1c31162b --- /dev/null +++ b/apps/weblibre/test/features/user/general_settings_deserialize_test.dart @@ -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 . + */ +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'); + } + }); + }); +}