From 8898f8a31713ab9eb0ecf4bb4888e06ff7d348d7 Mon Sep 17 00:00:00 2001 From: Fabian Freund Date: Mon, 2 Mar 2026 16:30:18 +0100 Subject: [PATCH] add tab view filter and sort options --- app/drift_schemas/tabs/drift_schema_v7.json | 467 ++++++ app/lib/core/sort_field.dart | 23 + .../domain/entities/bookmark_sort_type.dart | 41 +- .../entities/tab_view_filter_options.dart | 143 ++ .../entities/tab_view_filter_options.g.dart | 169 ++ .../features/browser/domain/providers.dart | 278 +++- .../features/browser/domain/providers.g.dart | 50 +- .../controllers/tab_view_controllers.dart | 42 + .../controllers/tab_view_controllers.g.dart | 94 ++ .../browser/presentation/screens/browser.dart | 6 +- .../widgets/browser_menu_sheet.dart | 13 +- .../browser_modules/bottom_app_bar.dart | 68 +- .../presentation/widgets/tab_menu.dart | 27 + .../tab_view/tab_context_menu_draggable.dart | 225 +++ .../widgets/tab_view/tab_grid_view.dart | 88 +- .../widgets/tab_view/tab_list_view.dart | 102 +- .../widgets/tab_view/tab_preview.dart | 611 +++++--- .../widgets/tab_view/tab_tree_view.dart | 15 +- .../widgets/tab_view/tab_view_header.dart | 895 +++++++---- .../full_search_suggestions.dart | 10 +- .../features/tabs/data/database/daos/tab.dart | 29 + .../features/tabs/data/database/database.dart | 5 +- .../tabs/data/database/database.steps.dart | 136 ++ .../tabs/data/database/definitions.drift | 1 + .../tabs/data/database/definitions.drift.dart | 55 + .../tabs/data/entities/tab_entity.dart | 32 +- .../features/tabs/domain/providers.dart | 18 + .../features/tabs/domain/providers.g.dart | 122 ++ .../tabs/domain/repositories/tab.dart | 38 + .../tabs/domain/repositories/tab.g.dart | 2 +- .../presentation/widgets/container_chips.dart | 6 +- .../screens/appearance_display_settings.dart | 1 - app/lib/utils/ui_helper.dart | 2 + app/test/drift/tabs/generated/schema.dart | 5 +- app/test/drift/tabs/generated/schema_v7.dart | 1392 +++++++++++++++++ 35 files changed, 4519 insertions(+), 692 deletions(-) create mode 100644 app/drift_schemas/tabs/drift_schema_v7.json create mode 100644 app/lib/core/sort_field.dart create mode 100644 app/lib/features/geckoview/features/browser/domain/entities/tab_view_filter_options.dart create mode 100644 app/lib/features/geckoview/features/browser/domain/entities/tab_view_filter_options.g.dart create mode 100644 app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_context_menu_draggable.dart create mode 100644 app/test/drift/tabs/generated/schema_v7.dart diff --git a/app/drift_schemas/tabs/drift_schema_v7.json b/app/drift_schemas/tabs/drift_schema_v7.json new file mode 100644 index 00000000..8e48b94e --- /dev/null +++ b/app/drift_schemas/tabs/drift_schema_v7.json @@ -0,0 +1,467 @@ +{ + "_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": "container", + "was_declared_in_moor": true, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": "PRIMARY KEY NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + "primary-key" + ] + }, + { + "name": "name", + "getter_name": "name", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "color", + "getter_name": "color", + "moor_type": "int", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const ColorConverter()", + "dart_type_name": "Color" + } + }, + { + "name": "metadata", + "getter_name": "metadata", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const ContainerMetadataConverter()", + "dart_type_name": "ContainerMetadata" + } + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [] + } + }, + { + "id": 1, + "references": [ + 0 + ], + "type": "table", + "data": { + "name": "tab", + "was_declared_in_moor": true, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": "PRIMARY KEY NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + "primary-key" + ] + }, + { + "name": "source", + "getter_name": "source", + "moor_type": "int", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumIndexConverter(TabSource.values)", + "dart_type_name": "TabSource" + } + }, + { + "name": "parent_id", + "getter_name": "parentId", + "moor_type": "string", + "nullable": true, + "customConstraints": "REFERENCES tab(id)ON DELETE SET NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "container_id", + "getter_name": "containerId", + "moor_type": "string", + "nullable": true, + "customConstraints": "REFERENCES container(id)ON DELETE CASCADE", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + { + "foreign_key": { + "to": { + "table": "container", + "column": "id" + }, + "initially_deferred": false, + "on_update": null, + "on_delete": "cascade" + } + } + ] + }, + { + "name": "order_key", + "getter_name": "orderKey", + "moor_type": "string", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "url", + "getter_name": "url", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const UriConverterNullable()", + "dart_type_name": "Uri?" + } + }, + { + "name": "title", + "getter_name": "title", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "tab_mode", + "getter_name": "tabMode", + "moor_type": "int", + "nullable": false, + "customConstraints": "NOT NULL DEFAULT 0", + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumIndexConverter(TabModeDbValue.values)", + "dart_type_name": "TabModeDbValue" + } + }, + { + "name": "isolation_context_id", + "getter_name": "isolationContextId", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_pinned", + "getter_name": "isPinned", + "moor_type": "bool", + "nullable": false, + "customConstraints": "NOT NULL DEFAULT 0", + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_probably_readerable", + "getter_name": "isProbablyReaderable", + "moor_type": "bool", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "extracted_content_markdown", + "getter_name": "extractedContentMarkdown", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "extracted_content_plain", + "getter_name": "extractedContentPlain", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "full_content_markdown", + "getter_name": "fullContentMarkdown", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "full_content_plain", + "getter_name": "fullContentPlain", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "timestamp", + "getter_name": "timestamp", + "moor_type": "dateTime", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [ + "CHECK((tab_mode = 2 AND isolation_context_id IS NOT NULL)OR(tab_mode != 2 AND isolation_context_id IS NULL))" + ] + } + }, + { + "id": 2, + "references": [ + 1 + ], + "type": "table", + "data": { + "name": "tab_fts", + "was_declared_in_moor": true, + "columns": [ + { + "name": "title", + "getter_name": "title", + "moor_type": "string", + "nullable": false, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "url", + "getter_name": "url", + "moor_type": "string", + "nullable": false, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "extracted_content_plain", + "getter_name": "extractedContentPlain", + "moor_type": "string", + "nullable": false, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "full_content_plain", + "getter_name": "fullContentPlain", + "moor_type": "string", + "nullable": false, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": true, + "create_virtual_stmt": "CREATE VIRTUAL TABLE \"tab_fts\" USING fts5(title, url, extracted_content_plain, full_content_plain, content=tab, tokenize=\"trigram\")", + "without_rowid": false, + "constraints": [] + } + }, + { + "id": 3, + "references": [ + 1 + ], + "type": "trigger", + "data": { + "on": 1, + "references_in_body": [ + 1 + ], + "name": "tab_maintain_parent_chain_on_delete", + "sql": "CREATE TRIGGER tab_maintain_parent_chain_on_delete BEFORE DELETE ON tab BEGIN\n -- Update all children of the deleted row to point to its parent (grandparent)\n -- Only if the grandparent exists, otherwise set to NULL\n UPDATE tab\n SET parent_id = CASE\n WHEN OLD.parent_id IS NOT NULL AND\n EXISTS(SELECT 1 FROM tab WHERE id = OLD.parent_id)\n THEN OLD.parent_id\n ELSE NULL\n END\n WHERE parent_id = OLD.id;\nEND;" + } + }, + { + "id": 4, + "references": [ + 1, + 2 + ], + "type": "trigger", + "data": { + "on": 1, + "references_in_body": [ + 1, + 2 + ], + "name": "tab_after_insert", + "sql": "CREATE TRIGGER tab_after_insert AFTER INSERT ON tab BEGIN\n INSERT INTO \n tab_fts(rowid, title, url, extracted_content_plain, full_content_plain) \n VALUES (new.rowid, new.title, new.url, new.extracted_content_plain, new.full_content_plain);\nEND;" + } + }, + { + "id": 5, + "references": [ + 1, + 2 + ], + "type": "trigger", + "data": { + "on": 1, + "references_in_body": [ + 1, + 2 + ], + "name": "tab_after_delete", + "sql": "CREATE TRIGGER tab_after_delete AFTER DELETE ON tab BEGIN\n INSERT INTO \n tab_fts(tab_fts, rowid, title, url, extracted_content_plain, full_content_plain) \n VALUES('delete', old.rowid, old.title, old.url, old.extracted_content_plain, old.full_content_plain);\nEND;" + } + }, + { + "id": 6, + "references": [ + 1, + 2 + ], + "type": "trigger", + "data": { + "on": 1, + "references_in_body": [ + 1, + 2 + ], + "name": "tab_after_update", + "sql": "CREATE TRIGGER tab_after_update AFTER UPDATE ON tab BEGIN\n INSERT INTO \n tab_fts(tab_fts, rowid, title, url, extracted_content_plain, full_content_plain) \n VALUES('delete', old.rowid, old.title, old.url, old.extracted_content_plain, old.full_content_plain);\n INSERT INTO \n tab_fts(rowid, title, url, extracted_content_plain, full_content_plain) \n VALUES (new.rowid, new.title, new.url, new.extracted_content_plain, new.full_content_plain);\nEND;" + } + } + ], + "fixed_sql": [ + { + "name": "container", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"container\" (\"id\" TEXT PRIMARY KEY NOT NULL, \"name\" TEXT, \"color\" INTEGER NOT NULL, \"metadata\" TEXT);" + } + ] + }, + { + "name": "tab", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"tab\" (\"id\" TEXT PRIMARY KEY NOT NULL, \"source\" INTEGER NOT NULL, \"parent_id\" TEXT REFERENCES tab(id)ON DELETE SET NULL, \"container_id\" TEXT REFERENCES container(id)ON DELETE CASCADE, \"order_key\" TEXT NOT NULL, \"url\" TEXT, \"title\" TEXT, \"tab_mode\" INTEGER NOT NULL DEFAULT 0, \"isolation_context_id\" TEXT, \"is_pinned\" INTEGER NOT NULL DEFAULT 0, \"is_probably_readerable\" INTEGER, \"extracted_content_markdown\" TEXT, \"extracted_content_plain\" TEXT, \"full_content_markdown\" TEXT, \"full_content_plain\" TEXT, \"timestamp\" INTEGER NOT NULL, CHECK((tab_mode = 2 AND isolation_context_id IS NOT NULL)OR(tab_mode != 2 AND isolation_context_id IS NULL)));" + } + ] + }, + { + "name": "tab_fts", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE VIRTUAL TABLE IF NOT EXISTS \"tab_fts\" USING fts5(title, url, extracted_content_plain, full_content_plain, content=tab, tokenize=\"trigram\");" + } + ] + }, + { + "name": "tab_maintain_parent_chain_on_delete", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TRIGGER tab_maintain_parent_chain_on_delete BEFORE DELETE ON tab BEGIN UPDATE tab SET parent_id = CASE WHEN OLD.parent_id IS NOT NULL AND EXISTS (SELECT 1 FROM tab WHERE id = OLD.parent_id) THEN OLD.parent_id ELSE NULL END WHERE parent_id = OLD.id;END" + } + ] + }, + { + "name": "tab_after_insert", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TRIGGER tab_after_insert AFTER INSERT ON tab BEGIN INSERT INTO tab_fts (\"rowid\", title, url, extracted_content_plain, full_content_plain) VALUES (new.\"rowid\", new.title, new.url, new.extracted_content_plain, new.full_content_plain);END" + } + ] + }, + { + "name": "tab_after_delete", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TRIGGER tab_after_delete AFTER DELETE ON tab BEGIN INSERT INTO tab_fts (tab_fts, \"rowid\", title, url, extracted_content_plain, full_content_plain) VALUES ('delete', old.\"rowid\", old.title, old.url, old.extracted_content_plain, old.full_content_plain);END" + } + ] + }, + { + "name": "tab_after_update", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TRIGGER tab_after_update AFTER UPDATE ON tab BEGIN INSERT INTO tab_fts (tab_fts, \"rowid\", title, url, extracted_content_plain, full_content_plain) VALUES ('delete', old.\"rowid\", old.title, old.url, old.extracted_content_plain, old.full_content_plain);INSERT INTO tab_fts (\"rowid\", title, url, extracted_content_plain, full_content_plain) VALUES (new.\"rowid\", new.title, new.url, new.extracted_content_plain, new.full_content_plain);END" + } + ] + } + ] +} \ No newline at end of file diff --git a/app/lib/core/sort_field.dart b/app/lib/core/sort_field.dart new file mode 100644 index 00000000..8505e393 --- /dev/null +++ b/app/lib/core/sort_field.dart @@ -0,0 +1,23 @@ +/* + * 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 . + */ + +/// Reusable sort directions for list items that share common sortable fields +/// (title, URL, date). +enum SortField { titleAsc, titleDesc, urlAsc, urlDesc, dateAsc, dateDesc } diff --git a/app/lib/features/geckoview/features/bookmarks/domain/entities/bookmark_sort_type.dart b/app/lib/features/geckoview/features/bookmarks/domain/entities/bookmark_sort_type.dart index 9fe12940..74ab4934 100644 --- a/app/lib/features/geckoview/features/bookmarks/domain/entities/bookmark_sort_type.dart +++ b/app/lib/features/geckoview/features/bookmarks/domain/entities/bookmark_sort_type.dart @@ -17,18 +17,22 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ +import 'package:weblibre/core/sort_field.dart'; import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/bookmark_item.dart'; enum BookmarkSortType { - manual('Default'), - titleAsc('Title A-Z'), - titleDesc('Title Z-A'), - urlAsc('URL A-Z'), - dateAddedDesc('Newest First'); + manual('Default', null), + titleAsc('Title A-Z', SortField.titleAsc), + titleDesc('Title Z-A', SortField.titleDesc), + urlAsc('URL A-Z', SortField.urlAsc), + urlDesc('URL Z-A', SortField.urlDesc), + dateAddedDesc('Newest First', SortField.dateDesc), + dateAddedAsc('Oldest First', SortField.dateAsc); final String label; + final SortField? sortField; - const BookmarkSortType(this.label); + const BookmarkSortType(this.label, this.sortField); } int compareBookmarkItems( @@ -36,21 +40,24 @@ int compareBookmarkItems( BookmarkItem b, BookmarkSortType sort, ) { - return switch (sort) { - BookmarkSortType.manual => 0, - BookmarkSortType.titleAsc => a.title.toLowerCase().compareTo( + final sortField = sort.sortField; + if (sortField == null) { + return 0; + } + + return switch (sortField) { + SortField.titleAsc => a.title.toLowerCase().compareTo( b.title.toLowerCase(), ), - BookmarkSortType.titleDesc => b.title.toLowerCase().compareTo( + SortField.titleDesc => b.title.toLowerCase().compareTo( a.title.toLowerCase(), ), - BookmarkSortType.urlAsc => _compareByUrl(a, b), - BookmarkSortType.dateAddedDesc => b.dateAdded.compareTo(a.dateAdded), + SortField.urlAsc => _urlKey(a).compareTo(_urlKey(b)), + SortField.urlDesc => _urlKey(b).compareTo(_urlKey(a)), + SortField.dateAsc => a.dateAdded.compareTo(b.dateAdded), + SortField.dateDesc => b.dateAdded.compareTo(a.dateAdded), }; } -int _compareByUrl(BookmarkItem a, BookmarkItem b) { - final aUrl = a is BookmarkEntry ? a.url.toString() : a.title.toLowerCase(); - final bUrl = b is BookmarkEntry ? b.url.toString() : b.title.toLowerCase(); - return aUrl.compareTo(bUrl); -} +String _urlKey(BookmarkItem item) => + item is BookmarkEntry ? item.url.toString() : item.title.toLowerCase(); diff --git a/app/lib/features/geckoview/features/browser/domain/entities/tab_view_filter_options.dart b/app/lib/features/geckoview/features/browser/domain/entities/tab_view_filter_options.dart new file mode 100644 index 00000000..42e9038c --- /dev/null +++ b/app/lib/features/geckoview/features/browser/domain/entities/tab_view_filter_options.dart @@ -0,0 +1,143 @@ +/* + * 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:copy_with_extension/copy_with_extension.dart'; +import 'package:fast_equatable/fast_equatable.dart'; +import 'package:flutter/material.dart'; +import 'package:json_annotation/json_annotation.dart'; +import 'package:weblibre/core/sort_field.dart'; +import 'package:weblibre/data/database/converters/date_time_range.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; + +part 'tab_view_filter_options.g.dart'; + +enum TabTypeFilter { + all('All Tabs'), + regularOnly('Regular'), + privateOnly('Private'), + isolatedOnly('Isolated'); + + final String label; + + const TabTypeFilter(this.label); + + bool matches(TabMode? tabMode) => switch (this) { + all => true, + regularOnly => tabMode is RegularTabMode, + privateOnly => tabMode is PrivateTabMode, + isolatedOnly => tabMode is IsolatedTabMode, + }; +} + +enum TabSortType { + manual('Default', null), + titleAsc('Title A-Z', SortField.titleAsc), + titleDesc('Title Z-A', SortField.titleDesc), + urlAsc('URL A-Z', SortField.urlAsc), + urlDesc('URL Z-A', SortField.urlDesc), + newestFirst('Newest First', SortField.dateDesc), + oldestFirst('Oldest First', SortField.dateAsc); + + final String label; + final SortField? sortField; + + const TabSortType(this.label, this.sortField); +} + +enum TabQuickInterval { + last1h('Last Hour', Duration(hours: 1)), + last3h('Last 3 Hours', Duration(hours: 3)), + last8h('Last 8 Hours', Duration(hours: 8)), + last1d('Last Day', Duration(days: 1)), + last3d('Last 3 Days', Duration(days: 3)), + last1w('Last Week', Duration(days: 7)), + last1m('Last Month', Duration(days: 30)); + + final String label; + final Duration duration; + + const TabQuickInterval(this.label, this.duration); + + DateTimeRange toDateRange() { + final now = DateTime.now(); + return DateTimeRange(start: now.subtract(duration), end: now); + } +} + +@JsonSerializable() +@CopyWith() +class TabViewFilterOptions with FastEquatable { + final TabTypeFilter tabTypeFilter; + final TabSortType sortType; + final bool sortPinnedFirst; + @DateTimeRangeConverter() + final DateTimeRange? dateRange; + final TabQuickInterval? quickInterval; + + TabViewFilterOptions({ + required this.tabTypeFilter, + required this.sortType, + required this.sortPinnedFirst, + required this.dateRange, + required this.quickInterval, + }); + + TabViewFilterOptions.withDefaults() + : this( + tabTypeFilter: TabTypeFilter.all, + sortType: TabSortType.manual, + sortPinnedFirst: true, + dateRange: null, + quickInterval: null, + ); + + bool get hasActiveFilter => + tabTypeFilter != TabTypeFilter.all || + sortType != TabSortType.manual || + dateRange != null || + quickInterval != null; + + DateTimeRange? get effectiveDateRange => + quickInterval?.toDateRange() ?? dateRange; + + bool matchesTab(TabMode? tabMode, DateTime? timestamp) { + if (!tabTypeFilter.matches(tabMode)) return false; + final range = effectiveDateRange; + if (range != null && + timestamp != null && + (timestamp.isBefore(range.start) || timestamp.isAfter(range.end))) { + return false; + } + return true; + } + + @override + List get hashParameters => [ + tabTypeFilter, + sortType, + sortPinnedFirst, + dateRange, + quickInterval, + ]; + + factory TabViewFilterOptions.fromJson(Map json) => + _$TabViewFilterOptionsFromJson(json); + + Map toJson() => _$TabViewFilterOptionsToJson(this); +} diff --git a/app/lib/features/geckoview/features/browser/domain/entities/tab_view_filter_options.g.dart b/app/lib/features/geckoview/features/browser/domain/entities/tab_view_filter_options.g.dart new file mode 100644 index 00000000..02e0b0d9 --- /dev/null +++ b/app/lib/features/geckoview/features/browser/domain/entities/tab_view_filter_options.g.dart @@ -0,0 +1,169 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'tab_view_filter_options.dart'; + +// ************************************************************************** +// CopyWithGenerator +// ************************************************************************** + +abstract class _$TabViewFilterOptionsCWProxy { + TabViewFilterOptions tabTypeFilter(TabTypeFilter tabTypeFilter); + + TabViewFilterOptions sortType(TabSortType sortType); + + TabViewFilterOptions sortPinnedFirst(bool sortPinnedFirst); + + TabViewFilterOptions dateRange(DateTimeRange? dateRange); + + TabViewFilterOptions quickInterval(TabQuickInterval? quickInterval); + + /// Creates a new instance with the provided field values. + /// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `TabViewFilterOptions(...).copyWith.fieldName(value)`. + /// + /// Example: + /// ```dart + /// TabViewFilterOptions(...).copyWith(id: 12, name: "My name") + /// ``` + TabViewFilterOptions call({ + TabTypeFilter tabTypeFilter, + TabSortType sortType, + bool sortPinnedFirst, + DateTimeRange? dateRange, + TabQuickInterval? quickInterval, + }); +} + +/// Callable proxy for `copyWith` functionality. +/// Use as `instanceOfTabViewFilterOptions.copyWith(...)` or call `instanceOfTabViewFilterOptions.copyWith.fieldName(value)` for a single field. +class _$TabViewFilterOptionsCWProxyImpl + implements _$TabViewFilterOptionsCWProxy { + const _$TabViewFilterOptionsCWProxyImpl(this._value); + + final TabViewFilterOptions _value; + + @override + TabViewFilterOptions tabTypeFilter(TabTypeFilter tabTypeFilter) => + call(tabTypeFilter: tabTypeFilter); + + @override + TabViewFilterOptions sortType(TabSortType sortType) => + call(sortType: sortType); + + @override + TabViewFilterOptions sortPinnedFirst(bool sortPinnedFirst) => + call(sortPinnedFirst: sortPinnedFirst); + + @override + TabViewFilterOptions dateRange(DateTimeRange? dateRange) => + call(dateRange: dateRange); + + @override + TabViewFilterOptions quickInterval(TabQuickInterval? quickInterval) => + call(quickInterval: quickInterval); + + @override + /// Creates a new instance with the provided field values. + /// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `TabViewFilterOptions(...).copyWith.fieldName(value)`. + /// + /// Example: + /// ```dart + /// TabViewFilterOptions(...).copyWith(id: 12, name: "My name") + /// ``` + TabViewFilterOptions call({ + Object? tabTypeFilter = const $CopyWithPlaceholder(), + Object? sortType = const $CopyWithPlaceholder(), + Object? sortPinnedFirst = const $CopyWithPlaceholder(), + Object? dateRange = const $CopyWithPlaceholder(), + Object? quickInterval = const $CopyWithPlaceholder(), + }) { + return TabViewFilterOptions( + tabTypeFilter: + tabTypeFilter == const $CopyWithPlaceholder() || tabTypeFilter == null + ? _value.tabTypeFilter + // ignore: cast_nullable_to_non_nullable + : tabTypeFilter as TabTypeFilter, + sortType: sortType == const $CopyWithPlaceholder() || sortType == null + ? _value.sortType + // ignore: cast_nullable_to_non_nullable + : sortType as TabSortType, + sortPinnedFirst: + sortPinnedFirst == const $CopyWithPlaceholder() || + sortPinnedFirst == null + ? _value.sortPinnedFirst + // ignore: cast_nullable_to_non_nullable + : sortPinnedFirst as bool, + dateRange: dateRange == const $CopyWithPlaceholder() + ? _value.dateRange + // ignore: cast_nullable_to_non_nullable + : dateRange as DateTimeRange?, + quickInterval: quickInterval == const $CopyWithPlaceholder() + ? _value.quickInterval + // ignore: cast_nullable_to_non_nullable + : quickInterval as TabQuickInterval?, + ); + } +} + +extension $TabViewFilterOptionsCopyWith on TabViewFilterOptions { + /// Returns a callable class used to build a new instance with modified fields. + /// Example: `instanceOfTabViewFilterOptions.copyWith(...)` or `instanceOfTabViewFilterOptions.copyWith.fieldName(...)`. + // ignore: library_private_types_in_public_api + _$TabViewFilterOptionsCWProxy get copyWith => + _$TabViewFilterOptionsCWProxyImpl(this); +} + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +TabViewFilterOptions _$TabViewFilterOptionsFromJson( + Map json, +) => TabViewFilterOptions( + tabTypeFilter: $enumDecode(_$TabTypeFilterEnumMap, json['tabTypeFilter']), + sortType: $enumDecode(_$TabSortTypeEnumMap, json['sortType']), + sortPinnedFirst: json['sortPinnedFirst'] as bool, + dateRange: const DateTimeRangeConverter().fromJson( + json['dateRange'] as Map?, + ), + quickInterval: $enumDecodeNullable( + _$TabQuickIntervalEnumMap, + json['quickInterval'], + ), +); + +Map _$TabViewFilterOptionsToJson( + TabViewFilterOptions instance, +) => { + 'tabTypeFilter': _$TabTypeFilterEnumMap[instance.tabTypeFilter]!, + 'sortType': _$TabSortTypeEnumMap[instance.sortType]!, + 'sortPinnedFirst': instance.sortPinnedFirst, + 'dateRange': const DateTimeRangeConverter().toJson(instance.dateRange), + 'quickInterval': _$TabQuickIntervalEnumMap[instance.quickInterval], +}; + +const _$TabTypeFilterEnumMap = { + TabTypeFilter.all: 'all', + TabTypeFilter.regularOnly: 'regularOnly', + TabTypeFilter.privateOnly: 'privateOnly', + TabTypeFilter.isolatedOnly: 'isolatedOnly', +}; + +const _$TabSortTypeEnumMap = { + TabSortType.manual: 'manual', + TabSortType.titleAsc: 'titleAsc', + TabSortType.titleDesc: 'titleDesc', + TabSortType.urlAsc: 'urlAsc', + TabSortType.urlDesc: 'urlDesc', + TabSortType.newestFirst: 'newestFirst', + TabSortType.oldestFirst: 'oldestFirst', +}; + +const _$TabQuickIntervalEnumMap = { + TabQuickInterval.last1h: 'last1h', + TabQuickInterval.last3h: 'last3h', + TabQuickInterval.last8h: 'last8h', + TabQuickInterval.last1d: 'last1d', + TabQuickInterval.last3d: 'last3d', + TabQuickInterval.last1w: 'last1w', + TabQuickInterval.last1m: 'last1m', +}; diff --git a/app/lib/features/geckoview/features/browser/domain/providers.dart b/app/lib/features/geckoview/features/browser/domain/providers.dart index e42ef3fb..9cb89573 100644 --- a/app/lib/features/geckoview/features/browser/domain/providers.dart +++ b/app/lib/features/geckoview/features/browser/domain/providers.dart @@ -23,6 +23,7 @@ import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; import 'package:nullability/nullability.dart'; import 'package:riverpod/riverpod.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/core/sort_field.dart'; import 'package:weblibre/features/bangs/data/models/bang_data.dart'; import 'package:weblibre/features/bangs/data/models/bang_key.dart'; import 'package:weblibre/features/bangs/domain/repositories/data.dart'; @@ -30,6 +31,8 @@ import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart'; import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_list.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; +import 'package:weblibre/features/geckoview/features/browser/domain/entities/tab_view_filter_options.dart'; +import 'package:weblibre/features/geckoview/features/browser/presentation/controllers/tab_view_controllers.dart'; import 'package:weblibre/features/geckoview/features/history/domain/repositories/history.dart'; import 'package:weblibre/features/geckoview/features/search/domain/entities/tab_preview.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/entities/container_filter.dart'; @@ -46,6 +49,19 @@ part 'providers.g.dart'; typedef TabStateWirthContainer = (TabState, ContainerData?); +@Riverpod() +bool canManualTabReorder(Ref ref) { + final filterOptions = ref.watch(tabViewFilterControllerProvider); + + final hasActiveSearch = ref.watch( + tabSearchRepositoryProvider( + TabSearchPartition.preview, + ).select((value) => (value.value?.query ?? '').isNotEmpty), + ); + + return !filterOptions.hasActiveFilter && !hasActiveSearch; +} + @Riverpod(keepAlive: true) class SelectedBangTrigger extends _$SelectedBangTrigger { // ignore: document_ignores api decision @@ -100,6 +116,9 @@ EquatableValue> containerTabEntities( ).select((value) => value.value), ); final tabList = ref.watch(tabListProvider); + final orderKeys = ref.watch( + watchTabOrderKeysProvider.select((value) => value.value), + ); final availableTabs = containerTabs?.where((tabId) => tabList.value.contains(tabId)).toList() ?? @@ -112,6 +131,7 @@ EquatableValue> containerTabEntities( .map( (t) => DefaultTabEntity( tabId: t, + orderKey: orderKeys?[t] ?? '', containerId: containerFilter.containerId, ), ) @@ -123,8 +143,11 @@ EquatableValue> containerTabEntities( (value) => EquatableValue( value.value?.entries .map( - (e) => - DefaultTabEntity(tabId: e.key, containerId: e.value), + (e) => DefaultTabEntity( + tabId: e.key, + orderKey: orderKeys?[e.key] ?? '', + containerId: e.value, + ), ) .toList() ?? [], @@ -199,7 +222,13 @@ selectedContainerTabStatesWithContainer(Ref ref) { final tabStates = ref.watch(tabStatesProvider); - return EquatableValue([ + final pinnedTabIds = ref.watch( + watchPinnedTabIdsProvider.select((value) => value.value), + ); + + final orderKeys = {for (final tab in sortedTabs) tab.tabId: tab.orderKey}; + + final items = [ for (final tabEntity in sortedTabs) if (tabStates.containsKey(tabEntity.tabId)) ( @@ -208,7 +237,22 @@ selectedContainerTabStatesWithContainer(Ref ref) { (containerId) => containerData?[containerId], ), ), - ]); + ]; + + items.sort((a, b) { + final aPinned = pinnedTabIds?.contains(a.$1.id) ?? false; + final bPinned = pinnedTabIds?.contains(b.$1.id) ?? false; + + if (aPinned != bPinned) { + return aPinned ? -1 : 1; + } + + final aOrderKey = orderKeys[a.$1.id] ?? ''; + final bOrderKey = orderKeys[b.$1.id] ?? ''; + return aOrderKey.compareTo(bOrderKey); + }); + + return EquatableValue(items); } @Riverpod() @@ -324,6 +368,10 @@ EquatableValue> suggestedTabEntities( ).select((value) => EquatableValue(value.value)), ); + final orderKeys = ref.watch( + watchTabOrderKeysProvider.select((value) => value.value), + ); + final suggestions = ref.watch( containerTabSuggestionsProvider(containerId).select( (value) => EquatableValue( @@ -335,6 +383,7 @@ EquatableValue> suggestedTabEntities( .map( (tabId) => DefaultTabEntity( tabId: tabId, + orderKey: orderKeys?[tabId] ?? '', containerId: containerId, ), ) @@ -348,6 +397,103 @@ EquatableValue> suggestedTabEntities( return suggestions; } +List _applyTabFiltersAndSort( + List entities, + TabViewFilterOptions filterOptions, + Map tabStates, + Set pinnedTabIds, + Map? tabTimestamps, +) { + final sortField = filterOptions.sortType.sortField; + final filteredRows = <_TabFilterRow>[]; + var hasPinned = false; + + for (final entity in entities) { + final tabState = tabStates[entity.tabId]; + final timestamp = tabTimestamps?[entity.tabId]; + + if (!filterOptions.matchesTab(tabState?.tabMode, timestamp)) { + continue; + } + + final isPinned = pinnedTabIds.contains(entity.tabId); + hasPinned = hasPinned || isPinned; + + filteredRows.add( + _TabFilterRow( + entity: entity, + isPinned: isPinned, + titleKey: + sortField == SortField.titleAsc || sortField == SortField.titleDesc + ? (tabState?.titleOrAuthority ?? '').toLowerCase() + : null, + urlKey: sortField == SortField.urlAsc || sortField == SortField.urlDesc + ? (tabState?.url.toString() ?? '') + : null, + dateKey: + sortField == SortField.dateAsc || sortField == SortField.dateDesc + ? (timestamp ?? DateTime(0)) + : null, + ), + ); + } + + if (sortField != null) { + filteredRows.sort((a, b) { + if (filterOptions.sortPinnedFirst && a.isPinned != b.isPinned) { + return b.isPinned ? 1 : -1; + } + + final cmp = switch (sortField) { + SortField.titleAsc => a.titleKey!.compareTo(b.titleKey!), + SortField.titleDesc => b.titleKey!.compareTo(a.titleKey!), + SortField.urlAsc => a.urlKey!.compareTo(b.urlKey!), + SortField.urlDesc => b.urlKey!.compareTo(a.urlKey!), + SortField.dateAsc => a.dateKey!.compareTo(b.dateKey!), + SortField.dateDesc => b.dateKey!.compareTo(a.dateKey!), + }; + + if (cmp == 0) return a.entity.orderKey.compareTo(b.entity.orderKey); + + return cmp; + }); + + return filteredRows.map((row) => row.entity).toList(); + } + + if (!hasPinned || !filterOptions.sortPinnedFirst) { + return filteredRows.map((row) => row.entity).toList(); + } + + final pinned = []; + final unpinned = []; + for (final row in filteredRows) { + if (row.isPinned) { + pinned.add(row.entity); + } else { + unpinned.add(row.entity); + } + } + + return [...pinned, ...unpinned]; +} + +class _TabFilterRow { + final TabEntity entity; + final bool isPinned; + final String? titleKey; + final String? urlKey; + final DateTime? dateKey; + + const _TabFilterRow({ + required this.entity, + required this.isPinned, + required this.titleKey, + required this.urlKey, + required this.dateKey, + }); +} + @Riverpod() EquatableValue> seamlessFilteredTabEntities( Ref ref, { @@ -355,6 +501,10 @@ EquatableValue> seamlessFilteredTabEntities( required ContainerFilter containerFilter, required bool groupTrees, }) { + final orderKeys = ref.watch( + watchTabOrderKeysProvider.select((value) => value.value), + ); + final tabSearchResults = ref .watch( tabSearchRepositoryProvider(searchPartition).select( @@ -364,6 +514,7 @@ EquatableValue> seamlessFilteredTabEntities( .map( (tab) => SearchResultTabEntity( tabId: tab.id, + orderKey: orderKeys?[tab.id] ?? '', containerId: tab.containerId, searchQuery: result.query, ), @@ -379,53 +530,98 @@ EquatableValue> seamlessFilteredTabEntities( containerTabEntitiesProvider(containerFilter), ); - if (tabSearchResults == null) { - if (groupTrees) { - final trees = ref.watch( - watchTabTreesProvider.select( - (value) => EquatableValue( - value.value?.map((tree) { - // Find the container ID for the latest tab - final containerForTab = availableTabs.value - .where((t) => t.tabId == tree.latestTabId) - .firstOrNull - ?.containerId; + // Tree mode: no filtering/sorting, return as-is + if (groupTrees && tabSearchResults == null) { + final trees = ref.watch( + watchTabTreesProvider.select( + (value) => EquatableValue( + value.value?.map((tree) { + // Find the container ID for the latest tab + final containerForTab = availableTabs.value + .where((t) => t.tabId == tree.latestTabId) + .firstOrNull + ?.containerId; - return TabTreeEntity( - tabId: tree.latestTabId, - containerId: containerForTab, - rootId: tree.rootTabId, - totalTabs: tree.totalTabs, - ); - }).toList() ?? - [], - ), + return TabTreeEntity( + tabId: tree.latestTabId, + orderKey: orderKeys?[tree.latestTabId] ?? '', + containerId: containerForTab, + rootId: tree.rootTabId, + totalTabs: tree.totalTabs, + ); + }).toList() ?? + [], ), - ); + ), + ); + return EquatableValue( + trees.value + .where( + (tree) => availableTabs.value.any( + (available) => available.tabId == tree.tabId, + ), + ) + .toList(), + ); + } + + final tabStates = ref.watch(tabStatesProvider); + + final filterOptions = ref.watch(tabViewFilterControllerProvider); + + final pinnedTabIds = ref.watch( + watchPinnedTabIdsProvider.select( + (value) => value.value ?? const {}, + ), + ); + + // Only pull timestamps from DB when date filtering/sorting is active + final needsTimestamps = + filterOptions.effectiveDateRange != null || + filterOptions.sortType.sortField == SortField.dateAsc || + filterOptions.sortType.sortField == SortField.dateDesc; + final tabTimestamps = needsTimestamps + ? ref.watch(watchTabTimestampsProvider.select((value) => value.value)) + : null; + + if (tabSearchResults == null) { + if (filterOptions.hasActiveFilter || pinnedTabIds.isNotEmpty) { return EquatableValue( - trees.value - .where( - (tree) => availableTabs.value.any( - (available) => available.tabId == tree.tabId, - ), - ) - .toList(), + _applyTabFiltersAndSort( + availableTabs.value, + filterOptions, + tabStates, + pinnedTabIds, + tabTimestamps, + ), ); } return availableTabs; } - return EquatableValue( - tabSearchResults - .where( - (tab) => availableTabs.value.any( - (available) => available.tabId == tab.tabId, - ), - ) - .toList(), - ); + final searchFiltered = tabSearchResults + .where( + (tab) => availableTabs.value.any( + (available) => available.tabId == tab.tabId, + ), + ) + .toList(); + + if (filterOptions.hasActiveFilter || pinnedTabIds.isNotEmpty) { + return EquatableValue( + _applyTabFiltersAndSort( + searchFiltered, + filterOptions, + tabStates, + pinnedTabIds, + tabTimestamps, + ), + ); + } + + return EquatableValue(searchFiltered); } @Riverpod() diff --git a/app/lib/features/geckoview/features/browser/domain/providers.g.dart b/app/lib/features/geckoview/features/browser/domain/providers.g.dart index 01af2c7d..36d46f2e 100644 --- a/app/lib/features/geckoview/features/browser/domain/providers.g.dart +++ b/app/lib/features/geckoview/features/browser/domain/providers.g.dart @@ -9,6 +9,48 @@ part of 'providers.dart'; // GENERATED CODE - DO NOT MODIFY BY HAND // ignore_for_file: type=lint, type=warning +@ProviderFor(canManualTabReorder) +final canManualTabReorderProvider = CanManualTabReorderProvider._(); + +final class CanManualTabReorderProvider + extends $FunctionalProvider + with $Provider { + CanManualTabReorderProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'canManualTabReorderProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$canManualTabReorderHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + bool create(Ref ref) { + return canManualTabReorder(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(bool value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$canManualTabReorderHash() => + r'ba5d961933464b6e005d7945802908a9a4ae034b'; + @ProviderFor(SelectedBangTrigger) final selectedBangTriggerProvider = SelectedBangTriggerFamily._(); @@ -269,7 +311,7 @@ final class ContainerTabEntitiesProvider } String _$containerTabEntitiesHash() => - r'350b3e2a2672ab0a303344c7d8301af24c991ee5'; + r'bcd932968bae46fb5e27a60819ea7aad7a8e41e7'; final class ContainerTabEntitiesFamily extends $Family with @@ -487,7 +529,7 @@ final class SelectedContainerTabStatesWithContainerProvider } String _$selectedContainerTabStatesWithContainerHash() => - r'2fd5e12595b3aced1e235c9626073c34655d57fa'; + r'e2dc85fc72ee29736d8fdaabb1abadd23cc4bac0'; @ProviderFor(quickTabSwitcherTabStates) final quickTabSwitcherTabStatesProvider = QuickTabSwitcherTabStatesFamily._(); @@ -816,7 +858,7 @@ final class SuggestedTabEntitiesProvider } String _$suggestedTabEntitiesHash() => - r'1c3d9a4f85db60301fe5c5b55fb55512d1b59696'; + r'b140a1d0badad3d976b91ab9154300873a8bd4e9'; final class SuggestedTabEntitiesFamily extends $Family with $FunctionalFamilyOverride>, String?> { @@ -920,7 +962,7 @@ final class SeamlessFilteredTabEntitiesProvider } String _$seamlessFilteredTabEntitiesHash() => - r'b0477aa6297183575264d54a82575dfb01fa4c32'; + r'bc6833f975e9af3b2117268d7644dc56131c85a3'; final class SeamlessFilteredTabEntitiesFamily extends $Family with diff --git a/app/lib/features/geckoview/features/browser/presentation/controllers/tab_view_controllers.dart b/app/lib/features/geckoview/features/browser/presentation/controllers/tab_view_controllers.dart index 908d727e..42fbf785 100644 --- a/app/lib/features/geckoview/features/browser/presentation/controllers/tab_view_controllers.dart +++ b/app/lib/features/geckoview/features/browser/presentation/controllers/tab_view_controllers.dart @@ -22,8 +22,10 @@ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; import 'package:riverpod/experimental/persist.dart'; +import 'package:riverpod_annotation/experimental/json_persist.dart'; import 'package:riverpod_annotation/experimental/persist.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/features/geckoview/features/browser/domain/entities/tab_view_filter_options.dart'; import 'package:weblibre/features/user/data/providers.dart'; part 'tab_view_controllers.g.dart'; @@ -63,6 +65,46 @@ class TabsViewModeController extends _$TabsViewModeController { } } +@Riverpod(keepAlive: true) +@JsonPersist() +class TabViewFilterController extends _$TabViewFilterController { + void setTabTypeFilter(TabTypeFilter filter) { + state = state.copyWith.tabTypeFilter(filter); + } + + void setSortType(TabSortType sort) { + state = state.copyWith.sortType(sort); + } + + void setSortPinnedFirst(bool value) { + state = state.copyWith(sortPinnedFirst: value); + } + + void setDateRange(DateTimeRange? range) { + // ignore: avoid_redundant_argument_values + state = state.copyWith(dateRange: range, quickInterval: null); + } + + void setQuickInterval(TabQuickInterval? interval) { + // ignore: avoid_redundant_argument_values + state = state.copyWith(quickInterval: interval, dateRange: null); + } + + void reset() { + state = TabViewFilterOptions.withDefaults(); + } + + @override + TabViewFilterOptions build() { + persist( + ref.watch(riverpodDatabaseStorageProvider), + key: 'TabViewFilterOptions', + ); + + return stateOrNull ?? TabViewFilterOptions.withDefaults(); + } +} + @Riverpod() class TabsReorderableController extends _$TabsReorderableController { void toggle() { diff --git a/app/lib/features/geckoview/features/browser/presentation/controllers/tab_view_controllers.g.dart b/app/lib/features/geckoview/features/browser/presentation/controllers/tab_view_controllers.g.dart index 8e3fbc74..16997820 100644 --- a/app/lib/features/geckoview/features/browser/presentation/controllers/tab_view_controllers.g.dart +++ b/app/lib/features/geckoview/features/browser/presentation/controllers/tab_view_controllers.g.dart @@ -62,6 +62,63 @@ abstract class _$TabsViewModeController extends $Notifier { } } +@ProviderFor(TabViewFilterController) +@JsonPersist() +final tabViewFilterControllerProvider = TabViewFilterControllerProvider._(); + +@JsonPersist() +final class TabViewFilterControllerProvider + extends $NotifierProvider { + TabViewFilterControllerProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'tabViewFilterControllerProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$tabViewFilterControllerHash(); + + @$internal + @override + TabViewFilterController create() => TabViewFilterController(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(TabViewFilterOptions value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$tabViewFilterControllerHash() => + r'95e8a03d60ebe05e0c5df38f810f951a4fe2ed78'; + +@JsonPersist() +abstract class _$TabViewFilterControllerBase + extends $Notifier { + TabViewFilterOptions build(); + @$mustCallSuper + @override + void runBuild() { + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + TabViewFilterOptions, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} + @ProviderFor(TabsReorderableController) final tabsReorderableControllerProvider = TabsReorderableControllerProvider._(); @@ -114,3 +171,40 @@ abstract class _$TabsReorderableController extends $Notifier { element.handleCreate(ref, build); } } + +// ************************************************************************** +// JsonGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +abstract class _$TabViewFilterController extends _$TabViewFilterControllerBase { + /// The default key used by [persist]. + String get key { + const resolvedKey = "TabViewFilterController"; + return resolvedKey; + } + + /// A variant of [persist], for JSON-specific encoding. + /// + /// You can override [key] to customize the key used for storage. + PersistResult persist( + FutureOr> storage, { + String? key, + String Function(TabViewFilterOptions state)? encode, + TabViewFilterOptions Function(String encoded)? decode, + StorageOptions options = const StorageOptions(), + }) { + return NotifierPersistX(this).persist( + storage, + key: key ?? this.key, + encode: encode ?? $jsonCodex.encode, + decode: + decode ?? + (encoded) { + final e = $jsonCodex.decode(encoded); + return TabViewFilterOptions.fromJson(e as Map); + }, + options: options, + ); + } +} diff --git a/app/lib/features/geckoview/features/browser/presentation/screens/browser.dart b/app/lib/features/geckoview/features/browser/presentation/screens/browser.dart index 8fc2df7b..f77586bf 100644 --- a/app/lib/features/geckoview/features/browser/presentation/screens/browser.dart +++ b/app/lib/features/geckoview/features/browser/presentation/screens/browser.dart @@ -291,7 +291,6 @@ class BrowserScreen extends HookConsumerWidget { ), ); - ref.listen(overlayControllerProvider, (previous, next) { if (next != null) { overlayController.show(); @@ -872,7 +871,10 @@ class _Browser extends HookConsumerWidget { } // Dismiss modal routes (e.g. showModalBottomSheet) - final rootNavigator = Navigator.of(context, rootNavigator: true); + final rootNavigator = Navigator.of( + context, + rootNavigator: true, + ); if (rootNavigator.canPop()) { rootNavigator.pop(); return true; diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_menu_sheet.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_menu_sheet.dart index dbf5f657..98c5b1c6 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_menu_sheet.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_menu_sheet.dart @@ -1368,7 +1368,9 @@ class _ExtensionsCard extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final addonService = ref.watch(addonServiceProvider); - final extensionsExpanded = ref.watch(persistedBoolProvider(PersistedBoolKey.extensionsExpanded)); + final extensionsExpanded = ref.watch( + persistedBoolProvider(PersistedBoolKey.extensionsExpanded), + ); final pageExtensions = ref.watch( webExtensionsStateProvider( WebExtensionActionType.page, @@ -1393,8 +1395,13 @@ class _ExtensionsCard extends HookConsumerWidget { leading: const Icon(MdiIcons.puzzle), title: const Text('Extensions'), initiallyExpanded: extensionsExpanded, - onExpansionChanged: (_) => - ref.read(persistedBoolProvider(PersistedBoolKey.extensionsExpanded).notifier).toggle(), + onExpansionChanged: (_) => ref + .read( + persistedBoolProvider( + PersistedBoolKey.extensionsExpanded, + ).notifier, + ) + .toggle(), children: [ // Page extensions if (pageExtensions.isNotEmpty) ...[ 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 1f0ad67a..d48cdb17 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 @@ -20,6 +20,7 @@ import 'dart:async'; +import 'package:fast_equatable/fast_equatable.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; @@ -369,15 +370,39 @@ class BrowserTabBar extends HookConsumerWidget { } } -typedef _QuickTabItem = ({ - Color? color, - String id, - TabMode tabMode, - bool isHistory, - String title, - Uri url, - TabState? tabState, -}); +class _QuickTabItem with FastEquatable { + final Color? color; + final String id; + final TabMode tabMode; + final bool isHistory; + final bool isPinned; + final String title; + final Uri url; + final TabState? tabState; + + _QuickTabItem({ + required this.color, + required this.id, + required this.tabMode, + required this.isHistory, + required this.isPinned, + required this.title, + required this.url, + required this.tabState, + }); + + @override + List get hashParameters => [ + color, + id, + tabMode, + isHistory, + isPinned, + title, + url, + tabState, + ]; +} class ContextualToolbar extends HookConsumerWidget { const ContextualToolbar({ @@ -443,6 +468,14 @@ class QuickTabSwitcher extends HookConsumerWidget { (s) => s.quickTabSwitcherShowTitles, ), ); + final effectiveMode = ref.watch( + generalSettingsWithDefaultsProvider.select( + (settings) => settings.effectiveUiQuickTabSwitcherMode(), + ), + ); + final pinnedTabIds = ref.watch( + watchPinnedTabIdsProvider.select((value) => value.value), + ); final tabStates = ref.watch( quickTabSwitcherTabStatesProvider(quickTabSwitcherMode), ); @@ -451,11 +484,12 @@ class QuickTabSwitcher extends HookConsumerWidget { .value; final availableItems = tabStates.value .map<_QuickTabItem>( - (state) => ( + (state) => _QuickTabItem( id: state.$1.id, title: state.$1.titleOrAuthority, tabMode: state.$1.tabMode, isHistory: false, + isPinned: pinnedTabIds?.contains(state.$1.id) ?? false, url: state.$1.url, color: state.$2?.color, tabState: state.$1, @@ -465,11 +499,12 @@ class QuickTabSwitcher extends HookConsumerWidget { (historySuggestions ?? []).map<_QuickTabItem>((state) { final url = Uri.parse(state.url); - return ( + return _QuickTabItem( id: state.url, title: state.title ?? url.authority, tabMode: TabMode.regular, isHistory: true, + isPinned: false, url: url, color: null, tabState: null, @@ -496,6 +531,7 @@ class QuickTabSwitcher extends HookConsumerWidget { labelPadding: (item) => (!showTitles && !item.isHistory && + !item.isPinned && item.tabMode is! PrivateTabMode && item.tabMode is! IsolatedTabMode) ? EdgeInsets.zero @@ -527,6 +563,15 @@ class QuickTabSwitcher extends HookConsumerWidget { size: 20, ), ), + if (item.isPinned) + Padding( + padding: const EdgeInsets.only(left: 8.0), + child: Icon( + MdiIcons.pin, + color: Theme.of(context).colorScheme.primary, + size: 20, + ), + ), if (item.isHistory) const Padding( padding: EdgeInsets.only(left: 8.0), @@ -575,6 +620,7 @@ class QuickTabSwitcher extends HookConsumerWidget { enableReloadButton: false, enableNavigationButtons: false, enableAddToHomeScreen: false, + enablePinTab: effectiveMode == QuickTabSwitcherMode.containerTabs, builder: (context, controller, _) { return InkWell( onLongPress: () { diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_menu.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_menu.dart index 6289d226..206beeeb 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_menu.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_menu.dart @@ -68,6 +68,7 @@ class TabMenu extends HookConsumerWidget { final bool enableShare; final bool enableExport; final bool enableCloseTab; + final bool enablePinTab; final bool enableReloadButton; final bool enableNavigationButtons; @@ -87,6 +88,7 @@ class TabMenu extends HookConsumerWidget { this.enableShare = true, this.enableExport = true, this.enableCloseTab = true, + this.enablePinTab = true, this.enableReloadButton = true, this.enableNavigationButtons = true, }); @@ -579,6 +581,31 @@ class TabMenu extends HookConsumerWidget { leadingIcon: const Icon(MdiIcons.fileExport), child: const Text('Export'), ), + if (enablePinTab) + Consumer( + builder: (context, childRef, child) { + final isPinned = childRef.watch( + watchPinnedTabIdsProvider.select( + (v) => v.value?.contains(selectedTabId) ?? false, + ), + ); + + return MenuItemButton( + closeOnActivate: false, + onPressed: () async { + await childRef + .read(tabDataRepositoryProvider.notifier) + .setPinned(selectedTabId, pinned: !isPinned); + + if (context.mounted) { + MenuController.maybeOf(context)?.close(); + } + }, + leadingIcon: Icon(isPinned ? MdiIcons.pinOff : MdiIcons.pin), + child: Text(isPinned ? 'Unpin tab' : 'Pin tab'), + ); + }, + ), if (enableCloseTab) MenuItemButton( onPressed: () async { diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_context_menu_draggable.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_context_menu_draggable.dart new file mode 100644 index 00000000..c6ab6a6a --- /dev/null +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_context_menu_draggable.dart @@ -0,0 +1,225 @@ +/* + * 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/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/data/models/drag_data.dart'; +import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_menu.dart'; +import 'package:weblibre/presentation/hooks/menu_controller.dart'; + +/// Chrome-like tab context menu + drag coordination. +/// +/// On long press: shows a context menu via [TabMenu]. +/// If user moves finger: closes menu, starts drag. +/// If user releases without moving: menu persists. +/// +/// For non-reorderable mode: wraps with [LongPressDraggable]. +/// For reorderable mode: uses manual long-press timer (drag handled externally). +class TabContextMenuDraggable extends HookConsumerWidget { + final String tabId; + final TabDragData? data; + final Widget child; + + /// Size for the drag feedback widget. + final Size feedbackSize; + + /// Whether drag is handled externally (reorderable mode). + /// When true, no [LongPressDraggable] is used; only menu + manual timer. + final bool externalDrag; + + const TabContextMenuDraggable({ + required this.tabId, + required this.child, + required this.feedbackSize, + this.data, + this.externalDrag = false, + super.key, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final menuController = useMenuController(); + final isDragMoving = useMemoized(() => ValueNotifier(false)); + final startPosition = useRef(Offset.zero); + final isDragging = useRef(false); + final longPressTimer = useRef(null); + + // Clean up ValueNotifier + useEffect(() => isDragMoving.dispose, [isDragMoving]); + + if (externalDrag) { + return _buildReorderableMode( + context, + menuController: menuController, + startPosition: startPosition, + longPressTimer: longPressTimer, + ); + } + + return _buildDraggableMode( + context, + menuController: menuController, + isDragMoving: isDragMoving, + startPosition: startPosition, + isDragging: isDragging, + ); + } + + Widget _buildTabMenu({ + required MenuController menuController, + required Widget Function(BuildContext, MenuController, Widget?) builder, + }) { + return TabMenu( + selectedTabId: tabId, + controller: menuController, + enableFindInPage: false, + enableFetchFeeds: false, + enableDesktopMode: false, + enableReaderMode: false, + enableReloadButton: false, + enableNavigationButtons: false, + enableAddToHomeScreen: false, + enableCloseTab: false, + builder: builder, + ); + } + + /// Non-reorderable mode: Listener + TabMenu + LongPressDraggable + Widget _buildDraggableMode( + BuildContext context, { + required MenuController menuController, + required ValueNotifier isDragMoving, + required ObjectRef startPosition, + required ObjectRef isDragging, + }) { + return Listener( + onPointerDown: (event) { + startPosition.value = event.position; + }, + onPointerMove: (event) { + if (isDragging.value && + !isDragMoving.value && + (event.position - startPosition.value).distance > kTouchSlop) { + isDragMoving.value = true; + if (menuController.isOpen) { + menuController.close(); + } + } + }, + onPointerUp: (_) { + // Reset drag tracking on pointer up (for cases where drag wasn't started) + isDragging.value = false; + }, + child: _buildTabMenu( + menuController: menuController, + builder: (context, controller, _) { + return LongPressDraggable( + data: data, + onDragStarted: () { + isDragging.value = true; + isDragMoving.value = false; + menuController.open(); + }, + onDragEnd: (_) { + isDragging.value = false; + isDragMoving.value = false; + }, + onDraggableCanceled: (_, _) { + isDragging.value = false; + isDragMoving.value = false; + }, + feedback: ValueListenableBuilder( + valueListenable: isDragMoving, + builder: (_, moving, feedbackChild) { + if (!moving) return const SizedBox.shrink(); + return feedbackChild!; + }, + child: Material( + color: Colors.transparent, + child: Transform.scale( + scale: 1.05, + child: SizedBox( + height: feedbackSize.height, + width: feedbackSize.width, + child: child, + ), + ), + ), + ), + childWhenDragging: ValueListenableBuilder( + valueListenable: isDragMoving, + builder: (_, moving, _) { + if (!moving) return child; + return SizedBox( + height: feedbackSize.height, + width: feedbackSize.width, + ); + }, + ), + child: child, + ); + }, + ), + ); + } + + /// Reorderable mode: Listener + TabMenu (drag handled externally) + Widget _buildReorderableMode( + BuildContext context, { + required MenuController menuController, + required ObjectRef startPosition, + required ObjectRef longPressTimer, + }) { + return Listener( + onPointerDown: (event) { + startPosition.value = event.position; + longPressTimer.value?.cancel(); + longPressTimer.value = Timer(kLongPressTimeout, () { + menuController.open(); + }); + }, + onPointerMove: (event) { + if ((event.position - startPosition.value).distance > kTouchSlop) { + // User started moving - cancel menu timer and close menu if open + longPressTimer.value?.cancel(); + longPressTimer.value = null; + if (menuController.isOpen) { + menuController.close(); + } + } + }, + onPointerUp: (_) { + longPressTimer.value?.cancel(); + longPressTimer.value = null; + }, + onPointerCancel: (_) { + longPressTimer.value?.cancel(); + longPressTimer.value = null; + }, + child: _buildTabMenu( + menuController: menuController, + builder: (context, controller, _) => child, + ), + ); + } +} diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_grid_view.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_grid_view.dart index 4afc2f36..92d5cd3f 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_grid_view.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_grid_view.dart @@ -37,6 +37,7 @@ import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; import 'package:weblibre/features/geckoview/features/browser/domain/providers.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/controllers/tab_view_controllers.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/draggable_scrollable_header.dart'; +import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_context_menu_draggable.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_drop_target.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_preview.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_view_header.dart'; @@ -141,6 +142,8 @@ class _TabGridView extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final screenWidth = MediaQuery.of(context).size.width; + final canManualReorder = ref.watch(canManualTabReorderProvider); + final reorderEnabled = tabsReorderable && canManualReorder; final containerId = ref.watch(selectedContainerProvider); @@ -154,7 +157,9 @@ class _TabGridView extends HookConsumerWidget { ), ); - final tabSuggestionsEnabled = ref.watch(persistedBoolProvider(PersistedBoolKey.tabSuggestions)); + final tabSuggestionsEnabled = ref.watch( + persistedBoolProvider(PersistedBoolKey.tabSuggestions), + ); final suggestedTabEntities = tabSuggestionsEnabled ? ref.watch(suggestedTabEntitiesProvider(containerId)) @@ -164,6 +169,9 @@ class _TabGridView extends HookConsumerWidget { filteredTabEntities.value.length + //Limit to 3 sugegstions for now math.min(suggestedTabEntities.value.length, 3); + final displayItemCount = reorderEnabled + ? filteredTabEntities.value.length + : itemCount; final activeTab = ref.watch(selectedTabProvider); @@ -239,34 +247,22 @@ class _TabGridView extends HookConsumerWidget { fadingSize: 5, controller: scrollController, builder: (context, controller) { - return !tabsReorderable + return !reorderEnabled ? _TabGrid( key: ValueKey(crossAxisCount), crossAxisCount: crossAxisCount, - itemCount: itemCount, + itemCount: displayItemCount, scrollController: controller, itemBuilder: (widget, _) { if (widget is CustomDraggable) { - return LongPressDraggable( - feedback: Material( - color: Colors - .transparent, // removes white corners when having shadow - child: Transform.scale( - scale: 1.05, - child: SizedBox( - height: itemSize.height, - width: itemSize.width, - child: widget.child, - ), - ), - ), - data: widget.data, - childWhenDragging: SizedBox( - height: itemSize.height, - width: itemSize.width, - ), - child: widget.child, - ); + if (widget.data case final TabDragData dragData) { + return TabContextMenuDraggable( + tabId: dragData.tabId, + data: dragData, + feedbackSize: itemSize, + child: widget.child, + ); + } } return widget; @@ -280,7 +276,7 @@ class _TabGridView extends HookConsumerWidget { //Rebuild when cross axis count changes key: ValueKey(crossAxisCount), scrollController: controller, - itemCount: itemCount, + itemCount: displayItemCount, onDragStarted: (index) { ref.read(willAcceptDropProvider.notifier).clear(); }, @@ -307,25 +303,35 @@ class _TabGridView extends HookConsumerWidget { .read(tabDataRepositoryProvider.notifier) .getTabContainerId(tabId); + var targetIndex = newIndex; + if (targetIndex > oldIndex) { + targetIndex -= 1; + } + + targetIndex = targetIndex.clamp( + 0, + filteredTabEntities.value.length - 1, + ); + final String key; - if (newIndex <= 0) { + if (targetIndex <= 0) { key = await containerRepository.getLeadingOrderKey( containerId, ); - } else if (newIndex >= + } else if (targetIndex >= filteredTabEntities.value.length - 1) { key = await containerRepository.getTrailingOrderKey( containerId, ); } else { - if (newIndex < oldIndex) { + if (targetIndex < oldIndex) { key = (await containerRepository.getOrderKeyAfterTab( - filteredTabEntities.value[newIndex - 1].tabId, + filteredTabEntities.value[targetIndex - 1].tabId, containerId, ))!; } else { key = await containerRepository.getOrderKeyBeforeTab( - filteredTabEntities.value[newIndex + 1].tabId, + filteredTabEntities.value[targetIndex + 1].tabId, containerId, ); } @@ -335,12 +341,30 @@ class _TabGridView extends HookConsumerWidget { .read(tabDataRepositoryProvider.notifier) .assignOrderKey(tabId, key); }, - childBuilder: (itemBuilder) { + childBuilder: (reorderableItemBuilder) { return _TabGrid( crossAxisCount: crossAxisCount, - itemCount: itemCount, + itemCount: displayItemCount, scrollController: controller, - itemBuilder: itemBuilder, + itemBuilder: (widget, index) { + // Wrap with context menu before passing to reorderable + Widget wrapped = widget; + if (widget is CustomDraggable) { + if (widget.data case final TabDragData dragData) { + wrapped = CustomDraggable( + key: widget.key!, + data: widget.data, + child: TabContextMenuDraggable( + tabId: dragData.tabId, + feedbackSize: Size.zero, + externalDrag: true, + child: widget.child, + ), + ); + } + } + return reorderableItemBuilder(wrapped, index); + }, suggestedContainerId: containerId, filteredTabEntities: filteredTabEntities, suggestedTabEntities: suggestedTabEntities, diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_list_view.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_list_view.dart index 10c600be..1de17c56 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_list_view.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_list_view.dart @@ -36,6 +36,7 @@ import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; import 'package:weblibre/features/geckoview/features/browser/domain/providers.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/controllers/tab_view_controllers.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/draggable_scrollable_header.dart'; +import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_context_menu_draggable.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_drop_target.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_preview.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_view_header.dart'; @@ -147,7 +148,7 @@ class _TabListView extends HookConsumerWidget { required this.onClose, }); - static const _itemHeight = 80.0; + static const _itemHeight = 86.0; @override Widget build(BuildContext context, WidgetRef ref) { @@ -204,6 +205,8 @@ class _TabListView extends HookConsumerWidget { Widget _buildLocalTabsView(BuildContext context, WidgetRef ref) { final containerId = ref.watch(selectedContainerProvider); + final canManualReorder = ref.watch(canManualTabReorderProvider); + final reorderEnabled = tabsReorderable && canManualReorder; final filteredTabEntities = ref.watch( seamlessFilteredTabEntitiesProvider( @@ -215,7 +218,9 @@ class _TabListView extends HookConsumerWidget { ), ); - final tabSuggestionsEnabled = ref.watch(persistedBoolProvider(PersistedBoolKey.tabSuggestions)); + final tabSuggestionsEnabled = ref.watch( + persistedBoolProvider(PersistedBoolKey.tabSuggestions), + ); final suggestedTabEntities = tabSuggestionsEnabled ? ref.watch(suggestedTabEntitiesProvider(containerId)) @@ -225,6 +230,9 @@ class _TabListView extends HookConsumerWidget { filteredTabEntities.value.length + //Limit to 3 sugegstions for now math.min(suggestedTabEntities.value.length, 3); + final displayItemCount = reorderEnabled + ? filteredTabEntities.value.length + : itemCount; final activeTab = ref.watch(selectedTabProvider); @@ -282,11 +290,11 @@ class _TabListView extends HookConsumerWidget { fadingSize: 5, controller: scrollController, builder: (context, controller) { - return !tabsReorderable + return !reorderEnabled ? ListView.builder( padding: EdgeInsets.zero, controller: scrollController, - itemCount: itemCount, + itemCount: displayItemCount, itemExtent: _itemHeight, itemBuilder: (context, index) { final TabEntity entity; @@ -322,29 +330,23 @@ class _TabListView extends HookConsumerWidget { return TabDropTarget( targetTabId: entity.tabId, enabled: suggestedId == null, - child: LongPressDraggable( - feedback: Material( - color: Colors - .transparent, // removes white corners when having shadow - child: Transform.scale( - scale: 1.05, - child: SizedBox( - height: _itemHeight, - width: MediaQuery.of(context).size.width, + child: tab.data is TabDragData + ? TabContextMenuDraggable( + tabId: (tab.data! as TabDragData).tabId, + data: tab.data! as TabDragData, + feedbackSize: Size( + MediaQuery.of(context).size.width, + _itemHeight, + ), child: tab.child, - ), - ), - ), - data: tab.data, - childWhenDragging: const SizedBox(height: _itemHeight), - child: tab.child, - ), + ) + : tab.child, ); }, ) : ReorderableListView.builder( scrollController: controller, - itemCount: itemCount, + itemCount: displayItemCount, itemExtent: _itemHeight, onReorderStart: (index) { ref.read(willAcceptDropProvider.notifier).clear(); @@ -364,25 +366,35 @@ class _TabListView extends HookConsumerWidget { .read(tabDataRepositoryProvider.notifier) .getTabContainerId(tabId); + var targetIndex = newIndex; + if (targetIndex > oldIndex) { + targetIndex -= 1; + } + + targetIndex = targetIndex.clamp( + 0, + filteredTabEntities.value.length - 1, + ); + final String key; - if (newIndex <= 0) { + if (targetIndex <= 0) { key = await containerRepository.getLeadingOrderKey( containerId, ); - } else if (newIndex >= + } else if (targetIndex >= filteredTabEntities.value.length - 1) { key = await containerRepository.getTrailingOrderKey( containerId, ); } else { - if (newIndex < oldIndex) { + if (targetIndex < oldIndex) { key = (await containerRepository.getOrderKeyAfterTab( - filteredTabEntities.value[newIndex - 1].tabId, + filteredTabEntities.value[targetIndex - 1].tabId, containerId, ))!; } else { key = await containerRepository.getOrderKeyBeforeTab( - filteredTabEntities.value[newIndex + 1].tabId, + filteredTabEntities.value[targetIndex + 1].tabId, containerId, ); } @@ -393,17 +405,20 @@ class _TabListView extends HookConsumerWidget { .assignOrderKey(tabId, key); }, itemBuilder: (context, index) { - final CustomDraggable tab; - if (index < filteredTabEntities.value.length) { final entity = filteredTabEntities.value[index]; - tab = CustomDraggable( + return CustomDraggable( key: Key(entity.tabId), data: TabDragData(entity.tabId), - child: _TabDraggable( - entity: entity, - onClose: onClose, - height: _itemHeight, + child: TabContextMenuDraggable( + tabId: entity.tabId, + feedbackSize: Size.zero, + externalDrag: true, + child: _TabDraggable( + entity: entity, + onClose: onClose, + height: _itemHeight, + ), ), ); } else { @@ -411,7 +426,7 @@ class _TabListView extends HookConsumerWidget { index - filteredTabEntities.value.length; final entity = suggestedTabEntities.value[suggestedIndex]; - tab = CustomDraggable( + return CustomDraggable( key: Key('suggested_${entity.tabId}'), child: _TabDraggable( entity: entity, @@ -421,25 +436,6 @@ class _TabListView extends HookConsumerWidget { ), ); } - - // return LongPressDraggable( - // key: tab.key, - // feedback: Material( - // color: Colors - // .transparent, // removes white corners when having shadow - // child: Transform.scale( - // scale: 1.05, - // child: SizedBox( - // height: _itemHeight, - // width: MediaQuery.of(context).size.width, - // child: tab.child, - // ), - // ), - // ), - // data: tab.data, - // child: tab.child, - // ); - return tab; }, ); }, diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_preview.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_preview.dart index f1b5b91b..8021f9c8 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_preview.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_preview.dart @@ -32,6 +32,7 @@ import 'package:weblibre/features/geckoview/features/browser/presentation/widget import 'package:weblibre/features/geckoview/features/find_in_page/domain/entities/find_in_page_state.dart'; import 'package:weblibre/features/geckoview/features/find_in_page/presentation/controllers/find_in_page.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; import 'package:weblibre/presentation/hooks/menu_controller.dart'; @@ -73,29 +74,46 @@ class GridTabItemContainer extends StatelessWidget { super.key, }); + static const borderRadius = BorderRadius.all(Radius.circular(12.0)); + @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final appColors = AppColors.of(context); final bgColor = switch (tabMode) { - PrivateTabMode() => appColors.privateTabBackground, - IsolatedTabMode() => appColors.isolatedTabBackground, - RegularTabMode() => colorScheme.surfaceContainerHighest, + PrivateTabMode() => appColors.privateTabBackground.withAlpha(80), + IsolatedTabMode() => appColors.isolatedTabBackground.withAlpha(80), + RegularTabMode() => colorScheme.surfaceContainerHigh, }; + final borderWidth = isActive ? 4.0 : 3.0; + final innerRadius = BorderRadius.all(Radius.circular(12.0 - borderWidth)); + return Container( decoration: BoxDecoration( border: Border.all( - color: isActive ? colorScheme.primary : colorScheme.outline, - width: isActive ? 2.0 : 1.0, + color: isActive + ? colorScheme.primary + : colorScheme.outline.withAlpha(40), + width: borderWidth, ), - borderRadius: const BorderRadius.all(Radius.circular(16.0)), + borderRadius: borderRadius, + boxShadow: isActive + ? [ + BoxShadow( + color: colorScheme.primary.withAlpha(60), + blurRadius: 8.0, + spreadRadius: 1.0, + ), + ] + : null, ), child: Material( - color: bgColor, - borderRadius: const BorderRadius.all(Radius.circular(14.0)), - child: child, + color: colorScheme.surface, + borderRadius: innerRadius, + clipBehavior: Clip.antiAlias, + child: Material(color: bgColor, child: child), ), ); } @@ -107,10 +125,11 @@ class GridTabPreview extends HookConsumerWidget { final VoidCallback? onTap; final VoidCallback? onDoubleTap; - final VoidCallback? onLongPress; final VoidCallback? onDelete; final void Function(String host)? onDeleteAll; + final bool showPinBadge; + final Widget? trailingChild; const GridTabPreview({ @@ -118,15 +137,17 @@ class GridTabPreview extends HookConsumerWidget { required this.isActive, this.onTap, this.onDoubleTap, - this.onLongPress, this.onDelete, this.onDeleteAll, + this.showPinBadge = false, this.trailingChild, super.key, }); @override Widget build(BuildContext context, WidgetRef ref) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; final appColors = AppColors.of(context); final tabState = @@ -139,137 +160,232 @@ class GridTabPreview extends HookConsumerWidget { final extendedDeleteMenuController = useMenuController(); + // ignore: avoid_bool_literals_in_conditional_expressions + final isPinned = showPinBadge + ? ref.watch( + watchPinnedTabIdsProvider.select( + (v) => v.value?.contains(tabId) ?? false, + ), + ) + : false; + final modeTextColor = switch (tabState.tabMode) { PrivateTabMode() => appColors.privateTabForeground, IsolatedTabMode() => appColors.isolatedTabForeground, RegularTabMode() => null, }; + final subtitleColor = switch (tabState.tabMode) { + PrivateTabMode() => Color.lerp( + appColors.privateTabPurple, + Colors.white, + 0.4, + )!, + IsolatedTabMode() => Color.lerp( + appColors.isolatedTabTeal, + Colors.white, + 0.4, + )!, + RegularTabMode() => colorScheme.onSurfaceVariant, + }; + + final (modeBadgeIcon, modeBadgeColor) = switch (tabState.tabMode) { + PrivateTabMode() => (MdiIcons.dominoMask, appColors.privateTabPurple), + IsolatedTabMode() => (MdiIcons.snowflake, appColors.isolatedTabTeal), + RegularTabMode() => (null, null), + }; + return GridTabItemContainer( isActive: isActive, tabMode: tabState.tabMode, child: InkWell( - borderRadius: const BorderRadius.all(Radius.circular(14.0)), + borderRadius: GridTabItemContainer.borderRadius, onTap: onTap, onDoubleTap: onDoubleTap, - onLongPress: onLongPress, child: Column( children: [ - Row( - children: [ - Expanded( - child: Padding( - padding: const EdgeInsets.only(left: 6.0, top: 2.0), - child: Text( - overflow: TextOverflow.ellipsis, - tabState.titleOrAuthority, - maxLines: 2, - style: modeTextColor != null - ? TextStyle(color: modeTextColor) - : null, - ), - ), - ), - if (onDelete != null || onDeleteAll != null) - MenuAnchor( - controller: extendedDeleteMenuController, - builder: (context, controller, child) { - return child!; - }, - menuChildren: [ - MenuItemButton( - onPressed: () { - onDeleteAll?.call(tabState.url.host); - }, - - leadingIcon: const Icon(MdiIcons.closeBoxMultiple), - child: Text('Close all from ${tabState.url.host}'), - ), - ], - child: IconButton( - visualDensity: const VisualDensity( - horizontal: -4.0, - vertical: -4.0, - ), - onPressed: onDelete, - onLongPress: onDeleteAll != null - ? () { - if (extendedDeleteMenuController.isOpen) { - extendedDeleteMenuController.close(); - } else { - extendedDeleteMenuController.open(); - } - } - : null, - icon: const Icon(Icons.close), - ), - ), - ?trailingChild, - ], - ), - Row( - children: [ - const SizedBox(width: 6.0), - TabIcon(tabState: tabState, iconSize: 16.0), - const SizedBox(width: 6.0), - Expanded( - child: Text( - tabState.url.authority, - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: modeTextColor), - ), - ), - if (tabState.tabMode is PrivateTabMode || - tabState.tabMode is IsolatedTabMode) ...[ - const SizedBox(width: 6.0), - SizedBox( - height: 16, - width: 24, - child: Stack( - fit: StackFit.expand, - children: [ - Positioned( - top: -4, - child: Icon( - tabState.tabMode is IsolatedTabMode - ? MdiIcons.snowflake - : MdiIcons.dominoMask, - color: tabState.tabMode is IsolatedTabMode - ? appColors.isolatedTabTeal - : appColors.privateTabPurple, - ), - ), - ], - ), - ), - ], - const SizedBox(width: 8.0), - ], - ), - const SizedBox(height: 6), - if (tabState.thumbnail != null && !tabState.thumbnail!.isDisposed) - Expanded( - child: ClipRRect( - borderRadius: const BorderRadius.only( - bottomLeft: Radius.circular(14.0), - bottomRight: Radius.circular(14.0), - ), - child: SizedBox( - width: double.infinity, - child: RepaintBoundary( + // Thumbnail or icon area + Expanded( + child: Stack( + fit: StackFit.expand, + children: [ + if (tabState.thumbnail != null && + !tabState.thumbnail!.isDisposed) + RepaintBoundary( child: SafeRawImage( image: tabState.thumbnail, - fit: BoxFit.fitWidth, + fit: BoxFit.cover, + ), + ) + else + Center(child: TabIcon(tabState: tabState, iconSize: 48)), + // Close button overlay + if (onDelete != null || onDeleteAll != null) + Positioned( + top: 6.0, + right: 6.0, + child: MenuAnchor( + controller: extendedDeleteMenuController, + builder: (context, controller, child) { + return child!; + }, + menuChildren: [ + MenuItemButton( + onPressed: () { + onDeleteAll?.call(tabState.url.host); + }, + leadingIcon: const Icon(MdiIcons.closeBoxMultiple), + child: Text('Close all from ${tabState.url.host}'), + ), + ], + child: SizedBox( + width: 28, + height: 28, + child: Material( + color: colorScheme.surfaceContainerHighest + .withAlpha(200), + borderRadius: const BorderRadius.all( + Radius.circular(8.0), + ), + child: InkWell( + borderRadius: const BorderRadius.all( + Radius.circular(8.0), + ), + onTap: onDelete, + onLongPress: onDeleteAll != null + ? () { + if (extendedDeleteMenuController.isOpen) { + extendedDeleteMenuController.close(); + } else { + extendedDeleteMenuController.open(); + } + } + : null, + child: Icon( + Icons.close, + size: 16, + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ), ), ), - ), - ), - ) - else - Expanded( - child: Center(child: TabIcon(tabState: tabState, iconSize: 48)), + if (trailingChild != null || isPinned) + Positioned( + top: 6.0, + left: 6.0, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (trailingChild != null) trailingChild!, + if (isPinned) + Padding( + padding: EdgeInsets.only( + left: trailingChild != null ? 4.0 : 0, + ), + child: SizedBox( + width: 28, + height: 28, + child: Material( + color: colorScheme.surfaceContainerHighest + .withAlpha(200), + borderRadius: const BorderRadius.all( + Radius.circular(8.0), + ), + child: InkWell( + borderRadius: const BorderRadius.all( + Radius.circular(8.0), + ), + onTap: () async { + await ref + .read( + tabDataRepositoryProvider.notifier, + ) + .setPinned(tabId, pinned: false); + + if (context.mounted) { + ui_helper.showInfoMessage( + context, + 'Tab unpinned', + action: SnackBarAction( + label: 'Undo', + onPressed: () async { + await ref + .read( + tabDataRepositoryProvider + .notifier, + ) + .setPinned( + tabId, + pinned: true, + ); + }, + ), + ); + } + }, + child: Icon( + MdiIcons.pin, + size: 16, + color: colorScheme.primary, + ), + ), + ), + ), + ), + ], + ), + ), + ], ), + ), + // Bottom info band + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: 10.0, + vertical: 8.0, + ), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest.withAlpha(120), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + tabState.titleOrAuthority, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + color: modeTextColor, + ), + ), + const SizedBox(height: 2.0), + Row( + children: [ + TabIcon(tabState: tabState, iconSize: 14.0), + const SizedBox(width: 4), + Expanded( + child: Text( + tabState.url.authority, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textTheme.bodySmall?.copyWith( + color: subtitleColor, + ), + ), + ), + if (modeBadgeIcon != null) ...[ + const SizedBox(width: 4), + Icon(modeBadgeIcon, color: modeBadgeColor, size: 14), + ], + ], + ), + ], + ), + ), ], ), ), @@ -282,19 +398,20 @@ class ListTabPreview extends HookConsumerWidget { final bool isActive; final VoidCallback? onTap; - final VoidCallback? onLongPress; final VoidCallback? onDelete; final void Function(String host)? onDeleteAll; + final bool showPinBadge; + final Widget? trailingChild; const ListTabPreview({ required this.tabId, required this.isActive, this.onTap, - this.onLongPress, this.onDelete, this.onDeleteAll, + this.showPinBadge = false, this.trailingChild, super.key, }); @@ -302,6 +419,7 @@ class ListTabPreview extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; final appColors = AppColors.of(context); final tabState = @@ -318,17 +436,30 @@ class ListTabPreview extends HookConsumerWidget { final extendedDeleteMenuController = useMenuController(); + // ignore: avoid_bool_literals_in_conditional_expressions + final isPinned = showPinBadge + ? ref.watch( + watchPinnedTabIdsProvider.select( + (v) => v.value?.contains(tabId) ?? false, + ), + ) + : false; + final leadingWidget = switch ((tabListShowFavicons, tabState.thumbnail)) { - (false, final thumbnail?) when !thumbnail.isDisposed => RepaintBoundary( - child: SafeRawImage(image: thumbnail, fit: BoxFit.fitHeight), + (false, final thumbnail?) when !thumbnail.isDisposed => ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(8.0)), + child: RepaintBoundary( + child: SafeRawImage(image: thumbnail, fit: BoxFit.fitHeight), + ), ), _ => TabIcon(tabState: tabState, iconSize: 32), }; final listBgColor = switch (tabState.tabMode) { - PrivateTabMode() => appColors.privateTabBackground, - IsolatedTabMode() => appColors.isolatedTabBackground, - RegularTabMode() => null, + PrivateTabMode() => appColors.privateTabBackground.withAlpha(80), + IsolatedTabMode() => appColors.isolatedTabBackground.withAlpha(80), + RegularTabMode() when isActive => colorScheme.primary.withAlpha(20), + RegularTabMode() => Colors.transparent, }; final listTextColor = switch (tabState.tabMode) { PrivateTabMode() => appColors.privateTabForeground, @@ -341,84 +472,128 @@ class ListTabPreview extends HookConsumerWidget { RegularTabMode() => (null, null), }; + final subtitleColor = switch (tabState.tabMode) { + PrivateTabMode() => Color.lerp( + appColors.privateTabPurple, + Colors.white, + 0.4, + )!, + IsolatedTabMode() => Color.lerp( + appColors.isolatedTabTeal, + Colors.white, + 0.4, + )!, + RegularTabMode() => colorScheme.onSurfaceVariant, + }; + + const borderRadius = BorderRadius.all(Radius.circular(12.0)); + return Container( + margin: const EdgeInsets.symmetric(vertical: 3.0, horizontal: 4.0), decoration: BoxDecoration( color: listBgColor, - border: isActive ? Border.all(color: colorScheme.primary) : null, - borderRadius: const BorderRadius.all(Radius.circular(4.0)), + borderRadius: borderRadius, + border: isActive + ? Border(left: BorderSide(color: colorScheme.primary, width: 4.0)) + : null, ), - child: Row( - children: [ - Expanded( - child: ListTile( - key: ValueKey(tabState.id), - onTap: onTap, - onLongPress: onLongPress, - contentPadding: const EdgeInsets.only(left: 4), - leading: leadingWidget, - title: Text( - overflow: TextOverflow.ellipsis, - tabState.titleOrAuthority, - maxLines: 2, - style: listTextColor != null - ? TextStyle(color: listTextColor) - : null, - ), - subtitle: Row( - children: [ - if (modeBadgeIcon != null) ...[ - Icon(modeBadgeIcon, color: modeBadgeColor, size: 14), - const SizedBox(width: 4), - ], - Expanded( - child: UriBreadcrumb( - uri: tabState.url, - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: listTextColor), - ), - ), - ], - ), - ), - ), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (onDelete != null || onDeleteAll != null) - MenuAnchor( - controller: extendedDeleteMenuController, - builder: (context, controller, child) { - return child!; - }, - menuChildren: [ - MenuItemButton( - onPressed: () { - onDeleteAll?.call(tabState.url.host); - }, - - leadingIcon: const Icon(MdiIcons.closeBoxMultiple), - child: Text('Close all from ${tabState.url.host}'), - ), - ], - child: IconButton( - onPressed: onDelete, - onLongPress: onDeleteAll != null - ? () { - if (extendedDeleteMenuController.isOpen) { - extendedDeleteMenuController.close(); - } else { - extendedDeleteMenuController.open(); - } - } - : null, - icon: const Icon(Icons.close), + child: Material( + color: Colors.transparent, + borderRadius: borderRadius, + clipBehavior: Clip.antiAlias, + child: InkWell( + borderRadius: borderRadius, + onTap: onTap, + child: Padding( + padding: const EdgeInsets.only(left: 12.0, top: 10.0, bottom: 10.0), + child: Row( + children: [ + leadingWidget, + const SizedBox(width: 14.0), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + tabState.titleOrAuthority, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + color: listTextColor, + ), + ), + const SizedBox(height: 3.0), + Row( + children: [ + if (isPinned) ...[ + Icon( + MdiIcons.pin, + color: colorScheme.primary, + size: 14, + ), + const SizedBox(width: 4), + ], + if (modeBadgeIcon != null) ...[ + Icon( + modeBadgeIcon, + color: modeBadgeColor, + size: 14, + ), + const SizedBox(width: 4), + ], + Expanded( + child: UriBreadcrumb( + uri: tabState.url, + style: textTheme.bodySmall?.copyWith( + color: subtitleColor, + ), + ), + ), + ], + ), + ], ), ), - ?trailingChild, - ], + if (onDelete != null || onDeleteAll != null) + MenuAnchor( + controller: extendedDeleteMenuController, + builder: (context, controller, child) { + return child!; + }, + menuChildren: [ + MenuItemButton( + onPressed: () { + onDeleteAll?.call(tabState.url.host); + }, + leadingIcon: const Icon(MdiIcons.closeBoxMultiple), + child: Text('Close all from ${tabState.url.host}'), + ), + ], + child: IconButton( + onPressed: onDelete, + onLongPress: onDeleteAll != null + ? () { + if (extendedDeleteMenuController.isOpen) { + extendedDeleteMenuController.close(); + } else { + extendedDeleteMenuController.open(); + } + } + : null, + icon: Icon( + Icons.close, + size: 20, + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ?trailingChild, + ], + ), ), - ], + ), ), ); } @@ -530,6 +705,7 @@ class SingleGridTabPreview extends HookConsumerWidget { child: GridTabPreview( tabId: tabId, isActive: tabId == activeTabId, + showPinBadge: true, onTap: () async { if (tabId != activeTabId) { //Close first to avoid rebuilds @@ -563,15 +739,6 @@ class SingleGridTabPreview extends HookConsumerWidget { ); } }, - // onDoubleTap: () { - // ref.read(overlayDialogControllerProvider.notifier).show( - // TabActionDialog( - // initialTab: tab, - // onDismiss: - // ref.read(overlayDialogControllerProvider.notifier).dismiss, - // ), - // ); - // }, onDelete: () async { onBeforeDelete?.call(); @@ -655,6 +822,7 @@ class SingleListTabPreview extends HookConsumerWidget { child: ListTabPreview( tabId: tabId, isActive: tabId == activeTabId, + showPinBadge: true, onTap: () async { if (tabId != activeTabId) { //Close first to avoid rebuilds @@ -688,15 +856,6 @@ class SingleListTabPreview extends HookConsumerWidget { ); } }, - // onDoubleTap: () { - // ref.read(overlayDialogControllerProvider.notifier).show( - // TabActionDialog( - // initialTab: tab, - // onDismiss: - // ref.read(overlayDialogControllerProvider.notifier).dismiss, - // ), - // ); - // }, onDelete: () async { onBeforeDelete?.call(); @@ -741,10 +900,16 @@ class SuggestedSingleGridTabPreview extends StatelessWidget { tabId: tabId, isActive: tabId == activeTabId, onTap: onTap, - trailingChild: const IconButton( - visualDensity: VisualDensity(horizontal: -4.0, vertical: -4.0), - icon: Icon(MdiIcons.creation), - onPressed: null, + trailingChild: SizedBox( + width: 28, + height: 28, + child: Material( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest.withAlpha(200), + borderRadius: const BorderRadius.all(Radius.circular(8.0)), + child: const Icon(MdiIcons.creation, size: 16), + ), ), ), ); diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_tree_view.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_tree_view.dart index 0f5960ee..23913b5c 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_tree_view.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_tree_view.dart @@ -97,24 +97,13 @@ class _TabTreePreview extends HookConsumerWidget { Badge.count( isLabelVisible: entity.totalTabs > 1, count: entity.totalTabs, - alignment: AlignmentDirectional.bottomEnd, - offset: const Offset(-8, -24), + alignment: AlignmentDirectional.topStart, + offset: const Offset(8, 8), backgroundColor: Theme.of(context).colorScheme.primaryContainer, textColor: Theme.of(context).colorScheme.onPrimaryContainer, child: GridTabPreview( tabId: entity.tabId, isActive: entity.tabId == activeTabId, - onLongPress: () async { - if (entity.tabId != activeTabId) { - //Close first to avoid rebuilds - onClose(); - await ref - .read(tabRepositoryProvider.notifier) - .selectTab(entity.tabId); - } else { - onClose(); - } - }, onTap: () async { if (entity.totalTabs > 1) { await TabTreeRoute(entity.rootId).push(context); diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_view_header.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_view_header.dart index 32cb5ee4..9f1b6a4f 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_view_header.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_view_header.dart @@ -25,6 +25,7 @@ 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:intl/intl.dart'; import 'package:weblibre/core/design/app_colors.dart'; import 'package:weblibre/core/logger.dart'; import 'package:weblibre/core/providers/persisted_bool.dart'; @@ -34,6 +35,8 @@ import 'package:weblibre/features/geckoview/domain/providers.dart'; 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/bookmarks/domain/repositories/bookmarks.dart'; +import 'package:weblibre/features/geckoview/features/browser/domain/entities/tab_view_filter_options.dart'; +import 'package:weblibre/features/geckoview/features/browser/domain/providers.dart'; import 'package:weblibre/features/geckoview/features/browser/domain/services/browser_data.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/controllers/tab_view_controllers.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/dialogs/bookmark_all_dialog.dart'; @@ -220,6 +223,7 @@ class TabViewHeader extends HookConsumerWidget { final viewModeMenuController = useMenuController(); final tabsActionMenuController = useMenuController(); + final filterMenuController = useMenuController(); final hasSearchText = useListenableSelector( searchTextController, @@ -248,6 +252,39 @@ class TabViewHeader extends HookConsumerWidget { (scope) => scope == TabsTrayScope.synced, ), ); + final canManualTabReorder = ref.watch(canManualTabReorderProvider); + + final tabsReorderable = ref.watch(tabsReorderableControllerProvider); + + final canManualReorder = + !isSyncedScope && + tabsViewMode != TabsViewMode.tree && + canManualTabReorder; + + final didShowReorderDisabledInfo = useRef(false); + + useEffect(() { + if (tabsReorderable && !canManualReorder) { + WidgetsBinding.instance.addPostFrameCallback((_) { + ref.read(tabsReorderableControllerProvider.notifier).hide(); + + if (!didShowReorderDisabledInfo.value) { + didShowReorderDisabledInfo.value = true; + + if (context.mounted) { + ui_helper.showInfoMessage( + context, + 'Tab reordering is only available in default manual mode', + ); + } + } + }); + } else { + didShowReorderDisabledInfo.value = false; + } + + return null; + }, [tabsReorderable, canManualReorder]); useOnListenableChange(searchTextController, () async { if (ref.exists(tabSearchRepositoryProvider(TabSearchPartition.preview))) { @@ -269,307 +306,600 @@ class TabViewHeader extends HookConsumerWidget { children: [ if (!searchMode.value) Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: const Icon(MdiIcons.tabSearch), - iconSize: 18, - padding: EdgeInsets.zero, - tooltip: 'Search inside tabs', - onPressed: () { - switch (ref.read(tabsViewModeControllerProvider)) { - case TabsViewMode.tree: - case TabsViewMode.list: - break; - case TabsViewMode.grid: + if (tabsViewMode != TabsViewMode.tree) + IconButton( + icon: const Icon(MdiIcons.tabSearch), + iconSize: 18, + padding: EdgeInsets.zero, + tooltip: 'Search inside tabs', + onPressed: () { + searchMode.value = true; + searchTextFocus.requestFocus(); + }, + ), + if (!isSyncedScope && tabsViewMode != TabsViewMode.tree) + Consumer( + builder: (context, ref, child) { + final filterOptions = ref.watch( + tabViewFilterControllerProvider, + ); + final hasActiveFilter = filterOptions.hasActiveFilter; + + return MenuAnchor( + controller: filterMenuController, + consumeOutsideTap: true, + menuChildren: [ + // Tab type filter + SubmenuButton( + leadingIcon: const Icon(MdiIcons.tabUnselected), + menuChildren: TabTypeFilter.values.map((type) { + final appColors = AppColors.of(context); + final (icon, color) = switch (type) { + TabTypeFilter.all => ( + MdiIcons.tabUnselected, + null, + ), + TabTypeFilter.regularOnly => ( + MdiIcons.tab, + null, + ), + TabTypeFilter.privateOnly => ( + MdiIcons.dominoMask, + appColors.privateTabPurple, + ), + TabTypeFilter.isolatedOnly => ( + MdiIcons.snowflake, + appColors.isolatedTabTeal, + ), + }; + + return MenuItemButton( + leadingIcon: Icon( + filterOptions.tabTypeFilter == type + ? Icons.radio_button_checked + : Icons.radio_button_unchecked, + ), + trailingIcon: Icon(icon, color: color), + child: Text(type.label), + onPressed: () { + ref + .read( + tabViewFilterControllerProvider + .notifier, + ) + .setTabTypeFilter(type); + }, + ); + }).toList(), + child: const Text('Tab Type'), + ), + // Sort + SubmenuButton( + leadingIcon: const Icon(Icons.sort), + menuChildren: [ + ...TabSortType.values.map( + (sort) => MenuItemButton( + leadingIcon: Icon( + filterOptions.sortType == sort + ? Icons.radio_button_checked + : Icons.radio_button_unchecked, + ), + child: Text(sort.label), + onPressed: () { + ref + .read( + tabViewFilterControllerProvider + .notifier, + ) + .setSortType(sort); + }, + ), + ), + const Divider(), + MenuItemButton( + leadingIcon: Icon( + filterOptions.sortPinnedFirst || + filterOptions + .sortType + .sortField == + null + ? Icons.check_box + : Icons.check_box_outline_blank, + ), + onPressed: + filterOptions.sortType.sortField == null + ? null + : () { + ref + .read( + tabViewFilterControllerProvider + .notifier, + ) + .setSortPinnedFirst( + !filterOptions + .sortPinnedFirst, + ); + }, + child: const Text('Sort Pinned First'), + ), + ], + child: const Text('Sort'), + ), + const Divider(), + // Date range picker + MenuItemButton( + closeOnActivate: false, + leadingIcon: const Icon(MdiIcons.calendarRange), + trailingIcon: filterOptions.dateRange != null + ? IconButton( + onPressed: () { + ref + .read( + tabViewFilterControllerProvider + .notifier, + ) + .setDateRange(null); + }, + icon: const Icon(Icons.clear), + ) + : null, + child: filterOptions.dateRange != null + ? Text( + '${DateFormat.yMd().format(filterOptions.dateRange!.start)} - ${DateFormat.yMd().format(filterOptions.dateRange!.end)}', + ) + : const Text('Filter Date'), + onPressed: () async { + final range = await showDateRangePicker( + context: context, + initialDateRange: filterOptions.dateRange, + firstDate: DateTime.now().subtract( + const Duration(days: 365), + ), + lastDate: DateTime.now(), + ); + if (range != null) { + ref + .read( + tabViewFilterControllerProvider + .notifier, + ) + .setDateRange( + DateTimeRange( + start: range.start, + end: range.end.add( + const Duration(days: 1) - + const Duration( + milliseconds: 1, + ), + ), + ), + ); + } + }, + ), + // Quick intervals + SubmenuButton( + leadingIcon: const Icon(MdiIcons.clockOutline), + menuChildren: TabQuickInterval.values + .map( + (interval) => MenuItemButton( + leadingIcon: Icon( + filterOptions.quickInterval == + interval + ? Icons.radio_button_checked + : Icons.radio_button_unchecked, + ), + child: Text(interval.label), + onPressed: () { + ref + .read( + tabViewFilterControllerProvider + .notifier, + ) + .setQuickInterval( + filterOptions.quickInterval == + interval + ? null + : interval, + ); + }, + ), + ) + .toList(), + child: const Text('Quick Interval'), + ), + const Divider(), + // Reset + MenuItemButton( + leadingIcon: const Icon(MdiIcons.restore), + child: const Text('Reset Filter'), + onPressed: () { + ref + .read( + tabViewFilterControllerProvider + .notifier, + ) + .reset(); + }, + ), + ], + child: IconButton( + tooltip: 'Filter & Sort', + onPressed: () { + if (filterMenuController.isOpen) { + filterMenuController.close(); + } else { + filterMenuController.open(); + } + }, + icon: Badge( + isLabelVisible: hasActiveFilter, + child: const Icon(MdiIcons.filter, size: 18), + ), + ), + ); + }, + ), + const Spacer(), + MenuAnchor( + controller: viewModeMenuController, + menuChildren: TabsViewMode.values + .map( + (mode) => MenuItemButton( + leadingIcon: Icon(mode.icon), + child: Text(mode.label), + onPressed: () { + if (mode == TabsViewMode.tree) { + searchTextController.clear(); + searchMode.value = false; + } + ref .read( tabsViewModeControllerProvider.notifier, ) - .set(TabsViewMode.list); - } + .set(mode); + }, + ), + ) + .toList(), + child: IconButton( + tooltip: 'Change view mode', + onPressed: isSyncedScope + ? null + : () { + if (viewModeMenuController.isOpen) { + viewModeMenuController.close(); + } else { + viewModeMenuController.open(); + } + }, + icon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(tabsViewMode.icon, size: 18), + const Icon(Icons.arrow_drop_down, size: 18), + ], + ), + ), + ), + if (enableAiFeatures && + switch (tabsViewMode) { + TabsViewMode.list || TabsViewMode.grid => true, + TabsViewMode.tree => false, + }) + Consumer( + builder: (context, ref, child) { + final tabSuggestionsEnabled = ref.watch( + persistedBoolProvider( + PersistedBoolKey.tabSuggestions, + ), + ); + final downloadProgress = ref.watch( + mlDownloadStateProvider, + ); - searchMode.value = true; - searchTextFocus.requestFocus(); - }, - ), - const SizedBox( - height: 32, - child: VerticalDivider(indent: 4, endIndent: 4), - ), - MenuAnchor( - controller: viewModeMenuController, - menuChildren: TabsViewMode.values - .map( - (mode) => MenuItemButton( - leadingIcon: Icon(mode.icon), - child: Text(mode.label), - onPressed: () { + return Badge( + isLabelVisible: downloadProgress != null, + offset: const Offset(-2, 2), + label: downloadProgress != null + ? Text( + '${downloadProgress.progress.toInt()}%', + style: const TextStyle(fontSize: 10), + ) + : null, + child: IconButton.filledTonal( + icon: const Icon(MdiIcons.imageAutoAdjust), + isSelected: tabSuggestionsEnabled, + iconSize: 18, + padding: EdgeInsets.zero, + tooltip: downloadProgress != null + ? 'Downloading AI models (${downloadProgress.progress.toInt()}%)' + : tabSuggestionsEnabled + ? 'Disable AI tab suggestions' + : 'Enable AI tab suggestions', + onPressed: () async { + if (!tabSuggestionsEnabled) { + final result = + await showEnableAiTabSuggestionsDialog( + context, + ); + + if (result == true) { ref .read( - tabsViewModeControllerProvider - .notifier, + persistedBoolProvider( + PersistedBoolKey.tabSuggestions, + ).notifier, ) - .set(mode); - }, - ), - ) - .toList(), - child: IconButton( - tooltip: 'Change view mode', - onPressed: isSyncedScope - ? null - : () { - if (viewModeMenuController.isOpen) { - viewModeMenuController.close(); - } else { - viewModeMenuController.open(); - } - }, - icon: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(tabsViewMode.icon, size: 18), - const Icon(Icons.arrow_drop_down, size: 18), - ], - ), - ), - ), - if (enableAiFeatures && - switch (tabsViewMode) { - TabsViewMode.list || TabsViewMode.grid => true, - TabsViewMode.tree => false, - }) - Consumer( - builder: (context, ref, child) { - final tabSuggestionsEnabled = ref.watch( - persistedBoolProvider(PersistedBoolKey.tabSuggestions), - ); - final downloadProgress = ref.watch( - mlDownloadStateProvider, - ); - - return Badge( - isLabelVisible: downloadProgress != null, - offset: const Offset(-2, 2), - label: downloadProgress != null - ? Text( - '${downloadProgress.progress.toInt()}%', - style: const TextStyle(fontSize: 10), + .set(true); + } + } else { + ref + .read( + persistedBoolProvider( + PersistedBoolKey.tabSuggestions, + ).notifier, ) - : null, - child: IconButton.filledTonal( - icon: const Icon(MdiIcons.imageAutoAdjust), - isSelected: tabSuggestionsEnabled, - iconSize: 18, - padding: EdgeInsets.zero, - tooltip: downloadProgress != null - ? 'Downloading AI models (${downloadProgress.progress.toInt()}%)' - : tabSuggestionsEnabled - ? 'Disable AI tab suggestions' - : 'Enable AI tab suggestions', - onPressed: () async { - if (!tabSuggestionsEnabled) { + .set(false); + } + }, + ), + ); + }, + ), + if (switch (tabsViewMode) { + TabsViewMode.list || TabsViewMode.grid => true, + TabsViewMode.tree => false, + }) + IconButton.filledTonal( + icon: const Icon(Icons.swap_vert), + isSelected: tabsReorderable, + iconSize: 18, + padding: EdgeInsets.zero, + tooltip: tabsReorderable + ? 'Disable reordering mode' + : canManualReorder + ? 'Enable reordering mode' + : 'Reordering requires default manual mode', + onPressed: canManualReorder + ? () { + final wasEnabled = tabsReorderable; + + ref + .read( + tabsReorderableControllerProvider + .notifier, + ) + .toggle(); + + // Show info when enabling reordering + if (!wasEnabled && context.mounted) { + ui_helper.showInfoMessage( + context, + 'Drag and drop tabs to reorder them', + ); + } + } + : null, + ), + MenuAnchor( + controller: tabsActionMenuController, + menuChildren: [ + SubmenuButton( + leadingIcon: const Icon(MdiIcons.closeCircle), + menuChildren: [ + MenuItemButton( + leadingIcon: const Icon(MdiIcons.closeCircle), + onPressed: isSyncedScope + ? null + : () async { final result = - await showEnableAiTabSuggestionsDialog( + await showCloseAllTabsDialog(context); + + if (result == true) { + final count = await ref + .read( + tabDataRepositoryProvider + .notifier, + ) + .closeContainerTabs( + selectedContainerId, + ); + + if (context.mounted) { + ui_helper.showTabUndoClose( + context, + ref + .read( + tabRepositoryProvider + .notifier, + ) + .undoClose, + count: count.length, + ); + } + } + }, + child: const Text('All Tabs'), + ), + MenuItemButton( + leadingIcon: Icon( + MdiIcons.dominoMask, + color: AppColors.of(context).privateTabPurple, + ), + onPressed: isSyncedScope + ? null + : () async { + final result = + await showCloseAllPrivateTabsDialog( context, ); if (result == true) { - ref + final count = await ref .read( - persistedBoolProvider(PersistedBoolKey.tabSuggestions) + tabDataRepositoryProvider .notifier, ) - .set(true); - } - } else { - ref - .read( - persistedBoolProvider(PersistedBoolKey.tabSuggestions) - .notifier, - ) - .set(false); - } - }, - ), - ); - }, - ), - if (switch (tabsViewMode) { - TabsViewMode.list || TabsViewMode.grid => true, - TabsViewMode.tree => false, - }) - Consumer( - builder: (context, ref, child) { - final tabsReorderabe = ref.watch( - tabsReorderableControllerProvider, - ); + .closeContainerTabs( + selectedContainerId, + includeRegular: false, + includeIsolated: false, + ); - return IconButton.filledTonal( - icon: const Icon(Icons.swap_vert), - isSelected: tabsReorderabe, - iconSize: 18, - padding: EdgeInsets.zero, - tooltip: tabsReorderabe - ? 'Disable reordering mode' - : 'Enable reordering mode', - onPressed: () { - final wasEnabled = tabsReorderabe; - - ref - .read( - tabsReorderableControllerProvider - .notifier, - ) - .toggle(); - - // Show info when enabling reordering - if (!wasEnabled && context.mounted) { - ui_helper.showInfoMessage( - context, - 'Drag and drop tabs to reorder them', - ); - } - }, - ); - }, - ), - ], - ), - MenuAnchor( - controller: tabsActionMenuController, - menuChildren: [ - MenuItemButton( - leadingIcon: const Icon(MdiIcons.closeCircle), - onPressed: isSyncedScope - ? null - : () async { - final result = await showCloseAllTabsDialog( - context, - ); - - if (result == true) { - final count = await ref - .read( - tabDataRepositoryProvider.notifier, - ) - .closeContainerTabs( - selectedContainerId, - ); - - if (context.mounted) { - ui_helper.showTabUndoClose( - context, - ref - .read( - tabRepositoryProvider.notifier, - ) - .undoClose, - count: count.length, - ); - } - } - }, - child: const Text('Close All Tabs'), - ), - MenuItemButton( - leadingIcon: const Icon(MdiIcons.incognitoCircleOff), - onPressed: isSyncedScope - ? null - : () async { - final result = - await showCloseAllPrivateTabsDialog( - context, - ); - - if (result == true) { - final count = await ref - .read( - tabDataRepositoryProvider.notifier, - ) - .closeContainerTabs( - selectedContainerId, - includeRegular: false, - includeIsolated: false, - ); - - if (context.mounted) { - ui_helper.showTabUndoClose( - context, - ref - .read( - tabRepositoryProvider.notifier, - ) - .undoClose, - count: count.length, - ); - } - } - }, - child: const Text('Close Private Tabs'), - ), - if (showIsolatedTabUi) - MenuItemButton( - leadingIcon: Icon( - MdiIcons.snowflake, - color: AppColors.of(context).isolatedTabTeal, - ), - onPressed: isSyncedScope - ? null - : () async { - // Count distinct isolation groups that will be destroyed - final allStates = ref.read( - tabStatesProvider, - ); - final isolatedContextIds = allStates.values - .where( - (s) => - s.tabMode is IsolatedTabMode && - s.isolationContextId != null, - ) - .map((s) => s.isolationContextId!) - .toSet(); - - if (isolatedContextIds.isNotEmpty && - context.mounted) { - final confirmed = await ui_helper - .confirmIsolatedTabClose( + if (context.mounted) { + ui_helper.showTabUndoClose( context, - groupCount: - isolatedContextIds.length, + ref + .read( + tabRepositoryProvider + .notifier, + ) + .undoClose, + count: count.length, ); - if (!confirmed) return; - } - - final count = await ref - .read( - tabDataRepositoryProvider.notifier, - ) - .closeContainerTabs( - selectedContainerId, - includeRegular: false, - includePrivate: false, + } + } + }, + child: const Text('Private Tabs'), + ), + if (showIsolatedTabUi) + MenuItemButton( + leadingIcon: Icon( + MdiIcons.snowflake, + color: AppColors.of(context).isolatedTabTeal, + ), + onPressed: isSyncedScope + ? null + : () async { + // Count distinct isolation groups that will be destroyed + final allStates = ref.read( + tabStatesProvider, ); + final isolatedContextIds = allStates + .values + .where( + (s) => + s.tabMode + is IsolatedTabMode && + s.isolationContextId != null, + ) + .map((s) => s.isolationContextId!) + .toSet(); - if (context.mounted) { - ui_helper.showTabUndoClose( - context, - ref + if (isolatedContextIds.isNotEmpty && + context.mounted) { + final confirmed = await ui_helper + .confirmIsolatedTabClose( + context, + groupCount: + isolatedContextIds.length, + ); + if (!confirmed) return; + } + + final count = await ref + .read( + tabDataRepositoryProvider + .notifier, + ) + .closeContainerTabs( + selectedContainerId, + includeRegular: false, + includePrivate: false, + ); + + if (context.mounted) { + ui_helper.showTabUndoClose( + context, + ref + .read( + tabRepositoryProvider + .notifier, + ) + .undoClose, + count: count.length, + ); + } + }, + child: const Text('Isolated Tabs'), + ), + if (tabsViewMode != TabsViewMode.tree) + MenuItemButton( + leadingIcon: const Icon(MdiIcons.filterOutline), + onPressed: + isSyncedScope || + !ref + .read( + tabViewFilterControllerProvider, + ) + .hasActiveFilter + ? null + : () async { + final filteredIds = await ref + .read( + tabDataRepositoryProvider + .notifier, + ) + .getFilteredTabIds( + selectedContainerId, + ); + + if (filteredIds.isEmpty) return; + + // Check for isolated tabs + final allStates = ref.read( + tabStatesProvider, + ); + final isolatedContextIds = filteredIds + .map((id) => allStates[id]) + .where( + (s) => + s != null && + s.tabMode + is IsolatedTabMode && + s.isolationContextId != null, + ) + .map((s) => s!.isolationContextId!) + .toSet(); + + if (isolatedContextIds.isNotEmpty && + context.mounted) { + final confirmed = await ui_helper + .confirmIsolatedTabClose( + context, + groupCount: + isolatedContextIds.length, + ); + if (!confirmed) return; + } + + await ref .read( tabRepositoryProvider.notifier, ) - .undoClose, - count: count.length, - ); - } - }, - child: const Text('Close Isolated Tabs'), - ), - const Divider(), + .closeTabs(filteredIds); + + if (context.mounted) { + ui_helper.showTabUndoClose( + context, + ref + .read( + tabRepositoryProvider + .notifier, + ) + .undoClose, + count: filteredIds.length, + ); + } + }, + child: const Text('Filtered Tabs'), + ), + ], + child: const Text('Close Tabs'), + ), MenuItemButton( leadingIcon: const Icon(MdiIcons.bookmarkPlusOutline), onPressed: isSyncedScope @@ -799,11 +1129,10 @@ class TabViewHeader extends HookConsumerWidget { TextField( controller: searchTextController, focusNode: searchTextFocus, - // enableIMEPersonalizedLearning: !incognitoEnabled, decoration: InputDecoration( - // border: InputBorder.none, + border: InputBorder.none, prefixIcon: const Icon(MdiIcons.tabSearch, size: 18), - hintText: 'Search inside tabs...', + hintText: 'Search tabs', floatingLabelBehavior: FloatingLabelBehavior.always, suffixIcon: Row( mainAxisSize: MainAxisSize.min, @@ -816,9 +1145,12 @@ class TabViewHeader extends HookConsumerWidget { ), IconButton( onPressed: () { - searchTextController.clear(); - searchTextFocus.requestFocus(); - searchMode.value = false; + if (searchTextController.text.isNotEmpty) { + searchTextController.clear(); + searchTextFocus.requestFocus(); + } else { + searchMode.value = false; + } }, icon: const Icon(Icons.clear), ), @@ -826,6 +1158,7 @@ class TabViewHeader extends HookConsumerWidget { ), ), ), + const Divider(), if (showContainerUi) Consumer( builder: (context, ref, child) { diff --git a/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/full_search_suggestions.dart b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/full_search_suggestions.dart index d42dfb27..c1402f64 100644 --- a/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/full_search_suggestions.dart +++ b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/full_search_suggestions.dart @@ -58,7 +58,9 @@ class FullSearchTermSuggestions extends HookConsumerWidget { final searchSuggestions = ref.watch(searchSuggestionsProvider()); final searchHistory = ref.watch(searchHistoryProvider); - final expanded = ref.watch(persistedBoolProvider(PersistedBoolKey.searchSuggestionsExpanded)); + final expanded = ref.watch( + persistedBoolProvider(PersistedBoolKey.searchSuggestionsExpanded), + ); useOnListenableChange(searchTextController, () { ref @@ -111,7 +113,11 @@ class FullSearchTermSuggestions extends HookConsumerWidget { padding: const EdgeInsets.only(top: 6.0), child: IconButton( onPressed: ref - .read(persistedBoolProvider(PersistedBoolKey.searchSuggestionsExpanded).notifier) + .read( + persistedBoolProvider( + PersistedBoolKey.searchSuggestionsExpanded, + ).notifier, + ) .toggle, icon: Icon(expanded ? Icons.unfold_less : Icons.unfold_more), ), diff --git a/app/lib/features/geckoview/features/tabs/data/database/daos/tab.dart b/app/lib/features/geckoview/features/tabs/data/database/daos/tab.dart index c5c4e408..8070535a 100644 --- a/app/lib/features/geckoview/features/tabs/data/database/daos/tab.dart +++ b/app/lib/features/geckoview/features/tabs/data/database/daos/tab.dart @@ -128,6 +128,14 @@ class TabDao extends DatabaseAccessor with $TabDaoMixin { ); } + Selectable> getTabOrderKeys() { + final query = selectOnly(db.tab)..addColumns([db.tab.id, db.tab.orderKey]); + + return query.map( + (row) => MapEntry(row.read(db.tab.id)!, row.read(db.tab.orderKey)!), + ); + } + Future _generateOrderKey({ required Value parentId, required Value containerId, @@ -463,6 +471,27 @@ class TabDao extends DatabaseAccessor with $TabDaoMixin { return db.definitionsDrift.isolatedContextContainerPairs(); } + Future setPinned(String id, {required bool pinned}) { + final statement = _updateByIdStatement(id); + return statement.write(TabCompanion(isPinned: Value(pinned))); + } + + Selectable getPinnedTabIds() { + final query = selectOnly(db.tab) + ..addColumns([db.tab.id]) + ..where(db.tab.isPinned.equals(true)); + + return query.map((row) => row.read(db.tab.id)!); + } + + Selectable> getTabTimestamps() { + final query = selectOnly(db.tab)..addColumns([db.tab.id, db.tab.timestamp]); + + return query.map( + (row) => MapEntry(row.read(db.tab.id)!, row.read(db.tab.timestamp)!), + ); + } + Future> getUnassignedTabsOlderThan(DateTime threshold) { final query = selectOnly(db.tab) ..addColumns([db.tab.id]) diff --git a/app/lib/features/geckoview/features/tabs/data/database/database.dart b/app/lib/features/geckoview/features/tabs/data/database/database.dart index 1992efbd..8c752cc5 100644 --- a/app/lib/features/geckoview/features/tabs/data/database/database.dart +++ b/app/lib/features/geckoview/features/tabs/data/database/database.dart @@ -31,7 +31,7 @@ import 'package:weblibre/features/search/domain/fts_tokenizer.dart'; @DriftDatabase(include: {'definitions.drift'}, daos: [ContainerDao, TabDao]) class TabDatabase extends $TabDatabase with TrigramQueryBuilderMixin { @override - final int schemaVersion = 6; + final int schemaVersion = 7; @override final int ftsTokenLimit = 10; @@ -115,5 +115,8 @@ class TabDatabase extends $TabDatabase with TrigramQueryBuilderMixin { await m.alterTable(TableMigration(schema.tab)); }, + from6To7: (m, schema) async { + await m.addColumn(schema.tab, schema.tab.isPinned); + }, ); } diff --git a/app/lib/features/geckoview/features/tabs/data/database/database.steps.dart b/app/lib/features/geckoview/features/tabs/data/database/database.steps.dart index b27dc10c..fa115a95 100644 --- a/app/lib/features/geckoview/features/tabs/data/database/database.steps.dart +++ b/app/lib/features/geckoview/features/tabs/data/database/database.steps.dart @@ -594,11 +594,140 @@ i1.GeneratedColumn _column_18(String aliasedName) => type: i1.DriftSqlType.string, $customConstraints: '', ); + +final class Schema7 extends i0.VersionedSchema { + Schema7({required super.database}) : super(version: 7); + @override + late final List entities = [ + container, + tab, + tabFts, + tabMaintainParentChainOnDelete, + tabAfterInsert, + tabAfterDelete, + tabAfterUpdate, + ]; + late final Shape0 container = Shape0( + source: i0.VersionedTable( + entityName: 'container', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [_column_0, _column_1, _column_2, _column_3], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape5 tab = Shape5( + source: i0.VersionedTable( + entityName: 'tab', + withoutRowId: false, + isStrict: false, + tableConstraints: [ + 'CHECK((tab_mode = 2 AND isolation_context_id IS NOT NULL)OR(tab_mode != 2 AND isolation_context_id IS NULL))', + ], + columns: [ + _column_0, + _column_16, + _column_4, + _column_5, + _column_6, + _column_7, + _column_8, + _column_17, + _column_18, + _column_19, + _column_10, + _column_11, + _column_12, + _column_13, + _column_14, + _column_15, + ], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape2 tabFts = Shape2( + source: i0.VersionedVirtualTable( + entityName: 'tab_fts', + moduleAndArgs: + 'fts5(title, url, extracted_content_plain, full_content_plain, content=tab, tokenize="trigram")', + columns: [_column_8, _column_7, _column_12, _column_14], + attachedDatabase: database, + ), + alias: null, + ); + final i1.Trigger tabMaintainParentChainOnDelete = i1.Trigger( + 'CREATE TRIGGER tab_maintain_parent_chain_on_delete BEFORE DELETE ON tab BEGIN UPDATE tab SET parent_id = CASE WHEN OLD.parent_id IS NOT NULL AND EXISTS (SELECT 1 FROM tab WHERE id = OLD.parent_id) THEN OLD.parent_id ELSE NULL END WHERE parent_id = OLD.id;END', + 'tab_maintain_parent_chain_on_delete', + ); + final i1.Trigger tabAfterInsert = i1.Trigger( + 'CREATE TRIGGER tab_after_insert AFTER INSERT ON tab BEGIN INSERT INTO tab_fts ("rowid", title, url, extracted_content_plain, full_content_plain) VALUES (new."rowid", new.title, new.url, new.extracted_content_plain, new.full_content_plain);END', + 'tab_after_insert', + ); + final i1.Trigger tabAfterDelete = i1.Trigger( + 'CREATE TRIGGER tab_after_delete AFTER DELETE ON tab BEGIN INSERT INTO tab_fts (tab_fts, "rowid", title, url, extracted_content_plain, full_content_plain) VALUES (\'delete\', old."rowid", old.title, old.url, old.extracted_content_plain, old.full_content_plain);END', + 'tab_after_delete', + ); + final i1.Trigger tabAfterUpdate = i1.Trigger( + 'CREATE TRIGGER tab_after_update AFTER UPDATE ON tab BEGIN INSERT INTO tab_fts (tab_fts, "rowid", title, url, extracted_content_plain, full_content_plain) VALUES (\'delete\', old."rowid", old.title, old.url, old.extracted_content_plain, old.full_content_plain);INSERT INTO tab_fts ("rowid", title, url, extracted_content_plain, full_content_plain) VALUES (new."rowid", new.title, new.url, new.extracted_content_plain, new.full_content_plain);END', + 'tab_after_update', + ); +} + +class Shape5 extends i0.VersionedTable { + Shape5({required super.source, required super.alias}) : super.aliased(); + i1.GeneratedColumn get id => + columnsByName['id']! as i1.GeneratedColumn; + i1.GeneratedColumn get source => + columnsByName['source']! as i1.GeneratedColumn; + i1.GeneratedColumn get parentId => + columnsByName['parent_id']! as i1.GeneratedColumn; + i1.GeneratedColumn get containerId => + columnsByName['container_id']! as i1.GeneratedColumn; + i1.GeneratedColumn get orderKey => + columnsByName['order_key']! as i1.GeneratedColumn; + i1.GeneratedColumn get url => + columnsByName['url']! as i1.GeneratedColumn; + i1.GeneratedColumn get title => + columnsByName['title']! as i1.GeneratedColumn; + i1.GeneratedColumn get tabMode => + columnsByName['tab_mode']! as i1.GeneratedColumn; + i1.GeneratedColumn get isolationContextId => + columnsByName['isolation_context_id']! as i1.GeneratedColumn; + i1.GeneratedColumn get isPinned => + columnsByName['is_pinned']! as i1.GeneratedColumn; + i1.GeneratedColumn get isProbablyReaderable => + columnsByName['is_probably_readerable']! as i1.GeneratedColumn; + i1.GeneratedColumn get extractedContentMarkdown => + columnsByName['extracted_content_markdown']! + as i1.GeneratedColumn; + i1.GeneratedColumn get extractedContentPlain => + columnsByName['extracted_content_plain']! as i1.GeneratedColumn; + i1.GeneratedColumn get fullContentMarkdown => + columnsByName['full_content_markdown']! as i1.GeneratedColumn; + i1.GeneratedColumn get fullContentPlain => + columnsByName['full_content_plain']! as i1.GeneratedColumn; + i1.GeneratedColumn get timestamp => + columnsByName['timestamp']! as i1.GeneratedColumn; +} + +i1.GeneratedColumn _column_19(String aliasedName) => + i1.GeneratedColumn( + 'is_pinned', + aliasedName, + false, + type: i1.DriftSqlType.int, + $customConstraints: 'NOT NULL DEFAULT 0', + defaultValue: const i1.CustomExpression('0'), + ); i0.MigrationStepWithVersion migrationSteps({ required Future Function(i1.Migrator m, Schema3 schema) from2To3, required Future Function(i1.Migrator m, Schema4 schema) from3To4, required Future Function(i1.Migrator m, Schema5 schema) from4To5, required Future Function(i1.Migrator m, Schema6 schema) from5To6, + required Future Function(i1.Migrator m, Schema7 schema) from6To7, }) { return (currentVersion, database) async { switch (currentVersion) { @@ -622,6 +751,11 @@ i0.MigrationStepWithVersion migrationSteps({ final migrator = i1.Migrator(database, schema); await from5To6(migrator, schema); return 6; + case 6: + final schema = Schema7(database: database); + final migrator = i1.Migrator(database, schema); + await from6To7(migrator, schema); + return 7; default: throw ArgumentError.value('Unknown migration from $currentVersion'); } @@ -633,11 +767,13 @@ i1.OnUpgrade stepByStep({ required Future Function(i1.Migrator m, Schema4 schema) from3To4, required Future Function(i1.Migrator m, Schema5 schema) from4To5, required Future Function(i1.Migrator m, Schema6 schema) from5To6, + required Future Function(i1.Migrator m, Schema7 schema) from6To7, }) => i0.VersionedSchema.stepByStepHelper( step: migrationSteps( from2To3: from2To3, from3To4: from3To4, from4To5: from4To5, from5To6: from5To6, + from6To7: from6To7, ), ); diff --git a/app/lib/features/geckoview/features/tabs/data/database/definitions.drift b/app/lib/features/geckoview/features/tabs/data/database/definitions.drift index 585843a8..8949ceda 100644 --- a/app/lib/features/geckoview/features/tabs/data/database/definitions.drift +++ b/app/lib/features/geckoview/features/tabs/data/database/definitions.drift @@ -24,6 +24,7 @@ CREATE TABLE tab( title TEXT, tab_mode ENUM(TabModeDbValue) NOT NULL DEFAULT 0, isolation_context_id TEXT, + is_pinned BOOL NOT NULL DEFAULT 0, is_probably_readerable BOOL, extracted_content_markdown TEXT, extracted_content_plain TEXT, diff --git a/app/lib/features/geckoview/features/tabs/data/database/definitions.drift.dart b/app/lib/features/geckoview/features/tabs/data/database/definitions.drift.dart index 927a7245..f5287f2f 100644 --- a/app/lib/features/geckoview/features/tabs/data/database/definitions.drift.dart +++ b/app/lib/features/geckoview/features/tabs/data/database/definitions.drift.dart @@ -333,6 +333,7 @@ typedef $TabCreateCompanionBuilder = i0.Value title, i0.Value tabMode, i0.Value isolationContextId, + i0.Value isPinned, i0.Value isProbablyReaderable, i0.Value extractedContentMarkdown, i0.Value extractedContentPlain, @@ -352,6 +353,7 @@ typedef $TabUpdateCompanionBuilder = i0.Value title, i0.Value tabMode, i0.Value isolationContextId, + i0.Value isPinned, i0.Value isProbablyReaderable, i0.Value extractedContentMarkdown, i0.Value extractedContentPlain, @@ -445,6 +447,11 @@ class $TabFilterComposer extends i0.Composer { builder: (column) => i0.ColumnFilters(column), ); + i0.ColumnFilters get isPinned => $composableBuilder( + column: $table.isPinned, + builder: (column) => i0.ColumnFilters(column), + ); + i0.ColumnFilters get isProbablyReaderable => $composableBuilder( column: $table.isProbablyReaderable, builder: (column) => i0.ColumnFilters(column), @@ -551,6 +558,11 @@ class $TabOrderingComposer extends i0.Composer { builder: (column) => i0.ColumnOrderings(column), ); + i0.ColumnOrderings get isPinned => $composableBuilder( + column: $table.isPinned, + builder: (column) => i0.ColumnOrderings(column), + ); + i0.ColumnOrderings get isProbablyReaderable => $composableBuilder( column: $table.isProbablyReaderable, builder: (column) => i0.ColumnOrderings(column), @@ -643,6 +655,9 @@ class $TabAnnotationComposer extends i0.Composer { builder: (column) => column, ); + i0.GeneratedColumn get isPinned => + $composableBuilder(column: $table.isPinned, builder: (column) => column); + i0.GeneratedColumn get isProbablyReaderable => $composableBuilder( column: $table.isProbablyReaderable, builder: (column) => column, @@ -736,6 +751,7 @@ class $TabTableManager i0.Value title = const i0.Value.absent(), i0.Value tabMode = const i0.Value.absent(), i0.Value isolationContextId = const i0.Value.absent(), + i0.Value isPinned = const i0.Value.absent(), i0.Value isProbablyReaderable = const i0.Value.absent(), i0.Value extractedContentMarkdown = const i0.Value.absent(), @@ -755,6 +771,7 @@ class $TabTableManager title: title, tabMode: tabMode, isolationContextId: isolationContextId, + isPinned: isPinned, isProbablyReaderable: isProbablyReaderable, extractedContentMarkdown: extractedContentMarkdown, extractedContentPlain: extractedContentPlain, @@ -774,6 +791,7 @@ class $TabTableManager i0.Value title = const i0.Value.absent(), i0.Value tabMode = const i0.Value.absent(), i0.Value isolationContextId = const i0.Value.absent(), + i0.Value isPinned = const i0.Value.absent(), i0.Value isProbablyReaderable = const i0.Value.absent(), i0.Value extractedContentMarkdown = const i0.Value.absent(), @@ -793,6 +811,7 @@ class $TabTableManager title: title, tabMode: tabMode, isolationContextId: isolationContextId, + isPinned: isPinned, isProbablyReaderable: isProbablyReaderable, extractedContentMarkdown: extractedContentMarkdown, extractedContentPlain: extractedContentPlain, @@ -1312,6 +1331,15 @@ class Tab extends i0.Table with i0.TableInfo { requiredDuringInsert: false, $customConstraints: '', ); + late final i0.GeneratedColumn isPinned = i0.GeneratedColumn( + 'is_pinned', + aliasedName, + false, + type: i0.DriftSqlType.bool, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0', + defaultValue: const i0.CustomExpression('0'), + ); late final i0.GeneratedColumn isProbablyReaderable = i0.GeneratedColumn( 'is_probably_readerable', @@ -1377,6 +1405,7 @@ class Tab extends i0.Table with i0.TableInfo { title, tabMode, isolationContextId, + isPinned, isProbablyReaderable, extractedContentMarkdown, extractedContentPlain, @@ -1437,6 +1466,10 @@ class Tab extends i0.Table with i0.TableInfo { i0.DriftSqlType.string, data['${effectivePrefix}isolation_context_id'], ), + isPinned: attachedDatabase.typeMapping.read( + i0.DriftSqlType.bool, + data['${effectivePrefix}is_pinned'], + )!, isProbablyReaderable: attachedDatabase.typeMapping.read( i0.DriftSqlType.bool, data['${effectivePrefix}is_probably_readerable'], @@ -1493,6 +1526,7 @@ class TabData extends i0.DataClass implements i0.Insertable { final String? title; final i7.TabModeDbValue tabMode; final String? isolationContextId; + final bool isPinned; final bool? isProbablyReaderable; final String? extractedContentMarkdown; final String? extractedContentPlain; @@ -1509,6 +1543,7 @@ class TabData extends i0.DataClass implements i0.Insertable { this.title, required this.tabMode, this.isolationContextId, + required this.isPinned, this.isProbablyReaderable, this.extractedContentMarkdown, this.extractedContentPlain, @@ -1544,6 +1579,7 @@ class TabData extends i0.DataClass implements i0.Insertable { if (!nullToAbsent || isolationContextId != null) { map['isolation_context_id'] = i0.Variable(isolationContextId); } + map['is_pinned'] = i0.Variable(isPinned); if (!nullToAbsent || isProbablyReaderable != null) { map['is_probably_readerable'] = i0.Variable(isProbablyReaderable); } @@ -1588,6 +1624,7 @@ class TabData extends i0.DataClass implements i0.Insertable { isolationContextId: serializer.fromJson( json['isolation_context_id'], ), + isPinned: serializer.fromJson(json['is_pinned']), isProbablyReaderable: serializer.fromJson( json['is_probably_readerable'], ), @@ -1621,6 +1658,7 @@ class TabData extends i0.DataClass implements i0.Insertable { i3.Tab.$convertertabMode.toJson(tabMode), ), 'isolation_context_id': serializer.toJson(isolationContextId), + 'is_pinned': serializer.toJson(isPinned), 'is_probably_readerable': serializer.toJson(isProbablyReaderable), 'extracted_content_markdown': serializer.toJson( extractedContentMarkdown, @@ -1644,6 +1682,7 @@ class TabData extends i0.DataClass implements i0.Insertable { i0.Value title = const i0.Value.absent(), i7.TabModeDbValue? tabMode, i0.Value isolationContextId = const i0.Value.absent(), + bool? isPinned, i0.Value isProbablyReaderable = const i0.Value.absent(), i0.Value extractedContentMarkdown = const i0.Value.absent(), i0.Value extractedContentPlain = const i0.Value.absent(), @@ -1662,6 +1701,7 @@ class TabData extends i0.DataClass implements i0.Insertable { isolationContextId: isolationContextId.present ? isolationContextId.value : this.isolationContextId, + isPinned: isPinned ?? this.isPinned, isProbablyReaderable: isProbablyReaderable.present ? isProbablyReaderable.value : this.isProbablyReaderable, @@ -1694,6 +1734,7 @@ class TabData extends i0.DataClass implements i0.Insertable { isolationContextId: data.isolationContextId.present ? data.isolationContextId.value : this.isolationContextId, + isPinned: data.isPinned.present ? data.isPinned.value : this.isPinned, isProbablyReaderable: data.isProbablyReaderable.present ? data.isProbablyReaderable.value : this.isProbablyReaderable, @@ -1725,6 +1766,7 @@ class TabData extends i0.DataClass implements i0.Insertable { ..write('title: $title, ') ..write('tabMode: $tabMode, ') ..write('isolationContextId: $isolationContextId, ') + ..write('isPinned: $isPinned, ') ..write('isProbablyReaderable: $isProbablyReaderable, ') ..write('extractedContentMarkdown: $extractedContentMarkdown, ') ..write('extractedContentPlain: $extractedContentPlain, ') @@ -1746,6 +1788,7 @@ class TabData extends i0.DataClass implements i0.Insertable { title, tabMode, isolationContextId, + isPinned, isProbablyReaderable, extractedContentMarkdown, extractedContentPlain, @@ -1766,6 +1809,7 @@ class TabData extends i0.DataClass implements i0.Insertable { other.title == this.title && other.tabMode == this.tabMode && other.isolationContextId == this.isolationContextId && + other.isPinned == this.isPinned && other.isProbablyReaderable == this.isProbablyReaderable && other.extractedContentMarkdown == this.extractedContentMarkdown && other.extractedContentPlain == this.extractedContentPlain && @@ -1784,6 +1828,7 @@ class TabCompanion extends i0.UpdateCompanion { final i0.Value title; final i0.Value tabMode; final i0.Value isolationContextId; + final i0.Value isPinned; final i0.Value isProbablyReaderable; final i0.Value extractedContentMarkdown; final i0.Value extractedContentPlain; @@ -1801,6 +1846,7 @@ class TabCompanion extends i0.UpdateCompanion { this.title = const i0.Value.absent(), this.tabMode = const i0.Value.absent(), this.isolationContextId = const i0.Value.absent(), + this.isPinned = const i0.Value.absent(), this.isProbablyReaderable = const i0.Value.absent(), this.extractedContentMarkdown = const i0.Value.absent(), this.extractedContentPlain = const i0.Value.absent(), @@ -1819,6 +1865,7 @@ class TabCompanion extends i0.UpdateCompanion { this.title = const i0.Value.absent(), this.tabMode = const i0.Value.absent(), this.isolationContextId = const i0.Value.absent(), + this.isPinned = const i0.Value.absent(), this.isProbablyReaderable = const i0.Value.absent(), this.extractedContentMarkdown = const i0.Value.absent(), this.extractedContentPlain = const i0.Value.absent(), @@ -1840,6 +1887,7 @@ class TabCompanion extends i0.UpdateCompanion { i0.Expression? title, i0.Expression? tabMode, i0.Expression? isolationContextId, + i0.Expression? isPinned, i0.Expression? isProbablyReaderable, i0.Expression? extractedContentMarkdown, i0.Expression? extractedContentPlain, @@ -1859,6 +1907,7 @@ class TabCompanion extends i0.UpdateCompanion { if (tabMode != null) 'tab_mode': tabMode, if (isolationContextId != null) 'isolation_context_id': isolationContextId, + if (isPinned != null) 'is_pinned': isPinned, if (isProbablyReaderable != null) 'is_probably_readerable': isProbablyReaderable, if (extractedContentMarkdown != null) @@ -1883,6 +1932,7 @@ class TabCompanion extends i0.UpdateCompanion { i0.Value? title, i0.Value? tabMode, i0.Value? isolationContextId, + i0.Value? isPinned, i0.Value? isProbablyReaderable, i0.Value? extractedContentMarkdown, i0.Value? extractedContentPlain, @@ -1901,6 +1951,7 @@ class TabCompanion extends i0.UpdateCompanion { title: title ?? this.title, tabMode: tabMode ?? this.tabMode, isolationContextId: isolationContextId ?? this.isolationContextId, + isPinned: isPinned ?? this.isPinned, isProbablyReaderable: isProbablyReaderable ?? this.isProbablyReaderable, extractedContentMarkdown: extractedContentMarkdown ?? this.extractedContentMarkdown, @@ -1949,6 +2000,9 @@ class TabCompanion extends i0.UpdateCompanion { isolationContextId.value, ); } + if (isPinned.present) { + map['is_pinned'] = i0.Variable(isPinned.value); + } if (isProbablyReaderable.present) { map['is_probably_readerable'] = i0.Variable( isProbablyReaderable.value, @@ -1993,6 +2047,7 @@ class TabCompanion extends i0.UpdateCompanion { ..write('title: $title, ') ..write('tabMode: $tabMode, ') ..write('isolationContextId: $isolationContextId, ') + ..write('isPinned: $isPinned, ') ..write('isProbablyReaderable: $isProbablyReaderable, ') ..write('extractedContentMarkdown: $extractedContentMarkdown, ') ..write('extractedContentPlain: $extractedContentPlain, ') diff --git a/app/lib/features/geckoview/features/tabs/data/entities/tab_entity.dart b/app/lib/features/geckoview/features/tabs/data/entities/tab_entity.dart index c1f7b4c6..7b2e07ca 100644 --- a/app/lib/features/geckoview/features/tabs/data/entities/tab_entity.dart +++ b/app/lib/features/geckoview/features/tabs/data/entities/tab_entity.dart @@ -21,39 +21,56 @@ import 'package:fast_equatable/fast_equatable.dart'; sealed class TabEntity with FastEquatable { String get tabId; + String get orderKey; } class DefaultTabEntity extends TabEntity { @override final String tabId; + @override + final String orderKey; final String? containerId; - DefaultTabEntity({required this.tabId, required this.containerId}); + DefaultTabEntity({ + required this.tabId, + required this.orderKey, + required this.containerId, + }); @override - List get hashParameters => [tabId, containerId]; + List get hashParameters => [tabId, orderKey, containerId]; } class SearchResultTabEntity extends TabEntity { @override final String tabId; + @override + final String orderKey; final String? containerId; final String searchQuery; SearchResultTabEntity({ required this.tabId, + required this.orderKey, required this.searchQuery, required this.containerId, }); @override - List get hashParameters => [tabId, searchQuery, containerId]; + List get hashParameters => [ + tabId, + orderKey, + searchQuery, + containerId, + ]; } class TabTreeEntity extends TabEntity { @override final String tabId; + @override + final String orderKey; final String? containerId; @@ -63,11 +80,18 @@ class TabTreeEntity extends TabEntity { TabTreeEntity({ required this.tabId, + required this.orderKey, required this.containerId, required this.rootId, required this.totalTabs, }); @override - List get hashParameters => [tabId, containerId, rootId, totalTabs]; + List get hashParameters => [ + tabId, + orderKey, + containerId, + rootId, + totalTabs, + ]; } diff --git a/app/lib/features/geckoview/features/tabs/domain/providers.dart b/app/lib/features/geckoview/features/tabs/domain/providers.dart index 3b9f1ebc..cc979e2b 100644 --- a/app/lib/features/geckoview/features/tabs/domain/providers.dart +++ b/app/lib/features/geckoview/features/tabs/domain/providers.dart @@ -115,6 +115,24 @@ Stream> watchContainerTabsData(Ref ref, String? containerId) { return db.containerDao.getContainerTabsData(containerId).watch(); } +@Riverpod(keepAlive: true) +Stream> watchPinnedTabIds(Ref ref) { + final db = ref.watch(tabDatabaseProvider); + return db.tabDao.getPinnedTabIds().watch().map((ids) => ids.toSet()); +} + +@Riverpod() +Stream> watchTabTimestamps(Ref ref) { + final db = ref.watch(tabDatabaseProvider); + return db.tabDao.getTabTimestamps().watch().map(Map.fromEntries); +} + +@Riverpod(keepAlive: true) +Stream> watchTabOrderKeys(Ref ref) { + final db = ref.watch(tabDatabaseProvider); + return db.tabDao.getTabOrderKeys().watch().map(Map.fromEntries); +} + @Riverpod() Stream watchContainerData(Ref ref, String containerId) { final db = ref.watch(tabDatabaseProvider); diff --git a/app/lib/features/geckoview/features/tabs/domain/providers.g.dart b/app/lib/features/geckoview/features/tabs/domain/providers.g.dart index 9d1e8949..de6f40fa 100644 --- a/app/lib/features/geckoview/features/tabs/domain/providers.g.dart +++ b/app/lib/features/geckoview/features/tabs/domain/providers.g.dart @@ -525,6 +525,128 @@ final class WatchContainerTabsDataFamily extends $Family String toString() => r'watchContainerTabsDataProvider'; } +@ProviderFor(watchPinnedTabIds) +final watchPinnedTabIdsProvider = WatchPinnedTabIdsProvider._(); + +final class WatchPinnedTabIdsProvider + extends + $FunctionalProvider< + AsyncValue>, + Set, + Stream> + > + with $FutureModifier>, $StreamProvider> { + WatchPinnedTabIdsProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'watchPinnedTabIdsProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$watchPinnedTabIdsHash(); + + @$internal + @override + $StreamProviderElement> $createElement( + $ProviderPointer pointer, + ) => $StreamProviderElement(pointer); + + @override + Stream> create(Ref ref) { + return watchPinnedTabIds(ref); + } +} + +String _$watchPinnedTabIdsHash() => r'5623faf1a4d90185c718654f4172092e28dd1e54'; + +@ProviderFor(watchTabTimestamps) +final watchTabTimestampsProvider = WatchTabTimestampsProvider._(); + +final class WatchTabTimestampsProvider + extends + $FunctionalProvider< + AsyncValue>, + Map, + Stream> + > + with + $FutureModifier>, + $StreamProvider> { + WatchTabTimestampsProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'watchTabTimestampsProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$watchTabTimestampsHash(); + + @$internal + @override + $StreamProviderElement> $createElement( + $ProviderPointer pointer, + ) => $StreamProviderElement(pointer); + + @override + Stream> create(Ref ref) { + return watchTabTimestamps(ref); + } +} + +String _$watchTabTimestampsHash() => + r'8b3eea3dded71795f80c607117978bbec1de7cff'; + +@ProviderFor(watchTabOrderKeys) +final watchTabOrderKeysProvider = WatchTabOrderKeysProvider._(); + +final class WatchTabOrderKeysProvider + extends + $FunctionalProvider< + AsyncValue>, + Map, + Stream> + > + with + $FutureModifier>, + $StreamProvider> { + WatchTabOrderKeysProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'watchTabOrderKeysProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$watchTabOrderKeysHash(); + + @$internal + @override + $StreamProviderElement> $createElement( + $ProviderPointer pointer, + ) => $StreamProviderElement(pointer); + + @override + Stream> create(Ref ref) { + return watchTabOrderKeys(ref); + } +} + +String _$watchTabOrderKeysHash() => r'675ef0d1b1c5445face8d6ab727507ca1a5e2c44'; + @ProviderFor(watchContainerData) final watchContainerDataProvider = WatchContainerDataFamily._(); diff --git a/app/lib/features/geckoview/features/tabs/domain/repositories/tab.dart b/app/lib/features/geckoview/features/tabs/domain/repositories/tab.dart index 0d000bb8..c6a4f066 100644 --- a/app/lib/features/geckoview/features/tabs/domain/repositories/tab.dart +++ b/app/lib/features/geckoview/features/tabs/domain/repositories/tab.dart @@ -22,6 +22,7 @@ import 'package:weblibre/features/geckoview/domain/entities/tab_container_select import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart'; 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/presentation/controllers/tab_view_controllers.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/database/definitions.drift.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart'; @@ -114,6 +115,13 @@ class TabDataRepository extends _$TabDataRepository { } } + Future setPinned(String tabId, {required bool pinned}) { + return ref + .read(tabDatabaseProvider) + .tabDao + .setPinned(tabId, pinned: pinned); + } + Future assignOrderKey(String tabId, String orderKey) { return ref .read(tabDatabaseProvider) @@ -235,6 +243,36 @@ class TabDataRepository extends _$TabDataRepository { ); } + Future> getFilteredTabIds(String? containerId) async { + final db = ref.read(tabDatabaseProvider); + final filterOptions = ref.read(tabViewFilterControllerProvider); + final tabStates = ref.read(tabStatesProvider); + + // Get all tab IDs in this container from DB + final containerTabIds = await db.containerDao + .getContainerTabIds(containerId) + .get(); + + // Only keep tabs that exist in the engine + final candidateIds = containerTabIds + .where((id) => tabStates.containsKey(id)) + .toList(); + + if (candidateIds.isEmpty) return const []; + + if (!filterOptions.hasActiveFilter) return candidateIds; + + // Only fetch timestamps when date filtering is active + final timestamps = filterOptions.effectiveDateRange != null + ? Map.fromEntries(await db.tabDao.getTabTimestamps().get()) + : null; + + return candidateIds.where((id) { + final state = tabStates[id]; + return filterOptions.matchesTab(state?.tabMode, timestamps?[id]); + }).toList(); + } + Future deleteUnassignedTabsOlderThan(DateTime threshold) async { final tabIds = await ref .read(tabDatabaseProvider) diff --git a/app/lib/features/geckoview/features/tabs/domain/repositories/tab.g.dart b/app/lib/features/geckoview/features/tabs/domain/repositories/tab.g.dart index 540d6b7f..0a243827 100644 --- a/app/lib/features/geckoview/features/tabs/domain/repositories/tab.g.dart +++ b/app/lib/features/geckoview/features/tabs/domain/repositories/tab.g.dart @@ -41,7 +41,7 @@ final class TabDataRepositoryProvider } } -String _$tabDataRepositoryHash() => r'41ca88333684db71f809e35874b0e5bdddde0f60'; +String _$tabDataRepositoryHash() => r'c64a44c7d9de566617798a0a3bdcad51bf436658'; abstract class _$TabDataRepository extends $Notifier { void build(); diff --git a/app/lib/features/geckoview/features/tabs/presentation/widgets/container_chips.dart b/app/lib/features/geckoview/features/tabs/presentation/widgets/container_chips.dart index 8bd76187..b644850d 100644 --- a/app/lib/features/geckoview/features/tabs/presentation/widgets/container_chips.dart +++ b/app/lib/features/geckoview/features/tabs/presentation/widgets/container_chips.dart @@ -109,7 +109,9 @@ class _ContainerSuggestionsChip extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final tabSuggestionsEnabled = ref.watch(persistedBoolProvider(PersistedBoolKey.tabSuggestions)); + final tabSuggestionsEnabled = ref.watch( + persistedBoolProvider(PersistedBoolKey.tabSuggestions), + ); final enableAiFeatures = ref.watch( generalSettingsWithDefaultsProvider.select( (settings) => settings.enableLocalAiFeatures, @@ -306,7 +308,7 @@ class ContainerChips extends HookConsumerWidget { ), if (displayMenu) IconButton( - visualDensity: VisualDensity.compact, + // visualDensity: VisualDensity.compact, onPressed: () async { await const ContainerListRoute().push(context); }, diff --git a/app/lib/features/settings/presentation/screens/appearance_display_settings.dart b/app/lib/features/settings/presentation/screens/appearance_display_settings.dart index 9932e04a..7b399a61 100644 --- a/app/lib/features/settings/presentation/screens/appearance_display_settings.dart +++ b/app/lib/features/settings/presentation/screens/appearance_display_settings.dart @@ -508,7 +508,6 @@ class _PullToRefreshTile extends HookConsumerWidget { } } - class _DoubleBackCloseTabTile extends HookConsumerWidget { const _DoubleBackCloseTabTile(); diff --git a/app/lib/utils/ui_helper.dart b/app/lib/utils/ui_helper.dart index e7b60ce3..fba724bd 100644 --- a/app/lib/utils/ui_helper.dart +++ b/app/lib/utils/ui_helper.dart @@ -67,9 +67,11 @@ void showInfoMessage( String message, { Duration duration = const Duration(seconds: 4), bool persist = false, + SnackBarAction? action, }) { final snackBar = _createFloatingSnackBar( content: Text(message), + action: action, duration: duration, persist: persist, ); diff --git a/app/test/drift/tabs/generated/schema.dart b/app/test/drift/tabs/generated/schema.dart index 35ab6136..5f201e00 100644 --- a/app/test/drift/tabs/generated/schema.dart +++ b/app/test/drift/tabs/generated/schema.dart @@ -9,6 +9,7 @@ import 'schema_v3.dart' as v3; import 'schema_v4.dart' as v4; import 'schema_v5.dart' as v5; import 'schema_v6.dart' as v6; +import 'schema_v7.dart' as v7; class GeneratedHelper implements SchemaInstantiationHelper { @override @@ -24,10 +25,12 @@ class GeneratedHelper implements SchemaInstantiationHelper { return v5.DatabaseAtV5(db); case 6: return v6.DatabaseAtV6(db); + case 7: + return v7.DatabaseAtV7(db); default: throw MissingSchemaException(version, versions); } } - static const versions = const [2, 3, 4, 5, 6]; + static const versions = const [2, 3, 4, 5, 6, 7]; } diff --git a/app/test/drift/tabs/generated/schema_v7.dart b/app/test/drift/tabs/generated/schema_v7.dart new file mode 100644 index 00000000..47fed5f9 --- /dev/null +++ b/app/test/drift/tabs/generated/schema_v7.dart @@ -0,0 +1,1392 @@ +// dart format width=80 +// GENERATED BY drift_dev, DO NOT MODIFY. +// ignore_for_file: type=lint,unused_import +// +import 'package:drift/drift.dart'; + +class Container extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Container(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'PRIMARY KEY NOT NULL', + ); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn color = GeneratedColumn( + 'color', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn metadata = GeneratedColumn( + 'metadata', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + @override + List get $columns => [id, name, color, metadata]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'container'; + @override + Set get $primaryKey => {id}; + @override + ContainerData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return ContainerData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + ), + color: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}color'], + )!, + metadata: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}metadata'], + ), + ); + } + + @override + Container createAlias(String alias) { + return Container(attachedDatabase, alias); + } + + @override + bool get dontWriteConstraints => true; +} + +class ContainerData extends DataClass implements Insertable { + final String id; + final String? name; + final int color; + final String? metadata; + const ContainerData({ + required this.id, + this.name, + required this.color, + this.metadata, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + if (!nullToAbsent || name != null) { + map['name'] = Variable(name); + } + map['color'] = Variable(color); + if (!nullToAbsent || metadata != null) { + map['metadata'] = Variable(metadata); + } + return map; + } + + factory ContainerData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return ContainerData( + id: serializer.fromJson(json['id']), + name: serializer.fromJson(json['name']), + color: serializer.fromJson(json['color']), + metadata: serializer.fromJson(json['metadata']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'name': serializer.toJson(name), + 'color': serializer.toJson(color), + 'metadata': serializer.toJson(metadata), + }; + } + + ContainerData copyWith({ + String? id, + Value name = const Value.absent(), + int? color, + Value metadata = const Value.absent(), + }) => ContainerData( + id: id ?? this.id, + name: name.present ? name.value : this.name, + color: color ?? this.color, + metadata: metadata.present ? metadata.value : this.metadata, + ); + ContainerData copyWithCompanion(ContainerCompanion data) { + return ContainerData( + id: data.id.present ? data.id.value : this.id, + name: data.name.present ? data.name.value : this.name, + color: data.color.present ? data.color.value : this.color, + metadata: data.metadata.present ? data.metadata.value : this.metadata, + ); + } + + @override + String toString() { + return (StringBuffer('ContainerData(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('color: $color, ') + ..write('metadata: $metadata') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, name, color, metadata); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ContainerData && + other.id == this.id && + other.name == this.name && + other.color == this.color && + other.metadata == this.metadata); +} + +class ContainerCompanion extends UpdateCompanion { + final Value id; + final Value name; + final Value color; + final Value metadata; + final Value rowid; + const ContainerCompanion({ + this.id = const Value.absent(), + this.name = const Value.absent(), + this.color = const Value.absent(), + this.metadata = const Value.absent(), + this.rowid = const Value.absent(), + }); + ContainerCompanion.insert({ + required String id, + this.name = const Value.absent(), + required int color, + this.metadata = const Value.absent(), + this.rowid = const Value.absent(), + }) : id = Value(id), + color = Value(color); + static Insertable custom({ + Expression? id, + Expression? name, + Expression? color, + Expression? metadata, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (name != null) 'name': name, + if (color != null) 'color': color, + if (metadata != null) 'metadata': metadata, + if (rowid != null) 'rowid': rowid, + }); + } + + ContainerCompanion copyWith({ + Value? id, + Value? name, + Value? color, + Value? metadata, + Value? rowid, + }) { + return ContainerCompanion( + id: id ?? this.id, + name: name ?? this.name, + color: color ?? this.color, + metadata: metadata ?? this.metadata, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (name.present) { + map['name'] = Variable(name.value); + } + if (color.present) { + map['color'] = Variable(color.value); + } + if (metadata.present) { + map['metadata'] = Variable(metadata.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('ContainerCompanion(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('color: $color, ') + ..write('metadata: $metadata, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class Tab extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Tab(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'PRIMARY KEY NOT NULL', + ); + late final GeneratedColumn source = GeneratedColumn( + 'source', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn parentId = GeneratedColumn( + 'parent_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'REFERENCES tab(id)ON DELETE SET NULL', + ); + late final GeneratedColumn containerId = GeneratedColumn( + 'container_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'REFERENCES container(id)ON DELETE CASCADE', + ); + late final GeneratedColumn orderKey = GeneratedColumn( + 'order_key', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn url = GeneratedColumn( + 'url', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn title = GeneratedColumn( + 'title', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn tabMode = GeneratedColumn( + 'tab_mode', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn isolationContextId = + GeneratedColumn( + 'isolation_context_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn isPinned = GeneratedColumn( + 'is_pinned', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn isProbablyReaderable = GeneratedColumn( + 'is_probably_readerable', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn extractedContentMarkdown = + GeneratedColumn( + 'extracted_content_markdown', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn extractedContentPlain = + GeneratedColumn( + 'extracted_content_plain', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn fullContentMarkdown = + GeneratedColumn( + 'full_content_markdown', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn fullContentPlain = GeneratedColumn( + 'full_content_plain', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn timestamp = GeneratedColumn( + 'timestamp', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + @override + List get $columns => [ + id, + source, + parentId, + containerId, + orderKey, + url, + title, + tabMode, + isolationContextId, + isPinned, + isProbablyReaderable, + extractedContentMarkdown, + extractedContentPlain, + fullContentMarkdown, + fullContentPlain, + timestamp, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'tab'; + @override + Set get $primaryKey => {id}; + @override + TabData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return TabData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + source: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}source'], + )!, + parentId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}parent_id'], + ), + containerId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}container_id'], + ), + orderKey: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}order_key'], + )!, + url: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}url'], + ), + title: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}title'], + ), + tabMode: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}tab_mode'], + )!, + isolationContextId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}isolation_context_id'], + ), + isPinned: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}is_pinned'], + )!, + isProbablyReaderable: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}is_probably_readerable'], + ), + extractedContentMarkdown: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}extracted_content_markdown'], + ), + extractedContentPlain: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}extracted_content_plain'], + ), + fullContentMarkdown: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}full_content_markdown'], + ), + fullContentPlain: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}full_content_plain'], + ), + timestamp: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}timestamp'], + )!, + ); + } + + @override + Tab createAlias(String alias) { + return Tab(attachedDatabase, alias); + } + + @override + List get customConstraints => const [ + 'CHECK((tab_mode = 2 AND isolation_context_id IS NOT NULL)OR(tab_mode != 2 AND isolation_context_id IS NULL))', + ]; + @override + bool get dontWriteConstraints => true; +} + +class TabData extends DataClass implements Insertable { + final String id; + final int source; + final String? parentId; + final String? containerId; + final String orderKey; + final String? url; + final String? title; + final int tabMode; + final String? isolationContextId; + final int isPinned; + final int? isProbablyReaderable; + final String? extractedContentMarkdown; + final String? extractedContentPlain; + final String? fullContentMarkdown; + final String? fullContentPlain; + final int timestamp; + const TabData({ + required this.id, + required this.source, + this.parentId, + this.containerId, + required this.orderKey, + this.url, + this.title, + required this.tabMode, + this.isolationContextId, + required this.isPinned, + this.isProbablyReaderable, + this.extractedContentMarkdown, + this.extractedContentPlain, + this.fullContentMarkdown, + this.fullContentPlain, + required this.timestamp, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['source'] = Variable(source); + if (!nullToAbsent || parentId != null) { + map['parent_id'] = Variable(parentId); + } + if (!nullToAbsent || containerId != null) { + map['container_id'] = Variable(containerId); + } + map['order_key'] = Variable(orderKey); + if (!nullToAbsent || url != null) { + map['url'] = Variable(url); + } + if (!nullToAbsent || title != null) { + map['title'] = Variable(title); + } + map['tab_mode'] = Variable(tabMode); + if (!nullToAbsent || isolationContextId != null) { + map['isolation_context_id'] = Variable(isolationContextId); + } + map['is_pinned'] = Variable(isPinned); + if (!nullToAbsent || isProbablyReaderable != null) { + map['is_probably_readerable'] = Variable(isProbablyReaderable); + } + if (!nullToAbsent || extractedContentMarkdown != null) { + map['extracted_content_markdown'] = Variable( + extractedContentMarkdown, + ); + } + if (!nullToAbsent || extractedContentPlain != null) { + map['extracted_content_plain'] = Variable(extractedContentPlain); + } + if (!nullToAbsent || fullContentMarkdown != null) { + map['full_content_markdown'] = Variable(fullContentMarkdown); + } + if (!nullToAbsent || fullContentPlain != null) { + map['full_content_plain'] = Variable(fullContentPlain); + } + map['timestamp'] = Variable(timestamp); + return map; + } + + factory TabData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return TabData( + id: serializer.fromJson(json['id']), + source: serializer.fromJson(json['source']), + parentId: serializer.fromJson(json['parentId']), + containerId: serializer.fromJson(json['containerId']), + orderKey: serializer.fromJson(json['orderKey']), + url: serializer.fromJson(json['url']), + title: serializer.fromJson(json['title']), + tabMode: serializer.fromJson(json['tabMode']), + isolationContextId: serializer.fromJson( + json['isolationContextId'], + ), + isPinned: serializer.fromJson(json['isPinned']), + isProbablyReaderable: serializer.fromJson( + json['isProbablyReaderable'], + ), + extractedContentMarkdown: serializer.fromJson( + json['extractedContentMarkdown'], + ), + extractedContentPlain: serializer.fromJson( + json['extractedContentPlain'], + ), + fullContentMarkdown: serializer.fromJson( + json['fullContentMarkdown'], + ), + fullContentPlain: serializer.fromJson(json['fullContentPlain']), + timestamp: serializer.fromJson(json['timestamp']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'source': serializer.toJson(source), + 'parentId': serializer.toJson(parentId), + 'containerId': serializer.toJson(containerId), + 'orderKey': serializer.toJson(orderKey), + 'url': serializer.toJson(url), + 'title': serializer.toJson(title), + 'tabMode': serializer.toJson(tabMode), + 'isolationContextId': serializer.toJson(isolationContextId), + 'isPinned': serializer.toJson(isPinned), + 'isProbablyReaderable': serializer.toJson(isProbablyReaderable), + 'extractedContentMarkdown': serializer.toJson( + extractedContentMarkdown, + ), + 'extractedContentPlain': serializer.toJson( + extractedContentPlain, + ), + 'fullContentMarkdown': serializer.toJson(fullContentMarkdown), + 'fullContentPlain': serializer.toJson(fullContentPlain), + 'timestamp': serializer.toJson(timestamp), + }; + } + + TabData copyWith({ + String? id, + int? source, + Value parentId = const Value.absent(), + Value containerId = const Value.absent(), + String? orderKey, + Value url = const Value.absent(), + Value title = const Value.absent(), + int? tabMode, + Value isolationContextId = const Value.absent(), + int? isPinned, + Value isProbablyReaderable = const Value.absent(), + Value extractedContentMarkdown = const Value.absent(), + Value extractedContentPlain = const Value.absent(), + Value fullContentMarkdown = const Value.absent(), + Value fullContentPlain = const Value.absent(), + int? timestamp, + }) => TabData( + id: id ?? this.id, + source: source ?? this.source, + parentId: parentId.present ? parentId.value : this.parentId, + containerId: containerId.present ? containerId.value : this.containerId, + orderKey: orderKey ?? this.orderKey, + url: url.present ? url.value : this.url, + title: title.present ? title.value : this.title, + tabMode: tabMode ?? this.tabMode, + isolationContextId: isolationContextId.present + ? isolationContextId.value + : this.isolationContextId, + isPinned: isPinned ?? this.isPinned, + isProbablyReaderable: isProbablyReaderable.present + ? isProbablyReaderable.value + : this.isProbablyReaderable, + extractedContentMarkdown: extractedContentMarkdown.present + ? extractedContentMarkdown.value + : this.extractedContentMarkdown, + extractedContentPlain: extractedContentPlain.present + ? extractedContentPlain.value + : this.extractedContentPlain, + fullContentMarkdown: fullContentMarkdown.present + ? fullContentMarkdown.value + : this.fullContentMarkdown, + fullContentPlain: fullContentPlain.present + ? fullContentPlain.value + : this.fullContentPlain, + timestamp: timestamp ?? this.timestamp, + ); + TabData copyWithCompanion(TabCompanion data) { + return TabData( + id: data.id.present ? data.id.value : this.id, + source: data.source.present ? data.source.value : this.source, + parentId: data.parentId.present ? data.parentId.value : this.parentId, + containerId: data.containerId.present + ? data.containerId.value + : this.containerId, + orderKey: data.orderKey.present ? data.orderKey.value : this.orderKey, + url: data.url.present ? data.url.value : this.url, + title: data.title.present ? data.title.value : this.title, + tabMode: data.tabMode.present ? data.tabMode.value : this.tabMode, + isolationContextId: data.isolationContextId.present + ? data.isolationContextId.value + : this.isolationContextId, + isPinned: data.isPinned.present ? data.isPinned.value : this.isPinned, + isProbablyReaderable: data.isProbablyReaderable.present + ? data.isProbablyReaderable.value + : this.isProbablyReaderable, + extractedContentMarkdown: data.extractedContentMarkdown.present + ? data.extractedContentMarkdown.value + : this.extractedContentMarkdown, + extractedContentPlain: data.extractedContentPlain.present + ? data.extractedContentPlain.value + : this.extractedContentPlain, + fullContentMarkdown: data.fullContentMarkdown.present + ? data.fullContentMarkdown.value + : this.fullContentMarkdown, + fullContentPlain: data.fullContentPlain.present + ? data.fullContentPlain.value + : this.fullContentPlain, + timestamp: data.timestamp.present ? data.timestamp.value : this.timestamp, + ); + } + + @override + String toString() { + return (StringBuffer('TabData(') + ..write('id: $id, ') + ..write('source: $source, ') + ..write('parentId: $parentId, ') + ..write('containerId: $containerId, ') + ..write('orderKey: $orderKey, ') + ..write('url: $url, ') + ..write('title: $title, ') + ..write('tabMode: $tabMode, ') + ..write('isolationContextId: $isolationContextId, ') + ..write('isPinned: $isPinned, ') + ..write('isProbablyReaderable: $isProbablyReaderable, ') + ..write('extractedContentMarkdown: $extractedContentMarkdown, ') + ..write('extractedContentPlain: $extractedContentPlain, ') + ..write('fullContentMarkdown: $fullContentMarkdown, ') + ..write('fullContentPlain: $fullContentPlain, ') + ..write('timestamp: $timestamp') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + source, + parentId, + containerId, + orderKey, + url, + title, + tabMode, + isolationContextId, + isPinned, + isProbablyReaderable, + extractedContentMarkdown, + extractedContentPlain, + fullContentMarkdown, + fullContentPlain, + timestamp, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is TabData && + other.id == this.id && + other.source == this.source && + other.parentId == this.parentId && + other.containerId == this.containerId && + other.orderKey == this.orderKey && + other.url == this.url && + other.title == this.title && + other.tabMode == this.tabMode && + other.isolationContextId == this.isolationContextId && + other.isPinned == this.isPinned && + other.isProbablyReaderable == this.isProbablyReaderable && + other.extractedContentMarkdown == this.extractedContentMarkdown && + other.extractedContentPlain == this.extractedContentPlain && + other.fullContentMarkdown == this.fullContentMarkdown && + other.fullContentPlain == this.fullContentPlain && + other.timestamp == this.timestamp); +} + +class TabCompanion extends UpdateCompanion { + final Value id; + final Value source; + final Value parentId; + final Value containerId; + final Value orderKey; + final Value url; + final Value title; + final Value tabMode; + final Value isolationContextId; + final Value isPinned; + final Value isProbablyReaderable; + final Value extractedContentMarkdown; + final Value extractedContentPlain; + final Value fullContentMarkdown; + final Value fullContentPlain; + final Value timestamp; + final Value rowid; + const TabCompanion({ + this.id = const Value.absent(), + this.source = const Value.absent(), + this.parentId = const Value.absent(), + this.containerId = const Value.absent(), + this.orderKey = const Value.absent(), + this.url = const Value.absent(), + this.title = const Value.absent(), + this.tabMode = const Value.absent(), + this.isolationContextId = const Value.absent(), + this.isPinned = const Value.absent(), + this.isProbablyReaderable = const Value.absent(), + this.extractedContentMarkdown = const Value.absent(), + this.extractedContentPlain = const Value.absent(), + this.fullContentMarkdown = const Value.absent(), + this.fullContentPlain = const Value.absent(), + this.timestamp = const Value.absent(), + this.rowid = const Value.absent(), + }); + TabCompanion.insert({ + required String id, + required int source, + this.parentId = const Value.absent(), + this.containerId = const Value.absent(), + required String orderKey, + this.url = const Value.absent(), + this.title = const Value.absent(), + this.tabMode = const Value.absent(), + this.isolationContextId = const Value.absent(), + this.isPinned = const Value.absent(), + this.isProbablyReaderable = const Value.absent(), + this.extractedContentMarkdown = const Value.absent(), + this.extractedContentPlain = const Value.absent(), + this.fullContentMarkdown = const Value.absent(), + this.fullContentPlain = const Value.absent(), + required int timestamp, + this.rowid = const Value.absent(), + }) : id = Value(id), + source = Value(source), + orderKey = Value(orderKey), + timestamp = Value(timestamp); + static Insertable custom({ + Expression? id, + Expression? source, + Expression? parentId, + Expression? containerId, + Expression? orderKey, + Expression? url, + Expression? title, + Expression? tabMode, + Expression? isolationContextId, + Expression? isPinned, + Expression? isProbablyReaderable, + Expression? extractedContentMarkdown, + Expression? extractedContentPlain, + Expression? fullContentMarkdown, + Expression? fullContentPlain, + Expression? timestamp, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (source != null) 'source': source, + if (parentId != null) 'parent_id': parentId, + if (containerId != null) 'container_id': containerId, + if (orderKey != null) 'order_key': orderKey, + if (url != null) 'url': url, + if (title != null) 'title': title, + if (tabMode != null) 'tab_mode': tabMode, + if (isolationContextId != null) + 'isolation_context_id': isolationContextId, + if (isPinned != null) 'is_pinned': isPinned, + if (isProbablyReaderable != null) + 'is_probably_readerable': isProbablyReaderable, + if (extractedContentMarkdown != null) + 'extracted_content_markdown': extractedContentMarkdown, + if (extractedContentPlain != null) + 'extracted_content_plain': extractedContentPlain, + if (fullContentMarkdown != null) + 'full_content_markdown': fullContentMarkdown, + if (fullContentPlain != null) 'full_content_plain': fullContentPlain, + if (timestamp != null) 'timestamp': timestamp, + if (rowid != null) 'rowid': rowid, + }); + } + + TabCompanion copyWith({ + Value? id, + Value? source, + Value? parentId, + Value? containerId, + Value? orderKey, + Value? url, + Value? title, + Value? tabMode, + Value? isolationContextId, + Value? isPinned, + Value? isProbablyReaderable, + Value? extractedContentMarkdown, + Value? extractedContentPlain, + Value? fullContentMarkdown, + Value? fullContentPlain, + Value? timestamp, + Value? rowid, + }) { + return TabCompanion( + id: id ?? this.id, + source: source ?? this.source, + parentId: parentId ?? this.parentId, + containerId: containerId ?? this.containerId, + orderKey: orderKey ?? this.orderKey, + url: url ?? this.url, + title: title ?? this.title, + tabMode: tabMode ?? this.tabMode, + isolationContextId: isolationContextId ?? this.isolationContextId, + isPinned: isPinned ?? this.isPinned, + isProbablyReaderable: isProbablyReaderable ?? this.isProbablyReaderable, + extractedContentMarkdown: + extractedContentMarkdown ?? this.extractedContentMarkdown, + extractedContentPlain: + extractedContentPlain ?? this.extractedContentPlain, + fullContentMarkdown: fullContentMarkdown ?? this.fullContentMarkdown, + fullContentPlain: fullContentPlain ?? this.fullContentPlain, + timestamp: timestamp ?? this.timestamp, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (source.present) { + map['source'] = Variable(source.value); + } + if (parentId.present) { + map['parent_id'] = Variable(parentId.value); + } + if (containerId.present) { + map['container_id'] = Variable(containerId.value); + } + if (orderKey.present) { + map['order_key'] = Variable(orderKey.value); + } + if (url.present) { + map['url'] = Variable(url.value); + } + if (title.present) { + map['title'] = Variable(title.value); + } + if (tabMode.present) { + map['tab_mode'] = Variable(tabMode.value); + } + if (isolationContextId.present) { + map['isolation_context_id'] = Variable(isolationContextId.value); + } + if (isPinned.present) { + map['is_pinned'] = Variable(isPinned.value); + } + if (isProbablyReaderable.present) { + map['is_probably_readerable'] = Variable(isProbablyReaderable.value); + } + if (extractedContentMarkdown.present) { + map['extracted_content_markdown'] = Variable( + extractedContentMarkdown.value, + ); + } + if (extractedContentPlain.present) { + map['extracted_content_plain'] = Variable( + extractedContentPlain.value, + ); + } + if (fullContentMarkdown.present) { + map['full_content_markdown'] = Variable( + fullContentMarkdown.value, + ); + } + if (fullContentPlain.present) { + map['full_content_plain'] = Variable(fullContentPlain.value); + } + if (timestamp.present) { + map['timestamp'] = Variable(timestamp.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('TabCompanion(') + ..write('id: $id, ') + ..write('source: $source, ') + ..write('parentId: $parentId, ') + ..write('containerId: $containerId, ') + ..write('orderKey: $orderKey, ') + ..write('url: $url, ') + ..write('title: $title, ') + ..write('tabMode: $tabMode, ') + ..write('isolationContextId: $isolationContextId, ') + ..write('isPinned: $isPinned, ') + ..write('isProbablyReaderable: $isProbablyReaderable, ') + ..write('extractedContentMarkdown: $extractedContentMarkdown, ') + ..write('extractedContentPlain: $extractedContentPlain, ') + ..write('fullContentMarkdown: $fullContentMarkdown, ') + ..write('fullContentPlain: $fullContentPlain, ') + ..write('timestamp: $timestamp, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class TabFts extends Table + with TableInfo, VirtualTableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + TabFts(this.attachedDatabase, [this._alias]); + late final GeneratedColumn title = GeneratedColumn( + 'title', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn url = GeneratedColumn( + 'url', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn extractedContentPlain = + GeneratedColumn( + 'extracted_content_plain', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn fullContentPlain = GeneratedColumn( + 'full_content_plain', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + @override + List get $columns => [ + title, + url, + extractedContentPlain, + fullContentPlain, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'tab_fts'; + @override + Set get $primaryKey => const {}; + @override + TabFtsData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return TabFtsData( + title: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}title'], + ), + url: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}url'], + ), + extractedContentPlain: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}extracted_content_plain'], + ), + fullContentPlain: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}full_content_plain'], + ), + ); + } + + @override + TabFts createAlias(String alias) { + return TabFts(attachedDatabase, alias); + } + + @override + bool get dontWriteConstraints => true; + @override + String get moduleAndArgs => + 'fts5(title, url, extracted_content_plain, full_content_plain, content=tab, tokenize="trigram")'; +} + +class TabFtsData extends DataClass implements Insertable { + final String? title; + final String? url; + final String? extractedContentPlain; + final String? fullContentPlain; + const TabFtsData({ + this.title, + this.url, + this.extractedContentPlain, + this.fullContentPlain, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (!nullToAbsent || title != null) { + map['title'] = Variable(title); + } + if (!nullToAbsent || url != null) { + map['url'] = Variable(url); + } + if (!nullToAbsent || extractedContentPlain != null) { + map['extracted_content_plain'] = Variable(extractedContentPlain); + } + if (!nullToAbsent || fullContentPlain != null) { + map['full_content_plain'] = Variable(fullContentPlain); + } + return map; + } + + factory TabFtsData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return TabFtsData( + title: serializer.fromJson(json['title']), + url: serializer.fromJson(json['url']), + extractedContentPlain: serializer.fromJson( + json['extractedContentPlain'], + ), + fullContentPlain: serializer.fromJson(json['fullContentPlain']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'title': serializer.toJson(title), + 'url': serializer.toJson(url), + 'extractedContentPlain': serializer.toJson( + extractedContentPlain, + ), + 'fullContentPlain': serializer.toJson(fullContentPlain), + }; + } + + TabFtsData copyWith({ + Value title = const Value.absent(), + Value url = const Value.absent(), + Value extractedContentPlain = const Value.absent(), + Value fullContentPlain = const Value.absent(), + }) => TabFtsData( + title: title.present ? title.value : this.title, + url: url.present ? url.value : this.url, + extractedContentPlain: extractedContentPlain.present + ? extractedContentPlain.value + : this.extractedContentPlain, + fullContentPlain: fullContentPlain.present + ? fullContentPlain.value + : this.fullContentPlain, + ); + TabFtsData copyWithCompanion(TabFtsCompanion data) { + return TabFtsData( + title: data.title.present ? data.title.value : this.title, + url: data.url.present ? data.url.value : this.url, + extractedContentPlain: data.extractedContentPlain.present + ? data.extractedContentPlain.value + : this.extractedContentPlain, + fullContentPlain: data.fullContentPlain.present + ? data.fullContentPlain.value + : this.fullContentPlain, + ); + } + + @override + String toString() { + return (StringBuffer('TabFtsData(') + ..write('title: $title, ') + ..write('url: $url, ') + ..write('extractedContentPlain: $extractedContentPlain, ') + ..write('fullContentPlain: $fullContentPlain') + ..write(')')) + .toString(); + } + + @override + int get hashCode => + Object.hash(title, url, extractedContentPlain, fullContentPlain); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is TabFtsData && + other.title == this.title && + other.url == this.url && + other.extractedContentPlain == this.extractedContentPlain && + other.fullContentPlain == this.fullContentPlain); +} + +class TabFtsCompanion extends UpdateCompanion { + final Value title; + final Value url; + final Value extractedContentPlain; + final Value fullContentPlain; + final Value rowid; + const TabFtsCompanion({ + this.title = const Value.absent(), + this.url = const Value.absent(), + this.extractedContentPlain = const Value.absent(), + this.fullContentPlain = const Value.absent(), + this.rowid = const Value.absent(), + }); + TabFtsCompanion.insert({ + this.title = const Value.absent(), + this.url = const Value.absent(), + this.extractedContentPlain = const Value.absent(), + this.fullContentPlain = const Value.absent(), + this.rowid = const Value.absent(), + }); + static Insertable custom({ + Expression? title, + Expression? url, + Expression? extractedContentPlain, + Expression? fullContentPlain, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (title != null) 'title': title, + if (url != null) 'url': url, + if (extractedContentPlain != null) + 'extracted_content_plain': extractedContentPlain, + if (fullContentPlain != null) 'full_content_plain': fullContentPlain, + if (rowid != null) 'rowid': rowid, + }); + } + + TabFtsCompanion copyWith({ + Value? title, + Value? url, + Value? extractedContentPlain, + Value? fullContentPlain, + Value? rowid, + }) { + return TabFtsCompanion( + title: title ?? this.title, + url: url ?? this.url, + extractedContentPlain: + extractedContentPlain ?? this.extractedContentPlain, + fullContentPlain: fullContentPlain ?? this.fullContentPlain, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (title.present) { + map['title'] = Variable(title.value); + } + if (url.present) { + map['url'] = Variable(url.value); + } + if (extractedContentPlain.present) { + map['extracted_content_plain'] = Variable( + extractedContentPlain.value, + ); + } + if (fullContentPlain.present) { + map['full_content_plain'] = Variable(fullContentPlain.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('TabFtsCompanion(') + ..write('title: $title, ') + ..write('url: $url, ') + ..write('extractedContentPlain: $extractedContentPlain, ') + ..write('fullContentPlain: $fullContentPlain, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class DatabaseAtV7 extends GeneratedDatabase { + DatabaseAtV7(QueryExecutor e) : super(e); + late final Container container = Container(this); + late final Tab tab = Tab(this); + late final TabFts tabFts = TabFts(this); + late final Trigger tabMaintainParentChainOnDelete = Trigger( + 'CREATE TRIGGER tab_maintain_parent_chain_on_delete BEFORE DELETE ON tab BEGIN UPDATE tab SET parent_id = CASE WHEN OLD.parent_id IS NOT NULL AND EXISTS (SELECT 1 FROM tab WHERE id = OLD.parent_id) THEN OLD.parent_id ELSE NULL END WHERE parent_id = OLD.id;END', + 'tab_maintain_parent_chain_on_delete', + ); + late final Trigger tabAfterInsert = Trigger( + 'CREATE TRIGGER tab_after_insert AFTER INSERT ON tab BEGIN INSERT INTO tab_fts ("rowid", title, url, extracted_content_plain, full_content_plain) VALUES (new."rowid", new.title, new.url, new.extracted_content_plain, new.full_content_plain);END', + 'tab_after_insert', + ); + late final Trigger tabAfterDelete = Trigger( + 'CREATE TRIGGER tab_after_delete AFTER DELETE ON tab BEGIN INSERT INTO tab_fts (tab_fts, "rowid", title, url, extracted_content_plain, full_content_plain) VALUES (\'delete\', old."rowid", old.title, old.url, old.extracted_content_plain, old.full_content_plain);END', + 'tab_after_delete', + ); + late final Trigger tabAfterUpdate = Trigger( + 'CREATE TRIGGER tab_after_update AFTER UPDATE ON tab BEGIN INSERT INTO tab_fts (tab_fts, "rowid", title, url, extracted_content_plain, full_content_plain) VALUES (\'delete\', old."rowid", old.title, old.url, old.extracted_content_plain, old.full_content_plain);INSERT INTO tab_fts ("rowid", title, url, extracted_content_plain, full_content_plain) VALUES (new."rowid", new.title, new.url, new.extracted_content_plain, new.full_content_plain);END', + 'tab_after_update', + ); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + container, + tab, + tabFts, + tabMaintainParentChainOnDelete, + tabAfterInsert, + tabAfterDelete, + tabAfterUpdate, + ]; + @override + StreamQueryUpdateRules get streamUpdateRules => const StreamQueryUpdateRules([ + WritePropagation( + on: TableUpdateQuery.onTableName( + 'container', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('tab', kind: UpdateKind.delete)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'tab', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('tab', kind: UpdateKind.update)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'tab', + limitUpdateKind: UpdateKind.insert, + ), + result: [TableUpdate('tab_fts', kind: UpdateKind.insert)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'tab', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('tab_fts', kind: UpdateKind.insert)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'tab', + limitUpdateKind: UpdateKind.update, + ), + result: [TableUpdate('tab_fts', kind: UpdateKind.insert)], + ), + ]); + @override + int get schemaVersion => 7; +}