diff --git a/app/drift_schemas/user/drift_schema_v3.json b/app/drift_schemas/user/drift_schema_v3.json new file mode 100644 index 00000000..2ba3d9ed --- /dev/null +++ b/app/drift_schemas/user/drift_schema_v3.json @@ -0,0 +1,320 @@ +{ + "_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": "setting", + "was_declared_in_moor": true, + "columns": [ + { + "name": "key", + "getter_name": "key", + "moor_type": "string", + "nullable": false, + "customConstraints": "PRIMARY KEY NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + "primary-key" + ] + }, + { + "name": "partition_key", + "getter_name": "partitionKey", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "value", + "getter_name": "value", + "moor_type": "any", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "strict": true + } + }, + { + "id": 1, + "references": [], + "type": "table", + "data": { + "name": "icon_cache", + "was_declared_in_moor": true, + "columns": [ + { + "name": "origin", + "getter_name": "origin", + "moor_type": "string", + "nullable": false, + "customConstraints": "PRIMARY KEY NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + "primary-key" + ] + }, + { + "name": "icon_data", + "getter_name": "iconData", + "moor_type": "blob", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "fetch_date", + "getter_name": "fetchDate", + "moor_type": "dateTime", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [] + } + }, + { + "id": 2, + "references": [], + "type": "table", + "data": { + "name": "onboarding", + "was_declared_in_moor": true, + "columns": [ + { + "name": "revision", + "getter_name": "revision", + "moor_type": "int", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "completion_date", + "getter_name": "completionDate", + "moor_type": "dateTime", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [] + } + }, + { + "id": 3, + "references": [], + "type": "table", + "data": { + "name": "riverpod", + "was_declared_in_moor": true, + "columns": [ + { + "name": "key", + "getter_name": "key", + "moor_type": "string", + "nullable": false, + "customConstraints": "PRIMARY KEY NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + "primary-key" + ] + }, + { + "name": "json", + "getter_name": "json", + "moor_type": "string", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "expireAt", + "getter_name": "expireAt", + "moor_type": "dateTime", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "destroyKey", + "getter_name": "destroyKey", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": true, + "constraints": [] + } + }, + { + "id": 4, + "references": [], + "type": "table", + "data": { + "name": "toolbar_button_configs", + "was_declared_in_moor": true, + "columns": [ + { + "name": "button_id", + "getter_name": "buttonId", + "moor_type": "string", + "nullable": false, + "customConstraints": "NOT NULL PRIMARY KEY", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + "primary-key" + ] + }, + { + "name": "order_key", + "getter_name": "orderKey", + "moor_type": "string", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_visible", + "getter_name": "isVisible", + "moor_type": "bool", + "nullable": false, + "customConstraints": "NOT NULL DEFAULT TRUE", + "default_dart": "const CustomExpression('TRUE')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "fallback_id", + "getter_name": "fallbackId", + "moor_type": "string", + "nullable": true, + "customConstraints": "REFERENCES toolbar_button_configs(button_id)ON DELETE SET NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [] + } + }, + { + "id": 5, + "references": [ + 4 + ], + "type": "index", + "data": { + "on": 4, + "name": "idx_toolbar_order_key", + "sql": "CREATE INDEX idx_toolbar_order_key ON toolbar_button_configs(order_key);", + "unique": false, + "columns": [] + } + } + ], + "fixed_sql": [ + { + "name": "setting", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"setting\" (\"key\" TEXT PRIMARY KEY NOT NULL, \"partition_key\" TEXT, \"value\" ANY) STRICT;" + } + ] + }, + { + "name": "icon_cache", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"icon_cache\" (\"origin\" TEXT PRIMARY KEY NOT NULL, \"icon_data\" BLOB NOT NULL, \"fetch_date\" INTEGER NOT NULL);" + } + ] + }, + { + "name": "onboarding", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"onboarding\" (\"revision\" INTEGER NOT NULL, \"completion_date\" INTEGER NOT NULL);" + } + ] + }, + { + "name": "riverpod", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"riverpod\" (\"key\" TEXT PRIMARY KEY NOT NULL, \"json\" TEXT NOT NULL, \"expireAt\" INTEGER, \"destroyKey\" TEXT) WITHOUT ROWID;" + } + ] + }, + { + "name": "toolbar_button_configs", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"toolbar_button_configs\" (\"button_id\" TEXT NOT NULL PRIMARY KEY, \"order_key\" TEXT NOT NULL, \"is_visible\" INTEGER NOT NULL DEFAULT TRUE, \"fallback_id\" TEXT REFERENCES toolbar_button_configs(button_id)ON DELETE SET NULL);" + } + ] + }, + { + "name": "idx_toolbar_order_key", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE INDEX idx_toolbar_order_key ON toolbar_button_configs (order_key)" + } + ] + } + ] +} \ No newline at end of file diff --git a/app/lib/core/routing/routes.dart b/app/lib/core/routing/routes.dart index b849dcd1..403d50d4 100644 --- a/app/lib/core/routing/routes.dart +++ b/app/lib/core/routing/routes.dart @@ -60,6 +60,7 @@ import 'package:weblibre/features/settings/presentation/screens/addon_collection import 'package:weblibre/features/settings/presentation/screens/advanced_settings.dart'; import 'package:weblibre/features/settings/presentation/screens/appearance_display_settings.dart'; import 'package:weblibre/features/settings/presentation/screens/bang_settings.dart'; +import 'package:weblibre/features/settings/presentation/screens/contextual_toolbar_settings.dart'; import 'package:weblibre/features/settings/presentation/screens/custom_tracking_protection.dart'; import 'package:weblibre/features/settings/presentation/screens/doh_settings.dart'; import 'package:weblibre/features/settings/presentation/screens/error_logs_screen.dart'; diff --git a/app/lib/core/routing/routes.g.dart b/app/lib/core/routing/routes.g.dart index a6aaa4e0..fa87aea3 100644 --- a/app/lib/core/routing/routes.g.dart +++ b/app/lib/core/routing/routes.g.dart @@ -1476,6 +1476,11 @@ RouteBase get $settingsRoute => GoRouteData.$route( name: 'UnshortenerSettingsRoute', factory: $UnshortenerSettingsRoute._fromState, ), + GoRouteData.$route( + path: 'contextual_toolbar', + name: 'ContextualToolbarSettingsRoute', + factory: $ContextualToolbarSettingsRoute._fromState, + ), ], ); @@ -1924,6 +1929,27 @@ mixin $UnshortenerSettingsRoute on GoRouteData { void replace(BuildContext context) => context.replace(location); } +mixin $ContextualToolbarSettingsRoute on GoRouteData { + static ContextualToolbarSettingsRoute _fromState(GoRouterState state) => + const ContextualToolbarSettingsRoute(); + + @override + String get location => GoRouteData.$location('/settings/contextual_toolbar'); + + @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); +} + RouteBase get $torProxyRoute => GoRouteData.$route( path: '/tor', name: 'TorProxyRoute', diff --git a/app/lib/core/routing/routes.settings.dart b/app/lib/core/routing/routes.settings.dart index 6b4c8293..13d1e445 100644 --- a/app/lib/core/routing/routes.settings.dart +++ b/app/lib/core/routing/routes.settings.dart @@ -93,6 +93,10 @@ part of 'routes.dart'; name: 'UnshortenerSettingsRoute', path: 'unshortener', ), + TypedGoRoute( + name: 'ContextualToolbarSettingsRoute', + path: 'contextual_toolbar', + ), ], ) class SettingsRoute extends GoRouteData with $SettingsRoute { @@ -257,3 +261,13 @@ class UnshortenerSettingsRoute extends GoRouteData return const UnshortenerSettingsScreen(); } } + +class ContextualToolbarSettingsRoute extends GoRouteData + with $ContextualToolbarSettingsRoute { + const ContextualToolbarSettingsRoute(); + + @override + Widget build(BuildContext context, GoRouterState state) { + return const ContextualToolbarSettingsScreen(); + } +} diff --git a/app/lib/features/geckoview/features/browser/features/contextual_toolbar/data/providers/toolbar_button_configs.dart b/app/lib/features/geckoview/features/browser/features/contextual_toolbar/data/providers/toolbar_button_configs.dart new file mode 100644 index 00000000..a7ebe890 --- /dev/null +++ b/app/lib/features/geckoview/features/browser/features/contextual_toolbar/data/providers/toolbar_button_configs.dart @@ -0,0 +1,82 @@ +/* + * 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:fast_equatable/fast_equatable.dart'; +import 'package:lexo_rank/lexo_rank.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/data/repositories/contextual_toolbar_config_repository.dart'; +import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/domain/entities/toolbar_button_spec.dart'; +import 'package:weblibre/features/user/data/database/definitions.drift.dart' + show ToolbarButtonConfig; + +part 'toolbar_button_configs.g.dart'; + +List _buildDefaultToolbarButtonConfigs() { + String? lastKey; + + return toolbarButtonSpecs.map((spec) { + final key = lastKey == null + ? LexoRank.middle().value + : LexoRank.parse(lastKey!).genNext().value; + + lastKey = key; + + return ToolbarButtonConfig( + buttonId: spec.id.name, + orderKey: key, + isVisible: spec.defaultVisible, + fallbackId: spec.defaultFallback?.name, + ); + }).toList(); +} + +final defaultToolbarButtonConfigs = EquatableValue( + _buildDefaultToolbarButtonConfigs(), +); + +@Riverpod(keepAlive: true) +Stream> toolbarButtonConfigs(Ref ref) async* { + final repository = ref.watch(contextualToolbarConfigRepositoryProvider); + await repository.seedMissingDefaults(); + yield* repository.watchAll(); +} + +@Riverpod(keepAlive: true) +EquatableValue> effectiveToolbarButtonConfigs( + Ref ref, +) { + final configsAsync = ref.watch(toolbarButtonConfigsProvider); + + return configsAsync.when( + data: (configs) { + final filtered = configs + .where((config) => knownToolbarButtonIds.contains(config.buttonId)) + .toList(); + + if (filtered.isEmpty) { + return defaultToolbarButtonConfigs; + } + + return EquatableValue(filtered); + }, + loading: () => defaultToolbarButtonConfigs, + error: (_, _) => defaultToolbarButtonConfigs, + ); +} diff --git a/app/lib/features/geckoview/features/browser/features/contextual_toolbar/data/providers/toolbar_button_configs.g.dart b/app/lib/features/geckoview/features/browser/features/contextual_toolbar/data/providers/toolbar_button_configs.g.dart new file mode 100644 index 00000000..fe4637c0 --- /dev/null +++ b/app/lib/features/geckoview/features/browser/features/contextual_toolbar/data/providers/toolbar_button_configs.g.dart @@ -0,0 +1,102 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'toolbar_button_configs.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(toolbarButtonConfigs) +final toolbarButtonConfigsProvider = ToolbarButtonConfigsProvider._(); + +final class ToolbarButtonConfigsProvider + extends + $FunctionalProvider< + AsyncValue>, + List, + Stream> + > + with + $FutureModifier>, + $StreamProvider> { + ToolbarButtonConfigsProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'toolbarButtonConfigsProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$toolbarButtonConfigsHash(); + + @$internal + @override + $StreamProviderElement> $createElement( + $ProviderPointer pointer, + ) => $StreamProviderElement(pointer); + + @override + Stream> create(Ref ref) { + return toolbarButtonConfigs(ref); + } +} + +String _$toolbarButtonConfigsHash() => + r'02f79c2087a3a5413fe879405c05f4a4d1eaffeb'; + +@ProviderFor(effectiveToolbarButtonConfigs) +final effectiveToolbarButtonConfigsProvider = + EffectiveToolbarButtonConfigsProvider._(); + +final class EffectiveToolbarButtonConfigsProvider + extends + $FunctionalProvider< + EquatableValue>, + EquatableValue>, + EquatableValue> + > + with $Provider>> { + EffectiveToolbarButtonConfigsProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'effectiveToolbarButtonConfigsProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$effectiveToolbarButtonConfigsHash(); + + @$internal + @override + $ProviderElement>> $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + EquatableValue> create(Ref ref) { + return effectiveToolbarButtonConfigs(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(EquatableValue> value) { + return $ProviderOverride( + origin: this, + providerOverride: + $SyncValueProvider>>(value), + ); + } +} + +String _$effectiveToolbarButtonConfigsHash() => + r'dd876160f586c64237a42a29eb63cb119f962b02'; diff --git a/app/lib/features/geckoview/features/browser/features/contextual_toolbar/data/repositories/contextual_toolbar_config_repository.dart b/app/lib/features/geckoview/features/browser/features/contextual_toolbar/data/repositories/contextual_toolbar_config_repository.dart new file mode 100644 index 00000000..551c8f34 --- /dev/null +++ b/app/lib/features/geckoview/features/browser/features/contextual_toolbar/data/repositories/contextual_toolbar_config_repository.dart @@ -0,0 +1,89 @@ +/* + * 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:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/domain/entities/toolbar_button_spec.dart'; +import 'package:weblibre/features/user/data/database/daos/toolbar_button_config.dart'; +import 'package:weblibre/features/user/data/database/definitions.drift.dart' + show ToolbarButtonConfig; +import 'package:weblibre/features/user/data/providers.dart'; + +part 'contextual_toolbar_config_repository.g.dart'; + +class ContextualToolbarConfigRepository { + ContextualToolbarConfigRepository(this._dao); + + final ToolbarButtonConfigDao _dao; + + Stream> watchAll() => _dao.watchAll(); + + Future replaceAll(List configs) { + return _dao.replaceAll(configs); + } + + Future assignOrderKey(String buttonId, {required String orderKey}) { + return _dao.assignOrderKey(buttonId, orderKey: orderKey); + } + + Future assignVisibility(String buttonId, {required bool visible}) { + return _dao.assignVisibility(buttonId, visible: visible); + } + + Future assignFallback(String buttonId, String? fallbackId) { + return _dao.assignFallback(buttonId, fallbackId); + } + + Future generateLeadingOrderKey() { + return _dao.generateLeadingOrderKey().getSingle(); + } + + Future generateTrailingOrderKey() { + return _dao.generateTrailingOrderKey().getSingle(); + } + + Future generateOrderKeyAfterButtonId(String buttonId) { + return _dao.generateOrderKeyAfterButtonId(buttonId).getSingleOrNull(); + } + + Future generateOrderKeyBeforeButtonId(String buttonId) { + return _dao.generateOrderKeyBeforeButtonId(buttonId).getSingle(); + } + + Future seedMissingDefaults() { + return _dao.seedMissing( + toolbarButtonSpecs + .map( + (spec) => ( + buttonId: spec.id.name, + defaultVisible: spec.defaultVisible, + defaultFallback: spec.defaultFallback?.name, + ), + ) + .toList(), + ); + } +} + +@Riverpod(keepAlive: true) +ContextualToolbarConfigRepository contextualToolbarConfigRepository(Ref ref) { + return ContextualToolbarConfigRepository( + ref.watch(userDatabaseProvider).toolbarButtonConfigDao, + ); +} diff --git a/app/lib/features/geckoview/features/browser/features/contextual_toolbar/data/repositories/contextual_toolbar_config_repository.g.dart b/app/lib/features/geckoview/features/browser/features/contextual_toolbar/data/repositories/contextual_toolbar_config_repository.g.dart new file mode 100644 index 00000000..3fc849d6 --- /dev/null +++ b/app/lib/features/geckoview/features/browser/features/contextual_toolbar/data/repositories/contextual_toolbar_config_repository.g.dart @@ -0,0 +1,62 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'contextual_toolbar_config_repository.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(contextualToolbarConfigRepository) +final contextualToolbarConfigRepositoryProvider = + ContextualToolbarConfigRepositoryProvider._(); + +final class ContextualToolbarConfigRepositoryProvider + extends + $FunctionalProvider< + ContextualToolbarConfigRepository, + ContextualToolbarConfigRepository, + ContextualToolbarConfigRepository + > + with $Provider { + ContextualToolbarConfigRepositoryProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'contextualToolbarConfigRepositoryProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => + _$contextualToolbarConfigRepositoryHash(); + + @$internal + @override + $ProviderElement $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + ContextualToolbarConfigRepository create(Ref ref) { + return contextualToolbarConfigRepository(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(ContextualToolbarConfigRepository value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider( + value, + ), + ); + } +} + +String _$contextualToolbarConfigRepositoryHash() => + r'96bb0c00019e076ef65d3e901ba850de0fccbb1b'; diff --git a/app/lib/features/geckoview/features/browser/features/contextual_toolbar/domain/entities/toolbar_button_id.dart b/app/lib/features/geckoview/features/browser/features/contextual_toolbar/domain/entities/toolbar_button_id.dart new file mode 100644 index 00000000..ebc47c26 --- /dev/null +++ b/app/lib/features/geckoview/features/browser/features/contextual_toolbar/domain/entities/toolbar_button_id.dart @@ -0,0 +1,29 @@ +/* + * 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 . + */ + +enum ToolbarButtonId { + back, + forward, + bookmarks, + share, + addTab, + tabsCount, + navigationMenu, +} diff --git a/app/lib/features/geckoview/features/browser/features/contextual_toolbar/domain/entities/toolbar_button_spec.dart b/app/lib/features/geckoview/features/browser/features/contextual_toolbar/domain/entities/toolbar_button_spec.dart new file mode 100644 index 00000000..f3c1fe39 --- /dev/null +++ b/app/lib/features/geckoview/features/browser/features/contextual_toolbar/domain/entities/toolbar_button_spec.dart @@ -0,0 +1,88 @@ +/* + * 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:weblibre/features/geckoview/features/browser/features/contextual_toolbar/domain/entities/toolbar_button_id.dart'; + +class ToolbarButtonSpec { + final ToolbarButtonId id; + final bool defaultVisible; + final ToolbarButtonId? defaultFallback; + final bool canBeFallbackTarget; + + const ToolbarButtonSpec({ + required this.id, + required this.defaultVisible, + this.defaultFallback, + this.canBeFallbackTarget = true, + }); +} + +const backToolbarButtonSpec = ToolbarButtonSpec( + id: ToolbarButtonId.back, + defaultVisible: true, + defaultFallback: ToolbarButtonId.bookmarks, +); + +const forwardToolbarButtonSpec = ToolbarButtonSpec( + id: ToolbarButtonId.forward, + defaultVisible: true, + defaultFallback: ToolbarButtonId.share, +); + +const bookmarksToolbarButtonSpec = ToolbarButtonSpec( + id: ToolbarButtonId.bookmarks, + defaultVisible: false, +); + +const shareToolbarButtonSpec = ToolbarButtonSpec( + id: ToolbarButtonId.share, + defaultVisible: false, +); + +const addTabToolbarButtonSpec = ToolbarButtonSpec( + id: ToolbarButtonId.addTab, + defaultVisible: true, +); + +const tabsCountToolbarButtonSpec = ToolbarButtonSpec( + id: ToolbarButtonId.tabsCount, + defaultVisible: true, +); + +const navigationMenuToolbarButtonSpec = ToolbarButtonSpec( + id: ToolbarButtonId.navigationMenu, + defaultVisible: true, +); + +const toolbarButtonSpecs = [ + backToolbarButtonSpec, + forwardToolbarButtonSpec, + bookmarksToolbarButtonSpec, + shareToolbarButtonSpec, + addTabToolbarButtonSpec, + tabsCountToolbarButtonSpec, + navigationMenuToolbarButtonSpec, +]; + +final Map toolbarButtonSpecsById = { + for (final spec in toolbarButtonSpecs) spec.id.name: spec, +}; + +final Set knownToolbarButtonIds = toolbarButtonSpecsById.keys.toSet(); diff --git a/app/lib/features/geckoview/features/browser/features/contextual_toolbar/domain/entities/toolbar_fallback_choice.dart b/app/lib/features/geckoview/features/browser/features/contextual_toolbar/domain/entities/toolbar_fallback_choice.dart new file mode 100644 index 00000000..a151e1b0 --- /dev/null +++ b/app/lib/features/geckoview/features/browser/features/contextual_toolbar/domain/entities/toolbar_fallback_choice.dart @@ -0,0 +1,57 @@ +/* + * 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:fast_equatable/fast_equatable.dart'; + +sealed class ToolbarFallbackChoice with FastEquatable { + ToolbarFallbackChoice(); + + factory ToolbarFallbackChoice.fromStored(String? storedFallbackId) { + if (storedFallbackId != null) { + return ToolbarFallbackButton(buttonId: storedFallbackId); + } + return ToolbarFallbackNone(); + } + + String? resolveRuntimeFallbackId(); + + String? toStoredFallbackId() => resolveRuntimeFallbackId(); +} + +class ToolbarFallbackButton extends ToolbarFallbackChoice { + final String buttonId; + + ToolbarFallbackButton({required this.buttonId}); + + @override + List get hashParameters => [buttonId]; + + @override + String resolveRuntimeFallbackId() => buttonId; +} + +class ToolbarFallbackNone extends ToolbarFallbackChoice { + ToolbarFallbackNone(); + + @override + List get hashParameters => [null]; + + @override + String? resolveRuntimeFallbackId() => null; +} diff --git a/app/lib/features/geckoview/features/browser/features/contextual_toolbar/domain/services/toolbar_button_resolution.dart b/app/lib/features/geckoview/features/browser/features/contextual_toolbar/domain/services/toolbar_button_resolution.dart new file mode 100644 index 00000000..01fc7963 --- /dev/null +++ b/app/lib/features/geckoview/features/browser/features/contextual_toolbar/domain/services/toolbar_button_resolution.dart @@ -0,0 +1,116 @@ +/* + * 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:weblibre/features/geckoview/features/browser/features/contextual_toolbar/domain/entities/toolbar_fallback_choice.dart'; +import 'package:weblibre/features/user/data/database/definitions.drift.dart'; + +class ContextualToolbarButtonResolution { + const ContextualToolbarButtonResolution({ + required this.buttonId, + required this.isEnabled, + }); + + final String buttonId; + final bool isEnabled; +} + +List resolveVisibleContextualToolbarButtons({ + required List configs, + required Set knownButtonIds, + required bool Function(String buttonId) isPrimaryAvailable, +}) { + final configById = {for (final config in configs) config.buttonId: config}; + final resolvedButtons = []; + final seenIds = {}; + + for (final config in configs.where((config) => config.isVisible)) { + final resolvedButton = resolveContextualToolbarButton( + config: config, + configById: configById, + knownButtonIds: knownButtonIds, + isPrimaryAvailable: isPrimaryAvailable, + ); + if (resolvedButton == null) { + continue; + } + if (seenIds.add(resolvedButton.buttonId)) { + resolvedButtons.add(resolvedButton); + } + } + + return resolvedButtons; +} + +ContextualToolbarButtonResolution? resolveContextualToolbarButton({ + required ToolbarButtonConfig config, + required Map configById, + required Set knownButtonIds, + required bool Function(String buttonId) isPrimaryAvailable, + Set visited = const {}, +}) { + if (visited.contains(config.buttonId)) { + return null; + } + + if (!knownButtonIds.contains(config.buttonId)) { + return ContextualToolbarButtonResolution( + buttonId: config.buttonId, + isEnabled: true, + ); + } + + if (isPrimaryAvailable(config.buttonId)) { + return ContextualToolbarButtonResolution( + buttonId: config.buttonId, + isEnabled: true, + ); + } + + final fallbackId = ToolbarFallbackChoice.fromStored( + config.fallbackId, + ).resolveRuntimeFallbackId(); + if (fallbackId == null) { + return ContextualToolbarButtonResolution( + buttonId: config.buttonId, + isEnabled: false, + ); + } + + final fallbackConfig = configById[fallbackId]; + if (fallbackConfig == null) { + return knownButtonIds.contains(fallbackId) + ? ContextualToolbarButtonResolution( + buttonId: fallbackId, + isEnabled: true, + ) + : ContextualToolbarButtonResolution( + buttonId: config.buttonId, + isEnabled: false, + ); + } + + return resolveContextualToolbarButton( + config: fallbackConfig, + configById: configById, + knownButtonIds: knownButtonIds, + isPrimaryAvailable: isPrimaryAvailable, + visited: {...visited, config.buttonId}, + ); +} diff --git a/app/lib/features/geckoview/features/browser/features/contextual_toolbar/presentation/models/contextual_toolbar_scope.dart b/app/lib/features/geckoview/features/browser/features/contextual_toolbar/presentation/models/contextual_toolbar_scope.dart new file mode 100644 index 00000000..9fe222aa --- /dev/null +++ b/app/lib/features/geckoview/features/browser/features/contextual_toolbar/presentation/models/contextual_toolbar_scope.dart @@ -0,0 +1,44 @@ +/* + * 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:fast_equatable/fast_equatable.dart'; +import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart'; +import 'package:weblibre/features/geckoview/features/browser/domain/entities/sheet.dart'; + +class ContextualToolbarScope with FastEquatable { + final String? selectedTabId; + final Sheet? displayedSheet; + final TabState? tabState; + final bool isPreview; + + ContextualToolbarScope({ + required this.selectedTabId, + required this.displayedSheet, + required this.tabState, + required this.isPreview, + }); + + @override + List get hashParameters => [ + selectedTabId, + displayedSheet, + tabState, + isPreview, + ]; +} diff --git a/app/lib/features/geckoview/features/browser/features/contextual_toolbar/presentation/toolbar_button_registry.dart b/app/lib/features/geckoview/features/browser/features/contextual_toolbar/presentation/toolbar_button_registry.dart new file mode 100644 index 00000000..220c1437 --- /dev/null +++ b/app/lib/features/geckoview/features/browser/features/contextual_toolbar/presentation/toolbar_button_registry.dart @@ -0,0 +1,162 @@ +/* + * 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_material_design_icons/flutter_material_design_icons.dart'; +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/core/routing/routes.dart'; +import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/domain/entities/toolbar_button_spec.dart'; +import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/presentation/models/contextual_toolbar_scope.dart'; +import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/presentation/widgets/contextual_bar_buttons.dart'; +import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/navigation_buttons.dart'; +import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tabs_action_button.dart'; + +class ToolbarButtonDefinition { + final ToolbarButtonSpec spec; + final String label; + final IconData icon; + final bool Function(ContextualToolbarScope scope, WidgetRef ref)? + isPrimaryAvailable; + final Widget Function( + ContextualToolbarScope scope, + BuildContext context, + WidgetRef ref, + ) + builder; + + const ToolbarButtonDefinition({ + required this.spec, + required this.label, + required this.icon, + this.isPrimaryAvailable, + required this.builder, + }); +} + +final List toolbarButtonRegistry = [ + ToolbarButtonDefinition( + spec: backToolbarButtonSpec, + label: 'Back', + icon: Icons.arrow_back, + isPrimaryAvailable: (scope, ref) => + scope.tabState?.historyState.canGoBack == true || + scope.tabState?.isLoading == true, + builder: (scope, context, ref) { + if (scope.isPreview) { + return NavigateBackButtonView( + canGoBack: true, + isLoading: false, + onPressed: () {}, + onLongPress: () {}, + ); + } + return NavigateBackButton( + selectedTabId: scope.selectedTabId, + isLoading: scope.tabState?.isLoading ?? false, + ); + }, + ), + ToolbarButtonDefinition( + spec: forwardToolbarButtonSpec, + label: 'Forward', + icon: Icons.arrow_forward, + isPrimaryAvailable: (scope, ref) => + scope.tabState?.historyState.canGoForward == true, + builder: (scope, context, ref) { + if (scope.isPreview) { + return NavigateForwardButtonView( + canGoForward: true, + onPressed: () {}, + onLongPress: () {}, + ); + } + return NavigateForwardButton(selectedTabId: scope.selectedTabId); + }, + ), + ToolbarButtonDefinition( + spec: bookmarksToolbarButtonSpec, + label: 'Bookmarks', + icon: MdiIcons.bookmarkMultiple, + builder: (scope, context, ref) { + return IconButton( + onPressed: scope.isPreview + ? () {} + : () async { + await BookmarkListRoute( + entryGuid: BookmarkRoot.root.id, + ).push(context); + }, + icon: const Icon(MdiIcons.bookmarkMultiple), + ); + }, + ), + ToolbarButtonDefinition( + spec: shareToolbarButtonSpec, + label: 'Share', + icon: Icons.share, + builder: (scope, context, ref) => scope.isPreview + ? ShareMenuButtonView(onPressed: () {}) + : ShareMenuButton(selectedTabId: scope.selectedTabId), + ), + ToolbarButtonDefinition( + spec: addTabToolbarButtonSpec, + label: 'New Tab', + icon: MdiIcons.tabPlus, + builder: (scope, context, ref) => scope.isPreview + ? AddTabButtonView(onPressed: () {}, onLongPress: () {}) + : const AddTabButton(), + ), + ToolbarButtonDefinition( + spec: tabsCountToolbarButtonSpec, + label: 'Tabs', + icon: MdiIcons.tab, + builder: (scope, context, ref) => scope.isPreview + ? TabsCountButtonView( + isActive: false, + onTap: () {}, + onLongPress: () {}, + buttonBuilder: (isActive, onTap, onLongPress) { + return TabsActionButtonView( + isActive: isActive, + tabCountText: '5', + onTap: onTap, + onLongPress: onLongPress, + ); + }, + ) + : TabsCountButton( + selectedTabId: scope.selectedTabId, + displayedSheet: scope.displayedSheet, + showLongPressMenu: false, + ), + ), + ToolbarButtonDefinition( + spec: navigationMenuToolbarButtonSpec, + label: 'Menu', + icon: Icons.more_vert, + builder: (scope, context, ref) => scope.isPreview + ? NavigationMenuButtonView(onTap: () {}) + : NavigationMenuButton(selectedTabId: scope.selectedTabId), + ), +]; + +final Map toolbarButtonRegistryById = { + for (final def in toolbarButtonRegistry) def.spec.id.name: def, +}; diff --git a/app/lib/features/geckoview/features/browser/features/contextual_toolbar/presentation/widgets/contextual_bar_buttons.dart b/app/lib/features/geckoview/features/browser/features/contextual_toolbar/presentation/widgets/contextual_bar_buttons.dart new file mode 100644 index 00000000..cd6f400f --- /dev/null +++ b/app/lib/features/geckoview/features/browser/features/contextual_toolbar/presentation/widgets/contextual_bar_buttons.dart @@ -0,0 +1,221 @@ +/* + * 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_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/domain/controllers/bottom_sheet.dart'; +import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; +import 'package:weblibre/features/geckoview/features/browser/domain/entities/sheet.dart'; +import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_menu_sheet.dart'; +import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/share_bottom_sheet.dart'; +import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_creation_menu.dart'; +import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tabs_action_button.dart'; +import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/toolbar_button.dart'; +import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; +import 'package:weblibre/presentation/hooks/menu_controller.dart'; + +class ShareMenuButton extends StatelessWidget { + final String? selectedTabId; + + const ShareMenuButton({super.key, required this.selectedTabId}); + + @override + Widget build(BuildContext context) { + return ShareMenuButtonView( + onPressed: () async { + final tabId = selectedTabId; + if (tabId != null) { + await showShareBottomSheet(context, selectedTabId: tabId); + } + }, + ); + } +} + +class ShareMenuButtonView extends StatelessWidget { + const ShareMenuButtonView({super.key, this.onPressed}); + + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) { + return IconButton(onPressed: onPressed, icon: const Icon(Icons.share)); + } +} + +class NavigationMenuButton extends StatelessWidget { + final String? selectedTabId; + + const NavigationMenuButton({super.key, required this.selectedTabId}); + + @override + Widget build(BuildContext context) { + return NavigationMenuButtonView( + onTap: () async { + await showBrowserMenuSheet(context); + }, + ); + } +} + +class NavigationMenuButtonView extends StatelessWidget { + const NavigationMenuButtonView({super.key, this.onTap}); + + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + return ToolbarButton(onTap: onTap, child: const Icon(Icons.more_vert)); + } +} + +class AddTabButton extends HookConsumerWidget { + const AddTabButton({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final tabMenuController = useMenuController(); + + return TabCreationMenu( + controller: tabMenuController, + child: AddTabButtonView( + onPressed: () async { + final settings = ref.read(generalSettingsWithDefaultsProvider); + + await SearchRoute( + tabType: + ref.read(selectedTabTypeProvider) ?? + settings.effectiveDefaultCreateTabType, + ).push(context); + + if (context.mounted) { + const BrowserRoute().go(context); + } + }, + onLongPress: () { + if (tabMenuController.isOpen) { + tabMenuController.close(); + } else { + tabMenuController.open(); + } + }, + ), + ); + } +} + +class AddTabButtonView extends StatelessWidget { + const AddTabButtonView({super.key, this.onPressed, this.onLongPress}); + + final VoidCallback? onPressed; + final VoidCallback? onLongPress; + + @override + Widget build(BuildContext context) { + return IconButton( + onPressed: onPressed, + icon: const Icon(MdiIcons.tabPlus), + onLongPress: onLongPress, + ); + } +} + +class TabsCountButtonView extends StatelessWidget { + const TabsCountButtonView({ + super.key, + required this.isActive, + required this.onTap, + this.onLongPress, + this.buttonBuilder, + }); + + final bool isActive; + final VoidCallback onTap; + final VoidCallback? onLongPress; + final Widget Function( + bool isActive, + VoidCallback onTap, + VoidCallback? onLongPress, + )? + buttonBuilder; + + @override + Widget build(BuildContext context) { + return (buttonBuilder != null) + ? buttonBuilder!(isActive, onTap, onLongPress) + : TabsActionButton( + isActive: isActive, + onTap: onTap, + onLongPress: onLongPress, + ); + } +} + +class TabsCountButton extends HookConsumerWidget { + const TabsCountButton({ + super.key, + required this.selectedTabId, + required this.displayedSheet, + required this.showLongPressMenu, + }); + + final String? selectedTabId; + final Sheet? displayedSheet; + final bool showLongPressMenu; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final tabMenuController = useMenuController(); + + return TabCreationMenu( + controller: tabMenuController, + child: TabsCountButtonView( + isActive: displayedSheet is ViewTabsSheet, + onTap: () async { + final tabViewBottomSheet = ref + .read(generalSettingsWithDefaultsProvider) + .tabViewBottomSheet; + + if (tabViewBottomSheet) { + if (displayedSheet case ViewTabsSheet()) { + ref.read(bottomSheetControllerProvider.notifier).requestDismiss(); + } else { + ref + .read(bottomSheetControllerProvider.notifier) + .show(ViewTabsSheet()); + } + } else { + await const TabViewRoute().push(context); + } + }, + onLongPress: showLongPressMenu + ? () { + if (tabMenuController.isOpen) { + tabMenuController.close(); + } else { + tabMenuController.open(); + } + } + : null, + ), + ); + } +} diff --git a/app/lib/features/geckoview/features/browser/features/contextual_toolbar/presentation/widgets/contextual_toolbar.dart b/app/lib/features/geckoview/features/browser/features/contextual_toolbar/presentation/widgets/contextual_toolbar.dart new file mode 100644 index 00000000..c3f74afa --- /dev/null +++ b/app/lib/features/geckoview/features/browser/features/contextual_toolbar/presentation/widgets/contextual_toolbar.dart @@ -0,0 +1,128 @@ +/* + * 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_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; +import 'package:weblibre/features/geckoview/features/browser/domain/entities/sheet.dart'; +import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/data/providers/toolbar_button_configs.dart'; +import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/domain/entities/toolbar_button_spec.dart'; +import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/domain/services/toolbar_button_resolution.dart'; +import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/presentation/models/contextual_toolbar_scope.dart'; +import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/presentation/toolbar_button_registry.dart'; + +class ContextualToolbar extends HookConsumerWidget { + const ContextualToolbar({ + super.key, + required this.selectedTabId, + required this.displayedSheet, + }); + + final String? selectedTabId; + final Sheet? displayedSheet; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final tabState = ref.watch(tabStateProvider(selectedTabId)); + final configs = ref.watch(effectiveToolbarButtonConfigsProvider); + + final scope = ContextualToolbarScope( + selectedTabId: selectedTabId, + displayedSheet: displayedSheet, + tabState: tabState, + isPreview: false, + ); + + final resolvedButtons = useMemoized( + () => resolveVisibleContextualToolbarButtons( + configs: configs.value, + knownButtonIds: knownToolbarButtonIds, + isPrimaryAvailable: (buttonId) { + final def = toolbarButtonRegistryById[buttonId]; + return def?.isPrimaryAvailable?.call(scope, ref) ?? true; + }, + ), + [configs, scope], + ); + + final buttons = resolvedButtons + .map((button) => _buildButton(scope, context, ref, button)) + .toList(); + + return ContextualToolbarView(buttons: buttons); + } + + Widget _buildButton( + ContextualToolbarScope scope, + BuildContext context, + WidgetRef ref, + ContextualToolbarButtonResolution button, + ) { + final def = toolbarButtonRegistryById[button.buttonId]; + if (def == null) return const SizedBox.shrink(); + + final child = def.builder(scope, context, ref); + + if (button.isEnabled) { + return child; + } + + return Opacity(opacity: 0.38, child: IgnorePointer(child: child)); + } +} + +class ContextualToolbarView extends StatelessWidget { + const ContextualToolbarView({super.key, required this.buttons}); + + final List buttons; + + static const _minButtonWidth = 48.0; + + @override + Widget build(BuildContext context) { + if (buttons.isEmpty) return const SizedBox.shrink(); + + return LayoutBuilder( + builder: (context, constraints) { + final fitsEvenly = + constraints.maxWidth >= _minButtonWidth * buttons.length; + + if (fitsEvenly) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: buttons, + ); + } + + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: buttons + .map( + (button) => SizedBox(width: _minButtonWidth, child: button), + ) + .toList(), + ), + ); + }, + ); + } +} diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart index 38359c2b..d54c9851 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart @@ -25,10 +25,8 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; -import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:weblibre/core/design/app_colors.dart'; -import 'package:weblibre/core/routing/routes.dart'; import 'package:weblibre/features/geckoview/domain/controllers/bottom_sheet.dart'; import 'package:weblibre/features/geckoview/domain/entities/states/readerable.dart'; import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart'; @@ -36,16 +34,13 @@ import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; import 'package:weblibre/features/geckoview/features/browser/domain/entities/sheet.dart'; import 'package:weblibre/features/geckoview/features/browser/domain/providers.dart'; +import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/presentation/widgets/contextual_bar_buttons.dart'; +import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/presentation/widgets/contextual_toolbar.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/controllers/toolbar_visibility.dart'; -import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_menu_sheet.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/app_bar_title.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/extension_shortcut_menu.dart'; -import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/navigation_buttons.dart'; -import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/share_bottom_sheet.dart'; -import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_creation_menu.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_icon.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_menu.dart'; -import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tabs_action_button.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/toolbar_button.dart'; import 'package:weblibre/features/geckoview/features/readerview/presentation/controllers/readerable.dart'; import 'package:weblibre/features/geckoview/features/readerview/presentation/widgets/reader_button.dart'; @@ -460,90 +455,6 @@ class QuickTabSwitcherItem with FastEquatable { ]; } -class ContextualToolbar extends HookConsumerWidget { - const ContextualToolbar({ - super.key, - required this.selectedTabId, - required this.displayedSheet, - }); - - final String? selectedTabId; - final Sheet? displayedSheet; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final tabState = ref.watch(tabStateProvider(selectedTabId)); - - return ContextualToolbarView( - canGoBack: - tabState?.historyState.canGoBack == true || - tabState?.isLoading == true, - canGoForward: tabState?.historyState.canGoForward == true, - onBookmarksTap: () async { - await BookmarkListRoute(entryGuid: BookmarkRoot.root.id).push(context); - }, - backButton: NavigateBackButton( - selectedTabId: selectedTabId, - isLoading: tabState?.isLoading ?? false, - ), - forwardButton: NavigateForwardButton(selectedTabId: selectedTabId), - shareButton: ShareMenuButton(selectedTabId: selectedTabId), - addTabButton: const AddTabButton(), - tabsCountButton: TabsCountButton( - selectedTabId: selectedTabId, - displayedSheet: displayedSheet, - showLongPressMenu: false, - ), - navigationButton: NavigationMenuButton(selectedTabId: selectedTabId), - ); - } -} - -class ContextualToolbarView extends StatelessWidget { - const ContextualToolbarView({ - super.key, - required this.canGoBack, - required this.canGoForward, - required this.onBookmarksTap, - required this.backButton, - required this.forwardButton, - required this.shareButton, - required this.addTabButton, - required this.tabsCountButton, - required this.navigationButton, - }); - - final bool canGoBack; - final bool canGoForward; - final VoidCallback onBookmarksTap; - final Widget backButton; - final Widget forwardButton; - final Widget shareButton; - final Widget addTabButton; - final Widget tabsCountButton; - final Widget navigationButton; - - @override - Widget build(BuildContext context) { - return Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - if (canGoBack) - backButton - else - IconButton( - onPressed: onBookmarksTap, - icon: const Icon(MdiIcons.bookmarkMultiple), - ), - if (canGoForward) forwardButton else shareButton, - addTabButton, - tabsCountButton, - navigationButton, - ], - ); - } -} - class QuickTabSwitcher extends HookConsumerWidget { final QuickTabSwitcherMode quickTabSwitcherMode; @@ -760,191 +671,3 @@ class QuickTabSwitcherView extends StatelessWidget { ); } } - -class ShareMenuButton extends StatelessWidget { - final String? selectedTabId; - - const ShareMenuButton({super.key, required this.selectedTabId}); - - @override - Widget build(BuildContext context) { - return ShareMenuButtonView( - onPressed: () async { - final tabId = selectedTabId; - if (tabId != null) { - await showShareBottomSheet(context, selectedTabId: tabId); - } - }, - ); - } -} - -class ShareMenuButtonView extends StatelessWidget { - const ShareMenuButtonView({super.key, this.onPressed}); - - final VoidCallback? onPressed; - - @override - Widget build(BuildContext context) { - return IconButton(onPressed: onPressed, icon: const Icon(Icons.share)); - } -} - -class NavigationMenuButton extends StatelessWidget { - final String? selectedTabId; - - const NavigationMenuButton({super.key, required this.selectedTabId}); - - @override - Widget build(BuildContext context) { - return NavigationMenuButtonView( - onTap: () async { - await showBrowserMenuSheet(context); - }, - ); - } -} - -class NavigationMenuButtonView extends StatelessWidget { - const NavigationMenuButtonView({super.key, this.onTap}); - - final VoidCallback? onTap; - - @override - Widget build(BuildContext context) { - return ToolbarButton(onTap: onTap, child: const Icon(Icons.more_vert)); - } -} - -class AddTabButton extends HookConsumerWidget { - const AddTabButton({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final tabMenuController = useMenuController(); - - return TabCreationMenu( - controller: tabMenuController, - child: AddTabButtonView( - onPressed: () async { - final settings = ref.read(generalSettingsWithDefaultsProvider); - - await SearchRoute( - tabType: - ref.read(selectedTabTypeProvider) ?? - settings.effectiveDefaultCreateTabType, - ).push(context); - - if (context.mounted) { - const BrowserRoute().go(context); - } - }, - onLongPress: () { - if (tabMenuController.isOpen) { - tabMenuController.close(); - } else { - tabMenuController.open(); - } - }, - ), - ); - } -} - -class AddTabButtonView extends StatelessWidget { - const AddTabButtonView({super.key, this.onPressed, this.onLongPress}); - - final VoidCallback? onPressed; - final VoidCallback? onLongPress; - - @override - Widget build(BuildContext context) { - return IconButton( - onPressed: onPressed, - icon: const Icon(MdiIcons.tabPlus), - onLongPress: onLongPress, - ); - } -} - -class TabsCountButtonView extends StatelessWidget { - const TabsCountButtonView({ - super.key, - required this.isActive, - required this.onTap, - this.onLongPress, - this.buttonBuilder, - }); - - final bool isActive; - final VoidCallback onTap; - final VoidCallback? onLongPress; - final Widget Function( - bool isActive, - VoidCallback onTap, - VoidCallback? onLongPress, - )? - buttonBuilder; - - @override - Widget build(BuildContext context) { - return (buttonBuilder != null) - ? buttonBuilder!(isActive, onTap, onLongPress) - : TabsActionButton( - isActive: isActive, - onTap: onTap, - onLongPress: onLongPress, - ); - } -} - -class TabsCountButton extends HookConsumerWidget { - const TabsCountButton({ - super.key, - required this.selectedTabId, - required this.displayedSheet, - required this.showLongPressMenu, - }); - - final String? selectedTabId; - final Sheet? displayedSheet; - final bool showLongPressMenu; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final tabMenuController = useMenuController(); - - return TabCreationMenu( - controller: tabMenuController, - child: TabsCountButtonView( - isActive: displayedSheet is ViewTabsSheet, - onTap: () async { - final tabViewBottomSheet = ref - .read(generalSettingsWithDefaultsProvider) - .tabViewBottomSheet; - - if (tabViewBottomSheet) { - if (displayedSheet case ViewTabsSheet()) { - ref.read(bottomSheetControllerProvider.notifier).requestDismiss(); - } else { - ref - .read(bottomSheetControllerProvider.notifier) - .show(ViewTabsSheet()); - } - } else { - await const TabViewRoute().push(context); - } - }, - onLongPress: showLongPressMenu - ? () { - if (tabMenuController.isOpen) { - tabMenuController.close(); - } else { - tabMenuController.open(); - } - } - : null, - ), - ); - } -} diff --git a/app/lib/features/geckoview/features/preferences/data/repositories/preference_settings.g.dart b/app/lib/features/geckoview/features/preferences/data/repositories/preference_settings.g.dart index 035fa017..a5136dad 100644 --- a/app/lib/features/geckoview/features/preferences/data/repositories/preference_settings.g.dart +++ b/app/lib/features/geckoview/features/preferences/data/repositories/preference_settings.g.dart @@ -45,7 +45,7 @@ final class StartupPreferenceEnforcementServiceProvider } String _$startupPreferenceEnforcementServiceHash() => - r'107cdcf7550b7977b53a3cf7419861cdcac0fe3a'; + r'5cfe5aec33a9e9e077704ff519c10b0398c5085d'; abstract class _$StartupPreferenceEnforcementService extends $Notifier { void build(); diff --git a/app/lib/features/settings/presentation/screens/contextual_toolbar_settings.dart b/app/lib/features/settings/presentation/screens/contextual_toolbar_settings.dart new file mode 100644 index 00000000..5135750c --- /dev/null +++ b/app/lib/features/settings/presentation/screens/contextual_toolbar_settings.dart @@ -0,0 +1,321 @@ +/* + * 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/features/geckoview/features/browser/features/contextual_toolbar/data/providers/toolbar_button_configs.dart'; +import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/data/repositories/contextual_toolbar_config_repository.dart'; +import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/domain/entities/toolbar_fallback_choice.dart'; +import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/presentation/models/contextual_toolbar_scope.dart'; +import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/presentation/toolbar_button_registry.dart'; +import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/presentation/widgets/contextual_toolbar.dart'; +import 'package:weblibre/features/user/data/database/definitions.drift.dart'; + +class ContextualToolbarSettingsScreen extends HookConsumerWidget { + const ContextualToolbarSettingsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final configs = ref.watch(effectiveToolbarButtonConfigsProvider); + final repository = ref.watch(contextualToolbarConfigRepositoryProvider); + + return Scaffold( + appBar: AppBar( + title: const Text('Customize Toolbar'), + actions: [ + MenuAnchor( + builder: (context, controller, child) => IconButton( + onPressed: () { + if (controller.isOpen) { + controller.close(); + } else { + controller.open(); + } + }, + icon: const Icon(Icons.more_vert), + ), + menuChildren: [ + MenuItemButton( + leadingIcon: const Icon(Icons.restore), + onPressed: () => _resetToDefaults(ref), + child: const Text('Reset to Defaults'), + ), + ], + ), + ], + ), + body: SafeArea( + child: CustomScrollView( + slivers: [ + SliverPersistentHeader( + pinned: true, + delegate: _ToolbarPreviewDelegate(configs: configs.value), + ), + SliverToBoxAdapter( + child: ListTile( + title: Text( + 'Button Order', + style: Theme.of(context).textTheme.labelLarge, + ), + ), + ), + SliverReorderableList( + itemCount: configs.value.length, + onReorder: (oldIndex, newIndex) => + _onReorder(configs.value, oldIndex, newIndex, repository), + itemBuilder: (context, index) { + final config = configs.value[index]; + return _ToolbarButtonConfigTile( + key: ValueKey(config.buttonId), + index: index, + config: config, + repository: repository, + ); + }, + ), + ], + ), + ), + ); + } + + ({String movedId, int targetIndex}) _resolveToolbarReorder( + List configs, + int oldIndex, + int newIndex, + ) { + var targetIndex = newIndex; + if (targetIndex > oldIndex) { + targetIndex -= 1; + } + targetIndex = targetIndex.clamp(0, configs.length - 1); + return (movedId: configs[oldIndex].buttonId, targetIndex: targetIndex); + } + + void _onReorder( + List configs, + int oldIndex, + int newIndex, + ContextualToolbarConfigRepository repository, + ) { + if (oldIndex == newIndex) return; + final reorder = _resolveToolbarReorder(configs, oldIndex, newIndex); + if (reorder.targetIndex == oldIndex) return; + unawaited( + _reorderViaDb( + repository, + configs, + oldIndex, + reorder.targetIndex, + reorder.movedId, + ), + ); + } + + Future _reorderViaDb( + ContextualToolbarConfigRepository repository, + List configs, + int oldIndex, + int targetIndex, + String movedId, + ) async { + final String orderKey; + if (targetIndex <= 0) { + orderKey = await repository.generateLeadingOrderKey(); + } else if (targetIndex >= configs.length - 1) { + orderKey = await repository.generateTrailingOrderKey(); + } else if (targetIndex < oldIndex) { + orderKey = + await repository.generateOrderKeyAfterButtonId( + configs[targetIndex - 1].buttonId, + ) ?? + await repository.generateLeadingOrderKey(); + } else { + orderKey = await repository.generateOrderKeyBeforeButtonId( + configs[targetIndex + 1].buttonId, + ); + } + await repository.assignOrderKey(movedId, orderKey: orderKey); + } + + Future _resetToDefaults(WidgetRef ref) async { + final repository = ref.read(contextualToolbarConfigRepositoryProvider); + await repository.replaceAll(defaultToolbarButtonConfigs.value); + } +} + +class _ToolbarButtonConfigTile extends HookConsumerWidget { + const _ToolbarButtonConfigTile({ + super.key, + required this.index, + required this.config, + required this.repository, + }); + + final int index; + final ToolbarButtonConfig config; + final ContextualToolbarConfigRepository repository; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final def = toolbarButtonRegistryById[config.buttonId]; + if (def == null) return const SizedBox.shrink(); + + final isVisible = config.isVisible; + final hasStatefulFallback = + def.isPrimaryAvailable != null || def.spec.defaultFallback != null; + + final fallbackOptions = toolbarButtonRegistry + .where( + (d) => + d.spec.id.name != config.buttonId && d.spec.canBeFallbackTarget, + ) + .toList(); + + return Material( + color: Colors.transparent, + child: ListTile( + leading: Icon(def.icon), + title: Text(def.label), + subtitle: hasStatefulFallback + ? _FallbackPicker( + current: ToolbarFallbackChoice.fromStored(config.fallbackId), + options: fallbackOptions, + onChanged: (newFallback) => repository.assignFallback( + config.buttonId, + (newFallback ?? ToolbarFallbackNone()).toStoredFallbackId(), + ), + ) + : null, + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Switch.adaptive( + value: isVisible, + onChanged: (v) => + repository.assignVisibility(config.buttonId, visible: v), + ), + ReorderableDragStartListener( + index: index, + child: const Padding( + padding: EdgeInsets.all(8), + child: Icon(Icons.drag_handle), + ), + ), + ], + ), + ), + ); + } +} + +class _FallbackPicker extends StatelessWidget { + const _FallbackPicker({ + required this.current, + required this.options, + required this.onChanged, + }); + + final ToolbarFallbackChoice current; + final List options; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return DropdownButton( + value: current, + hint: const Text('No fallback'), + isExpanded: true, + underline: const SizedBox.shrink(), + items: [ + DropdownMenuItem( + value: ToolbarFallbackNone(), + child: const Text('No fallback'), + ), + for (final opt in options) + DropdownMenuItem( + value: ToolbarFallbackButton(buttonId: opt.spec.id.name), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(opt.icon, size: 16), + const SizedBox(width: 8), + Text(opt.label), + ], + ), + ), + ], + onChanged: onChanged, + ); + } +} + +class _ToolbarPreviewDelegate extends SliverPersistentHeaderDelegate { + const _ToolbarPreviewDelegate({required this.configs}); + + final List configs; + + @override + double get minExtent => 56.0; + @override + double get maxExtent => 56.0; + + @override + Widget build( + BuildContext context, + double shrinkOffset, + bool overlapsContent, + ) { + return ColoredBox( + color: Theme.of(context).colorScheme.surfaceContainer, + child: IgnorePointer(child: _ToolbarPreview(configs: configs)), + ); + } + + @override + bool shouldRebuild(_ToolbarPreviewDelegate old) => old.configs != configs; +} + +class _ToolbarPreview extends ConsumerWidget { + const _ToolbarPreview({required this.configs}); + + final List configs; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final visibleConfigs = configs.where((c) => c.isVisible).toList(); + + final scope = ContextualToolbarScope( + selectedTabId: null, + displayedSheet: null, + tabState: null, + isPreview: true, + ); + + final buttons = visibleConfigs.map((config) { + final def = toolbarButtonRegistryById[config.buttonId]; + if (def == null) return const SizedBox.shrink(); + return def.builder(scope, context, ref); + }).toList(); + + return ContextualToolbarView(buttons: buttons); + } +} diff --git a/app/lib/features/settings/presentation/screens/tab_bar_settings.dart b/app/lib/features/settings/presentation/screens/tab_bar_settings.dart index 253663f5..5527af8d 100644 --- a/app/lib/features/settings/presentation/screens/tab_bar_settings.dart +++ b/app/lib/features/settings/presentation/screens/tab_bar_settings.dart @@ -22,8 +22,11 @@ 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/domain/entities/states/security.dart'; import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart'; +import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/presentation/widgets/contextual_bar_buttons.dart'; +import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/presentation/widgets/contextual_toolbar.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/app_bar_title.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/navigation_buttons.dart'; @@ -77,6 +80,7 @@ class _TabBarLayoutSection extends StatelessWidget { _TabBarLayoutModeSection(), _ShowExtensionShortcutTile(), _ShowContextualTabBarTile(), + _CustomizeToolbarButtonsTile(), _AutoHideTabBarTile(), _ShowQuickTabSwitcherBarTile(), _QuickTabSwitcherModeSection(), @@ -232,24 +236,22 @@ class _TabBarPreviewCard extends HookWidget { Widget buildContextualToolbar() { return ContextualToolbarView( - canGoBack: true, - canGoForward: false, - onBookmarksTap: () {}, - backButton: NavigateBackButtonView( - canGoBack: true, - isLoading: false, - onPressed: () {}, - onLongPress: () {}, - ), - forwardButton: NavigateForwardButtonView( - canGoForward: true, - onPressed: () {}, - onLongPress: () {}, - ), - shareButton: ShareMenuButtonView(onPressed: () {}), - addTabButton: AddTabButtonView(onPressed: () {}, onLongPress: () {}), - tabsCountButton: tabCountButton, - navigationButton: NavigationMenuButtonView(onTap: () {}), + buttons: [ + NavigateBackButtonView( + canGoBack: true, + isLoading: false, + onPressed: () {}, + onLongPress: () {}, + ), + NavigateForwardButtonView( + canGoForward: true, + onPressed: () {}, + onLongPress: () {}, + ), + AddTabButtonView(onPressed: () {}, onLongPress: () {}), + tabCountButton, + NavigationMenuButtonView(onTap: () {}), + ], ); } @@ -614,6 +616,29 @@ class _ShowContextualTabBarTile extends HookConsumerWidget { } } +class _CustomizeToolbarButtonsTile extends HookConsumerWidget { + const _CustomizeToolbarButtonsTile(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final tabBarShowContextualBar = ref.watch( + generalSettingsWithDefaultsProvider.select( + (s) => s.tabBarShowContextualBar, + ), + ); + + return ListTile( + leading: const Icon(Icons.tune), + title: const Text('Customize Toolbar Buttons'), + trailing: const Icon(Icons.chevron_right), + enabled: tabBarShowContextualBar, + onTap: () async { + await const ContextualToolbarSettingsRoute().push(context); + }, + ); + } +} + class _ShowExtensionShortcutTile extends HookConsumerWidget { const _ShowExtensionShortcutTile(); diff --git a/app/lib/features/user/data/database/daos/toolbar_button_config.dart b/app/lib/features/user/data/database/daos/toolbar_button_config.dart new file mode 100644 index 00000000..30286789 --- /dev/null +++ b/app/lib/features/user/data/database/daos/toolbar_button_config.dart @@ -0,0 +1,134 @@ +/* + * 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:weblibre/features/user/data/database/daos/toolbar_button_config.drift.dart'; +import 'package:weblibre/features/user/data/database/database.dart'; +import 'package:weblibre/features/user/data/database/definitions.drift.dart'; + +@DriftAccessor() +class ToolbarButtonConfigDao extends DatabaseAccessor + with $ToolbarButtonConfigDaoMixin { + ToolbarButtonConfigDao(super.attachedDatabase); + + Selectable selectAll() => + db.toolbarButtonConfigs.select() + ..orderBy([(t) => OrderingTerm.asc(t.orderKey)]); + + Stream> watchAll() => selectAll().watch(); + + Future> getAll() => selectAll().get(); + + Future upsert(ToolbarButtonConfig config) => + into(db.toolbarButtonConfigs).insertOnConflictUpdate(config); + + Future assignOrderKey(String buttonId, {required String orderKey}) => + (update(db.toolbarButtonConfigs) + ..where((t) => t.buttonId.equals(buttonId))) + .write(ToolbarButtonConfigsCompanion(orderKey: Value(orderKey))); + + Future assignVisibility(String buttonId, {required bool visible}) => + (update(db.toolbarButtonConfigs) + ..where((t) => t.buttonId.equals(buttonId))) + .write(ToolbarButtonConfigsCompanion(isVisible: Value(visible))); + + Future assignFallback(String buttonId, String? fallbackId) => + (update(db.toolbarButtonConfigs) + ..where((t) => t.buttonId.equals(buttonId))) + .write(ToolbarButtonConfigsCompanion(fallbackId: Value(fallbackId))); + + Future replaceAll(List configs) => + transaction(() async { + await delete(db.toolbarButtonConfigs).go(); + await _insertWithDeferredFallbacks(configs); + }); + + Future seedMissing( + List<({String buttonId, bool defaultVisible, String? defaultFallback})> + defaults, + ) async { + final existing = await getAll(); + final existingIds = {for (final r in existing) r.buttonId}; + + final missing = defaults + .where((d) => !existingIds.contains(d.buttonId)) + .toList(); + if (missing.isEmpty) return; + + await transaction(() async { + final inserted = []; + for (final def in missing) { + final orderKey = await generateTrailingOrderKey().getSingle(); + inserted.add( + ToolbarButtonConfig( + buttonId: def.buttonId, + orderKey: orderKey, + isVisible: def.defaultVisible, + fallbackId: def.defaultFallback, + ), + ); + await into(db.toolbarButtonConfigs).insert( + ToolbarButtonConfig( + buttonId: def.buttonId, + orderKey: orderKey, + isVisible: def.defaultVisible, + ), + ); + } + + await _assignFallbacks(inserted); + }); + } + + SingleSelectable generateLeadingOrderKey({int bucket = 0}) => + db.definitionsDrift.toolbarLeadingOrderKey(bucket: bucket); + + SingleSelectable generateTrailingOrderKey({int bucket = 0}) => + db.definitionsDrift.toolbarTrailingOrderKey(bucket: bucket); + + SingleOrNullSelectable generateOrderKeyAfterButtonId( + String buttonId, + ) => db.definitionsDrift.toolbarOrderKeyAfterButton(buttonId: buttonId); + + SingleSelectable generateOrderKeyBeforeButtonId(String buttonId) => + db.definitionsDrift.toolbarOrderKeyBeforeButton(buttonId: buttonId); + + Future _insertWithDeferredFallbacks( + List configs, + ) async { + for (final config in configs) { + await into(db.toolbarButtonConfigs).insert( + ToolbarButtonConfig( + buttonId: config.buttonId, + orderKey: config.orderKey, + isVisible: config.isVisible, + ), + ); + } + + await _assignFallbacks(configs); + } + + Future _assignFallbacks(List configs) async { + for (final config in configs) { + if (config.fallbackId == null) continue; + await assignFallback(config.buttonId, config.fallbackId); + } + } +} diff --git a/app/lib/features/user/data/database/daos/toolbar_button_config.drift.dart b/app/lib/features/user/data/database/daos/toolbar_button_config.drift.dart new file mode 100644 index 00000000..091683d2 --- /dev/null +++ b/app/lib/features/user/data/database/daos/toolbar_button_config.drift.dart @@ -0,0 +1,14 @@ +// dart format width=80 +// ignore_for_file: type=lint +import 'package:drift/drift.dart' as i0; +import 'package:weblibre/features/user/data/database/database.dart' as i1; + +mixin $ToolbarButtonConfigDaoMixin on i0.DatabaseAccessor { + ToolbarButtonConfigDaoManager get managers => + ToolbarButtonConfigDaoManager(this); +} + +class ToolbarButtonConfigDaoManager { + final $ToolbarButtonConfigDaoMixin _db; + ToolbarButtonConfigDaoManager(this._db); +} diff --git a/app/lib/features/user/data/database/database.dart b/app/lib/features/user/data/database/database.dart index a49bb4a5..16c1c4ec 100644 --- a/app/lib/features/user/data/database/database.dart +++ b/app/lib/features/user/data/database/database.dart @@ -24,16 +24,17 @@ import 'package:flutter/foundation.dart'; import 'package:weblibre/features/user/data/database/daos/cache.dart'; import 'package:weblibre/features/user/data/database/daos/onboarding.dart'; import 'package:weblibre/features/user/data/database/daos/setting.dart'; +import 'package:weblibre/features/user/data/database/daos/toolbar_button_config.dart'; import 'package:weblibre/features/user/data/database/database.drift.dart'; import 'package:weblibre/features/user/data/database/database.steps.dart'; @DriftDatabase( include: {'definitions.drift'}, - daos: [SettingDao, CacheDao, OnboardingDao], + daos: [SettingDao, CacheDao, OnboardingDao, ToolbarButtonConfigDao], ) class UserDatabase extends $UserDatabase { @override - final int schemaVersion = 2; + final int schemaVersion = 3; @override MigrationStrategy get migration => MigrationStrategy( @@ -45,6 +46,8 @@ class UserDatabase extends $UserDatabase { } await customStatement('PRAGMA foreign_keys = ON;'); + + await onAfterOpen?.call(this); }, onUpgrade: (m, from, to) async { // Following the advice from https://drift.simonbinder.eu/Migrations/api/#general-tips @@ -73,11 +76,17 @@ class UserDatabase extends $UserDatabase { }, ); - UserDatabase(super.e); + UserDatabase(super.e, {this.onAfterOpen}); + + final Future Function(UserDatabase db)? onAfterOpen; static final _upgrade = migrationSteps( from1To2: (m, schema) async { await m.createTable(schema.riverpod); }, + from2To3: (m, schema) async { + await m.createTable(schema.toolbarButtonConfigs); + await m.createIndex(schema.idxToolbarOrderKey); + }, ); } diff --git a/app/lib/features/user/data/database/database.drift.dart b/app/lib/features/user/data/database/database.drift.dart index 710a9363..f8e3cfd9 100644 --- a/app/lib/features/user/data/database/database.drift.dart +++ b/app/lib/features/user/data/database/database.drift.dart @@ -8,8 +8,10 @@ import 'package:weblibre/features/user/data/database/database.dart' as i3; import 'package:weblibre/features/user/data/database/daos/cache.dart' as i4; import 'package:weblibre/features/user/data/database/daos/onboarding.dart' as i5; -import 'package:drift/internal/modular.dart' as i6; -import 'package:sqlite3/common.dart' as i7; +import 'package:weblibre/features/user/data/database/daos/toolbar_button_config.dart' + as i6; +import 'package:drift/internal/modular.dart' as i7; +import 'package:sqlite3/common.dart' as i8; abstract class $UserDatabase extends i0.GeneratedDatabase { $UserDatabase(i0.QueryExecutor e) : super(e); @@ -18,12 +20,16 @@ abstract class $UserDatabase extends i0.GeneratedDatabase { late final i1.IconCache iconCache = i1.IconCache(this); late final i1.Onboarding onboarding = i1.Onboarding(this); late final i1.Riverpod riverpod = i1.Riverpod(this); + late final i1.ToolbarButtonConfigs toolbarButtonConfigs = + i1.ToolbarButtonConfigs(this); late final i2.SettingDao settingDao = i2.SettingDao(this as i3.UserDatabase); late final i4.CacheDao cacheDao = i4.CacheDao(this as i3.UserDatabase); late final i5.OnboardingDao onboardingDao = i5.OnboardingDao( this as i3.UserDatabase, ); - i1.DefinitionsDrift get definitionsDrift => i6.ReadDatabaseContainer( + late final i6.ToolbarButtonConfigDao toolbarButtonConfigDao = + i6.ToolbarButtonConfigDao(this as i3.UserDatabase); + i1.DefinitionsDrift get definitionsDrift => i7.ReadDatabaseContainer( this, ).accessor(i1.DefinitionsDrift.new); @override @@ -35,6 +41,8 @@ abstract class $UserDatabase extends i0.GeneratedDatabase { iconCache, onboarding, riverpod, + toolbarButtonConfigs, + i1.idxToolbarOrderKey, ]; } @@ -49,9 +57,11 @@ class $UserDatabaseManager { i1.$OnboardingTableManager(_db, _db.onboarding); i1.$RiverpodTableManager get riverpod => i1.$RiverpodTableManager(_db, _db.riverpod); + i1.$ToolbarButtonConfigsTableManager get toolbarButtonConfigs => + i1.$ToolbarButtonConfigsTableManager(_db, _db.toolbarButtonConfigs); } -extension DefineFunctions on i7.CommonDatabase { +extension DefineFunctions on i8.CommonDatabase { void defineFunctions({ required String Function(int, String?) lexoRankNext, required String Function(int, String?) lexoRankPrevious, @@ -60,7 +70,7 @@ extension DefineFunctions on i7.CommonDatabase { }) { createFunction( functionName: 'lexo_rank_next', - argumentCount: const i7.AllowedArgumentCount(2), + argumentCount: const i8.AllowedArgumentCount(2), function: (args) { final arg0 = args[0] as int; final arg1 = args[1] as String?; @@ -69,7 +79,7 @@ extension DefineFunctions on i7.CommonDatabase { ); createFunction( functionName: 'lexo_rank_previous', - argumentCount: const i7.AllowedArgumentCount(2), + argumentCount: const i8.AllowedArgumentCount(2), function: (args) { final arg0 = args[0] as int; final arg1 = args[1] as String?; @@ -78,7 +88,7 @@ extension DefineFunctions on i7.CommonDatabase { ); createFunction( functionName: 'lexo_rank_reorder_after', - argumentCount: const i7.AllowedArgumentCount(2), + argumentCount: const i8.AllowedArgumentCount(2), function: (args) { final arg0 = args[0] as String?; final arg1 = args[1] as String?; @@ -87,7 +97,7 @@ extension DefineFunctions on i7.CommonDatabase { ); createFunction( functionName: 'lexo_rank_reorder_before', - argumentCount: const i7.AllowedArgumentCount(2), + argumentCount: const i8.AllowedArgumentCount(2), function: (args) { final arg0 = args[0] as String?; final arg1 = args[1] as String?; diff --git a/app/lib/features/user/data/database/database.steps.dart b/app/lib/features/user/data/database/database.steps.dart index befb380a..ac0f72f8 100644 --- a/app/lib/features/user/data/database/database.steps.dart +++ b/app/lib/features/user/data/database/database.steps.dart @@ -192,8 +192,128 @@ i1.GeneratedColumn _column_10(String aliasedName) => type: i1.DriftSqlType.string, $customConstraints: '', ); + +final class Schema3 extends i0.VersionedSchema { + Schema3({required super.database}) : super(version: 3); + @override + late final List entities = [ + setting, + iconCache, + onboarding, + riverpod, + toolbarButtonConfigs, + idxToolbarOrderKey, + ]; + late final Shape0 setting = Shape0( + source: i0.VersionedTable( + entityName: 'setting', + withoutRowId: false, + isStrict: true, + tableConstraints: [], + columns: [_column_0, _column_1, _column_2], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape1 iconCache = Shape1( + source: i0.VersionedTable( + entityName: 'icon_cache', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [_column_3, _column_4, _column_5], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape2 onboarding = Shape2( + source: i0.VersionedTable( + entityName: 'onboarding', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [_column_6, _column_7], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape3 riverpod = Shape3( + source: i0.VersionedTable( + entityName: 'riverpod', + withoutRowId: true, + isStrict: false, + tableConstraints: [], + columns: [_column_0, _column_8, _column_9, _column_10], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape4 toolbarButtonConfigs = Shape4( + source: i0.VersionedTable( + entityName: 'toolbar_button_configs', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [_column_11, _column_12, _column_13, _column_14], + attachedDatabase: database, + ), + alias: null, + ); + final i1.Index idxToolbarOrderKey = i1.Index( + 'idx_toolbar_order_key', + 'CREATE INDEX idx_toolbar_order_key ON toolbar_button_configs (order_key)', + ); +} + +class Shape4 extends i0.VersionedTable { + Shape4({required super.source, required super.alias}) : super.aliased(); + i1.GeneratedColumn get buttonId => + columnsByName['button_id']! as i1.GeneratedColumn; + i1.GeneratedColumn get orderKey => + columnsByName['order_key']! as i1.GeneratedColumn; + i1.GeneratedColumn get isVisible => + columnsByName['is_visible']! as i1.GeneratedColumn; + i1.GeneratedColumn get fallbackId => + columnsByName['fallback_id']! as i1.GeneratedColumn; +} + +i1.GeneratedColumn _column_11(String aliasedName) => + i1.GeneratedColumn( + 'button_id', + aliasedName, + false, + type: i1.DriftSqlType.string, + $customConstraints: 'NOT NULL PRIMARY KEY', + ); +i1.GeneratedColumn _column_12(String aliasedName) => + i1.GeneratedColumn( + 'order_key', + aliasedName, + false, + type: i1.DriftSqlType.string, + $customConstraints: 'NOT NULL', + ); +i1.GeneratedColumn _column_13(String aliasedName) => + i1.GeneratedColumn( + 'is_visible', + aliasedName, + false, + type: i1.DriftSqlType.int, + $customConstraints: 'NOT NULL DEFAULT TRUE', + defaultValue: const i1.CustomExpression('TRUE'), + ); +i1.GeneratedColumn _column_14(String aliasedName) => + i1.GeneratedColumn( + 'fallback_id', + aliasedName, + true, + type: i1.DriftSqlType.string, + $customConstraints: + 'REFERENCES toolbar_button_configs(button_id)ON DELETE SET NULL', + ); i0.MigrationStepWithVersion migrationSteps({ required Future Function(i1.Migrator m, Schema2 schema) from1To2, + required Future Function(i1.Migrator m, Schema3 schema) from2To3, }) { return (currentVersion, database) async { switch (currentVersion) { @@ -202,6 +322,11 @@ i0.MigrationStepWithVersion migrationSteps({ final migrator = i1.Migrator(database, schema); await from1To2(migrator, schema); return 2; + case 2: + final schema = Schema3(database: database); + final migrator = i1.Migrator(database, schema); + await from2To3(migrator, schema); + return 3; default: throw ArgumentError.value('Unknown migration from $currentVersion'); } @@ -210,6 +335,7 @@ i0.MigrationStepWithVersion migrationSteps({ i1.OnUpgrade stepByStep({ required Future Function(i1.Migrator m, Schema2 schema) from1To2, + required Future Function(i1.Migrator m, Schema3 schema) from2To3, }) => i0.VersionedSchema.stepByStepHelper( - step: migrationSteps(from1To2: from1To2), + step: migrationSteps(from1To2: from1To2, from2To3: from2To3), ); diff --git a/app/lib/features/user/data/database/definitions.drift b/app/lib/features/user/data/database/definitions.drift index 67d5251e..9af1bac9 100644 --- a/app/lib/features/user/data/database/definitions.drift +++ b/app/lib/features/user/data/database/definitions.drift @@ -22,6 +22,61 @@ CREATE TABLE riverpod ( destroyKey TEXT ) WITHOUT ROWID; +CREATE TABLE toolbar_button_configs ( + button_id TEXT NOT NULL PRIMARY KEY, + order_key TEXT NOT NULL, + is_visible BOOLEAN NOT NULL DEFAULT TRUE, + fallback_id TEXT REFERENCES toolbar_button_configs(button_id) ON DELETE SET NULL +); + +CREATE INDEX idx_toolbar_order_key ON toolbar_button_configs(order_key); + +toolbarLeadingOrderKey(:bucket AS INTEGER): + SELECT lexo_rank_previous( + :bucket, + ( + SELECT order_key + FROM toolbar_button_configs + ORDER BY order_key + LIMIT 1 + ) + ); + +toolbarTrailingOrderKey(:bucket AS INTEGER): + SELECT lexo_rank_next( + :bucket, + ( + SELECT order_key + FROM toolbar_button_configs + ORDER BY order_key DESC + LIMIT 1 + ) + ); + +toolbarOrderKeyAfterButton(:button_id AS TEXT): + WITH ordered_table AS ( + SELECT + button_id, + order_key, + LEAD(order_key) OVER (ORDER BY order_key) AS next_order_key + FROM toolbar_button_configs + ) + SELECT lexo_rank_reorder_after(order_key, next_order_key) + FROM ordered_table + WHERE button_id = :button_id; + +toolbarOrderKeyBeforeButton(:button_id AS TEXT): + WITH ordered_table AS ( + SELECT + button_id, + order_key, + LAG(order_key) OVER (ORDER BY order_key) AS prev_order_key + FROM toolbar_button_configs + ) + SELECT lexo_rank_reorder_before(order_key, prev_order_key) + FROM ordered_table + WHERE button_id = :button_id; + evictCacheEntries: DELETE FROM icon_cache WHERE rowid IN ( diff --git a/app/lib/features/user/data/database/definitions.drift.dart b/app/lib/features/user/data/database/definitions.drift.dart index 63a4b753..c375ff44 100644 --- a/app/lib/features/user/data/database/definitions.drift.dart +++ b/app/lib/features/user/data/database/definitions.drift.dart @@ -670,6 +670,199 @@ typedef $RiverpodProcessedTableManager = i1.RiverpodData, i0.PrefetchHooks Function() >; +typedef $ToolbarButtonConfigsCreateCompanionBuilder = + i1.ToolbarButtonConfigsCompanion Function({ + required String buttonId, + required String orderKey, + i0.Value isVisible, + i0.Value fallbackId, + i0.Value rowid, + }); +typedef $ToolbarButtonConfigsUpdateCompanionBuilder = + i1.ToolbarButtonConfigsCompanion Function({ + i0.Value buttonId, + i0.Value orderKey, + i0.Value isVisible, + i0.Value fallbackId, + i0.Value rowid, + }); + +class $ToolbarButtonConfigsFilterComposer + extends i0.Composer { + $ToolbarButtonConfigsFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i0.ColumnFilters get buttonId => $composableBuilder( + column: $table.buttonId, + builder: (column) => i0.ColumnFilters(column), + ); + + i0.ColumnFilters get orderKey => $composableBuilder( + column: $table.orderKey, + builder: (column) => i0.ColumnFilters(column), + ); + + i0.ColumnFilters get isVisible => $composableBuilder( + column: $table.isVisible, + builder: (column) => i0.ColumnFilters(column), + ); + + i0.ColumnFilters get fallbackId => $composableBuilder( + column: $table.fallbackId, + builder: (column) => i0.ColumnFilters(column), + ); +} + +class $ToolbarButtonConfigsOrderingComposer + extends i0.Composer { + $ToolbarButtonConfigsOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i0.ColumnOrderings get buttonId => $composableBuilder( + column: $table.buttonId, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get orderKey => $composableBuilder( + column: $table.orderKey, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get isVisible => $composableBuilder( + column: $table.isVisible, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get fallbackId => $composableBuilder( + column: $table.fallbackId, + builder: (column) => i0.ColumnOrderings(column), + ); +} + +class $ToolbarButtonConfigsAnnotationComposer + extends i0.Composer { + $ToolbarButtonConfigsAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i0.GeneratedColumn get buttonId => + $composableBuilder(column: $table.buttonId, builder: (column) => column); + + i0.GeneratedColumn get orderKey => + $composableBuilder(column: $table.orderKey, builder: (column) => column); + + i0.GeneratedColumn get isVisible => + $composableBuilder(column: $table.isVisible, builder: (column) => column); + + i0.GeneratedColumn get fallbackId => $composableBuilder( + column: $table.fallbackId, + builder: (column) => column, + ); +} + +class $ToolbarButtonConfigsTableManager + extends + i0.RootTableManager< + i0.GeneratedDatabase, + i1.ToolbarButtonConfigs, + i1.ToolbarButtonConfig, + i1.$ToolbarButtonConfigsFilterComposer, + i1.$ToolbarButtonConfigsOrderingComposer, + i1.$ToolbarButtonConfigsAnnotationComposer, + $ToolbarButtonConfigsCreateCompanionBuilder, + $ToolbarButtonConfigsUpdateCompanionBuilder, + ( + i1.ToolbarButtonConfig, + i0.BaseReferences< + i0.GeneratedDatabase, + i1.ToolbarButtonConfigs, + i1.ToolbarButtonConfig + >, + ), + i1.ToolbarButtonConfig, + i0.PrefetchHooks Function() + > { + $ToolbarButtonConfigsTableManager( + i0.GeneratedDatabase db, + i1.ToolbarButtonConfigs table, + ) : super( + i0.TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + i1.$ToolbarButtonConfigsFilterComposer($db: db, $table: table), + createOrderingComposer: () => + i1.$ToolbarButtonConfigsOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => i1 + .$ToolbarButtonConfigsAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + i0.Value buttonId = const i0.Value.absent(), + i0.Value orderKey = const i0.Value.absent(), + i0.Value isVisible = const i0.Value.absent(), + i0.Value fallbackId = const i0.Value.absent(), + i0.Value rowid = const i0.Value.absent(), + }) => i1.ToolbarButtonConfigsCompanion( + buttonId: buttonId, + orderKey: orderKey, + isVisible: isVisible, + fallbackId: fallbackId, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String buttonId, + required String orderKey, + i0.Value isVisible = const i0.Value.absent(), + i0.Value fallbackId = const i0.Value.absent(), + i0.Value rowid = const i0.Value.absent(), + }) => i1.ToolbarButtonConfigsCompanion.insert( + buttonId: buttonId, + orderKey: orderKey, + isVisible: isVisible, + fallbackId: fallbackId, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), i0.BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $ToolbarButtonConfigsProcessedTableManager = + i0.ProcessedTableManager< + i0.GeneratedDatabase, + i1.ToolbarButtonConfigs, + i1.ToolbarButtonConfig, + i1.$ToolbarButtonConfigsFilterComposer, + i1.$ToolbarButtonConfigsOrderingComposer, + i1.$ToolbarButtonConfigsAnnotationComposer, + $ToolbarButtonConfigsCreateCompanionBuilder, + $ToolbarButtonConfigsUpdateCompanionBuilder, + ( + i1.ToolbarButtonConfig, + i0.BaseReferences< + i0.GeneratedDatabase, + i1.ToolbarButtonConfigs, + i1.ToolbarButtonConfig + >, + ), + i1.ToolbarButtonConfig, + i0.PrefetchHooks Function() + >; class Setting extends i0.Table with i0.TableInfo { @override @@ -1558,8 +1751,313 @@ class RiverpodCompanion extends i0.UpdateCompanion { } } +class ToolbarButtonConfigs extends i0.Table + with i0.TableInfo { + @override + final i0.GeneratedDatabase attachedDatabase; + final String? _alias; + ToolbarButtonConfigs(this.attachedDatabase, [this._alias]); + late final i0.GeneratedColumn buttonId = i0.GeneratedColumn( + 'button_id', + aliasedName, + false, + type: i0.DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL PRIMARY KEY', + ); + late final i0.GeneratedColumn orderKey = i0.GeneratedColumn( + 'order_key', + aliasedName, + false, + type: i0.DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final i0.GeneratedColumn isVisible = i0.GeneratedColumn( + 'is_visible', + aliasedName, + false, + type: i0.DriftSqlType.bool, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT TRUE', + defaultValue: const i0.CustomExpression('TRUE'), + ); + late final i0.GeneratedColumn fallbackId = i0.GeneratedColumn( + 'fallback_id', + aliasedName, + true, + type: i0.DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: + 'REFERENCES toolbar_button_configs(button_id)ON DELETE SET NULL', + ); + @override + List get $columns => [ + buttonId, + orderKey, + isVisible, + fallbackId, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'toolbar_button_configs'; + @override + Set get $primaryKey => {buttonId}; + @override + i1.ToolbarButtonConfig map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return i1.ToolbarButtonConfig( + buttonId: attachedDatabase.typeMapping.read( + i0.DriftSqlType.string, + data['${effectivePrefix}button_id'], + )!, + orderKey: attachedDatabase.typeMapping.read( + i0.DriftSqlType.string, + data['${effectivePrefix}order_key'], + )!, + isVisible: attachedDatabase.typeMapping.read( + i0.DriftSqlType.bool, + data['${effectivePrefix}is_visible'], + )!, + fallbackId: attachedDatabase.typeMapping.read( + i0.DriftSqlType.string, + data['${effectivePrefix}fallback_id'], + ), + ); + } + + @override + ToolbarButtonConfigs createAlias(String alias) { + return ToolbarButtonConfigs(attachedDatabase, alias); + } + + @override + bool get dontWriteConstraints => true; +} + +class ToolbarButtonConfig extends i0.DataClass + implements i0.Insertable { + final String buttonId; + final String orderKey; + final bool isVisible; + final String? fallbackId; + const ToolbarButtonConfig({ + required this.buttonId, + required this.orderKey, + required this.isVisible, + this.fallbackId, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['button_id'] = i0.Variable(buttonId); + map['order_key'] = i0.Variable(orderKey); + map['is_visible'] = i0.Variable(isVisible); + if (!nullToAbsent || fallbackId != null) { + map['fallback_id'] = i0.Variable(fallbackId); + } + return map; + } + + factory ToolbarButtonConfig.fromJson( + Map json, { + i0.ValueSerializer? serializer, + }) { + serializer ??= i0.driftRuntimeOptions.defaultSerializer; + return ToolbarButtonConfig( + buttonId: serializer.fromJson(json['button_id']), + orderKey: serializer.fromJson(json['order_key']), + isVisible: serializer.fromJson(json['is_visible']), + fallbackId: serializer.fromJson(json['fallback_id']), + ); + } + @override + Map toJson({i0.ValueSerializer? serializer}) { + serializer ??= i0.driftRuntimeOptions.defaultSerializer; + return { + 'button_id': serializer.toJson(buttonId), + 'order_key': serializer.toJson(orderKey), + 'is_visible': serializer.toJson(isVisible), + 'fallback_id': serializer.toJson(fallbackId), + }; + } + + i1.ToolbarButtonConfig copyWith({ + String? buttonId, + String? orderKey, + bool? isVisible, + i0.Value fallbackId = const i0.Value.absent(), + }) => i1.ToolbarButtonConfig( + buttonId: buttonId ?? this.buttonId, + orderKey: orderKey ?? this.orderKey, + isVisible: isVisible ?? this.isVisible, + fallbackId: fallbackId.present ? fallbackId.value : this.fallbackId, + ); + ToolbarButtonConfig copyWithCompanion(i1.ToolbarButtonConfigsCompanion data) { + return ToolbarButtonConfig( + buttonId: data.buttonId.present ? data.buttonId.value : this.buttonId, + orderKey: data.orderKey.present ? data.orderKey.value : this.orderKey, + isVisible: data.isVisible.present ? data.isVisible.value : this.isVisible, + fallbackId: data.fallbackId.present + ? data.fallbackId.value + : this.fallbackId, + ); + } + + @override + String toString() { + return (StringBuffer('ToolbarButtonConfig(') + ..write('buttonId: $buttonId, ') + ..write('orderKey: $orderKey, ') + ..write('isVisible: $isVisible, ') + ..write('fallbackId: $fallbackId') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(buttonId, orderKey, isVisible, fallbackId); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is i1.ToolbarButtonConfig && + other.buttonId == this.buttonId && + other.orderKey == this.orderKey && + other.isVisible == this.isVisible && + other.fallbackId == this.fallbackId); +} + +class ToolbarButtonConfigsCompanion + extends i0.UpdateCompanion { + final i0.Value buttonId; + final i0.Value orderKey; + final i0.Value isVisible; + final i0.Value fallbackId; + final i0.Value rowid; + const ToolbarButtonConfigsCompanion({ + this.buttonId = const i0.Value.absent(), + this.orderKey = const i0.Value.absent(), + this.isVisible = const i0.Value.absent(), + this.fallbackId = const i0.Value.absent(), + this.rowid = const i0.Value.absent(), + }); + ToolbarButtonConfigsCompanion.insert({ + required String buttonId, + required String orderKey, + this.isVisible = const i0.Value.absent(), + this.fallbackId = const i0.Value.absent(), + this.rowid = const i0.Value.absent(), + }) : buttonId = i0.Value(buttonId), + orderKey = i0.Value(orderKey); + static i0.Insertable custom({ + i0.Expression? buttonId, + i0.Expression? orderKey, + i0.Expression? isVisible, + i0.Expression? fallbackId, + i0.Expression? rowid, + }) { + return i0.RawValuesInsertable({ + if (buttonId != null) 'button_id': buttonId, + if (orderKey != null) 'order_key': orderKey, + if (isVisible != null) 'is_visible': isVisible, + if (fallbackId != null) 'fallback_id': fallbackId, + if (rowid != null) 'rowid': rowid, + }); + } + + i1.ToolbarButtonConfigsCompanion copyWith({ + i0.Value? buttonId, + i0.Value? orderKey, + i0.Value? isVisible, + i0.Value? fallbackId, + i0.Value? rowid, + }) { + return i1.ToolbarButtonConfigsCompanion( + buttonId: buttonId ?? this.buttonId, + orderKey: orderKey ?? this.orderKey, + isVisible: isVisible ?? this.isVisible, + fallbackId: fallbackId ?? this.fallbackId, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (buttonId.present) { + map['button_id'] = i0.Variable(buttonId.value); + } + if (orderKey.present) { + map['order_key'] = i0.Variable(orderKey.value); + } + if (isVisible.present) { + map['is_visible'] = i0.Variable(isVisible.value); + } + if (fallbackId.present) { + map['fallback_id'] = i0.Variable(fallbackId.value); + } + if (rowid.present) { + map['rowid'] = i0.Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('ToolbarButtonConfigsCompanion(') + ..write('buttonId: $buttonId, ') + ..write('orderKey: $orderKey, ') + ..write('isVisible: $isVisible, ') + ..write('fallbackId: $fallbackId, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +i0.Index get idxToolbarOrderKey => i0.Index( + 'idx_toolbar_order_key', + 'CREATE INDEX idx_toolbar_order_key ON toolbar_button_configs (order_key)', +); + class DefinitionsDrift extends i3.ModularAccessor { DefinitionsDrift(i0.GeneratedDatabase db) : super(db); + i0.Selectable toolbarLeadingOrderKey({required int bucket}) { + return customSelect( + 'SELECT lexo_rank_previous(?1, (SELECT order_key FROM toolbar_button_configs ORDER BY order_key LIMIT 1)) AS _c0', + variables: [i0.Variable(bucket)], + readsFrom: {toolbarButtonConfigs}, + ).map((i0.QueryRow row) => row.read('_c0')); + } + + i0.Selectable toolbarTrailingOrderKey({required int bucket}) { + return customSelect( + 'SELECT lexo_rank_next(?1, (SELECT order_key FROM toolbar_button_configs ORDER BY order_key DESC LIMIT 1)) AS _c0', + variables: [i0.Variable(bucket)], + readsFrom: {toolbarButtonConfigs}, + ).map((i0.QueryRow row) => row.read('_c0')); + } + + i0.Selectable toolbarOrderKeyAfterButton({required String buttonId}) { + return customSelect( + 'WITH ordered_table AS (SELECT button_id, order_key, LEAD(order_key)OVER (ORDER BY order_key RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE NO OTHERS) AS next_order_key FROM toolbar_button_configs) SELECT lexo_rank_reorder_after(order_key, next_order_key) AS _c0 FROM ordered_table WHERE button_id = ?1', + variables: [i0.Variable(buttonId)], + readsFrom: {toolbarButtonConfigs}, + ).map((i0.QueryRow row) => row.read('_c0')); + } + + i0.Selectable toolbarOrderKeyBeforeButton({ + required String buttonId, + }) { + return customSelect( + 'WITH ordered_table AS (SELECT button_id, order_key, LAG(order_key)OVER (ORDER BY order_key RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE NO OTHERS) AS prev_order_key FROM toolbar_button_configs) SELECT lexo_rank_reorder_before(order_key, prev_order_key) AS _c0 FROM ordered_table WHERE button_id = ?1', + variables: [i0.Variable(buttonId)], + readsFrom: {toolbarButtonConfigs}, + ).map((i0.QueryRow row) => row.read('_c0')); + } + Future evictCacheEntries({required int limit}) { return customUpdate( 'DELETE FROM icon_cache WHERE "rowid" IN (SELECT "rowid" FROM icon_cache ORDER BY fetch_date DESC LIMIT -1 OFFSET ?1)', @@ -1569,6 +2067,9 @@ class DefinitionsDrift extends i3.ModularAccessor { ); } + i1.ToolbarButtonConfigs get toolbarButtonConfigs => i3.ReadDatabaseContainer( + attachedDatabase, + ).resultSet('toolbar_button_configs'); i1.IconCache get iconCache => i3.ReadDatabaseContainer( attachedDatabase, ).resultSet('icon_cache'); diff --git a/app/lib/features/user/data/providers.dart b/app/lib/features/user/data/providers.dart index 6aad0600..bac5a7cb 100644 --- a/app/lib/features/user/data/providers.dart +++ b/app/lib/features/user/data/providers.dart @@ -27,6 +27,7 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:sqlite3_flutter_libs/sqlite3_flutter_libs.dart'; import 'package:weblibre/core/filesystem.dart'; +import 'package:weblibre/data/database/functions/lexo_rank_functions.dart'; import 'package:weblibre/features/user/data/database/database.dart'; import 'package:weblibre/features/user/data/database/riverpod_storage.dart'; @@ -43,7 +44,12 @@ UserDatabase userDatabase(Ref ref) { await applyWorkaroundToOpenSqlite3OnOldAndroidVersions(); } - return NativeDatabase.createInBackground(file); + return NativeDatabase.createInBackground( + file, + setup: (database) { + registerLexorankFunctions(database); + }, + ); }), ); diff --git a/app/lib/features/user/data/providers.g.dart b/app/lib/features/user/data/providers.g.dart index 37fa1e38..253e4c62 100644 --- a/app/lib/features/user/data/providers.g.dart +++ b/app/lib/features/user/data/providers.g.dart @@ -48,7 +48,7 @@ final class UserDatabaseProvider } } -String _$userDatabaseHash() => r'8cc40197f9dbb85ccd8263984218753d3690954b'; +String _$userDatabaseHash() => r'1d14f39dd506ec8890a0a15cc2546b3294a34a1c'; @ProviderFor(riverpodDatabaseStorage) final riverpodDatabaseStorageProvider = RiverpodDatabaseStorageProvider._(); diff --git a/app/test/drift/user/generated/drift_schema_v3.json b/app/test/drift/user/generated/drift_schema_v3.json new file mode 100644 index 00000000..a1b5d890 --- /dev/null +++ b/app/test/drift/user/generated/drift_schema_v3.json @@ -0,0 +1,298 @@ +{ + "_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": "setting", + "was_declared_in_moor": true, + "columns": [ + { + "name": "key", + "getter_name": "key", + "moor_type": "string", + "nullable": false, + "customConstraints": "PRIMARY KEY NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + "primary-key" + ] + }, + { + "name": "partition_key", + "getter_name": "partitionKey", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "value", + "getter_name": "value", + "moor_type": "any", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "strict": true + } + }, + { + "id": 1, + "references": [], + "type": "table", + "data": { + "name": "icon_cache", + "was_declared_in_moor": true, + "columns": [ + { + "name": "origin", + "getter_name": "origin", + "moor_type": "string", + "nullable": false, + "customConstraints": "PRIMARY KEY NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + "primary-key" + ] + }, + { + "name": "icon_data", + "getter_name": "iconData", + "moor_type": "blob", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "fetch_date", + "getter_name": "fetchDate", + "moor_type": "dateTime", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [] + } + }, + { + "id": 2, + "references": [], + "type": "table", + "data": { + "name": "onboarding", + "was_declared_in_moor": true, + "columns": [ + { + "name": "revision", + "getter_name": "revision", + "moor_type": "int", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "completion_date", + "getter_name": "completionDate", + "moor_type": "dateTime", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [] + } + }, + { + "id": 3, + "references": [], + "type": "table", + "data": { + "name": "riverpod", + "was_declared_in_moor": true, + "columns": [ + { + "name": "key", + "getter_name": "key", + "moor_type": "string", + "nullable": false, + "customConstraints": "PRIMARY KEY NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + "primary-key" + ] + }, + { + "name": "json", + "getter_name": "json", + "moor_type": "string", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "expireAt", + "getter_name": "expireAt", + "moor_type": "dateTime", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "destroyKey", + "getter_name": "destroyKey", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": true, + "constraints": [] + } + }, + { + "id": 4, + "references": [], + "type": "table", + "data": { + "name": "toolbar_button_configs", + "was_declared_in_moor": true, + "columns": [ + { + "name": "button_id", + "getter_name": "buttonId", + "moor_type": "string", + "nullable": false, + "customConstraints": "NOT NULL PRIMARY KEY", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + "primary-key" + ] + }, + { + "name": "order_key", + "getter_name": "orderKey", + "moor_type": "string", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_visible", + "getter_name": "isVisible", + "moor_type": "int", + "nullable": false, + "customConstraints": "NOT NULL DEFAULT 1", + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "fallback_id", + "getter_name": "fallbackId", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "strict": true + } + } + ], + "fixed_sql": [ + { + "name": "setting", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"setting\" (\"key\" TEXT PRIMARY KEY NOT NULL, \"partition_key\" TEXT, \"value\" ANY) STRICT;" + } + ] + }, + { + "name": "icon_cache", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"icon_cache\" (\"origin\" TEXT PRIMARY KEY NOT NULL, \"icon_data\" BLOB NOT NULL, \"fetch_date\" INTEGER NOT NULL);" + } + ] + }, + { + "name": "onboarding", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"onboarding\" (\"revision\" INTEGER NOT NULL, \"completion_date\" INTEGER NOT NULL);" + } + ] + }, + { + "name": "riverpod", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"riverpod\" (\"key\" TEXT PRIMARY KEY NOT NULL, \"json\" TEXT NOT NULL, \"expireAt\" INTEGER, \"destroyKey\" TEXT) WITHOUT ROWID;" + } + ] + }, + { + "name": "toolbar_button_configs", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"toolbar_button_configs\" (\"button_id\" TEXT NOT NULL PRIMARY KEY, \"order_key\" TEXT NOT NULL, \"is_visible\" INTEGER NOT NULL DEFAULT 1, \"fallback_id\" TEXT) STRICT;" + } + ] + } + ] +} \ No newline at end of file diff --git a/app/test/drift/user/generated/schema.dart b/app/test/drift/user/generated/schema.dart index f5e870e5..f7feae41 100644 --- a/app/test/drift/user/generated/schema.dart +++ b/app/test/drift/user/generated/schema.dart @@ -6,6 +6,7 @@ import 'package:drift/drift.dart'; import 'package:drift/internal/migrations.dart'; import 'schema_v1.dart' as v1; import 'schema_v2.dart' as v2; +import 'schema_v3.dart' as v3; class GeneratedHelper implements SchemaInstantiationHelper { @override @@ -15,10 +16,12 @@ class GeneratedHelper implements SchemaInstantiationHelper { return v1.DatabaseAtV1(db); case 2: return v2.DatabaseAtV2(db); + case 3: + return v3.DatabaseAtV3(db); default: throw MissingSchemaException(version, versions); } } - static const versions = const [1, 2]; + static const versions = const [1, 2, 3]; } diff --git a/app/test/drift/user/generated/schema_v3.dart b/app/test/drift/user/generated/schema_v3.dart new file mode 100644 index 00000000..a863c89a --- /dev/null +++ b/app/test/drift/user/generated/schema_v3.dart @@ -0,0 +1,1182 @@ +// dart format width=80 +import 'dart:typed_data' as i2; +// GENERATED BY drift_dev, DO NOT MODIFY. +// ignore_for_file: type=lint,unused_import +// +import 'package:drift/drift.dart'; + +class Setting extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Setting(this.attachedDatabase, [this._alias]); + late final GeneratedColumn key = GeneratedColumn( + 'key', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'PRIMARY KEY NOT NULL', + ); + late final GeneratedColumn partitionKey = GeneratedColumn( + 'partition_key', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn value = GeneratedColumn( + 'value', + aliasedName, + true, + type: DriftSqlType.any, + requiredDuringInsert: false, + $customConstraints: '', + ); + @override + List get $columns => [key, partitionKey, value]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'setting'; + @override + Set get $primaryKey => {key}; + @override + SettingData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return SettingData( + key: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}key'], + )!, + partitionKey: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}partition_key'], + ), + value: attachedDatabase.typeMapping.read( + DriftSqlType.any, + data['${effectivePrefix}value'], + ), + ); + } + + @override + Setting createAlias(String alias) { + return Setting(attachedDatabase, alias); + } + + @override + bool get isStrict => true; + @override + bool get dontWriteConstraints => true; +} + +class SettingData extends DataClass implements Insertable { + final String key; + final String? partitionKey; + final DriftAny? value; + const SettingData({required this.key, this.partitionKey, this.value}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['key'] = Variable(key); + if (!nullToAbsent || partitionKey != null) { + map['partition_key'] = Variable(partitionKey); + } + if (!nullToAbsent || value != null) { + map['value'] = Variable(value); + } + return map; + } + + factory SettingData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return SettingData( + key: serializer.fromJson(json['key']), + partitionKey: serializer.fromJson(json['partitionKey']), + value: serializer.fromJson(json['value']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'key': serializer.toJson(key), + 'partitionKey': serializer.toJson(partitionKey), + 'value': serializer.toJson(value), + }; + } + + SettingData copyWith({ + String? key, + Value partitionKey = const Value.absent(), + Value value = const Value.absent(), + }) => SettingData( + key: key ?? this.key, + partitionKey: partitionKey.present ? partitionKey.value : this.partitionKey, + value: value.present ? value.value : this.value, + ); + SettingData copyWithCompanion(SettingCompanion data) { + return SettingData( + key: data.key.present ? data.key.value : this.key, + partitionKey: data.partitionKey.present + ? data.partitionKey.value + : this.partitionKey, + value: data.value.present ? data.value.value : this.value, + ); + } + + @override + String toString() { + return (StringBuffer('SettingData(') + ..write('key: $key, ') + ..write('partitionKey: $partitionKey, ') + ..write('value: $value') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(key, partitionKey, value); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is SettingData && + other.key == this.key && + other.partitionKey == this.partitionKey && + other.value == this.value); +} + +class SettingCompanion extends UpdateCompanion { + final Value key; + final Value partitionKey; + final Value value; + final Value rowid; + const SettingCompanion({ + this.key = const Value.absent(), + this.partitionKey = const Value.absent(), + this.value = const Value.absent(), + this.rowid = const Value.absent(), + }); + SettingCompanion.insert({ + required String key, + this.partitionKey = const Value.absent(), + this.value = const Value.absent(), + this.rowid = const Value.absent(), + }) : key = Value(key); + static Insertable custom({ + Expression? key, + Expression? partitionKey, + Expression? value, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (key != null) 'key': key, + if (partitionKey != null) 'partition_key': partitionKey, + if (value != null) 'value': value, + if (rowid != null) 'rowid': rowid, + }); + } + + SettingCompanion copyWith({ + Value? key, + Value? partitionKey, + Value? value, + Value? rowid, + }) { + return SettingCompanion( + key: key ?? this.key, + partitionKey: partitionKey ?? this.partitionKey, + value: value ?? this.value, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (key.present) { + map['key'] = Variable(key.value); + } + if (partitionKey.present) { + map['partition_key'] = Variable(partitionKey.value); + } + if (value.present) { + map['value'] = Variable(value.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('SettingCompanion(') + ..write('key: $key, ') + ..write('partitionKey: $partitionKey, ') + ..write('value: $value, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class IconCache extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + IconCache(this.attachedDatabase, [this._alias]); + late final GeneratedColumn origin = GeneratedColumn( + 'origin', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'PRIMARY KEY NOT NULL', + ); + late final GeneratedColumn iconData = + GeneratedColumn( + 'icon_data', + aliasedName, + false, + type: DriftSqlType.blob, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn fetchDate = GeneratedColumn( + 'fetch_date', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + @override + List get $columns => [origin, iconData, fetchDate]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'icon_cache'; + @override + Set get $primaryKey => {origin}; + @override + IconCacheData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return IconCacheData( + origin: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}origin'], + )!, + iconData: attachedDatabase.typeMapping.read( + DriftSqlType.blob, + data['${effectivePrefix}icon_data'], + )!, + fetchDate: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}fetch_date'], + )!, + ); + } + + @override + IconCache createAlias(String alias) { + return IconCache(attachedDatabase, alias); + } + + @override + bool get dontWriteConstraints => true; +} + +class IconCacheData extends DataClass implements Insertable { + final String origin; + final i2.Uint8List iconData; + final int fetchDate; + const IconCacheData({ + required this.origin, + required this.iconData, + required this.fetchDate, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['origin'] = Variable(origin); + map['icon_data'] = Variable(iconData); + map['fetch_date'] = Variable(fetchDate); + return map; + } + + factory IconCacheData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return IconCacheData( + origin: serializer.fromJson(json['origin']), + iconData: serializer.fromJson(json['iconData']), + fetchDate: serializer.fromJson(json['fetchDate']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'origin': serializer.toJson(origin), + 'iconData': serializer.toJson(iconData), + 'fetchDate': serializer.toJson(fetchDate), + }; + } + + IconCacheData copyWith({ + String? origin, + i2.Uint8List? iconData, + int? fetchDate, + }) => IconCacheData( + origin: origin ?? this.origin, + iconData: iconData ?? this.iconData, + fetchDate: fetchDate ?? this.fetchDate, + ); + IconCacheData copyWithCompanion(IconCacheCompanion data) { + return IconCacheData( + origin: data.origin.present ? data.origin.value : this.origin, + iconData: data.iconData.present ? data.iconData.value : this.iconData, + fetchDate: data.fetchDate.present ? data.fetchDate.value : this.fetchDate, + ); + } + + @override + String toString() { + return (StringBuffer('IconCacheData(') + ..write('origin: $origin, ') + ..write('iconData: $iconData, ') + ..write('fetchDate: $fetchDate') + ..write(')')) + .toString(); + } + + @override + int get hashCode => + Object.hash(origin, $driftBlobEquality.hash(iconData), fetchDate); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is IconCacheData && + other.origin == this.origin && + $driftBlobEquality.equals(other.iconData, this.iconData) && + other.fetchDate == this.fetchDate); +} + +class IconCacheCompanion extends UpdateCompanion { + final Value origin; + final Value iconData; + final Value fetchDate; + final Value rowid; + const IconCacheCompanion({ + this.origin = const Value.absent(), + this.iconData = const Value.absent(), + this.fetchDate = const Value.absent(), + this.rowid = const Value.absent(), + }); + IconCacheCompanion.insert({ + required String origin, + required i2.Uint8List iconData, + required int fetchDate, + this.rowid = const Value.absent(), + }) : origin = Value(origin), + iconData = Value(iconData), + fetchDate = Value(fetchDate); + static Insertable custom({ + Expression? origin, + Expression? iconData, + Expression? fetchDate, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (origin != null) 'origin': origin, + if (iconData != null) 'icon_data': iconData, + if (fetchDate != null) 'fetch_date': fetchDate, + if (rowid != null) 'rowid': rowid, + }); + } + + IconCacheCompanion copyWith({ + Value? origin, + Value? iconData, + Value? fetchDate, + Value? rowid, + }) { + return IconCacheCompanion( + origin: origin ?? this.origin, + iconData: iconData ?? this.iconData, + fetchDate: fetchDate ?? this.fetchDate, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (origin.present) { + map['origin'] = Variable(origin.value); + } + if (iconData.present) { + map['icon_data'] = Variable(iconData.value); + } + if (fetchDate.present) { + map['fetch_date'] = Variable(fetchDate.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('IconCacheCompanion(') + ..write('origin: $origin, ') + ..write('iconData: $iconData, ') + ..write('fetchDate: $fetchDate, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class Onboarding extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Onboarding(this.attachedDatabase, [this._alias]); + late final GeneratedColumn revision = GeneratedColumn( + 'revision', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn completionDate = GeneratedColumn( + 'completion_date', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + @override + List get $columns => [revision, completionDate]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'onboarding'; + @override + Set get $primaryKey => const {}; + @override + OnboardingData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return OnboardingData( + revision: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}revision'], + )!, + completionDate: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}completion_date'], + )!, + ); + } + + @override + Onboarding createAlias(String alias) { + return Onboarding(attachedDatabase, alias); + } + + @override + bool get dontWriteConstraints => true; +} + +class OnboardingData extends DataClass implements Insertable { + final int revision; + final int completionDate; + const OnboardingData({required this.revision, required this.completionDate}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['revision'] = Variable(revision); + map['completion_date'] = Variable(completionDate); + return map; + } + + factory OnboardingData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return OnboardingData( + revision: serializer.fromJson(json['revision']), + completionDate: serializer.fromJson(json['completionDate']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'revision': serializer.toJson(revision), + 'completionDate': serializer.toJson(completionDate), + }; + } + + OnboardingData copyWith({int? revision, int? completionDate}) => + OnboardingData( + revision: revision ?? this.revision, + completionDate: completionDate ?? this.completionDate, + ); + OnboardingData copyWithCompanion(OnboardingCompanion data) { + return OnboardingData( + revision: data.revision.present ? data.revision.value : this.revision, + completionDate: data.completionDate.present + ? data.completionDate.value + : this.completionDate, + ); + } + + @override + String toString() { + return (StringBuffer('OnboardingData(') + ..write('revision: $revision, ') + ..write('completionDate: $completionDate') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(revision, completionDate); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is OnboardingData && + other.revision == this.revision && + other.completionDate == this.completionDate); +} + +class OnboardingCompanion extends UpdateCompanion { + final Value revision; + final Value completionDate; + final Value rowid; + const OnboardingCompanion({ + this.revision = const Value.absent(), + this.completionDate = const Value.absent(), + this.rowid = const Value.absent(), + }); + OnboardingCompanion.insert({ + required int revision, + required int completionDate, + this.rowid = const Value.absent(), + }) : revision = Value(revision), + completionDate = Value(completionDate); + static Insertable custom({ + Expression? revision, + Expression? completionDate, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (revision != null) 'revision': revision, + if (completionDate != null) 'completion_date': completionDate, + if (rowid != null) 'rowid': rowid, + }); + } + + OnboardingCompanion copyWith({ + Value? revision, + Value? completionDate, + Value? rowid, + }) { + return OnboardingCompanion( + revision: revision ?? this.revision, + completionDate: completionDate ?? this.completionDate, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (revision.present) { + map['revision'] = Variable(revision.value); + } + if (completionDate.present) { + map['completion_date'] = Variable(completionDate.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('OnboardingCompanion(') + ..write('revision: $revision, ') + ..write('completionDate: $completionDate, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class Riverpod extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Riverpod(this.attachedDatabase, [this._alias]); + late final GeneratedColumn key = GeneratedColumn( + 'key', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'PRIMARY KEY NOT NULL', + ); + late final GeneratedColumn json = GeneratedColumn( + 'json', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn expireAt = GeneratedColumn( + 'expireAt', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn destroyKey = GeneratedColumn( + 'destroyKey', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + @override + List get $columns => [key, json, expireAt, destroyKey]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'riverpod'; + @override + Set get $primaryKey => {key}; + @override + RiverpodData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return RiverpodData( + key: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}key'], + )!, + json: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}json'], + )!, + expireAt: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}expireAt'], + ), + destroyKey: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}destroyKey'], + ), + ); + } + + @override + Riverpod createAlias(String alias) { + return Riverpod(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get dontWriteConstraints => true; +} + +class RiverpodData extends DataClass implements Insertable { + final String key; + final String json; + final int? expireAt; + final String? destroyKey; + const RiverpodData({ + required this.key, + required this.json, + this.expireAt, + this.destroyKey, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['key'] = Variable(key); + map['json'] = Variable(json); + if (!nullToAbsent || expireAt != null) { + map['expireAt'] = Variable(expireAt); + } + if (!nullToAbsent || destroyKey != null) { + map['destroyKey'] = Variable(destroyKey); + } + return map; + } + + factory RiverpodData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return RiverpodData( + key: serializer.fromJson(json['key']), + json: serializer.fromJson(json['json']), + expireAt: serializer.fromJson(json['expireAt']), + destroyKey: serializer.fromJson(json['destroyKey']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'key': serializer.toJson(key), + 'json': serializer.toJson(json), + 'expireAt': serializer.toJson(expireAt), + 'destroyKey': serializer.toJson(destroyKey), + }; + } + + RiverpodData copyWith({ + String? key, + String? json, + Value expireAt = const Value.absent(), + Value destroyKey = const Value.absent(), + }) => RiverpodData( + key: key ?? this.key, + json: json ?? this.json, + expireAt: expireAt.present ? expireAt.value : this.expireAt, + destroyKey: destroyKey.present ? destroyKey.value : this.destroyKey, + ); + RiverpodData copyWithCompanion(RiverpodCompanion data) { + return RiverpodData( + key: data.key.present ? data.key.value : this.key, + json: data.json.present ? data.json.value : this.json, + expireAt: data.expireAt.present ? data.expireAt.value : this.expireAt, + destroyKey: data.destroyKey.present + ? data.destroyKey.value + : this.destroyKey, + ); + } + + @override + String toString() { + return (StringBuffer('RiverpodData(') + ..write('key: $key, ') + ..write('json: $json, ') + ..write('expireAt: $expireAt, ') + ..write('destroyKey: $destroyKey') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(key, json, expireAt, destroyKey); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is RiverpodData && + other.key == this.key && + other.json == this.json && + other.expireAt == this.expireAt && + other.destroyKey == this.destroyKey); +} + +class RiverpodCompanion extends UpdateCompanion { + final Value key; + final Value json; + final Value expireAt; + final Value destroyKey; + const RiverpodCompanion({ + this.key = const Value.absent(), + this.json = const Value.absent(), + this.expireAt = const Value.absent(), + this.destroyKey = const Value.absent(), + }); + RiverpodCompanion.insert({ + required String key, + required String json, + this.expireAt = const Value.absent(), + this.destroyKey = const Value.absent(), + }) : key = Value(key), + json = Value(json); + static Insertable custom({ + Expression? key, + Expression? json, + Expression? expireAt, + Expression? destroyKey, + }) { + return RawValuesInsertable({ + if (key != null) 'key': key, + if (json != null) 'json': json, + if (expireAt != null) 'expireAt': expireAt, + if (destroyKey != null) 'destroyKey': destroyKey, + }); + } + + RiverpodCompanion copyWith({ + Value? key, + Value? json, + Value? expireAt, + Value? destroyKey, + }) { + return RiverpodCompanion( + key: key ?? this.key, + json: json ?? this.json, + expireAt: expireAt ?? this.expireAt, + destroyKey: destroyKey ?? this.destroyKey, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (key.present) { + map['key'] = Variable(key.value); + } + if (json.present) { + map['json'] = Variable(json.value); + } + if (expireAt.present) { + map['expireAt'] = Variable(expireAt.value); + } + if (destroyKey.present) { + map['destroyKey'] = Variable(destroyKey.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('RiverpodCompanion(') + ..write('key: $key, ') + ..write('json: $json, ') + ..write('expireAt: $expireAt, ') + ..write('destroyKey: $destroyKey') + ..write(')')) + .toString(); + } +} + +class ToolbarButtonConfigs extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + ToolbarButtonConfigs(this.attachedDatabase, [this._alias]); + late final GeneratedColumn buttonId = GeneratedColumn( + 'button_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL PRIMARY KEY', + ); + late final GeneratedColumn orderKey = GeneratedColumn( + 'order_key', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn isVisible = GeneratedColumn( + 'is_visible', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT TRUE', + defaultValue: const CustomExpression('TRUE'), + ); + late final GeneratedColumn fallbackId = GeneratedColumn( + 'fallback_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: + 'REFERENCES toolbar_button_configs(button_id)ON DELETE SET NULL', + ); + @override + List get $columns => [ + buttonId, + orderKey, + isVisible, + fallbackId, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'toolbar_button_configs'; + @override + Set get $primaryKey => {buttonId}; + @override + ToolbarButtonConfigsData map( + Map data, { + String? tablePrefix, + }) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return ToolbarButtonConfigsData( + buttonId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}button_id'], + )!, + orderKey: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}order_key'], + )!, + isVisible: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}is_visible'], + )!, + fallbackId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}fallback_id'], + ), + ); + } + + @override + ToolbarButtonConfigs createAlias(String alias) { + return ToolbarButtonConfigs(attachedDatabase, alias); + } + + @override + bool get dontWriteConstraints => true; +} + +class ToolbarButtonConfigsData extends DataClass + implements Insertable { + final String buttonId; + final String orderKey; + final int isVisible; + final String? fallbackId; + const ToolbarButtonConfigsData({ + required this.buttonId, + required this.orderKey, + required this.isVisible, + this.fallbackId, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['button_id'] = Variable(buttonId); + map['order_key'] = Variable(orderKey); + map['is_visible'] = Variable(isVisible); + if (!nullToAbsent || fallbackId != null) { + map['fallback_id'] = Variable(fallbackId); + } + return map; + } + + factory ToolbarButtonConfigsData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return ToolbarButtonConfigsData( + buttonId: serializer.fromJson(json['buttonId']), + orderKey: serializer.fromJson(json['orderKey']), + isVisible: serializer.fromJson(json['isVisible']), + fallbackId: serializer.fromJson(json['fallbackId']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'buttonId': serializer.toJson(buttonId), + 'orderKey': serializer.toJson(orderKey), + 'isVisible': serializer.toJson(isVisible), + 'fallbackId': serializer.toJson(fallbackId), + }; + } + + ToolbarButtonConfigsData copyWith({ + String? buttonId, + String? orderKey, + int? isVisible, + Value fallbackId = const Value.absent(), + }) => ToolbarButtonConfigsData( + buttonId: buttonId ?? this.buttonId, + orderKey: orderKey ?? this.orderKey, + isVisible: isVisible ?? this.isVisible, + fallbackId: fallbackId.present ? fallbackId.value : this.fallbackId, + ); + ToolbarButtonConfigsData copyWithCompanion( + ToolbarButtonConfigsCompanion data, + ) { + return ToolbarButtonConfigsData( + buttonId: data.buttonId.present ? data.buttonId.value : this.buttonId, + orderKey: data.orderKey.present ? data.orderKey.value : this.orderKey, + isVisible: data.isVisible.present ? data.isVisible.value : this.isVisible, + fallbackId: data.fallbackId.present + ? data.fallbackId.value + : this.fallbackId, + ); + } + + @override + String toString() { + return (StringBuffer('ToolbarButtonConfigsData(') + ..write('buttonId: $buttonId, ') + ..write('orderKey: $orderKey, ') + ..write('isVisible: $isVisible, ') + ..write('fallbackId: $fallbackId') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(buttonId, orderKey, isVisible, fallbackId); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ToolbarButtonConfigsData && + other.buttonId == this.buttonId && + other.orderKey == this.orderKey && + other.isVisible == this.isVisible && + other.fallbackId == this.fallbackId); +} + +class ToolbarButtonConfigsCompanion + extends UpdateCompanion { + final Value buttonId; + final Value orderKey; + final Value isVisible; + final Value fallbackId; + final Value rowid; + const ToolbarButtonConfigsCompanion({ + this.buttonId = const Value.absent(), + this.orderKey = const Value.absent(), + this.isVisible = const Value.absent(), + this.fallbackId = const Value.absent(), + this.rowid = const Value.absent(), + }); + ToolbarButtonConfigsCompanion.insert({ + required String buttonId, + required String orderKey, + this.isVisible = const Value.absent(), + this.fallbackId = const Value.absent(), + this.rowid = const Value.absent(), + }) : buttonId = Value(buttonId), + orderKey = Value(orderKey); + static Insertable custom({ + Expression? buttonId, + Expression? orderKey, + Expression? isVisible, + Expression? fallbackId, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (buttonId != null) 'button_id': buttonId, + if (orderKey != null) 'order_key': orderKey, + if (isVisible != null) 'is_visible': isVisible, + if (fallbackId != null) 'fallback_id': fallbackId, + if (rowid != null) 'rowid': rowid, + }); + } + + ToolbarButtonConfigsCompanion copyWith({ + Value? buttonId, + Value? orderKey, + Value? isVisible, + Value? fallbackId, + Value? rowid, + }) { + return ToolbarButtonConfigsCompanion( + buttonId: buttonId ?? this.buttonId, + orderKey: orderKey ?? this.orderKey, + isVisible: isVisible ?? this.isVisible, + fallbackId: fallbackId ?? this.fallbackId, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (buttonId.present) { + map['button_id'] = Variable(buttonId.value); + } + if (orderKey.present) { + map['order_key'] = Variable(orderKey.value); + } + if (isVisible.present) { + map['is_visible'] = Variable(isVisible.value); + } + if (fallbackId.present) { + map['fallback_id'] = Variable(fallbackId.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('ToolbarButtonConfigsCompanion(') + ..write('buttonId: $buttonId, ') + ..write('orderKey: $orderKey, ') + ..write('isVisible: $isVisible, ') + ..write('fallbackId: $fallbackId, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class DatabaseAtV3 extends GeneratedDatabase { + DatabaseAtV3(QueryExecutor e) : super(e); + late final Setting setting = Setting(this); + late final IconCache iconCache = IconCache(this); + late final Onboarding onboarding = Onboarding(this); + late final Riverpod riverpod = Riverpod(this); + late final ToolbarButtonConfigs toolbarButtonConfigs = ToolbarButtonConfigs( + this, + ); + late final Index idxToolbarOrderKey = Index( + 'idx_toolbar_order_key', + 'CREATE INDEX idx_toolbar_order_key ON toolbar_button_configs (order_key)', + ); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + setting, + iconCache, + onboarding, + riverpod, + toolbarButtonConfigs, + idxToolbarOrderKey, + ]; + @override + int get schemaVersion => 3; +} diff --git a/app/test/drift/user/toolbar_button_config_dao_test.dart b/app/test/drift/user/toolbar_button_config_dao_test.dart new file mode 100644 index 00000000..7467ae3a --- /dev/null +++ b/app/test/drift/user/toolbar_button_config_dao_test.dart @@ -0,0 +1,69 @@ +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:weblibre/data/database/functions/lexo_rank_functions.dart'; +import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/data/providers/toolbar_button_configs.dart'; +import 'package:weblibre/features/user/data/database/database.dart'; + +void main() { + late UserDatabase db; + + setUp(() { + db = UserDatabase( + NativeDatabase.memory( + setup: (database) { + registerLexorankFunctions(database); + }, + ), + ); + }); + + tearDown(() async { + await db.close(); + }); + + group('ToolbarButtonConfigDao', () { + test( + 'seedMissing inserts self-referential fallback rows in two phases', + () async { + await db.toolbarButtonConfigDao.seedMissing([ + ( + buttonId: 'back', + defaultVisible: true, + defaultFallback: 'bookmarks', + ), + (buttonId: 'bookmarks', defaultVisible: false, defaultFallback: null), + (buttonId: 'forward', defaultVisible: true, defaultFallback: 'share'), + (buttonId: 'share', defaultVisible: false, defaultFallback: null), + ]); + + final configs = await db.toolbarButtonConfigDao.getAll(); + final configsById = { + for (final config in configs) config.buttonId: config, + }; + + expect(configsById['back']?.fallbackId, 'bookmarks'); + expect(configsById['forward']?.fallbackId, 'share'); + }, + ); + + test( + 'replaceAll persists default toolbar fallbacks without FK failures', + () async { + await expectLater( + db.toolbarButtonConfigDao.replaceAll( + buildDefaultToolbarButtonConfigs(), + ), + completes, + ); + + final configs = await db.toolbarButtonConfigDao.getAll(); + final configsById = { + for (final config in configs) config.buttonId: config, + }; + + expect(configsById['back']?.fallbackId, 'bookmarks'); + expect(configsById['forward']?.fallbackId, 'share'); + }, + ); + }); +} diff --git a/app/test/presentation/main_app_test.dart b/app/test/presentation/main_app_test.dart index f79aaeaa..7dd4c01c 100644 --- a/app/test/presentation/main_app_test.dart +++ b/app/test/presentation/main_app_test.dart @@ -34,6 +34,7 @@ void main() { ); test('forces animations off when the app setting is enabled', () { + // ignore: avoid_redundant_argument_values const mediaQuery = MediaQueryData(disableAnimations: false); final result = applyAppMediaQueryOverrides(