Add Supa account and search changes
This commit is contained in:
+13
-6
@@ -17,6 +17,7 @@
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'package:fading_scroll/fading_scroll.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||
@@ -71,12 +72,18 @@ class _SelectBookmarkFolderSheet extends HookConsumerWidget {
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height * 0.5,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: FolderTreePicker(
|
||||
selectedFolderGuid: selectedGuid,
|
||||
excludeFolderGuids: excludeFolderGuids,
|
||||
entryGuid: BookmarkRoot.root.id,
|
||||
),
|
||||
child: FadingScroll(
|
||||
fadingSize: 25,
|
||||
builder: (context, controller) {
|
||||
return SingleChildScrollView(
|
||||
controller: controller,
|
||||
child: FolderTreePicker(
|
||||
selectedFolderGuid: selectedGuid,
|
||||
excludeFolderGuids: excludeFolderGuids,
|
||||
entryGuid: BookmarkRoot.root.id,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
@@ -45,6 +45,7 @@ import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/ge
|
||||
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab_search.dart';
|
||||
import 'package:weblibre/features/user/data/models/general_settings.dart';
|
||||
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
|
||||
import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_controller.dart';
|
||||
|
||||
part 'providers.g.dart';
|
||||
|
||||
@@ -754,19 +755,25 @@ EquatableValue<List<TabPreview>> filteredTabPreviews(
|
||||
return EquatableValue([]);
|
||||
}
|
||||
|
||||
final sandboxCaptureMap =
|
||||
ref.watch(sandboxCaptureMapProvider).value ?? const {};
|
||||
|
||||
return EquatableValue(
|
||||
tabSearchResults.results
|
||||
.where((tab) => availableTabStates.value.containsKey(tab.id))
|
||||
.map((tab) {
|
||||
final tabState = availableTabStates.value[tab.id]!;
|
||||
final sandboxSourceUri = parseSandboxSource(
|
||||
sandboxCaptureMap[tab.id],
|
||||
);
|
||||
|
||||
return TabPreview(
|
||||
id: tab.id,
|
||||
containerId: tab.containerId,
|
||||
title: tab.title ?? tabState.title,
|
||||
icon: tabState.icon,
|
||||
url: tab.cleanUrl ?? tabState.url,
|
||||
highlightedUrl: tab.url,
|
||||
url: sandboxSourceUri ?? tab.cleanUrl ?? tabState.url,
|
||||
highlightedUrl: sandboxSourceUri?.toString() ?? tab.url,
|
||||
extractedContent: tab.extractedContent,
|
||||
fullContent: tab.fullContent,
|
||||
sourceSearchQuery: tabSearchResults.query,
|
||||
|
||||
@@ -1066,7 +1066,7 @@ final class FilteredTabPreviewsProvider
|
||||
}
|
||||
|
||||
String _$filteredTabPreviewsHash() =>
|
||||
r'2327ad86650b3baa6b9339e280abfc7463c72cf9';
|
||||
r'074df0d2000ae325fbd1db775abe4836491b32dd';
|
||||
|
||||
final class FilteredTabPreviewsFamily extends $Family
|
||||
with
|
||||
|
||||
+2
@@ -19,6 +19,7 @@
|
||||
*/
|
||||
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/providers.dart';
|
||||
import 'package:weblibre/features/user/data/models/general_settings.dart';
|
||||
|
||||
part 'browser_data.g.dart';
|
||||
@@ -46,6 +47,7 @@ class BrowserDataService extends _$BrowserDataService {
|
||||
await _service.deleteTabs();
|
||||
case DeleteBrowsingDataType.history:
|
||||
await _service.deleteBrowsingHistory();
|
||||
await ref.read(tabDatabaseProvider).historyDao.clear();
|
||||
case DeleteBrowsingDataType.cookies:
|
||||
await _service.deleteCookiesAndSiteData();
|
||||
case DeleteBrowsingDataType.cache:
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@ final class BrowserDataServiceProvider
|
||||
}
|
||||
|
||||
String _$browserDataServiceHash() =>
|
||||
r'861943be10c4dea325484b6a28ba21f3ff0d29fa';
|
||||
r'b6586d14ef13ab2905072e1e26cabc17846eaf73';
|
||||
|
||||
abstract class _$BrowserDataService extends $Notifier<void> {
|
||||
void build();
|
||||
|
||||
+5
-6
@@ -52,6 +52,7 @@ import 'package:weblibre/features/user/data/models/engine_settings.dart';
|
||||
import 'package:weblibre/features/user/domain/presentation/dialogs/quit_browser_dialog.dart';
|
||||
import 'package:weblibre/features/user/domain/repositories/engine_settings.dart';
|
||||
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
|
||||
import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_controller.dart';
|
||||
import 'package:weblibre/utils/exit_app.dart';
|
||||
import 'package:weblibre/utils/move_to_background.dart';
|
||||
import 'package:weblibre/utils/ui_helper.dart' as ui_helper;
|
||||
@@ -327,14 +328,12 @@ final List<ToolbarButtonDefinition> toolbarButtonRegistry = [
|
||||
: () async {
|
||||
final tabState = scope.tabState;
|
||||
if (tabState != null) {
|
||||
final searchText = tabState.url.scheme == 'about'
|
||||
? SearchRoute.emptySearchText
|
||||
: tabState.url.toString();
|
||||
final sandboxSourceUri = ref.read(
|
||||
sandboxSourceUriForTabProvider(tabId: tabState.id),
|
||||
);
|
||||
await SearchRoute(
|
||||
tabId: tabState.id,
|
||||
searchText: searchText.isEmpty
|
||||
? SearchRoute.emptySearchText
|
||||
: searchText,
|
||||
searchText: searchTextForTab(tabState, sandboxSourceUri),
|
||||
tabType: tabState.tabMode.toTabType(),
|
||||
).push(context);
|
||||
} else {
|
||||
|
||||
+10
-3
@@ -36,6 +36,7 @@ import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode
|
||||
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart'
|
||||
as tab_data;
|
||||
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
|
||||
import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_controller.dart';
|
||||
import 'package:weblibre/presentation/hooks/menu_controller.dart';
|
||||
import 'package:weblibre/utils/ui_helper.dart' as ui_helper;
|
||||
|
||||
@@ -356,6 +357,12 @@ Future<void> _cloneTabAsMode(
|
||||
final tabState = ref.read(tabStateProvider(selectedTabId));
|
||||
if (tabState == null) return;
|
||||
|
||||
// Sandbox-captured tab: clone the canonical source URL so the new tab
|
||||
// either re-captures or loads the real site — never the loopback loader.
|
||||
final cloneUrl =
|
||||
ref.read(sandboxSourceUriForTabProvider(tabId: tabState.id)) ??
|
||||
tabState.url;
|
||||
|
||||
final containerData = await ref
|
||||
.read(tab_data.tabDataRepositoryProvider.notifier)
|
||||
.getTabContainerData(selectedTabId);
|
||||
@@ -371,7 +378,7 @@ Future<void> _cloneTabAsMode(
|
||||
)
|
||||
: await repo.addTab(
|
||||
tabMode: TabMode.regular,
|
||||
url: tabState.url,
|
||||
url: cloneUrl,
|
||||
containerSelection: containerData == null
|
||||
? const TabContainerSelection.unassigned()
|
||||
: TabContainerSelection.specific(containerData),
|
||||
@@ -386,7 +393,7 @@ Future<void> _cloneTabAsMode(
|
||||
)
|
||||
: await repo.addTab(
|
||||
tabMode: TabMode.private,
|
||||
url: tabState.url,
|
||||
url: cloneUrl,
|
||||
containerSelection: containerData == null
|
||||
? const TabContainerSelection.unassigned()
|
||||
: TabContainerSelection.specific(containerData),
|
||||
@@ -394,7 +401,7 @@ Future<void> _cloneTabAsMode(
|
||||
),
|
||||
IsolatedTabMode() => await repo.addTab(
|
||||
tabMode: TabMode.newIsolated(),
|
||||
url: tabState.url,
|
||||
url: cloneUrl,
|
||||
containerSelection: containerData == null
|
||||
? const TabContainerSelection.unassigned()
|
||||
: TabContainerSelection.specific(containerData),
|
||||
|
||||
+2
-4
@@ -19,7 +19,7 @@
|
||||
*/
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Shows a dialog asking the user whether to keep a tab that was opened from another app.
|
||||
/// Shows a dialog asking the user whether to keep a temporary tab.
|
||||
///
|
||||
/// Returns true if the user wants to keep the tab, false if they want to discard it.
|
||||
Future<bool?> showKeepTabDialog(BuildContext context) {
|
||||
@@ -27,9 +27,7 @@ Future<bool?> showKeepTabDialog(BuildContext context) {
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Keep tab?'),
|
||||
content: const Text(
|
||||
'This tab was opened from another app. Do you want to keep it or discard it?',
|
||||
),
|
||||
content: const Text('Do you want to keep this tab or discard it?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
|
||||
+12
-5
@@ -18,6 +18,7 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import 'package:fading_scroll/fading_scroll.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||
@@ -58,11 +59,17 @@ class _SelectFolderSheet extends HookConsumerWidget {
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height * 0.5,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: FolderTreePicker(
|
||||
selectedFolderGuid: selectedFolderGuid,
|
||||
entryGuid: BookmarkRoot.root.id,
|
||||
),
|
||||
child: FadingScroll(
|
||||
fadingSize: 25,
|
||||
builder: (context, controller) {
|
||||
return SingleChildScrollView(
|
||||
controller: controller,
|
||||
child: FolderTreePicker(
|
||||
selectedFolderGuid: selectedFolderGuid,
|
||||
entryGuid: BookmarkRoot.root.id,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
+12
@@ -24,6 +24,7 @@ import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/domain/repositories/site_permissions.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/sheets/tracking_protection_provider.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
|
||||
import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_controller.dart';
|
||||
|
||||
part 'site_settings_badge_provider.g.dart';
|
||||
|
||||
@@ -49,6 +50,17 @@ Future<SiteSettingsBadgeState> showSiteSettingsBadge(Ref ref) async {
|
||||
return SiteSettingsBadgeState.hidden;
|
||||
}
|
||||
|
||||
// Sandbox-captured tabs render content from a loopback server, so
|
||||
// permissions/tracking-exception lookups would key on the loader origin
|
||||
// rather than the canonical site. Suppress the badge entirely — the user
|
||||
// can't meaningfully change permissions for a sandboxed page anyway.
|
||||
final sandboxSourceUri = ref.watch(
|
||||
sandboxSourceUriForTabProvider(tabId: tabState.id),
|
||||
);
|
||||
if (sandboxSourceUri != null) {
|
||||
return SiteSettingsBadgeState.hidden;
|
||||
}
|
||||
|
||||
// Check tracking protection exception
|
||||
final hasTrackingException = await ref.watch(
|
||||
hasTrackingProtectionExceptionProvider(tabState.id).future,
|
||||
|
||||
+1
-1
@@ -53,4 +53,4 @@ final class ShowSiteSettingsBadgeProvider
|
||||
}
|
||||
|
||||
String _$showSiteSettingsBadgeHash() =>
|
||||
r'fbfe409cdd6656d5cd26e377a68acde6ff4294fa';
|
||||
r'6345920e4391786e47a89964c7ffa75818732584';
|
||||
|
||||
+39
-17
@@ -57,6 +57,7 @@ import 'package:weblibre/features/geckoview/features/contextmenu/extensions/hit_
|
||||
import 'package:weblibre/features/geckoview/features/find_in_page/presentation/controllers/find_in_page.dart';
|
||||
import 'package:weblibre/features/geckoview/features/find_in_page/presentation/widgets/find_in_page.dart';
|
||||
import 'package:weblibre/features/geckoview/features/readerview/presentation/controllers/readerable.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/domain/providers/search_autofocus.dart';
|
||||
import 'package:weblibre/features/small_web/presentation/controllers/small_web_mode_controller.dart';
|
||||
import 'package:weblibre/features/small_web/presentation/widgets/small_web_browser_overlay.dart';
|
||||
import 'package:weblibre/features/sync/domain/repositories/sync.dart';
|
||||
@@ -1022,7 +1023,12 @@ class _Browser extends HookConsumerWidget {
|
||||
return OverlayPortal(
|
||||
controller: overlayController,
|
||||
overlayChildBuilder: (context) {
|
||||
return overlayBuilder!.call(context);
|
||||
// The OverlayPortal lifecycle is driven by ref.listen above, but
|
||||
// OverlayPortal can call this builder one extra frame after
|
||||
// dismiss() set the provider back to null (esp. on rebuilds
|
||||
// triggered by unrelated state changes). Render an empty box
|
||||
// instead of force-unwrapping a null builder.
|
||||
return overlayBuilder?.call(context) ?? const SizedBox.shrink();
|
||||
},
|
||||
child: Listener(
|
||||
onPointerDown: sheetDisplayed
|
||||
@@ -1035,6 +1041,9 @@ class _Browser extends HookConsumerWidget {
|
||||
child: BackButtonListener(
|
||||
onBackButtonPressed: () async {
|
||||
final tabState = ref.read(selectedTabStateProvider);
|
||||
final promptOnBackBehavior = ref
|
||||
.read(tabRepositoryProvider.notifier)
|
||||
.backPromptBehaviorFor(tabState?.id);
|
||||
|
||||
final tabCount = ref.read(
|
||||
tabListProvider.select((tabs) => tabs.value.length),
|
||||
@@ -1103,27 +1112,22 @@ class _Browser extends HookConsumerWidget {
|
||||
return true;
|
||||
}
|
||||
|
||||
//Go router has routes to go back to
|
||||
if (context.canPop()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (ref
|
||||
.read(tabRepositoryProvider.notifier)
|
||||
.hasLaunchedFromIntent(tabState?.id)) {
|
||||
if (promptOnBackBehavior != null) {
|
||||
if (!context.mounted) return false;
|
||||
|
||||
final keep = await showKeepTabDialog(context);
|
||||
if (keep == true) {
|
||||
ref
|
||||
.read(tabRepositoryProvider.notifier)
|
||||
.clearLaunchedFromIntent(tabState!.id);
|
||||
|
||||
await moveToBackground();
|
||||
if (keep == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (tabState != null) {
|
||||
if (keep) {
|
||||
if (tabState == null) {
|
||||
return true;
|
||||
}
|
||||
ref
|
||||
.read(tabRepositoryProvider.notifier)
|
||||
.clearBackPromptBehavior(tabState.id);
|
||||
} else if (tabState != null) {
|
||||
if (!await confirmIsolatedTabCloseIfNeeded(tabState.id)) {
|
||||
return true;
|
||||
}
|
||||
@@ -1133,7 +1137,25 @@ class _Browser extends HookConsumerWidget {
|
||||
.closeTab(tabState.id);
|
||||
}
|
||||
|
||||
await moveToBackground();
|
||||
if (!context.mounted) return true;
|
||||
|
||||
switch (promptOnBackBehavior) {
|
||||
case BackgroundAppTabBackPromptBehavior():
|
||||
await moveToBackground();
|
||||
break;
|
||||
case ReturnToSearchTabBackPromptBehavior(:final tabType):
|
||||
ref
|
||||
.read(searchAutofocusSuppressionProvider.notifier)
|
||||
.suppressNext();
|
||||
await SearchRoute(tabType: tabType).push(context);
|
||||
break;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
//Go router has routes to go back to
|
||||
if (context.canPop()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
+4
-14
@@ -248,6 +248,7 @@ class _ContainerHeader extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final containerColor = container.color;
|
||||
final containerPalette = ContainerColors.palette(context, containerColor);
|
||||
|
||||
return Container(
|
||||
width: 112,
|
||||
@@ -259,22 +260,11 @@ class _ContainerHeader extends StatelessWidget {
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
Color.alphaBlend(
|
||||
ContainerColors.forChip(containerColor),
|
||||
colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
Color.alphaBlend(
|
||||
containerColor.withValues(alpha: 0.12),
|
||||
colorScheme.surfaceContainer,
|
||||
),
|
||||
containerPalette.surfaceHighColor,
|
||||
containerPalette.surfaceColor,
|
||||
],
|
||||
),
|
||||
border: Border.all(
|
||||
color: Color.alphaBlend(
|
||||
containerColor.withValues(alpha: 0.25),
|
||||
colorScheme.outlineVariant.withValues(alpha: 0.45),
|
||||
),
|
||||
),
|
||||
border: Border.all(color: containerPalette.outlineColor),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: colorScheme.shadow.withValues(alpha: 0.08),
|
||||
|
||||
+87
-46
@@ -22,6 +22,7 @@ import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:fading_scroll/fading_scroll.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -73,6 +74,7 @@ import 'package:weblibre/features/tor/domain/services/tor_proxy.dart';
|
||||
import 'package:weblibre/features/user/domain/presentation/dialogs/quit_browser_dialog.dart';
|
||||
import 'package:weblibre/features/user/domain/providers.dart';
|
||||
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
|
||||
import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_controller.dart';
|
||||
import 'package:weblibre/presentation/controllers/website_title.dart';
|
||||
import 'package:weblibre/presentation/hooks/cached_future.dart';
|
||||
import 'package:weblibre/presentation/hooks/menu_controller.dart';
|
||||
@@ -122,47 +124,55 @@ class _BrowserMenuSheet extends HookConsumerWidget {
|
||||
|
||||
// Scrollable content
|
||||
Expanded(
|
||||
child: ListView(
|
||||
child: FadingScroll(
|
||||
controller: scrollController,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
children: [
|
||||
// Quick toggles (Desktop Mode / Reader Mode)
|
||||
if (selectedTabId != null) ...[
|
||||
_QuickTogglesGrid(selectedTabId: selectedTabId),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
fadingSize: 25,
|
||||
builder: (context, controller) {
|
||||
return ListView(
|
||||
controller: controller,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
children: [
|
||||
// Quick toggles (Desktop Mode / Reader Mode)
|
||||
if (selectedTabId != null) ...[
|
||||
_QuickTogglesGrid(selectedTabId: selectedTabId),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
// Page actions
|
||||
if (selectedTabId != null) ...[
|
||||
_PageActionsCard(selectedTabId: selectedTabId),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
// Page actions
|
||||
if (selectedTabId != null) ...[
|
||||
_PageActionsCard(selectedTabId: selectedTabId),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
// Extensions
|
||||
_ExtensionsCard(),
|
||||
const SizedBox(height: 16),
|
||||
// Extensions
|
||||
_ExtensionsCard(),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Tab actions
|
||||
if (selectedTabId != null) ...[
|
||||
_TabActionsCard(selectedTabId: selectedTabId),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
// Tab actions
|
||||
if (selectedTabId != null) ...[
|
||||
_TabActionsCard(selectedTabId: selectedTabId),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
// Quick links grid
|
||||
_QuickLinksGrid(showContainerUi: settings.showContainerUi),
|
||||
const SizedBox(height: 16),
|
||||
// Quick links grid
|
||||
_QuickLinksGrid(
|
||||
showContainerUi: settings.showContainerUi,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Profile
|
||||
_ProfileCard(),
|
||||
const SizedBox(height: 16),
|
||||
// Profile
|
||||
_ProfileCard(),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// App
|
||||
const _SettingsCard(),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
// App
|
||||
const _SettingsCard(),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
@@ -513,12 +523,15 @@ class _PageActionsCard extends HookConsumerWidget {
|
||||
title: const Text('Add Bookmark'),
|
||||
onTap: () async {
|
||||
final tabState = ref.read(tabStateProvider(selectedTabId))!;
|
||||
final bookmarkUrl =
|
||||
ref.read(sandboxSourceUriForTabProvider(tabId: tabState.id)) ??
|
||||
tabState.url;
|
||||
Navigator.pop(context);
|
||||
await BookmarkEntryAddRoute(
|
||||
bookmarkInfo: jsonEncode(
|
||||
BookmarkInfo(
|
||||
title: tabState.titleOrAuthority,
|
||||
url: tabState.url.toString(),
|
||||
url: bookmarkUrl.toString(),
|
||||
).encode(),
|
||||
),
|
||||
).push(context);
|
||||
@@ -737,7 +750,10 @@ class _PinTopSiteTile extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final tabState = ref.watch(tabStateProvider(selectedTabId));
|
||||
final url = tabState?.url;
|
||||
final sandboxSourceUri = ref.watch(
|
||||
sandboxSourceUriForTabProvider(tabId: selectedTabId),
|
||||
);
|
||||
final url = sandboxSourceUri ?? tabState?.url;
|
||||
|
||||
final isPinned = useCachedFuture(
|
||||
() => url != null
|
||||
@@ -978,6 +994,11 @@ class _CloneTabExpansion extends ConsumerWidget {
|
||||
icon: MdiIcons.tab,
|
||||
onTap: () async {
|
||||
final tabState = ref.read(tabStateProvider(selectedTabId))!;
|
||||
final cloneUrl =
|
||||
ref.read(
|
||||
sandboxSourceUriForTabProvider(tabId: tabState.id),
|
||||
) ??
|
||||
tabState.url;
|
||||
final containerData = await ref
|
||||
.read(tabDataRepositoryProvider.notifier)
|
||||
.getTabContainerData(selectedTabId);
|
||||
@@ -987,7 +1008,7 @@ class _CloneTabExpansion extends ConsumerWidget {
|
||||
.read(tabRepositoryProvider.notifier)
|
||||
.addTab(
|
||||
tabMode: TabMode.regular,
|
||||
url: tabState.url,
|
||||
url: cloneUrl,
|
||||
containerSelection: containerData == null
|
||||
? const TabContainerSelection.unassigned()
|
||||
: TabContainerSelection.specific(containerData),
|
||||
@@ -1017,6 +1038,11 @@ class _CloneTabExpansion extends ConsumerWidget {
|
||||
iconColor: appColors.privateTabPurple,
|
||||
onTap: () async {
|
||||
final tabState = ref.read(tabStateProvider(selectedTabId))!;
|
||||
final cloneUrl =
|
||||
ref.read(
|
||||
sandboxSourceUriForTabProvider(tabId: tabState.id),
|
||||
) ??
|
||||
tabState.url;
|
||||
final containerData = await ref
|
||||
.read(tabDataRepositoryProvider.notifier)
|
||||
.getTabContainerData(selectedTabId);
|
||||
@@ -1025,7 +1051,7 @@ class _CloneTabExpansion extends ConsumerWidget {
|
||||
? await ref
|
||||
.read(tabRepositoryProvider.notifier)
|
||||
.addTab(
|
||||
url: tabState.url,
|
||||
url: cloneUrl,
|
||||
tabMode: TabMode.private,
|
||||
containerSelection: containerData == null
|
||||
? const TabContainerSelection.unassigned()
|
||||
@@ -1057,6 +1083,11 @@ class _CloneTabExpansion extends ConsumerWidget {
|
||||
iconColor: appColors.isolatedTabTeal,
|
||||
onTap: () async {
|
||||
final tabState = ref.read(tabStateProvider(selectedTabId))!;
|
||||
final cloneUrl =
|
||||
ref.read(
|
||||
sandboxSourceUriForTabProvider(tabId: tabState.id),
|
||||
) ??
|
||||
tabState.url;
|
||||
final containerData = await ref
|
||||
.read(tabDataRepositoryProvider.notifier)
|
||||
.getTabContainerData(selectedTabId);
|
||||
@@ -1064,7 +1095,7 @@ class _CloneTabExpansion extends ConsumerWidget {
|
||||
final tabId = await ref
|
||||
.read(tabRepositoryProvider.notifier)
|
||||
.addTab(
|
||||
url: tabState.url,
|
||||
url: cloneUrl,
|
||||
tabMode: TabMode.newIsolated(),
|
||||
containerSelection: containerData == null
|
||||
? const TabContainerSelection.unassigned()
|
||||
@@ -1289,7 +1320,12 @@ class _ShareExpansion extends HookConsumerWidget {
|
||||
final settings = ref.watch(generalSettingsWithDefaultsProvider);
|
||||
final catalogAsync = ref.watch(urlCleanerCatalogServiceProvider);
|
||||
final tabState = ref.watch(tabStateProvider(selectedTabId));
|
||||
final tabUrl = tabState?.url;
|
||||
final sandboxSourceUri = ref.watch(
|
||||
sandboxSourceUriForTabProvider(tabId: selectedTabId),
|
||||
);
|
||||
// Sandbox-captured tabs: every share/copy/QR/cleaner action must operate
|
||||
// on the canonical source URL — never the loopback loader.
|
||||
final tabUrl = sandboxSourceUri ?? tabState?.url;
|
||||
|
||||
final cleanedUrl = useState<Uri?>(null);
|
||||
final cleaner = useUrlCleanerController(
|
||||
@@ -1526,16 +1562,23 @@ class _SendToDeviceExpansion extends ConsumerWidget {
|
||||
);
|
||||
if (tabState == null) return;
|
||||
|
||||
final sendUrl =
|
||||
ref.read(
|
||||
sandboxSourceUriForTabProvider(
|
||||
tabId: tabState.id,
|
||||
),
|
||||
) ??
|
||||
tabState.url;
|
||||
final title = tabState.title.isNotEmpty
|
||||
? tabState.title
|
||||
: tabState.url.toString();
|
||||
: sendUrl.toString();
|
||||
|
||||
final success = await ref
|
||||
.read(syncRepositoryProvider.notifier)
|
||||
.sendTabToDevice(
|
||||
deviceId: device.deviceId,
|
||||
title: title,
|
||||
url: tabState.url.toString(),
|
||||
url: sendUrl.toString(),
|
||||
private: tabState.tabMode == TabMode.private,
|
||||
);
|
||||
|
||||
@@ -1884,7 +1927,7 @@ class _QuickLinksGrid extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final torConnected = ref.watch(
|
||||
final isTorActive = ref.watch(
|
||||
torProxyServiceProvider.select((value) => value.value?.isRunning == true),
|
||||
);
|
||||
|
||||
@@ -1955,7 +1998,7 @@ class _QuickLinksGrid extends ConsumerWidget {
|
||||
Navigator.pop(context);
|
||||
await const TorProxyRoute().push(context);
|
||||
},
|
||||
badge: torConnected,
|
||||
badge: isTorActive,
|
||||
badgeColor: AppColors.of(context).torActiveGreen,
|
||||
),
|
||||
),
|
||||
@@ -1980,8 +2023,6 @@ class _QuickLinksGrid extends ConsumerWidget {
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Expanded(child: SizedBox.shrink()),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
+64
-42
@@ -34,7 +34,9 @@ import 'package:weblibre/features/geckoview/features/browser/presentation/provid
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_icon.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/toolbar_button.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/utils/container_colors.dart';
|
||||
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
|
||||
import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_controller.dart';
|
||||
import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart';
|
||||
|
||||
class CompactAppBarTitle extends ConsumerWidget {
|
||||
@@ -64,6 +66,10 @@ class CompactAppBarTitle extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
final sandboxSourceUri = ref.watch(
|
||||
sandboxSourceUriForTabProvider(tabId: tabState.id),
|
||||
);
|
||||
|
||||
return CompactAppBarTitleView(
|
||||
tabState: tabState,
|
||||
isTabTunneled:
|
||||
@@ -71,6 +77,7 @@ class CompactAppBarTitle extends ConsumerWidget {
|
||||
siteSettingsBadgeState: siteSettingsBadgeState,
|
||||
longPressUrlCopy: settings.tabBarLongPressUrlCopy,
|
||||
containerColor: containerColor,
|
||||
sandboxSourceUri: sandboxSourceUri,
|
||||
onSiteSettingsTap: () {
|
||||
ref
|
||||
.read(bottomSheetControllerProvider.notifier)
|
||||
@@ -79,7 +86,7 @@ class CompactAppBarTitle extends ConsumerWidget {
|
||||
onTitleTap: () async {
|
||||
await SearchRoute(
|
||||
tabId: tabState.id,
|
||||
searchText: _searchTextForTab(tabState),
|
||||
searchText: searchTextForTab(tabState, sandboxSourceUri),
|
||||
tabType: tabState.tabMode.toTabType(),
|
||||
).push(context);
|
||||
},
|
||||
@@ -98,6 +105,7 @@ class CompactAppBarTitleView extends StatelessWidget {
|
||||
this.tabIcon,
|
||||
this.longPressUrlCopy = true,
|
||||
this.containerColor,
|
||||
this.sandboxSourceUri,
|
||||
});
|
||||
|
||||
final TabState tabState;
|
||||
@@ -108,12 +116,16 @@ class CompactAppBarTitleView extends StatelessWidget {
|
||||
final Widget? tabIcon;
|
||||
final bool longPressUrlCopy;
|
||||
final Color? containerColor;
|
||||
final Uri? sandboxSourceUri;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final appColors = AppColors.of(context);
|
||||
final containerColor = this.containerColor;
|
||||
final containerPalette = containerColor != null
|
||||
? ContainerColors.palette(context, containerColor)
|
||||
: null;
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
@@ -154,17 +166,11 @@ class CompactAppBarTitleView extends StatelessWidget {
|
||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: containerColor != null
|
||||
? Color.alphaBlend(
|
||||
containerColor.withValues(alpha: 0.08),
|
||||
theme.colorScheme.surfaceContainerHighest,
|
||||
)
|
||||
? containerPalette!.surfaceColor
|
||||
: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: containerColor != null
|
||||
? Border.all(
|
||||
color: containerColor.withValues(alpha: 0.5),
|
||||
width: 2,
|
||||
)
|
||||
border: containerPalette != null
|
||||
? Border.all(color: containerPalette.outlineColor)
|
||||
: null,
|
||||
),
|
||||
child: Row(
|
||||
@@ -190,15 +196,23 @@ class CompactAppBarTitleView extends StatelessWidget {
|
||||
const Icon(MdiIcons.tunnelOutline, size: 16),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
_SecurityStatusIcon(
|
||||
tabState: tabState,
|
||||
size: 16,
|
||||
containerColor: containerColor,
|
||||
),
|
||||
if (sandboxSourceUri != null) ...[
|
||||
Icon(
|
||||
MdiIcons.archiveLockOutline,
|
||||
color: theme.colorScheme.tertiary,
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
] else
|
||||
_SecurityStatusIcon(
|
||||
tabState: tabState,
|
||||
size: 16,
|
||||
containerColor: containerPalette?.accentColor,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Flexible(
|
||||
child: UriBreadcrumb(
|
||||
uri: tabState.url,
|
||||
uri: sandboxSourceUri ?? tabState.url,
|
||||
showHttpScheme: false,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurface,
|
||||
@@ -206,7 +220,10 @@ class CompactAppBarTitleView extends StatelessWidget {
|
||||
onTooltipTriggered: longPressUrlCopy
|
||||
? () async {
|
||||
await Clipboard.setData(
|
||||
ClipboardData(text: tabState.url.toString()),
|
||||
ClipboardData(
|
||||
text: (sandboxSourceUri ?? tabState.url)
|
||||
.toString(),
|
||||
),
|
||||
);
|
||||
}
|
||||
: null,
|
||||
@@ -250,6 +267,10 @@ class AppBarTitle extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
final sandboxSourceUri = ref.watch(
|
||||
sandboxSourceUriForTabProvider(tabId: tabState.id),
|
||||
);
|
||||
|
||||
return AppBarTitleView(
|
||||
tabState: tabState,
|
||||
isTabTunneled:
|
||||
@@ -257,6 +278,7 @@ class AppBarTitle extends ConsumerWidget {
|
||||
siteSettingsBadgeState: siteSettingsBadgeState,
|
||||
longPressUrlCopy: settings.tabBarLongPressUrlCopy,
|
||||
containerColor: containerColor,
|
||||
sandboxSourceUri: sandboxSourceUri,
|
||||
onSiteSettingsTap: () {
|
||||
ref
|
||||
.read(bottomSheetControllerProvider.notifier)
|
||||
@@ -265,7 +287,7 @@ class AppBarTitle extends ConsumerWidget {
|
||||
onTitleTap: () async {
|
||||
await SearchRoute(
|
||||
tabId: tabState.id,
|
||||
searchText: _searchTextForTab(tabState),
|
||||
searchText: searchTextForTab(tabState, sandboxSourceUri),
|
||||
tabType: tabState.tabMode.toTabType(),
|
||||
).push(context);
|
||||
},
|
||||
@@ -284,6 +306,7 @@ class AppBarTitleView extends StatelessWidget {
|
||||
required this.longPressUrlCopy,
|
||||
this.tabIcon,
|
||||
this.containerColor,
|
||||
this.sandboxSourceUri,
|
||||
});
|
||||
|
||||
final TabState tabState;
|
||||
@@ -294,12 +317,16 @@ class AppBarTitleView extends StatelessWidget {
|
||||
final Widget? tabIcon;
|
||||
final bool longPressUrlCopy;
|
||||
final Color? containerColor;
|
||||
final Uri? sandboxSourceUri;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final appColors = AppColors.of(context);
|
||||
final containerColor = this.containerColor;
|
||||
final containerPalette = containerColor != null
|
||||
? ContainerColors.palette(context, containerColor)
|
||||
: null;
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
@@ -370,17 +397,11 @@ class AppBarTitleView extends StatelessWidget {
|
||||
: EdgeInsets.zero,
|
||||
decoration: BoxDecoration(
|
||||
color: containerColor != null
|
||||
? Color.alphaBlend(
|
||||
containerColor.withValues(alpha: 0.08),
|
||||
theme.colorScheme.surfaceContainerHighest,
|
||||
)
|
||||
? containerPalette!.surfaceColor
|
||||
: null,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: containerColor != null
|
||||
? Border.all(
|
||||
color: containerColor.withValues(alpha: 0.5),
|
||||
width: 2,
|
||||
)
|
||||
border: containerPalette != null
|
||||
? Border.all(color: containerPalette.outlineColor)
|
||||
: null,
|
||||
),
|
||||
child: Row(
|
||||
@@ -404,15 +425,23 @@ class AppBarTitleView extends StatelessWidget {
|
||||
const Icon(MdiIcons.tunnelOutline, size: 14),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
_SecurityStatusIcon(
|
||||
tabState: tabState,
|
||||
size: 14,
|
||||
containerColor: containerColor,
|
||||
),
|
||||
if (sandboxSourceUri != null) ...[
|
||||
Icon(
|
||||
MdiIcons.archiveLockOutline,
|
||||
color: theme.colorScheme.tertiary,
|
||||
size: 14,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
] else
|
||||
_SecurityStatusIcon(
|
||||
tabState: tabState,
|
||||
size: 14,
|
||||
containerColor: containerPalette?.accentColor,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: UriBreadcrumb(
|
||||
uri: tabState.url,
|
||||
uri: sandboxSourceUri ?? tabState.url,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
@@ -420,7 +449,8 @@ class AppBarTitleView extends StatelessWidget {
|
||||
? () async {
|
||||
await Clipboard.setData(
|
||||
ClipboardData(
|
||||
text: tabState.url.toString(),
|
||||
text: (sandboxSourceUri ?? tabState.url)
|
||||
.toString(),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -509,11 +539,3 @@ class _EmptyAppBarAddressField extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _searchTextForTab(TabState tabState) {
|
||||
final searchText = tabState.url.scheme == 'about'
|
||||
? ''
|
||||
: tabState.url.toString();
|
||||
|
||||
return searchText.isEmpty ? SearchRoute.emptySearchText : searchText;
|
||||
}
|
||||
|
||||
+125
-48
@@ -27,6 +27,7 @@ import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:nullability/nullability.dart';
|
||||
import 'package:weblibre/core/design/app_colors.dart';
|
||||
import 'package:weblibre/features/addons/presentation/widgets/pinned_addon_bar.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/controllers/bottom_sheet.dart';
|
||||
@@ -50,6 +51,7 @@ import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart'
|
||||
import 'package:weblibre/features/geckoview/features/tabs/utils/container_colors.dart';
|
||||
import 'package:weblibre/features/user/data/models/general_settings.dart';
|
||||
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
|
||||
import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_controller.dart';
|
||||
import 'package:weblibre/presentation/widgets/selectable_chips.dart';
|
||||
import 'package:weblibre/presentation/widgets/url_icon.dart';
|
||||
|
||||
@@ -240,6 +242,9 @@ class BrowserTabBar extends HookConsumerWidget {
|
||||
displayedSheet is! ViewTabsSheet)
|
||||
? containerColor
|
||||
: null;
|
||||
final effectiveContainerPalette = effectiveContainerColor != null
|
||||
? ContainerColors.palette(context, effectiveContainerColor)
|
||||
: null;
|
||||
|
||||
return BrowserTabBarView(
|
||||
showMainToolbar: showMainToolbar,
|
||||
@@ -247,7 +252,7 @@ class BrowserTabBar extends HookConsumerWidget {
|
||||
showQuickTabSwitcherBar: showQuickTabSwitcherBar,
|
||||
displayAppBar: displayAppBar,
|
||||
displayQuickTabSwitcher: displayQuickTabSwitcher,
|
||||
backgroundColor: null,
|
||||
backgroundColor: effectiveContainerPalette?.surfaceColor,
|
||||
title: showTabTitle
|
||||
? settings.tabBarLayout == TabBarLayout.compact
|
||||
? CompactAppBarTitle(containerColor: effectiveContainerColor)
|
||||
@@ -395,6 +400,8 @@ class BrowserTabBarView extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final effectiveBackgroundColor =
|
||||
backgroundColor ?? colorScheme.surfaceContainer;
|
||||
|
||||
return GestureDetector(
|
||||
// Tap handling moved to AppBarTitle for split icon/title behavior
|
||||
@@ -402,36 +409,38 @@ class BrowserTabBarView extends StatelessWidget {
|
||||
onHorizontalDragEnd: onHorizontalDragEnd,
|
||||
onVerticalDragStart: onVerticalDragStart,
|
||||
onVerticalDragEnd: onVerticalDragEnd,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (showQuickTabSwitcherBar)
|
||||
Visibility(
|
||||
visible: displayQuickTabSwitcher,
|
||||
maintainState: true,
|
||||
child: quickTabSwitcher,
|
||||
),
|
||||
if (showMainToolbar)
|
||||
Visibility(
|
||||
visible: displayAppBar,
|
||||
maintainState: true,
|
||||
child: AppBar(
|
||||
primary: false,
|
||||
automaticallyImplyLeading: false,
|
||||
backgroundColor:
|
||||
backgroundColor ?? colorScheme.surfaceContainer,
|
||||
scrolledUnderElevation: 0,
|
||||
shadowColor: Colors.transparent,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
titleSpacing: 0.0,
|
||||
leadingWidth: 40.0,
|
||||
toolbarHeight: kToolbarHeight,
|
||||
title: title,
|
||||
actions: actions,
|
||||
child: ColoredBox(
|
||||
color: effectiveBackgroundColor,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (showQuickTabSwitcherBar)
|
||||
Visibility(
|
||||
visible: displayQuickTabSwitcher,
|
||||
maintainState: true,
|
||||
child: quickTabSwitcher,
|
||||
),
|
||||
),
|
||||
if (showContextualToolbar) contextualToolbar,
|
||||
],
|
||||
if (showMainToolbar)
|
||||
Visibility(
|
||||
visible: displayAppBar,
|
||||
maintainState: true,
|
||||
child: AppBar(
|
||||
primary: false,
|
||||
automaticallyImplyLeading: false,
|
||||
backgroundColor: Colors.transparent,
|
||||
scrolledUnderElevation: 0,
|
||||
shadowColor: Colors.transparent,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
titleSpacing: 0.0,
|
||||
leadingWidth: 40.0,
|
||||
toolbarHeight: kToolbarHeight,
|
||||
title: title,
|
||||
actions: actions,
|
||||
),
|
||||
),
|
||||
if (showContextualToolbar) contextualToolbar,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -444,6 +453,7 @@ class QuickTabSwitcherItem with FastEquatable {
|
||||
final TabMode tabMode;
|
||||
final bool isHistory;
|
||||
final bool isPinned;
|
||||
final bool isSandbox;
|
||||
final String title;
|
||||
final Uri url;
|
||||
final Widget avatar;
|
||||
@@ -458,6 +468,7 @@ class QuickTabSwitcherItem with FastEquatable {
|
||||
required this.title,
|
||||
required this.url,
|
||||
required this.avatar,
|
||||
this.isSandbox = false,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -468,6 +479,7 @@ class QuickTabSwitcherItem with FastEquatable {
|
||||
tabMode,
|
||||
isHistory,
|
||||
isPinned,
|
||||
isSandbox,
|
||||
title,
|
||||
url,
|
||||
avatar,
|
||||
@@ -504,20 +516,31 @@ class QuickTabSwitcher extends HookConsumerWidget {
|
||||
final historySuggestions = ref
|
||||
.watch(quickTabSwitcherHistorySuggestionsProvider(quickTabSwitcherMode))
|
||||
.value;
|
||||
final sandboxCaptureMap =
|
||||
ref.watch(sandboxCaptureMapProvider).value ?? const {};
|
||||
final availableItems = tabStates.value
|
||||
.map<QuickTabSwitcherItem>(
|
||||
(state) => QuickTabSwitcherItem(
|
||||
.map<QuickTabSwitcherItem>((state) {
|
||||
final sandboxSourceUri = parseSandboxSource(
|
||||
sandboxCaptureMap[state.$1.id],
|
||||
);
|
||||
final displayUrl = sandboxSourceUri ?? state.$1.url;
|
||||
final displayTitle =
|
||||
sandboxSourceUri != null && state.$1.title.isEmpty
|
||||
? sandboxSourceUri.authority
|
||||
: state.$1.titleOrAuthority;
|
||||
return QuickTabSwitcherItem(
|
||||
color: state.$2?.color,
|
||||
id: state.$1.id,
|
||||
isActive: state.$1.id == selectedTabId,
|
||||
title: state.$1.titleOrAuthority,
|
||||
title: displayTitle,
|
||||
tabMode: state.$1.tabMode,
|
||||
isHistory: false,
|
||||
isPinned: pinnedTabIds?.contains(state.$1.id) ?? false,
|
||||
url: state.$1.url,
|
||||
isSandbox: sandboxSourceUri != null,
|
||||
url: displayUrl,
|
||||
avatar: TabIcon(tabState: state.$1, iconSize: 20),
|
||||
),
|
||||
)
|
||||
);
|
||||
})
|
||||
.followedBy(
|
||||
(historySuggestions ?? []).map<QuickTabSwitcherItem>((state) {
|
||||
final url = Uri.parse(state.url);
|
||||
@@ -694,6 +717,7 @@ class QuickTabSwitcherView extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appColors = AppColors.of(context);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
if (availableItems.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
@@ -715,16 +739,42 @@ class QuickTabSwitcherView extends StatelessWidget {
|
||||
itemId: (item) => item.id,
|
||||
selectedItem: activeItem,
|
||||
selectedBorderColor: Theme.of(context).colorScheme.primary,
|
||||
labelPadding: (item) =>
|
||||
(!showTitles &&
|
||||
!item.isHistory &&
|
||||
!item.isPinned &&
|
||||
item.tabMode is! PrivateTabMode &&
|
||||
item.tabMode is! IsolatedTabMode)
|
||||
? EdgeInsets.zero
|
||||
: null,
|
||||
decoration: SelectableChipDecoration(
|
||||
color: (item, isSelected) => switch (item.color) {
|
||||
final color? when isSelected => ContainerColors.palette(
|
||||
context,
|
||||
color,
|
||||
).selectedBackgroundColor,
|
||||
final color? => ContainerColors.palette(
|
||||
context,
|
||||
color,
|
||||
).backgroundColor,
|
||||
null => null,
|
||||
},
|
||||
side: (item, isSelected) => switch (item.color) {
|
||||
final color? when isSelected => ContainerColors.palette(
|
||||
context,
|
||||
color,
|
||||
).selectedBorderSide,
|
||||
final color? => ContainerColors.palette(
|
||||
context,
|
||||
color,
|
||||
).borderSide,
|
||||
null => null,
|
||||
},
|
||||
labelPadding: (item) =>
|
||||
(!showTitles &&
|
||||
!item.isHistory &&
|
||||
!item.isPinned &&
|
||||
!item.isSandbox &&
|
||||
item.tabMode is! PrivateTabMode &&
|
||||
item.tabMode is! IsolatedTabMode)
|
||||
? EdgeInsets.zero
|
||||
: null,
|
||||
),
|
||||
itemLabel: (item) {
|
||||
return Row(
|
||||
final isSelected = activeItem?.id == item.id;
|
||||
final row = Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (item.isHistory || showTitles)
|
||||
@@ -750,6 +800,15 @@ class QuickTabSwitcherView extends StatelessWidget {
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
if (item.isSandbox)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8.0),
|
||||
child: Icon(
|
||||
MdiIcons.archiveLockOutline,
|
||||
color: Theme.of(context).colorScheme.tertiary,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
if (item.isPinned)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8.0),
|
||||
@@ -766,11 +825,29 @@ class QuickTabSwitcherView extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
return item.color.mapNotNull(
|
||||
(color) => DefaultTextStyle.merge(
|
||||
style: TextStyle(
|
||||
color: isSelected
|
||||
? ContainerColors.palette(
|
||||
context,
|
||||
color,
|
||||
).selectedForegroundColor
|
||||
: ContainerColors.palette(
|
||||
context,
|
||||
color,
|
||||
).foregroundColor,
|
||||
fontWeight: isSelected
|
||||
? FontWeight.w700
|
||||
: FontWeight.w500,
|
||||
),
|
||||
child: row,
|
||||
),
|
||||
) ??
|
||||
row;
|
||||
},
|
||||
itemAvatar: (item) => item.avatar,
|
||||
itemBackgroundColor: (item) => item.color != null
|
||||
? ContainerColors.forChip(item.color!)
|
||||
: null,
|
||||
onSelected: onSelected,
|
||||
itemWrap: itemWrapBuilder,
|
||||
availableItems: availableItems,
|
||||
|
||||
+22
@@ -50,6 +50,7 @@ import 'package:weblibre/features/geckoview/features/history/domain/repositories
|
||||
import 'package:weblibre/features/geckoview/features/preferences/data/repositories/preference_observer.dart';
|
||||
import 'package:weblibre/features/geckoview/features/pwa/domain/providers.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/domain/services/local_index_pruner.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selected_container.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart';
|
||||
@@ -60,6 +61,7 @@ import 'package:weblibre/features/intent_gatekeeper/domain/services/native_gatek
|
||||
import 'package:weblibre/features/intent_gatekeeper/presentation/widgets/intent_gatekeeper_dialog.dart';
|
||||
import 'package:weblibre/features/share_intent/domain/entities/intent_container_mode.dart';
|
||||
import 'package:weblibre/features/share_intent/domain/entities/shared_content.dart';
|
||||
import 'package:weblibre/features/tor/domain/services/tor_proxy.dart';
|
||||
import 'package:weblibre/features/user/data/models/general_settings.dart';
|
||||
import 'package:weblibre/features/user/domain/providers/profile_auth.dart';
|
||||
import 'package:weblibre/features/user/domain/repositories/cache.dart';
|
||||
@@ -132,6 +134,8 @@ class _BrowserViewState extends ConsumerState<BrowserView>
|
||||
DateTime(0),
|
||||
DateTime.now().subtract(settings.historyAutoCleanInterval),
|
||||
);
|
||||
|
||||
unawaited(ref.read(localIndexPrunerProvider.notifier).prune());
|
||||
}
|
||||
|
||||
if (settings.unassignedTabsAutoCleanInterval > Duration.zero) {
|
||||
@@ -631,6 +635,24 @@ class _BrowserViewState extends ConsumerState<BrowserView>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
//Ensure tor events don't get dropped
|
||||
ref.listenManual(
|
||||
fireImmediately: true,
|
||||
torProxyServiceProvider,
|
||||
(previous, next) {
|
||||
if (next.hasValue) {
|
||||
debugPrint(next.requireValue.toString());
|
||||
}
|
||||
},
|
||||
onError: (error, stackTrace) {
|
||||
logger.e(
|
||||
'Error listening to torProxyServiceProvider',
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
+19
@@ -24,6 +24,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:skeletonizer/skeletonizer.dart';
|
||||
import 'package:weblibre/extensions/uri.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
|
||||
import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_controller.dart';
|
||||
|
||||
class CertificateTile extends HookConsumerWidget {
|
||||
const CertificateTile({super.key});
|
||||
@@ -36,6 +37,24 @@ class CertificateTile extends HookConsumerWidget {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final sandboxSourceUri = ref.watch(
|
||||
sandboxSourceUriForTabProvider(tabId: tabState.id),
|
||||
);
|
||||
if (sandboxSourceUri != null) {
|
||||
// Sandbox-captured page is served from a loopback server; the cert
|
||||
// chain shown would be for localhost, not the canonical site.
|
||||
return ListTile(
|
||||
leading: Icon(
|
||||
MdiIcons.archiveLockOutline,
|
||||
color: Theme.of(context).colorScheme.tertiary,
|
||||
),
|
||||
title: const Text('Sandboxed capture'),
|
||||
subtitle: const Text(
|
||||
'Page is served from an offline archive — no live connection.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final icon = useMemoized(() {
|
||||
if (tabState.url.isHttp) {
|
||||
return ListTile(
|
||||
|
||||
+20
-5
@@ -36,6 +36,7 @@ import 'package:weblibre/features/geckoview/features/tabs/data/database/definiti
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart';
|
||||
import 'package:weblibre/features/sync/domain/repositories/sync.dart';
|
||||
import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_controller.dart';
|
||||
import 'package:weblibre/presentation/hooks/cached_future.dart';
|
||||
import 'package:weblibre/utils/ui_helper.dart' as ui_helper;
|
||||
|
||||
@@ -51,8 +52,11 @@ class ShareMenuItemButton extends HookConsumerWidget {
|
||||
closeOnActivate: false,
|
||||
onPressed: () async {
|
||||
final tabState = ref.read(tabStateProvider(selectedTabId))!;
|
||||
final shareUrl =
|
||||
ref.read(sandboxSourceUriForTabProvider(tabId: tabState.id)) ??
|
||||
tabState.url;
|
||||
|
||||
await SharePlus.instance.share(ShareParams(uri: tabState.url));
|
||||
await SharePlus.instance.share(ShareParams(uri: shareUrl));
|
||||
|
||||
if (context.mounted) {
|
||||
MenuController.maybeOf(context)?.close();
|
||||
@@ -75,8 +79,11 @@ class ShowQrCodeMenuItemButton extends HookConsumerWidget {
|
||||
closeOnActivate: false,
|
||||
onPressed: () async {
|
||||
final tabState = ref.read(tabStateProvider(selectedTabId))!;
|
||||
final qrUrl =
|
||||
ref.read(sandboxSourceUriForTabProvider(tabId: tabState.id)) ??
|
||||
tabState.url;
|
||||
|
||||
await showQrCode(context, tabState.url.toString());
|
||||
await showQrCode(context, qrUrl.toString());
|
||||
|
||||
if (context.mounted) {
|
||||
MenuController.maybeOf(context)?.close();
|
||||
@@ -362,8 +369,11 @@ class CopyAddressMenuItemButton extends HookConsumerWidget {
|
||||
child: const Text('Copy Address'),
|
||||
onPressed: () async {
|
||||
final tabState = ref.read(tabStateProvider(selectedTabId))!;
|
||||
final copyUrl =
|
||||
ref.read(sandboxSourceUriForTabProvider(tabId: tabState.id)) ??
|
||||
tabState.url;
|
||||
|
||||
await Clipboard.setData(ClipboardData(text: tabState.url.toString()));
|
||||
await Clipboard.setData(ClipboardData(text: copyUrl.toString()));
|
||||
|
||||
if (context.mounted) {
|
||||
MenuController.maybeOf(context)?.close();
|
||||
@@ -419,16 +429,21 @@ class SendTabToDeviceMenuItemButton extends HookConsumerWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
final sendUrl =
|
||||
ref.read(
|
||||
sandboxSourceUriForTabProvider(tabId: tabState.id),
|
||||
) ??
|
||||
tabState.url;
|
||||
final title = tabState.title.isNotEmpty
|
||||
? tabState.title
|
||||
: tabState.url.toString();
|
||||
: sendUrl.toString();
|
||||
|
||||
final success = await ref
|
||||
.read(syncRepositoryProvider.notifier)
|
||||
.sendTabToDevice(
|
||||
deviceId: device.deviceId,
|
||||
title: title,
|
||||
url: tabState.url.toString(),
|
||||
url: sendUrl.toString(),
|
||||
private: tabState.tabMode == TabMode.private,
|
||||
);
|
||||
|
||||
|
||||
+17
-3
@@ -39,6 +39,7 @@ import 'package:weblibre/features/geckoview/features/open_link_tools/presentatio
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
|
||||
import 'package:weblibre/features/sync/domain/repositories/sync.dart';
|
||||
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
|
||||
import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_controller.dart';
|
||||
import 'package:weblibre/presentation/hooks/cached_future.dart';
|
||||
import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart';
|
||||
import 'package:weblibre/presentation/widgets/url_icon.dart';
|
||||
@@ -67,9 +68,15 @@ class ShareBottomSheet extends HookConsumerWidget {
|
||||
final settings = ref.watch(generalSettingsWithDefaultsProvider);
|
||||
final catalogAsync = ref.watch(urlCleanerCatalogServiceProvider);
|
||||
|
||||
final tabUrl = ref.watch(
|
||||
final rawTabUrl = ref.watch(
|
||||
tabStateProvider(selectedTabId).select((v) => v?.url),
|
||||
);
|
||||
final sandboxSourceUri = ref.watch(
|
||||
sandboxSourceUriForTabProvider(tabId: selectedTabId),
|
||||
);
|
||||
// For sandbox-captured tabs every share/copy/QR/cleaner action must
|
||||
// operate on the canonical source URL — never the loopback loader.
|
||||
final tabUrl = sandboxSourceUri ?? rawTabUrl;
|
||||
|
||||
final cleanedUrl = useState<Uri?>(null);
|
||||
final cleaner = useUrlCleanerController(
|
||||
@@ -421,16 +428,23 @@ class _SendToDeviceTile extends ConsumerWidget {
|
||||
);
|
||||
if (tabState == null) return;
|
||||
|
||||
final sendUrl =
|
||||
ref.read(
|
||||
sandboxSourceUriForTabProvider(
|
||||
tabId: tabState.id,
|
||||
),
|
||||
) ??
|
||||
tabState.url;
|
||||
final title = tabState.title.isNotEmpty
|
||||
? tabState.title
|
||||
: tabState.url.toString();
|
||||
: sendUrl.toString();
|
||||
|
||||
final success = await ref
|
||||
.read(syncRepositoryProvider.notifier)
|
||||
.sendTabToDevice(
|
||||
deviceId: device.deviceId,
|
||||
title: title,
|
||||
url: tabState.url.toString(),
|
||||
url: sendUrl.toString(),
|
||||
private: tabState.tabMode == TabMode.private,
|
||||
);
|
||||
|
||||
|
||||
+10
-7
@@ -30,6 +30,7 @@ import 'package:weblibre/features/geckoview/features/browser/presentation/widget
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/sheets/permissions_section.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/sheets/tracking_protection_section.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
|
||||
import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_controller.dart';
|
||||
import 'package:weblibre/presentation/widgets/website_title_tile.dart';
|
||||
|
||||
class ClampingScrollPhysicsWithoutImplicit extends ClampingScrollPhysics {
|
||||
@@ -112,16 +113,18 @@ class ViewTabSheetWidget extends HookConsumerWidget {
|
||||
// Dismiss sheet and open search screen with tab context
|
||||
onClose();
|
||||
|
||||
// Don't pre-fill for internal URLs
|
||||
final searchText = initialTabState.url.scheme == 'about'
|
||||
? ''
|
||||
: initialTabState.url.toString();
|
||||
final sandboxSourceUri = ref.read(
|
||||
sandboxSourceUriForTabProvider(
|
||||
tabId: initialTabState.id,
|
||||
),
|
||||
);
|
||||
|
||||
await SearchRoute(
|
||||
tabId: initialTabState.id,
|
||||
searchText: searchText.isEmpty
|
||||
? SearchRoute.emptySearchText
|
||||
: searchText,
|
||||
searchText: searchTextForTab(
|
||||
initialTabState,
|
||||
sandboxSourceUri,
|
||||
),
|
||||
tabType: initialTabState.tabMode.toTabType(),
|
||||
).push(context);
|
||||
},
|
||||
|
||||
+10
@@ -25,8 +25,10 @@ import 'package:skeletonizer/skeletonizer.dart';
|
||||
import 'package:weblibre/domain/entities/equatable_image.dart';
|
||||
import 'package:weblibre/domain/services/generic_website.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart';
|
||||
import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_controller.dart';
|
||||
import 'package:weblibre/presentation/hooks/cached_future.dart';
|
||||
import 'package:weblibre/presentation/widgets/safe_raw_image.dart';
|
||||
import 'package:weblibre/presentation/widgets/url_icon.dart';
|
||||
|
||||
class TabIcon extends HookConsumerWidget {
|
||||
final TabState tabState;
|
||||
@@ -37,6 +39,10 @@ class TabIcon extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final sandboxSourceUri = ref.watch(
|
||||
sandboxSourceUriForTabProvider(tabId: tabState.id),
|
||||
);
|
||||
|
||||
final icon = useCachedFuture(() async {
|
||||
if (tabState.icon case final EquatableImage tabIcon
|
||||
when !tabIcon.isDisposed) {
|
||||
@@ -50,6 +56,10 @@ class TabIcon extends HookConsumerWidget {
|
||||
return cachedIcon?.image;
|
||||
}, [tabState.icon, tabState.url]);
|
||||
|
||||
if (sandboxSourceUri != null) {
|
||||
return UrlIcon([sandboxSourceUri], iconSize: iconSize, cacheOnly: true);
|
||||
}
|
||||
|
||||
return Skeletonizer(
|
||||
enabled: icon.connectionState != ConnectionState.done,
|
||||
child: Skeleton.replace(
|
||||
|
||||
+25
-4
@@ -51,6 +51,7 @@ import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart'
|
||||
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart';
|
||||
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
|
||||
import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_controller.dart';
|
||||
import 'package:weblibre/presentation/hooks/menu_controller.dart';
|
||||
import 'package:weblibre/presentation/widgets/website_feed_menu_button.dart';
|
||||
import 'package:weblibre/utils/ui_helper.dart' as ui_helper;
|
||||
@@ -196,12 +197,17 @@ class TabMenu extends HookConsumerWidget {
|
||||
child: const Text('Add Bookmark'),
|
||||
onPressed: () async {
|
||||
final tabState = ref.read(tabStateProvider(selectedTabId))!;
|
||||
final bookmarkUrl =
|
||||
ref.read(
|
||||
sandboxSourceUriForTabProvider(tabId: tabState.id),
|
||||
) ??
|
||||
tabState.url;
|
||||
|
||||
await BookmarkEntryAddRoute(
|
||||
bookmarkInfo: jsonEncode(
|
||||
BookmarkInfo(
|
||||
title: tabState.titleOrAuthority,
|
||||
url: tabState.url.toString(),
|
||||
url: bookmarkUrl.toString(),
|
||||
).encode(),
|
||||
),
|
||||
).push(context);
|
||||
@@ -248,12 +254,17 @@ class TabMenu extends HookConsumerWidget {
|
||||
.read(tabDataRepositoryProvider.notifier)
|
||||
.getTabContainerData(selectedTabId);
|
||||
|
||||
final cloneUrl =
|
||||
ref.read(
|
||||
sandboxSourceUriForTabProvider(tabId: tabState.id),
|
||||
) ??
|
||||
tabState.url;
|
||||
final tabId = (tabState.tabMode is! RegularTabMode)
|
||||
? await ref
|
||||
.read(tabRepositoryProvider.notifier)
|
||||
.addTab(
|
||||
tabMode: TabMode.regular,
|
||||
url: tabState.url,
|
||||
url: cloneUrl,
|
||||
containerSelection: containerData == null
|
||||
? const TabContainerSelection.unassigned()
|
||||
: TabContainerSelection.specific(
|
||||
@@ -294,11 +305,16 @@ class TabMenu extends HookConsumerWidget {
|
||||
.read(tabDataRepositoryProvider.notifier)
|
||||
.getTabContainerData(selectedTabId);
|
||||
|
||||
final cloneUrl =
|
||||
ref.read(
|
||||
sandboxSourceUriForTabProvider(tabId: tabState.id),
|
||||
) ??
|
||||
tabState.url;
|
||||
final tabId = (tabState.tabMode is! PrivateTabMode)
|
||||
? await ref
|
||||
.read(tabRepositoryProvider.notifier)
|
||||
.addTab(
|
||||
url: tabState.url,
|
||||
url: cloneUrl,
|
||||
tabMode: TabMode.private,
|
||||
containerSelection: containerData == null
|
||||
? const TabContainerSelection.unassigned()
|
||||
@@ -341,10 +357,15 @@ class TabMenu extends HookConsumerWidget {
|
||||
.read(tabDataRepositoryProvider.notifier)
|
||||
.getTabContainerData(selectedTabId);
|
||||
|
||||
final cloneUrl =
|
||||
ref.read(
|
||||
sandboxSourceUriForTabProvider(tabId: tabState.id),
|
||||
) ??
|
||||
tabState.url;
|
||||
final tabId = await ref
|
||||
.read(tabRepositoryProvider.notifier)
|
||||
.addTab(
|
||||
url: tabState.url,
|
||||
url: cloneUrl,
|
||||
tabMode: TabMode.newIsolated(),
|
||||
containerSelection: containerData == null
|
||||
? const TabContainerSelection.unassigned()
|
||||
|
||||
+23
-6
@@ -36,6 +36,7 @@ import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode
|
||||
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/features/web_search/domain/controllers/sandbox_capture_controller.dart';
|
||||
import 'package:weblibre/presentation/hooks/menu_controller.dart';
|
||||
import 'package:weblibre/presentation/widgets/safe_raw_image.dart';
|
||||
import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart';
|
||||
@@ -173,6 +174,14 @@ class GridTabPreview extends HookConsumerWidget {
|
||||
) ??
|
||||
TabState.$default(tabId);
|
||||
|
||||
final sandboxSourceUri = ref.watch(
|
||||
sandboxSourceUriForTabProvider(tabId: tabId),
|
||||
);
|
||||
final displayUrl = sandboxSourceUri ?? tabState.url;
|
||||
final displayTitle = sandboxSourceUri != null && tabState.title.isEmpty
|
||||
? sandboxSourceUri.authority
|
||||
: tabState.titleOrAuthority;
|
||||
|
||||
final extendedDeleteMenuController = useMenuController();
|
||||
|
||||
// ignore: avoid_bool_literals_in_conditional_expressions
|
||||
@@ -249,7 +258,7 @@ class GridTabPreview extends HookConsumerWidget {
|
||||
menuChildren: [
|
||||
MenuItemButton(
|
||||
onPressed: () {
|
||||
onDeleteAll?.call(tabState.url.host);
|
||||
onDeleteAll?.call(displayUrl.host);
|
||||
},
|
||||
leadingIcon: const Icon(Icons.language),
|
||||
child: const Text('Close from Same Host'),
|
||||
@@ -389,7 +398,7 @@ class GridTabPreview extends HookConsumerWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
tabState.titleOrAuthority,
|
||||
displayTitle,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
@@ -404,7 +413,7 @@ class GridTabPreview extends HookConsumerWidget {
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
tabState.url.authority,
|
||||
displayUrl.authority,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
@@ -477,6 +486,14 @@ class ListTabPreview extends HookConsumerWidget {
|
||||
) ??
|
||||
TabState.$default(tabId);
|
||||
|
||||
final sandboxSourceUri = ref.watch(
|
||||
sandboxSourceUriForTabProvider(tabId: tabId),
|
||||
);
|
||||
final displayUrl = sandboxSourceUri ?? tabState.url;
|
||||
final displayTitle = sandboxSourceUri != null && tabState.title.isEmpty
|
||||
? sandboxSourceUri.authority
|
||||
: tabState.titleOrAuthority;
|
||||
|
||||
final tabListShowFavicons = ref.watch(
|
||||
generalSettingsWithDefaultsProvider.select((s) => s.tabListShowFavicons),
|
||||
);
|
||||
@@ -563,7 +580,7 @@ class ListTabPreview extends HookConsumerWidget {
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
tabState.titleOrAuthority,
|
||||
displayTitle,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
@@ -592,7 +609,7 @@ class ListTabPreview extends HookConsumerWidget {
|
||||
],
|
||||
Expanded(
|
||||
child: UriBreadcrumb(
|
||||
uri: tabState.url,
|
||||
uri: displayUrl,
|
||||
showHttpScheme: false,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: subtitleColor,
|
||||
@@ -616,7 +633,7 @@ class ListTabPreview extends HookConsumerWidget {
|
||||
menuChildren: [
|
||||
MenuItemButton(
|
||||
onPressed: () {
|
||||
onDeleteAll?.call(tabState.url.host);
|
||||
onDeleteAll?.call(displayUrl.host);
|
||||
},
|
||||
leadingIcon: const Icon(Icons.language),
|
||||
child: const Text('Close from Same Host'),
|
||||
|
||||
+18
-11
@@ -20,6 +20,7 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:fading_scroll/fading_scroll.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
@@ -302,18 +303,24 @@ class ViewTabTreesWidget extends HookConsumerWidget {
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4.0),
|
||||
child: GridView.builder(
|
||||
child: FadingScroll(
|
||||
controller: scrollController,
|
||||
padding: const EdgeInsets.only(bottom: 56),
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
//Sync values for itemHeight calculation _calculateItemHeight
|
||||
childAspectRatio: 0.75,
|
||||
mainAxisSpacing: 8.0,
|
||||
crossAxisSpacing: 8.0,
|
||||
crossAxisCount: crossAxisCount,
|
||||
),
|
||||
itemCount: tabs.length,
|
||||
itemBuilder: (context, index) => tabs[index],
|
||||
fadingSize: 5,
|
||||
builder: (context, controller) {
|
||||
return GridView.builder(
|
||||
controller: controller,
|
||||
padding: const EdgeInsets.only(bottom: 56),
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
//Sync values for itemHeight calculation _calculateItemHeight
|
||||
childAspectRatio: 0.75,
|
||||
mainAxisSpacing: 8.0,
|
||||
crossAxisSpacing: 8.0,
|
||||
crossAxisCount: crossAxisCount,
|
||||
),
|
||||
itemCount: tabs.length,
|
||||
itemBuilder: (context, index) => tabs[index],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
+2
-1
@@ -184,7 +184,8 @@ class _ContainerPickerSheet extends HookConsumerWidget {
|
||||
itemBuilder: (context, index) => ContainerListTile(
|
||||
ContainerData(
|
||||
id: Namespace.nil.value,
|
||||
color: Colors.transparent,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
orderKey: '',
|
||||
),
|
||||
isSelected: false,
|
||||
onTap: null,
|
||||
|
||||
+5
-4
@@ -17,6 +17,7 @@
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:nullability/nullability.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:weblibre/core/logger.dart';
|
||||
@@ -26,7 +27,7 @@ import 'package:weblibre/features/geckoview/features/find_in_page/domain/reposit
|
||||
|
||||
part 'find_in_page.g.dart';
|
||||
|
||||
@Riverpod()
|
||||
@Riverpod(keepAlive: true)
|
||||
class FindInPageController extends _$FindInPageController {
|
||||
void show() {
|
||||
state = state.copyWith.visible(true);
|
||||
@@ -49,7 +50,7 @@ class FindInPageController extends _$FindInPageController {
|
||||
final service = ref.read(findInPageRepositoryProvider(tabId).notifier);
|
||||
|
||||
final hasMatches =
|
||||
ref.read(selectedTabStateProvider)?.findResultState.hasMatches == true;
|
||||
ref.read(tabStatesProvider)[tabId]?.findResultState.hasMatches == true;
|
||||
|
||||
state = state.copyWith.visible(true);
|
||||
|
||||
@@ -70,7 +71,7 @@ class FindInPageController extends _$FindInPageController {
|
||||
FindInPageState build(String tabId) {
|
||||
ref.listen(
|
||||
fireImmediately: true,
|
||||
tabStateProvider(tabId),
|
||||
tabStatesProvider.select((tabs) => tabs[tabId]),
|
||||
(previous, next) async {
|
||||
if (!ref.mounted) return;
|
||||
//Ensure state is already initialized
|
||||
@@ -89,7 +90,7 @@ class FindInPageController extends _$FindInPageController {
|
||||
},
|
||||
onError: (error, stackTrace) {
|
||||
logger.e(
|
||||
'Error listening to selectedTabStateProvider',
|
||||
'Error listening to tabStatesProvider',
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
|
||||
+3
-3
@@ -20,7 +20,7 @@ final class FindInPageControllerProvider
|
||||
}) : super(
|
||||
retry: null,
|
||||
name: r'findInPageControllerProvider',
|
||||
isAutoDispose: true,
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
@@ -59,7 +59,7 @@ final class FindInPageControllerProvider
|
||||
}
|
||||
|
||||
String _$findInPageControllerHash() =>
|
||||
r'4aaa0c4b4a623e7274836e70e5f66e97a3ebf80a';
|
||||
r'21ca6178016dc141fcfcb71afc158d9b8eed0a7e';
|
||||
|
||||
final class FindInPageControllerFamily extends $Family
|
||||
with
|
||||
@@ -76,7 +76,7 @@ final class FindInPageControllerFamily extends $Family
|
||||
name: r'findInPageControllerProvider',
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
isAutoDispose: true,
|
||||
isAutoDispose: false,
|
||||
);
|
||||
|
||||
FindInPageControllerProvider call(String tabId) =>
|
||||
|
||||
+16
@@ -49,6 +49,22 @@ class FindInPageWidget extends HookConsumerWidget {
|
||||
text: searchResult?.lastSearchText ?? findInPageState.lastSearchText,
|
||||
);
|
||||
|
||||
// Sync text field when the find-in-page query is set externally (e.g.,
|
||||
// from a tab/history search hit). Comparing previous-vs-next on the
|
||||
// riverpod state, rather than the controller text, lets the user keep
|
||||
// an empty field after clearing without us repopulating it.
|
||||
ref.listen(
|
||||
findInPageControllerProvider(tabId).select((s) => s.lastSearchText),
|
||||
(previous, next) {
|
||||
if (next != null && next != previous && textController.text != next) {
|
||||
textController.text = next;
|
||||
textController.selection = TextSelection.collapsed(
|
||||
offset: next.length,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Create debouncer with automatic disposal
|
||||
final debouncer = useDebouncer(const Duration(milliseconds: 300));
|
||||
|
||||
|
||||
+67
@@ -81,6 +81,73 @@ class HistoryRepository extends _$HistoryRepository {
|
||||
);
|
||||
}
|
||||
|
||||
Future<HistoryMetadata?> getLatestHistoryMetadataForUrl(String url) {
|
||||
return _service.getLatestHistoryMetadataForUrl(url);
|
||||
}
|
||||
|
||||
Future<List<HistoryMetadata?>> getLatestHistoryMetadataForUrls(
|
||||
List<String> urls,
|
||||
) {
|
||||
return _service.getLatestHistoryMetadataForUrls(urls);
|
||||
}
|
||||
|
||||
Future<List<bool>> getVisited(List<String> urls) {
|
||||
return _service.getVisited(urls);
|
||||
}
|
||||
|
||||
Future<List<HistorySuggestion>> getSuggestions(
|
||||
String query, {
|
||||
int limit = 10,
|
||||
}) {
|
||||
return _service.getSuggestions(query, limit: limit);
|
||||
}
|
||||
|
||||
Future<List<HistoryMetadata>> queryHistoryMetadata(
|
||||
String query, {
|
||||
int limit = 10,
|
||||
}) {
|
||||
return _service.queryHistoryMetadata(query, limit: limit);
|
||||
}
|
||||
|
||||
Future<void> recordObservation(
|
||||
String url, {
|
||||
String? title,
|
||||
String? previewImageUrl,
|
||||
}) {
|
||||
return _service.recordObservation(
|
||||
url,
|
||||
title: title,
|
||||
previewImageUrl: previewImageUrl,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> noteViewTime(HistoryMetadataKey key, Duration viewTime) {
|
||||
return _service.noteHistoryMetadataViewTime(key, viewTime);
|
||||
}
|
||||
|
||||
Future<void> noteDocumentType(
|
||||
HistoryMetadataKey key,
|
||||
DocumentType documentType,
|
||||
) {
|
||||
return _service.noteHistoryMetadataDocumentType(key, documentType);
|
||||
}
|
||||
|
||||
Future<void> deleteVisitsFor(String url) {
|
||||
return _service.deleteVisitsFor(url);
|
||||
}
|
||||
|
||||
Future<void> deleteVisitsSince(DateTime since) {
|
||||
return _service.deleteVisitsSince(since);
|
||||
}
|
||||
|
||||
Future<void> deleteEverything() {
|
||||
return _service.deleteEverything();
|
||||
}
|
||||
|
||||
Future<void> deleteHistoryMetadataOlderThan(DateTime olderThan) {
|
||||
return _service.deleteHistoryMetadataOlderThan(olderThan);
|
||||
}
|
||||
|
||||
@override
|
||||
void build() {}
|
||||
}
|
||||
|
||||
+1
-1
@@ -41,7 +41,7 @@ final class HistoryRepositoryProvider
|
||||
}
|
||||
}
|
||||
|
||||
String _$historyRepositoryHash() => r'414b68ec6be3cc2eca3681cbc00990e53e6127ce';
|
||||
String _$historyRepositoryHash() => r'4fec6cf4ef7cdfcabf3ccfd4a43fbf634bfe377f';
|
||||
|
||||
abstract class _$HistoryRepository extends $Notifier<void> {
|
||||
void build();
|
||||
|
||||
+1
@@ -455,6 +455,7 @@ class OpenSharedContent extends HookConsumerWidget {
|
||||
alignment: Alignment.centerRight,
|
||||
child: CompactContainerSelector(
|
||||
selectedContainer: selectedContainer.value,
|
||||
emphasizeSelection: false,
|
||||
onSelectionChanged: (selection) async {
|
||||
containerSelectionTouched.value = true;
|
||||
switch (selection) {
|
||||
|
||||
+70
-62
@@ -17,6 +17,7 @@
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'package:fading_scroll/fading_scroll.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:weblibre/features/geckoview/features/open_link_tools/domain/entities/url_cleaner_result.dart';
|
||||
import 'package:weblibre/features/geckoview/features/open_link_tools/domain/services/url_cleaner_service.dart';
|
||||
@@ -103,72 +104,79 @@ class _TrackingDetailsDialogState extends State<TrackingDetailsDialog> {
|
||||
const SizedBox(height: 16),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 220),
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
itemCount: _items.length,
|
||||
separatorBuilder: (context, index) =>
|
||||
Divider(height: 1, color: colorScheme.outlineVariant),
|
||||
itemBuilder: (context, index) {
|
||||
final item = _items[index];
|
||||
final display = _splitMatch(item.match);
|
||||
child: FadingScroll(
|
||||
fadingSize: 25,
|
||||
builder: (context, controller) {
|
||||
return ListView.separated(
|
||||
controller: controller,
|
||||
shrinkWrap: true,
|
||||
itemCount: _items.length,
|
||||
separatorBuilder: (context, index) =>
|
||||
Divider(height: 1, color: colorScheme.outlineVariant),
|
||||
itemBuilder: (context, index) {
|
||||
final item = _items[index];
|
||||
final display = _splitMatch(item.match);
|
||||
|
||||
return CheckboxListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
activeColor: colorScheme.primary,
|
||||
checkColor: colorScheme.onPrimary,
|
||||
title: Padding(
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
display.key,
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (item.type == UrlCleanerMatchType.referralRule)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 3,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.tertiaryContainer,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(
|
||||
'Referral marketing',
|
||||
style: TextStyle(
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
fontSize: 11,
|
||||
return CheckboxListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
activeColor: colorScheme.primary,
|
||||
checkColor: colorScheme.onPrimary,
|
||||
title: Padding(
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
display.key,
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
subtitle: display.value == null
|
||||
? null
|
||||
: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Text(
|
||||
display.value!,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
if (item.type ==
|
||||
UrlCleanerMatchType.referralRule)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 3,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.tertiaryContainer,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(
|
||||
'Referral marketing',
|
||||
style: TextStyle(
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
value: _selected[index],
|
||||
onChanged: canApply
|
||||
? (checked) {
|
||||
setState(() {
|
||||
_selected[index] = checked ?? false;
|
||||
});
|
||||
}
|
||||
: null,
|
||||
),
|
||||
subtitle: display.value == null
|
||||
? null
|
||||
: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Text(
|
||||
display.value!,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
),
|
||||
value: _selected[index],
|
||||
onChanged: canApply
|
||||
? (checked) {
|
||||
setState(() {
|
||||
_selected[index] = checked ?? false;
|
||||
});
|
||||
}
|
||||
: null,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
+49
-24
@@ -19,7 +19,6 @@
|
||||
*/
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:fading_scroll/fading_scroll.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
|
||||
@@ -27,37 +26,63 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:weblibre/extensions/uri.dart';
|
||||
import 'package:weblibre/features/geckoview/features/open_link_tools/presentation/utils/open_in_custom_tab.dart';
|
||||
import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart';
|
||||
import 'package:weblibre/features/settings/presentation/widgets/sections.dart';
|
||||
import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart';
|
||||
import 'package:weblibre/features/user/data/models/general_settings.dart';
|
||||
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
|
||||
|
||||
const List<SettingsSectionDefinition> unshortenerSettingsSections = [
|
||||
SettingsSectionDefinition(
|
||||
title: 'Overview',
|
||||
entries: [
|
||||
SettingsEntryDefinition(
|
||||
title: 'Description',
|
||||
subtitle: 'Resolve shortened URLs using the unshorten.me service',
|
||||
keywords: ['short links', 'redirects'],
|
||||
child: _UnshortenerDescriptionTile(),
|
||||
),
|
||||
],
|
||||
),
|
||||
SettingsSectionDefinition(
|
||||
title: 'Behavior',
|
||||
entries: [
|
||||
SettingsEntryDefinition(
|
||||
title: 'Enable Unshortener',
|
||||
subtitle: 'Resolve shortened URLs to their destination',
|
||||
keywords: ['short links'],
|
||||
child: _UnshortenerEnabledTile(),
|
||||
),
|
||||
SettingsEntryDefinition(
|
||||
title: 'API Token',
|
||||
subtitle: 'Optional token for higher request limits',
|
||||
keywords: ['token'],
|
||||
child: _UnshortenerTokenField(),
|
||||
),
|
||||
],
|
||||
),
|
||||
SettingsSectionDefinition(
|
||||
title: 'Attribution',
|
||||
entries: [
|
||||
SettingsEntryDefinition(
|
||||
title: 'Service attribution',
|
||||
subtitle: 'Rate limits, service homepage, and privacy policy',
|
||||
keywords: ['privacy policy', 'rate limit'],
|
||||
child: _UnshortenerAttributionTile(),
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
class UnshortenerSettingsScreen extends StatelessWidget {
|
||||
const UnshortenerSettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Unshortener')),
|
||||
body: SafeArea(
|
||||
child: FadingScroll(
|
||||
fadingSize: 25,
|
||||
builder: (context, controller) {
|
||||
return ListView(
|
||||
controller: controller,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
children: const [
|
||||
SettingSection(name: 'Unshortener'),
|
||||
_UnshortenerDescriptionTile(),
|
||||
SettingSection(name: 'Behavior'),
|
||||
_UnshortenerEnabledTile(),
|
||||
_UnshortenerTokenField(),
|
||||
SettingSection(name: 'Attribution'),
|
||||
_UnshortenerAttributionTile(),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
return const SettingsDetailScaffold(
|
||||
title: 'Unshortener',
|
||||
subtitle:
|
||||
'Short-link resolution behavior, token configuration, and attribution.',
|
||||
icon: MdiIcons.linkVariant,
|
||||
sections: unshortenerSettingsSections,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+73
-29
@@ -17,7 +17,6 @@
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'package:fading_scroll/fading_scroll.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
|
||||
@@ -27,43 +26,88 @@ import 'package:weblibre/features/geckoview/features/open_link_tools/domain/serv
|
||||
import 'package:weblibre/features/geckoview/features/open_link_tools/presentation/dialogs/url_cleaner_restore_defaults_dialog.dart';
|
||||
import 'package:weblibre/features/geckoview/features/open_link_tools/presentation/widgets/attribution_link.dart';
|
||||
import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart';
|
||||
import 'package:weblibre/features/settings/presentation/widgets/sections.dart';
|
||||
import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart';
|
||||
import 'package:weblibre/features/user/data/models/general_settings.dart';
|
||||
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
|
||||
import 'package:weblibre/utils/ui_helper.dart';
|
||||
|
||||
const List<SettingsSectionDefinition> urlCleanerSettingsSections = [
|
||||
SettingsSectionDefinition(
|
||||
title: 'Overview',
|
||||
entries: [
|
||||
SettingsEntryDefinition(
|
||||
title: 'Description',
|
||||
subtitle: 'Tracking parameter removal and offline redirect cleanup',
|
||||
keywords: ['tracking parameters', 'redirects'],
|
||||
child: _UrlCleanerDescriptionTile(),
|
||||
),
|
||||
],
|
||||
),
|
||||
SettingsSectionDefinition(
|
||||
title: 'Behavior',
|
||||
entries: [
|
||||
SettingsEntryDefinition(
|
||||
title: 'Enable URL Cleaner',
|
||||
subtitle: 'Remove tracking parameters from URLs',
|
||||
keywords: ['clean urls'],
|
||||
child: _UrlCleanerEnabledTile(),
|
||||
),
|
||||
SettingsEntryDefinition(
|
||||
title: 'Auto-apply',
|
||||
subtitle: 'Automatically replace URL with cleaned version',
|
||||
keywords: ['auto apply'],
|
||||
child: _UrlCleanerAutoApplyTile(),
|
||||
),
|
||||
SettingsEntryDefinition(
|
||||
title: 'Allow referral marketing',
|
||||
subtitle: 'Keep referral and affiliate tracking parameters',
|
||||
keywords: ['affiliate', 'referral'],
|
||||
child: _UrlCleanerAllowReferralTile(),
|
||||
),
|
||||
],
|
||||
),
|
||||
SettingsSectionDefinition(
|
||||
title: 'Catalog',
|
||||
entries: [
|
||||
SettingsEntryDefinition(
|
||||
title: 'Auto-update catalog',
|
||||
subtitle: 'Check for rule updates weekly',
|
||||
child: _UrlCleanerAutoUpdateTile(),
|
||||
),
|
||||
SettingsEntryDefinition(
|
||||
title: 'Update catalog',
|
||||
subtitle: 'Fetch the latest URL cleaner rules',
|
||||
child: _UrlCleanerUpdateButton(),
|
||||
),
|
||||
SettingsEntryDefinition(
|
||||
title: 'Restore defaults',
|
||||
subtitle: 'Reset to bundled catalog and default settings',
|
||||
child: _UrlCleanerRestoreDefaultsButton(),
|
||||
),
|
||||
],
|
||||
),
|
||||
SettingsSectionDefinition(
|
||||
title: 'Attribution',
|
||||
entries: [
|
||||
SettingsEntryDefinition(
|
||||
title: 'Attribution',
|
||||
subtitle: 'Credits and source links',
|
||||
child: _UrlCleanerAttributionTile(),
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
class UrlCleanerSettingsScreen extends StatelessWidget {
|
||||
const UrlCleanerSettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('URL Cleaner')),
|
||||
body: SafeArea(
|
||||
child: FadingScroll(
|
||||
fadingSize: 25,
|
||||
builder: (context, controller) {
|
||||
return ListView(
|
||||
controller: controller,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
children: const [
|
||||
SettingSection(name: 'URL Cleaner'),
|
||||
_UrlCleanerDescriptionTile(),
|
||||
SettingSection(name: 'Behavior'),
|
||||
_UrlCleanerEnabledTile(),
|
||||
_UrlCleanerAutoApplyTile(),
|
||||
_UrlCleanerAllowReferralTile(),
|
||||
SettingSection(name: 'Catalog'),
|
||||
_UrlCleanerAutoUpdateTile(),
|
||||
_UrlCleanerUpdateButton(),
|
||||
_UrlCleanerRestoreDefaultsButton(),
|
||||
SettingSection(name: 'Attribution'),
|
||||
_UrlCleanerAttributionTile(),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
return const SettingsDetailScaffold(
|
||||
title: 'URL Cleaner',
|
||||
subtitle: 'URL cleanup behavior, rule catalog updates, and attribution.',
|
||||
icon: MdiIcons.broom,
|
||||
sections: urlCleanerSettingsSections,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import 'package:weblibre/features/geckoview/domain/providers.dart';
|
||||
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/features/pwa/domain/pwa_installability.dart';
|
||||
import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_controller.dart';
|
||||
|
||||
part 'providers.g.dart';
|
||||
|
||||
@@ -83,6 +84,15 @@ PwaManifest? currentTabManifest(Ref ref) {
|
||||
/// Boolean indicating if the current tab is installable as a PWA.
|
||||
@Riverpod()
|
||||
bool isCurrentTabInstallable(Ref ref) {
|
||||
final selectedTabId = ref.watch(selectedTabProvider);
|
||||
// Sandbox-captured tabs serve a loopback page; "installing" it would
|
||||
// either pin the loopback URL (broken after the capture server cycles)
|
||||
// or, worse, silently navigate the source URL.
|
||||
final sandboxSourceUri = ref.watch(
|
||||
sandboxSourceUriForTabProvider(tabId: selectedTabId),
|
||||
);
|
||||
if (sandboxSourceUri != null) return false;
|
||||
|
||||
final manifest = ref.watch(currentTabManifestProvider);
|
||||
|
||||
if (manifest == null) return false;
|
||||
@@ -126,6 +136,13 @@ bool isCurrentTabShortcutable(Ref ref) {
|
||||
final selectedTabId = ref.watch(selectedTabProvider);
|
||||
if (selectedTabId == null) return false;
|
||||
|
||||
// Same reasoning as `isCurrentTabInstallable`: never offer to pin a
|
||||
// sandbox-captured tab to the home screen.
|
||||
final sandboxSourceUri = ref.watch(
|
||||
sandboxSourceUriForTabProvider(tabId: selectedTabId),
|
||||
);
|
||||
if (sandboxSourceUri != null) return false;
|
||||
|
||||
final tabState = ref.watch(tabStateProvider(selectedTabId));
|
||||
if (tabState == null) return false;
|
||||
|
||||
|
||||
@@ -207,7 +207,7 @@ final class IsCurrentTabInstallableProvider
|
||||
}
|
||||
|
||||
String _$isCurrentTabInstallableHash() =>
|
||||
r'293cdb6dcea24446343330ccdb30dc9f21f03618';
|
||||
r'484c048dca283317d30b3847a32092784dde48be';
|
||||
|
||||
/// Installs the current tab as a PWA, embedding profile and container context
|
||||
/// in the shortcut intent so the PWA reopens with the same isolation.
|
||||
@@ -398,7 +398,7 @@ final class IsCurrentTabShortcutableProvider
|
||||
}
|
||||
|
||||
String _$isCurrentTabShortcutableHash() =>
|
||||
r'a18fa837facc92397dacb79f38bdefd303ff5d00';
|
||||
r'5ffd27964eef44991d16e1c07ce4c1315a676549';
|
||||
|
||||
/// Creates a basic bookmark shortcut on the home screen for the current tab.
|
||||
|
||||
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||
import 'package:nullability/nullability.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/domain/providers/engine_suggestions.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/models/history_query_result.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/history_search.dart';
|
||||
import 'package:weblibre/utils/url_canonical.dart';
|
||||
|
||||
part 'combined_history.g.dart';
|
||||
|
||||
/// Single row in the combined history section.
|
||||
///
|
||||
/// Items come from one of two sources and are deduplicated by canonical
|
||||
/// URL. The engine ordering is preserved; local-only matches are appended
|
||||
/// after, so the user keeps their familiar frecency-ranked top of list and
|
||||
/// content-search hits flesh out the long tail.
|
||||
class CombinedHistoryItem {
|
||||
final Uri uri;
|
||||
final String? title;
|
||||
final Uint8List? engineIcon;
|
||||
|
||||
/// Raw highlight markers (`***foo***`) from FTS5 over `extracted_content`
|
||||
/// or `full_content`. Renderer in `text_highlight.dart` converts them into
|
||||
/// styled spans. `null` for engine-only items.
|
||||
final String? highlightedTitle;
|
||||
final String? snippet;
|
||||
|
||||
/// Bookkeeping for the renderer / future "Engine"/"Local" badges.
|
||||
final CombinedHistorySource source;
|
||||
|
||||
const CombinedHistoryItem({
|
||||
required this.uri,
|
||||
required this.title,
|
||||
required this.engineIcon,
|
||||
required this.highlightedTitle,
|
||||
required this.snippet,
|
||||
required this.source,
|
||||
});
|
||||
}
|
||||
|
||||
enum CombinedHistorySource { engine, local }
|
||||
|
||||
/// Engine suggestions, augmented per-row with local snippet/highlight when
|
||||
/// available, then padded with local-only matches.
|
||||
///
|
||||
/// Implementation note: kept as a synchronous Riverpod provider that derives
|
||||
/// its data from `engineSuggestionsProvider` + `historySearchRepositoryProvider`.
|
||||
/// Both upstream providers are responsible for kicking off their own queries
|
||||
/// when the search text changes; this one just reacts.
|
||||
@Riverpod()
|
||||
List<CombinedHistoryItem> combinedHistorySuggestions(Ref ref) {
|
||||
final engineAsync = ref.watch(engineSuggestionsProvider);
|
||||
final localAsync = ref.watch(historySearchRepositoryProvider);
|
||||
|
||||
final engineSuggestions =
|
||||
engineAsync.value ?? const <GeckoSuggestion>[];
|
||||
final localResults = localAsync.value?.results ?? const <HistoryQueryResult>[];
|
||||
|
||||
// Index the local rows by canonical URL so engine items can pick up
|
||||
// snippet/title-highlight without an N×M scan.
|
||||
final localByCanonical = <String, HistoryQueryResult>{};
|
||||
for (final hit in localResults) {
|
||||
localByCanonical[hit.urlCanonical] = hit;
|
||||
}
|
||||
|
||||
final emitted = <String>{};
|
||||
final out = <CombinedHistoryItem>[];
|
||||
|
||||
// 1. Engine suggestions in their existing order, enriched where possible.
|
||||
// Single-pass filter+emit: skip suggestions that aren't usable history
|
||||
// items, deduplicate by canonical URL.
|
||||
for (final suggestion in engineSuggestions) {
|
||||
if (suggestion.type != GeckoSuggestionType.history) continue;
|
||||
if (suggestion.title?.isEmpty ?? true) continue;
|
||||
if (suggestion.description?.isEmpty ?? true) continue;
|
||||
|
||||
final uri = suggestion.description.mapNotNull(Uri.tryParse);
|
||||
if (uri == null) continue;
|
||||
final canonical = canonicalizeUrl(uri.toString())?.canonical;
|
||||
if (canonical == null) continue;
|
||||
if (!emitted.add(canonical)) continue;
|
||||
|
||||
final local = localByCanonical[canonical];
|
||||
out.add(
|
||||
CombinedHistoryItem(
|
||||
uri: uri,
|
||||
title: suggestion.title,
|
||||
engineIcon: suggestion.icon,
|
||||
highlightedTitle: local?.title,
|
||||
snippet: _pickSnippet(local),
|
||||
source: CombinedHistorySource.engine,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Local-only matches: URLs the engine didn't surface (typically because
|
||||
// the user's query matched only in extracted/full content rather than
|
||||
// title/url, which is exactly where the local FTS earns its keep).
|
||||
for (final hit in localResults) {
|
||||
if (!emitted.add(hit.urlCanonical)) continue;
|
||||
final uri = Uri.tryParse(hit.urlCanonical);
|
||||
if (uri == null) continue;
|
||||
|
||||
out.add(
|
||||
CombinedHistoryItem(
|
||||
uri: uri,
|
||||
title: hit.title,
|
||||
engineIcon: null,
|
||||
highlightedTitle: hit.title,
|
||||
snippet: _pickSnippet(hit),
|
||||
source: CombinedHistorySource.local,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/// Prefer the extracted-content snippet (reader text) when it carries a
|
||||
/// match, falling back to full content otherwise. Mirrors the heuristic in
|
||||
/// the existing tab/local-history widgets.
|
||||
String? _pickSnippet(HistoryQueryResult? hit) {
|
||||
if (hit == null) return null;
|
||||
if (hit.extractedContent?.contains(historyHighlightPrefix) ?? false) {
|
||||
return hit.extractedContent;
|
||||
}
|
||||
return hit.fullContent;
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'combined_history.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
/// Engine suggestions, augmented per-row with local snippet/highlight when
|
||||
/// available, then padded with local-only matches.
|
||||
///
|
||||
/// Implementation note: kept as a synchronous Riverpod provider that derives
|
||||
/// its data from `engineSuggestionsProvider` + `historySearchRepositoryProvider`.
|
||||
/// Both upstream providers are responsible for kicking off their own queries
|
||||
/// when the search text changes; this one just reacts.
|
||||
|
||||
@ProviderFor(combinedHistorySuggestions)
|
||||
final combinedHistorySuggestionsProvider =
|
||||
CombinedHistorySuggestionsProvider._();
|
||||
|
||||
/// Engine suggestions, augmented per-row with local snippet/highlight when
|
||||
/// available, then padded with local-only matches.
|
||||
///
|
||||
/// Implementation note: kept as a synchronous Riverpod provider that derives
|
||||
/// its data from `engineSuggestionsProvider` + `historySearchRepositoryProvider`.
|
||||
/// Both upstream providers are responsible for kicking off their own queries
|
||||
/// when the search text changes; this one just reacts.
|
||||
|
||||
final class CombinedHistorySuggestionsProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
List<CombinedHistoryItem>,
|
||||
List<CombinedHistoryItem>,
|
||||
List<CombinedHistoryItem>
|
||||
>
|
||||
with $Provider<List<CombinedHistoryItem>> {
|
||||
/// Engine suggestions, augmented per-row with local snippet/highlight when
|
||||
/// available, then padded with local-only matches.
|
||||
///
|
||||
/// Implementation note: kept as a synchronous Riverpod provider that derives
|
||||
/// its data from `engineSuggestionsProvider` + `historySearchRepositoryProvider`.
|
||||
/// Both upstream providers are responsible for kicking off their own queries
|
||||
/// when the search text changes; this one just reacts.
|
||||
CombinedHistorySuggestionsProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'combinedHistorySuggestionsProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$combinedHistorySuggestionsHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<List<CombinedHistoryItem>> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
List<CombinedHistoryItem> create(Ref ref) {
|
||||
return combinedHistorySuggestions(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(List<CombinedHistoryItem> value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<List<CombinedHistoryItem>>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$combinedHistorySuggestionsHash() =>
|
||||
r'a2cc2bc57529cb60e9ac75e83e3a7babb7f950ea';
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'search_autofocus.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class SearchAutofocusSuppression extends _$SearchAutofocusSuppression {
|
||||
void suppressNext() {
|
||||
state = true;
|
||||
}
|
||||
|
||||
void clear() {
|
||||
state = false;
|
||||
}
|
||||
|
||||
@override
|
||||
bool build() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'search_autofocus.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(SearchAutofocusSuppression)
|
||||
final searchAutofocusSuppressionProvider =
|
||||
SearchAutofocusSuppressionProvider._();
|
||||
|
||||
final class SearchAutofocusSuppressionProvider
|
||||
extends $NotifierProvider<SearchAutofocusSuppression, bool> {
|
||||
SearchAutofocusSuppressionProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'searchAutofocusSuppressionProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$searchAutofocusSuppressionHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
SearchAutofocusSuppression create() => SearchAutofocusSuppression();
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(bool value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<bool>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$searchAutofocusSuppressionHash() =>
|
||||
r'088e84ab9af2da9a1adc2316c3c594374720cc9e';
|
||||
|
||||
abstract class _$SearchAutofocusSuppression extends $Notifier<bool> {
|
||||
bool build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final ref = this.ref as $Ref<bool, bool>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<bool, bool>,
|
||||
bool,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleCreate(ref, build);
|
||||
}
|
||||
}
|
||||
+7
-3
@@ -58,11 +58,15 @@ List<ModuleOrderEntry> _mergeWithDefaults(
|
||||
final defaultSet = defaults.toSet();
|
||||
// Keep persisted entries that are still valid
|
||||
final result = persisted.where((e) => defaultSet.contains(e.type)).toList();
|
||||
// Add any new defaults not in persisted
|
||||
// Insert any new defaults at their position from the defaults list so newly
|
||||
// introduced modules land where they're meant to (e.g. at the top), instead
|
||||
// of trailing the user's persisted order.
|
||||
final persistedTypes = result.map((e) => e.type).toSet();
|
||||
for (final type in defaults) {
|
||||
for (var i = 0; i < defaults.length; i++) {
|
||||
final type = defaults[i];
|
||||
if (!persistedTypes.contains(type)) {
|
||||
result.add(ModuleOrderEntry(type: type, visible: true));
|
||||
final insertAt = i.clamp(0, result.length);
|
||||
result.insert(insertAt, ModuleOrderEntry(type: type, visible: true));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
|
||||
+6
@@ -19,16 +19,22 @@ Map<String, dynamic> _$ModuleOrderEntryToJson(ModuleOrderEntry instance) =>
|
||||
};
|
||||
|
||||
const _$SearchModuleTypeEnumMap = {
|
||||
SearchModuleType.recentSearches: 'recentSearches',
|
||||
SearchModuleType.searchProviders: 'searchProviders',
|
||||
SearchModuleType.searchSuggestions: 'searchSuggestions',
|
||||
SearchModuleType.tabs: 'tabs',
|
||||
SearchModuleType.articles: 'articles',
|
||||
SearchModuleType.bookmarks: 'bookmarks',
|
||||
SearchModuleType.history: 'history',
|
||||
SearchModuleType.localHistory: 'localHistory',
|
||||
SearchModuleType.combinedHistory: 'combinedHistory',
|
||||
SearchModuleType.historyHighlights: 'historyHighlights',
|
||||
SearchModuleType.topSites: 'topSites',
|
||||
SearchModuleType.recentHistory: 'recentHistory',
|
||||
SearchModuleType.recentArticles: 'recentArticles',
|
||||
SearchModuleType.recentTabs: 'recentTabs',
|
||||
SearchModuleType.containers: 'containers',
|
||||
SearchModuleType.frequentBangs: 'frequentBangs',
|
||||
};
|
||||
|
||||
// **************************************************************************
|
||||
|
||||
+45
-5
@@ -22,28 +22,58 @@ import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
part 'search_modules_view.g.dart';
|
||||
|
||||
enum SearchModuleType {
|
||||
recentSearches,
|
||||
searchProviders,
|
||||
searchSuggestions,
|
||||
tabs,
|
||||
articles,
|
||||
bookmarks,
|
||||
|
||||
/// Engine "History" suggestions, frecency-ranked from Places. Engine-only;
|
||||
/// no local FTS hits. Superseded in the default ordering by
|
||||
/// [combinedHistory] but kept as a separate module for users who want a
|
||||
/// pure engine view.
|
||||
history,
|
||||
|
||||
/// Local FTS5 hits over the indexed `extracted_content` /
|
||||
/// `full_content`. Pure local view; complementary to [history].
|
||||
/// Superseded in the default ordering by [combinedHistory] which folds
|
||||
/// these hits in alongside engine results — enabling both [localHistory]
|
||||
/// and [combinedHistory] will surface the same local URLs in two
|
||||
/// consecutive sections.
|
||||
localHistory,
|
||||
|
||||
/// Default "History" module: engine frecency results in their existing
|
||||
/// order, augmented with local content snippets where available, then
|
||||
/// padded with local-only matches at the tail. Prefer this over
|
||||
/// enabling [history] and [localHistory] separately.
|
||||
combinedHistory,
|
||||
|
||||
historyHighlights,
|
||||
topSites,
|
||||
recentHistory,
|
||||
recentArticles,
|
||||
recentTabs,
|
||||
containers;
|
||||
containers,
|
||||
frequentBangs;
|
||||
|
||||
String get label => switch (this) {
|
||||
recentSearches => 'Recent Searches',
|
||||
searchProviders => 'Search Providers',
|
||||
searchSuggestions => 'Suggestions',
|
||||
tabs => 'Tabs',
|
||||
articles => 'Articles',
|
||||
bookmarks => 'Bookmarks',
|
||||
history => 'History',
|
||||
history => 'History (engine)',
|
||||
localHistory => 'Local content',
|
||||
combinedHistory => 'History',
|
||||
historyHighlights => 'History Highlights',
|
||||
topSites => 'Top Sites',
|
||||
recentHistory => 'Recent History',
|
||||
recentArticles => 'Recent Articles',
|
||||
recentTabs => 'Recent Tabs',
|
||||
containers => 'Containers',
|
||||
frequentBangs => 'Frequent Bangs',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -51,6 +81,8 @@ enum SearchModuleGroup {
|
||||
emptyState(
|
||||
key: 'EmptyStateModuleOrder',
|
||||
defaultModules: [
|
||||
SearchModuleType.recentSearches,
|
||||
SearchModuleType.frequentBangs,
|
||||
SearchModuleType.topSites,
|
||||
SearchModuleType.recentArticles,
|
||||
SearchModuleType.recentTabs,
|
||||
@@ -62,10 +94,12 @@ enum SearchModuleGroup {
|
||||
search(
|
||||
key: 'SearchModuleOrder',
|
||||
defaultModules: [
|
||||
SearchModuleType.searchProviders,
|
||||
SearchModuleType.searchSuggestions,
|
||||
SearchModuleType.tabs,
|
||||
SearchModuleType.bookmarks,
|
||||
SearchModuleType.articles,
|
||||
SearchModuleType.history,
|
||||
SearchModuleType.combinedHistory,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -76,16 +110,22 @@ enum SearchModuleGroup {
|
||||
|
||||
extension SearchModuleTypeGroup on SearchModuleType {
|
||||
SearchModuleGroup get group => switch (this) {
|
||||
SearchModuleType.recentSearches ||
|
||||
SearchModuleType.topSites ||
|
||||
SearchModuleType.recentArticles ||
|
||||
SearchModuleType.recentTabs ||
|
||||
SearchModuleType.recentHistory ||
|
||||
SearchModuleType.historyHighlights ||
|
||||
SearchModuleType.containers => SearchModuleGroup.emptyState,
|
||||
SearchModuleType.containers ||
|
||||
SearchModuleType.frequentBangs => SearchModuleGroup.emptyState,
|
||||
SearchModuleType.searchProviders ||
|
||||
SearchModuleType.searchSuggestions ||
|
||||
SearchModuleType.tabs ||
|
||||
SearchModuleType.bookmarks ||
|
||||
SearchModuleType.articles ||
|
||||
SearchModuleType.history => SearchModuleGroup.search,
|
||||
SearchModuleType.history ||
|
||||
SearchModuleType.localHistory ||
|
||||
SearchModuleType.combinedHistory => SearchModuleGroup.search,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+278
-132
@@ -19,11 +19,14 @@
|
||||
*/
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:fading_scroll/fading_scroll.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:weblibre/core/design/app_colors.dart';
|
||||
import 'package:weblibre/core/routing/routes.dart';
|
||||
import 'package:weblibre/features/bangs/data/models/bang_data.dart';
|
||||
import 'package:weblibre/features/bangs/data/models/web_search_bang.dart';
|
||||
import 'package:weblibre/features/bangs/domain/providers/bangs.dart';
|
||||
import 'package:weblibre/features/bangs/domain/providers/search.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/controllers/bottom_sheet.dart';
|
||||
@@ -33,22 +36,29 @@ import 'package:weblibre/features/geckoview/domain/providers/tab_session.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/domain/providers.dart';
|
||||
import 'package:weblibre/features/geckoview/features/find_in_page/presentation/controllers/find_in_page.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/domain/providers/search_autofocus.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/domain/providers/search_module_order.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/domain/providers/search_modules_view.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/animated_tab_type_switcher.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/clipboard_fill.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/empty_state/containers_section.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/empty_state/frequent_bangs_section.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/empty_state/history_highlights_section.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/empty_state/recent_feed_articles_section.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/empty_state/recent_history_section.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/empty_state/recent_searches_section.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/empty_state/recent_tabs_section.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/empty_state/top_sites_section.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_field.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_module_reorder_view.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/bookmark_search.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/feed_search.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/full_search_suggestions.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/combined_history_suggestions.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/history_suggestions.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/local_history_suggestions.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/search_providers_section.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/search_term_suggestions_section.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/tab_search.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/entities/isolation_context.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
|
||||
@@ -56,7 +66,14 @@ import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selec
|
||||
import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/compact_container_selector.dart';
|
||||
import 'package:weblibre/features/tor/presentation/controllers/start_tor_proxy.dart';
|
||||
import 'package:weblibre/features/tor/presentation/widgets/tor_dialog.dart';
|
||||
import 'package:weblibre/features/search_credits/domain/repositories/web_search_settings.dart';
|
||||
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
|
||||
import 'package:weblibre/features/web_search/domain/controllers/search_controller.dart';
|
||||
import 'package:weblibre/features/web_search/presentation/open_in_new_tab.dart';
|
||||
import 'package:weblibre/features/web_search/presentation/widgets/route_through_tor_toggle.dart';
|
||||
import 'package:weblibre/features/web_search/presentation/widgets/search_filter_chips.dart';
|
||||
import 'package:weblibre/features/web_search/presentation/widgets/search_mode_selector.dart';
|
||||
import 'package:weblibre/features/web_search/presentation/widgets/web_search_results_section.dart';
|
||||
import 'package:weblibre/presentation/hooks/on_listenable_change_selector.dart';
|
||||
import 'package:weblibre/presentation/hooks/sampled_value_notifier.dart';
|
||||
import 'package:weblibre/utils/input_classification.dart';
|
||||
@@ -140,15 +157,24 @@ class SearchScreen extends HookConsumerWidget {
|
||||
|
||||
final privateTabMode = effectiveTabMode is PrivateTabMode;
|
||||
|
||||
final previousWebSearchQuery = ref.watch(
|
||||
metaSearchControllerProvider.select((s) {
|
||||
if (s.status != WebSearchStatus.idle && s.query.isNotEmpty) {
|
||||
return s.query;
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
);
|
||||
|
||||
final searchTextController = useTextEditingController(
|
||||
text: initialSearchText,
|
||||
text: initialSearchText ?? previousWebSearchQuery,
|
||||
);
|
||||
final sampledSearchText = useSampledValueNotifier(
|
||||
source: searchTextController,
|
||||
sampleDuration: const Duration(milliseconds: 150),
|
||||
);
|
||||
final hasUserProvidedInput = useState(
|
||||
initialSearchText?.isNotEmpty == true,
|
||||
initialSearchText?.isNotEmpty == true || previousWebSearchQuery != null,
|
||||
);
|
||||
|
||||
// Track if we started with a URL (edit mode) to show empty state initially
|
||||
@@ -159,6 +185,7 @@ class SearchScreen extends HookConsumerWidget {
|
||||
});
|
||||
final hasUserModifiedInput = useState(false);
|
||||
final isUrlInput = useState(false);
|
||||
final isEditingAfterSearch = useState(false);
|
||||
|
||||
useOnListenableChangeSelector(
|
||||
searchTextController,
|
||||
@@ -166,6 +193,15 @@ class SearchScreen extends HookConsumerWidget {
|
||||
() {
|
||||
final text = searchTextController.text;
|
||||
hasUserProvidedInput.value = text.isNotEmpty;
|
||||
|
||||
final metaState = ref.read(metaSearchControllerProvider);
|
||||
|
||||
if (text.isEmpty && metaState.status != WebSearchStatus.idle) {
|
||||
ref.read(metaSearchControllerProvider.notifier).reset();
|
||||
} else if (metaState.status != WebSearchStatus.idle) {
|
||||
isEditingAfterSearch.value = text != metaState.query;
|
||||
}
|
||||
|
||||
if (startedWithUrl) {
|
||||
hasUserModifiedInput.value = text != initialSearchText;
|
||||
}
|
||||
@@ -175,11 +211,22 @@ class SearchScreen extends HookConsumerWidget {
|
||||
},
|
||||
);
|
||||
|
||||
// Keep isEditingAfterSearch in sync when meta search state changes
|
||||
// (e.g. after submit/ready transitions), not just on text changes.
|
||||
ref.listen(metaSearchControllerProvider, (_, next) {
|
||||
if (next.status == WebSearchStatus.idle) {
|
||||
isEditingAfterSearch.value = false;
|
||||
} else {
|
||||
isEditingAfterSearch.value = searchTextController.text != next.query;
|
||||
}
|
||||
});
|
||||
|
||||
final showNoInputSections =
|
||||
(startedWithUrl && !hasUserModifiedInput.value) ||
|
||||
(!hasUserProvidedInput.value && searchTextController.text.isEmpty);
|
||||
|
||||
final searchFocusNode = useFocusNode();
|
||||
final pauseTime = useRef<DateTime?>(null);
|
||||
final textFieldKey = useMemoized(() => GlobalKey());
|
||||
final preferredHeight = useState<double>(kToolbarHeight);
|
||||
|
||||
@@ -187,8 +234,8 @@ class SearchScreen extends HookConsumerWidget {
|
||||
searchFocusNode,
|
||||
() => searchFocusNode.hasFocus,
|
||||
() {
|
||||
if (searchFocusNode.hasFocus && isEditMode) {
|
||||
// Select all text when the field is focused
|
||||
if (searchFocusNode.hasFocus &&
|
||||
(isEditMode || previousWebSearchQuery != null)) {
|
||||
searchTextController.selection = TextSelection(
|
||||
baseOffset: 0,
|
||||
extentOffset: searchTextController.text.length,
|
||||
@@ -198,7 +245,7 @@ class SearchScreen extends HookConsumerWidget {
|
||||
);
|
||||
|
||||
useEffect(() {
|
||||
if (isEditMode) {
|
||||
if (isEditMode || previousWebSearchQuery != null) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
searchTextController.selection = TextSelection(
|
||||
baseOffset: 0,
|
||||
@@ -211,6 +258,14 @@ class SearchScreen extends HookConsumerWidget {
|
||||
|
||||
//Request initial focus in a way our useOnListenableChangeSelector is triggered
|
||||
useEffect(() {
|
||||
if (ref.read(searchAutofocusSuppressionProvider)) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
ref.read(searchAutofocusSuppressionProvider.notifier).clear();
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
//Wait for first frame then request focus
|
||||
unawaited(
|
||||
Future.delayed(const Duration(milliseconds: 1000 ~/ 60)).whenComplete(
|
||||
@@ -264,7 +319,16 @@ class SearchScreen extends HookConsumerWidget {
|
||||
case AppLifecycleState.paused:
|
||||
//Fixes issue with disappearing keyboard after resume (even we request focus)
|
||||
searchFocusNode.unfocus();
|
||||
pauseTime.value ??= DateTime.now();
|
||||
case AppLifecycleState.resumed:
|
||||
if (pauseTime.value == null ||
|
||||
DateTime.now().difference(pauseTime.value!) <
|
||||
const Duration(minutes: 1)) {
|
||||
pauseTime.value = null;
|
||||
break;
|
||||
}
|
||||
|
||||
pauseTime.value = null;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
searchFocusNode.requestFocus();
|
||||
});
|
||||
@@ -295,63 +359,15 @@ class SearchScreen extends HookConsumerWidget {
|
||||
return null;
|
||||
}, [showBangIcon]);
|
||||
|
||||
Future<void> submitSearch(String query) async {
|
||||
if (activeBang != null && (formKey.currentState?.validate() == true)) {
|
||||
final searchUri = activeBang.getTemplateUrl(query);
|
||||
|
||||
if (!privateTabMode) {
|
||||
await ref
|
||||
.read(bangSearchProvider.notifier)
|
||||
.triggerBangSearch(activeBang, query);
|
||||
}
|
||||
|
||||
if (isEditMode) {
|
||||
// Load into existing tab
|
||||
await ref
|
||||
.read(tabSessionProvider(tabId: tabId).notifier)
|
||||
.loadUrl(url: searchUri);
|
||||
} else {
|
||||
// Create new tab
|
||||
await ref
|
||||
.read(tabRepositoryProvider.notifier)
|
||||
.addTab(
|
||||
url: searchUri,
|
||||
tabMode: effectiveTabMode,
|
||||
parentId: (selectedTabType.value == TabType.child)
|
||||
? ref.read(selectedTabProvider)
|
||||
: null,
|
||||
launchedFromIntent: launchedFromIntent,
|
||||
selectTab: true,
|
||||
containerSelection: selectedContainer == null
|
||||
? const TabContainerSelection.unassigned()
|
||||
: TabContainerSelection.specific(selectedContainer),
|
||||
);
|
||||
}
|
||||
|
||||
if (context.mounted) {
|
||||
ref.read(bottomSheetControllerProvider.notifier).requestDismiss();
|
||||
|
||||
const BrowserRoute().go(context);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final reorderGroup = ref.watch(searchReorderModeProvider);
|
||||
|
||||
final emptyStateOrder = ref.watch(
|
||||
searchModuleOrderProvider(SearchModuleGroup.emptyState),
|
||||
);
|
||||
final searchOrder = ref.watch(
|
||||
searchModuleOrderProvider(SearchModuleGroup.search),
|
||||
);
|
||||
|
||||
Future<void> openUriInTab(Uri uri) async {
|
||||
Future<void> openUriInTab(Uri uri, {String? findInPageQuery}) async {
|
||||
final String targetTabId;
|
||||
if (isEditMode) {
|
||||
targetTabId = tabId!;
|
||||
await ref
|
||||
.read(tabSessionProvider(tabId: tabId).notifier)
|
||||
.read(tabSessionProvider(tabId: targetTabId).notifier)
|
||||
.loadUrl(url: uri);
|
||||
} else {
|
||||
await ref
|
||||
targetTabId = await ref
|
||||
.read(tabRepositoryProvider.notifier)
|
||||
.addTab(
|
||||
url: uri,
|
||||
@@ -367,13 +383,89 @@ class SearchScreen extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
if (findInPageQuery != null && findInPageQuery.isNotEmpty) {
|
||||
await ref
|
||||
.read(findInPageControllerProvider(targetTabId).notifier)
|
||||
.findAll(text: findInPageQuery);
|
||||
}
|
||||
|
||||
if (context.mounted) {
|
||||
ref.read(bottomSheetControllerProvider.notifier).requestDismiss();
|
||||
const BrowserRoute().go(context);
|
||||
}
|
||||
}
|
||||
|
||||
/// Records the bang search, dispatches to the in-app web search engine for
|
||||
/// `wl` and returns null in that case, otherwise returns the resolved URI.
|
||||
Future<Uri?> resolveSearchUri(BangData bang, String query) async {
|
||||
if (!privateTabMode) {
|
||||
await ref
|
||||
.read(bangSearchProvider.notifier)
|
||||
.triggerBangSearch(bang, query);
|
||||
}
|
||||
|
||||
if (isWebSearchBang(bang)) {
|
||||
final settings = ref.read(webSearchSettingsControllerProvider);
|
||||
await ref
|
||||
.read(metaSearchControllerProvider.notifier)
|
||||
.submit(
|
||||
query,
|
||||
mode: settings.searchMode,
|
||||
language: settings.language,
|
||||
region: settings.region,
|
||||
safeSearch: settings.safeSearch,
|
||||
timeRange: settings.timeRange,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return bang.getTemplateUrl(query);
|
||||
}
|
||||
|
||||
Future<void> submitSearch(String query) async {
|
||||
if (activeBang != null && (formKey.currentState?.validate() == true)) {
|
||||
final uri = await resolveSearchUri(activeBang, query);
|
||||
if (uri != null) {
|
||||
await openUriInTab(uri);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Persist the results scroll offset across navigation so returning to
|
||||
// the search screen after opening a result restores the user's place
|
||||
// instead of jumping back to the top. The offset provider is reset on
|
||||
// every fresh submit/reset of the web search controller.
|
||||
final scrollController = useScrollController(
|
||||
initialScrollOffset: ref.read(webSearchScrollOffsetProvider),
|
||||
);
|
||||
useEffect(() {
|
||||
void listener() {
|
||||
if (scrollController.hasClients) {
|
||||
ref
|
||||
.read(webSearchScrollOffsetProvider.notifier)
|
||||
.update(scrollController.offset);
|
||||
}
|
||||
}
|
||||
|
||||
scrollController.addListener(listener);
|
||||
return () => scrollController.removeListener(listener);
|
||||
}, [scrollController]);
|
||||
|
||||
final reorderGroup = ref.watch(searchReorderModeProvider);
|
||||
|
||||
final emptyStateOrder = ref.watch(
|
||||
searchModuleOrderProvider(SearchModuleGroup.emptyState),
|
||||
);
|
||||
final searchOrder = ref.watch(
|
||||
searchModuleOrderProvider(SearchModuleGroup.search),
|
||||
);
|
||||
|
||||
final emptyStateWidgets = <SearchModuleType, Widget>{
|
||||
SearchModuleType.recentSearches: RecentSearchesSection(
|
||||
searchTextController: searchTextController,
|
||||
submitSearch: submitSearch,
|
||||
),
|
||||
SearchModuleType.frequentBangs: const FrequentBangsSection(),
|
||||
SearchModuleType.topSites: TopSitesSection(onUriSelected: openUriInTab),
|
||||
SearchModuleType.recentArticles: RecentFeedArticlesSection(
|
||||
onArticleSelected: (article) {
|
||||
@@ -431,6 +523,14 @@ class SearchScreen extends HookConsumerWidget {
|
||||
};
|
||||
|
||||
final searchWidgets = <SearchModuleType, Widget>{
|
||||
SearchModuleType.searchProviders: SearchProvidersSection(
|
||||
searchTextController: searchTextController,
|
||||
domain: isEditMode ? existingTabState.url.host : null,
|
||||
),
|
||||
SearchModuleType.searchSuggestions: SearchTermSuggestionsSection(
|
||||
searchTextController: searchTextController,
|
||||
submitSearch: submitSearch,
|
||||
),
|
||||
SearchModuleType.tabs: TabSearch(searchTextListenable: sampledSearchText),
|
||||
SearchModuleType.bookmarks: BookmarkSearch(
|
||||
searchTextListenable: sampledSearchText,
|
||||
@@ -443,13 +543,35 @@ class SearchScreen extends HookConsumerWidget {
|
||||
searchTextListenable: sampledSearchText,
|
||||
onUriSelected: openUriInTab,
|
||||
),
|
||||
SearchModuleType.localHistory: LocalHistorySuggestions(
|
||||
searchTextListenable: sampledSearchText,
|
||||
onUriSelected: openUriInTab,
|
||||
),
|
||||
SearchModuleType.combinedHistory: CombinedHistorySuggestions(
|
||||
searchTextListenable: sampledSearchText,
|
||||
onUriSelected: openUriInTab,
|
||||
),
|
||||
};
|
||||
|
||||
bool canShowSearchModule(SearchModuleType type) {
|
||||
if (!isUrlInput.value) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return switch (type) {
|
||||
SearchModuleType.searchProviders ||
|
||||
SearchModuleType.searchSuggestions ||
|
||||
SearchModuleType.articles => false,
|
||||
_ => true,
|
||||
};
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Form(
|
||||
key: formKey,
|
||||
child: CustomScrollView(
|
||||
controller: scrollController,
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
floating: true,
|
||||
@@ -525,6 +647,7 @@ class SearchScreen extends HookConsumerWidget {
|
||||
alignment: Alignment.centerRight,
|
||||
child: CompactContainerSelector(
|
||||
selectedContainer: selectedContainer,
|
||||
emphasizeSelection: false,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -541,31 +664,15 @@ class SearchScreen extends HookConsumerWidget {
|
||||
textEditingController: searchTextController,
|
||||
focusNode: searchFocusNode,
|
||||
maxLines: isEditMode ? 3 : 1,
|
||||
autofocus: true,
|
||||
label: const Text('Search or enter URL'),
|
||||
unfocusOnTapOutside: false,
|
||||
onSubmitted: (value) async {
|
||||
if (value.isNotEmpty) {
|
||||
final classification = classifyAddressBarInput(value);
|
||||
Uri? newUrl;
|
||||
String? searchQuery;
|
||||
if (value.isEmpty) return;
|
||||
|
||||
switch (classification) {
|
||||
case NavigateInputClassification(:final uri):
|
||||
newUrl = uri;
|
||||
case SearchInputClassification(:final query):
|
||||
searchQuery = query;
|
||||
case InvalidInputClassification():
|
||||
if (context.mounted) {
|
||||
ui_helper.showErrorMessage(
|
||||
context,
|
||||
'Invalid address',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (newUrl == null && searchQuery != null) {
|
||||
switch (classifyAddressBarInput(value)) {
|
||||
case NavigateInputClassification(:final uri):
|
||||
await openUriInTab(uri);
|
||||
case SearchInputClassification(:final query):
|
||||
// Read from both providers - use site if set, otherwise global
|
||||
final siteBang = isEditMode
|
||||
? ref.read(
|
||||
@@ -582,52 +689,22 @@ class SearchScreen extends HookConsumerWidget {
|
||||
globalBang ??
|
||||
await ref.read(defaultSearchBangProvider.future);
|
||||
|
||||
if (bang != null) {
|
||||
newUrl = bang.getTemplateUrl(searchQuery);
|
||||
if (bang == null) return;
|
||||
|
||||
if (!privateTabMode) {
|
||||
await ref
|
||||
.read(bangSearchProvider.notifier)
|
||||
.triggerBangSearch(bang, searchQuery);
|
||||
}
|
||||
final uri = await resolveSearchUri(bang, query);
|
||||
if (uri == null) {
|
||||
// Web search dispatched in-app; reset edit state.
|
||||
isEditingAfterSearch.value = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (newUrl != null) {
|
||||
if (isEditMode) {
|
||||
// Load into existing tab
|
||||
await ref
|
||||
.read(tabSessionProvider(tabId: tabId).notifier)
|
||||
.loadUrl(url: newUrl);
|
||||
} else {
|
||||
// Create new tab
|
||||
await ref
|
||||
.read(tabRepositoryProvider.notifier)
|
||||
.addTab(
|
||||
url: newUrl,
|
||||
tabMode: effectiveTabMode,
|
||||
parentId:
|
||||
(selectedTabType.value == TabType.child)
|
||||
? ref.read(selectedTabProvider)
|
||||
: null,
|
||||
launchedFromIntent: launchedFromIntent,
|
||||
selectTab: true,
|
||||
containerSelection: selectedContainer == null
|
||||
? const TabContainerSelection.unassigned()
|
||||
: TabContainerSelection.specific(
|
||||
selectedContainer,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
await openUriInTab(uri);
|
||||
case InvalidInputClassification():
|
||||
if (context.mounted) {
|
||||
ref
|
||||
.read(bottomSheetControllerProvider.notifier)
|
||||
.requestDismiss();
|
||||
|
||||
const BrowserRoute().go(context);
|
||||
ui_helper.showErrorMessage(
|
||||
context,
|
||||
'Invalid address',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
activeBang: activeBang,
|
||||
@@ -638,9 +715,46 @@ class SearchScreen extends HookConsumerWidget {
|
||||
SliverToBoxAdapter(
|
||||
child: ClipboardFillLink(controller: searchTextController),
|
||||
),
|
||||
if (isWebSearchBang(activeBang))
|
||||
const SliverPadding(
|
||||
padding: EdgeInsets.fromLTRB(0, 8, 0, 4),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_WebSearchOptionsRow(),
|
||||
WebSearchTorBootstrapProgress(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (reorderGroup != null)
|
||||
SearchModuleReorderView(group: reorderGroup)
|
||||
else if (showNoInputSections) ...[
|
||||
else if (isWebSearchBang(activeBang) &&
|
||||
ref.watch(
|
||||
metaSearchControllerProvider.select(
|
||||
(s) =>
|
||||
s.status != WebSearchStatus.idle ||
|
||||
s.query.isNotEmpty,
|
||||
),
|
||||
)) ...[
|
||||
// Once a web search has been dispatched, the screen shows
|
||||
// the fetched results only — search suggestions and search
|
||||
// providers belong to the normal search page, not the
|
||||
// results view.
|
||||
WebSearchResultsSection(
|
||||
resolveOpenTarget: () => WebSearchOpenTarget(
|
||||
tabMode: effectiveTabMode,
|
||||
containerSelection: selectedContainer == null
|
||||
? const TabContainerSelection.unassigned()
|
||||
: TabContainerSelection.specific(selectedContainer),
|
||||
parentId: (selectedTabType.value == TabType.child)
|
||||
? ref.read(selectedTabProvider)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
] else if (showNoInputSections) ...[
|
||||
for (final entry in emptyStateOrder)
|
||||
if (emptyStateWidgets.containsKey(entry.type))
|
||||
emptyStateWidgets[entry.type]!,
|
||||
@@ -648,17 +762,9 @@ class SearchScreen extends HookConsumerWidget {
|
||||
group: SearchModuleGroup.emptyState,
|
||||
),
|
||||
] else ...[
|
||||
if (!isUrlInput.value)
|
||||
FullSearchTermSuggestions(
|
||||
searchTextController: searchTextController,
|
||||
activeBang: activeBang,
|
||||
submitSearch: submitSearch,
|
||||
domain: isEditMode ? existingTabState.url.host : null,
|
||||
),
|
||||
for (final entry in searchOrder)
|
||||
if (searchWidgets.containsKey(entry.type) &&
|
||||
(!isUrlInput.value ||
|
||||
entry.type != SearchModuleType.articles))
|
||||
canShowSearchModule(entry.type))
|
||||
searchWidgets[entry.type]!,
|
||||
const _CustomizeSectionsButton(group: SearchModuleGroup.search),
|
||||
],
|
||||
@@ -692,3 +798,43 @@ class _CustomizeSectionsButton extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Horizontally scrollable row of web-search filter pills.
|
||||
///
|
||||
/// Order: Tor toggle first (always-visible safety control), then search
|
||||
/// mode, then the locale/freshness/safety filter pills. The row itself
|
||||
/// scrolls horizontally — adding more chips later doesn't break the
|
||||
/// layout on narrow screens.
|
||||
class _WebSearchOptionsRow extends StatelessWidget {
|
||||
const _WebSearchOptionsRow();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FadingScroll(
|
||||
fadingSize: 15,
|
||||
builder: (context, controller) {
|
||||
return SingleChildScrollView(
|
||||
controller: controller,
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Row(
|
||||
children: const [
|
||||
WebSearchStatusChip(),
|
||||
RouteThroughTorToggle(),
|
||||
SizedBox(width: 8),
|
||||
SearchModeSelector(),
|
||||
SizedBox(width: 8),
|
||||
LanguageSelector(),
|
||||
SizedBox(width: 8),
|
||||
CountrySelector(),
|
||||
SizedBox(width: 8),
|
||||
FreshnessSelector(),
|
||||
SizedBox(width: 8),
|
||||
SafeSearchSelector(),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
|
||||
import 'package:weblibre/features/bangs/data/models/bang_data.dart';
|
||||
import 'package:weblibre/presentation/widgets/selectable_chips.dart';
|
||||
import 'package:weblibre/presentation/widgets/url_icon.dart';
|
||||
|
||||
// These helpers are conceptually `BangChipStrip`-private — they capture
|
||||
// the chip's own selection / delete-affordance rules and are not part of
|
||||
// the public bang API. The `@visibleForTesting` annotation keeps them
|
||||
// reachable from `frequent_bangs_section_test.dart` (which asserts the
|
||||
// rules directly) without inviting unrelated call sites.
|
||||
@visibleForTesting
|
||||
bool isSelectedBangChip(BangData bang, BangData? selectedBang) =>
|
||||
selectedBang != null && bang.toKey() == selectedBang.toKey();
|
||||
|
||||
@visibleForTesting
|
||||
bool canDeleteBangChip(
|
||||
BangData bang, {
|
||||
BangData? selectedBang,
|
||||
required bool allowFrequencyResetAction,
|
||||
}) => isSelectedBangChip(bang, selectedBang) || allowFrequencyResetAction;
|
||||
|
||||
@visibleForTesting
|
||||
IconData? bangChipDeleteIcon(
|
||||
BangData bang, {
|
||||
BangData? selectedBang,
|
||||
required bool allowFrequencyResetAction,
|
||||
}) {
|
||||
if (isSelectedBangChip(bang, selectedBang)) {
|
||||
return Icons.clear;
|
||||
}
|
||||
|
||||
return allowFrequencyResetAction ? MdiIcons.restore : null;
|
||||
}
|
||||
|
||||
class BangChipStrip extends StatelessWidget {
|
||||
final List<BangData> bangs;
|
||||
final BangData? selectedBang;
|
||||
final BangData? deletableSelectedBang;
|
||||
final bool Function(BangData bang)? canDeleteBang;
|
||||
final int? maxCount;
|
||||
final List<Widget> prefixItems;
|
||||
final bool showTrailingMenu;
|
||||
final bool sortSelectedFirst;
|
||||
final bool allowFrequencyResetAction;
|
||||
final VoidCallback? onMenuPressed;
|
||||
final void Function(BangData bang) onSelected;
|
||||
final void Function(BangData bang) onDeleted;
|
||||
|
||||
const BangChipStrip({
|
||||
required this.bangs,
|
||||
required this.selectedBang,
|
||||
required this.onSelected,
|
||||
required this.onDeleted,
|
||||
this.deletableSelectedBang,
|
||||
this.canDeleteBang,
|
||||
this.maxCount,
|
||||
this.prefixItems = const [],
|
||||
this.showTrailingMenu = false,
|
||||
this.sortSelectedFirst = true,
|
||||
this.allowFrequencyResetAction = false,
|
||||
this.onMenuPressed,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hasContent = selectedBang != null || bangs.isNotEmpty;
|
||||
final resolvedDeletableSelectedBang = deletableSelectedBang ?? selectedBang;
|
||||
|
||||
return SizedBox(
|
||||
height: 48,
|
||||
child: Row(
|
||||
children: [
|
||||
if (hasContent)
|
||||
Expanded(
|
||||
child: SelectableChips<BangData, BangData, String>(
|
||||
itemId: (bang) => bang.trigger,
|
||||
itemAvatar: (bang) =>
|
||||
UrlIcon([bang.getDefaultUrl()], iconSize: 20),
|
||||
itemLabel: (bang) => Text(bang.websiteName),
|
||||
itemTooltip: (bang) => bang.trigger,
|
||||
availableItems: bangs,
|
||||
selectedItem: selectedBang,
|
||||
maxCount: maxCount,
|
||||
sortSelectedFirst: sortSelectedFirst,
|
||||
decoration: SelectableChipDecoration(
|
||||
canDelete: (bang) =>
|
||||
canDeleteBangChip(
|
||||
bang,
|
||||
selectedBang: resolvedDeletableSelectedBang,
|
||||
allowFrequencyResetAction: allowFrequencyResetAction,
|
||||
) &&
|
||||
(canDeleteBang?.call(bang) ?? true),
|
||||
deleteIcon: (bang) {
|
||||
final icon = bangChipDeleteIcon(
|
||||
bang,
|
||||
selectedBang: resolvedDeletableSelectedBang,
|
||||
allowFrequencyResetAction: allowFrequencyResetAction,
|
||||
);
|
||||
return icon == null ? null : Icon(icon);
|
||||
},
|
||||
),
|
||||
onSelected: onSelected,
|
||||
onDeleted: onDeleted,
|
||||
),
|
||||
)
|
||||
else if (prefixItems.isNotEmpty) ...[
|
||||
...prefixItems,
|
||||
const Spacer(),
|
||||
] else
|
||||
const Spacer(),
|
||||
if (showTrailingMenu)
|
||||
IconButton(
|
||||
onPressed: onMenuPressed,
|
||||
icon: const Icon(Icons.chevron_right),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:weblibre/features/bangs/data/models/bang_data.dart';
|
||||
import 'package:weblibre/features/bangs/domain/providers/bangs.dart';
|
||||
import 'package:weblibre/features/bangs/domain/repositories/data.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/domain/providers.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/domain/providers/search_modules_view.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/presentation/dialogs/reset_bang_dialog.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/bang_chip_strip.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/search_module_section.dart';
|
||||
|
||||
// Both helpers are conceptually `FrequentBangsSection`-private — they
|
||||
// implement the section's own display-ordering and delete-affordance
|
||||
// rules. `@visibleForTesting` keeps them reachable from the section's
|
||||
// test file without exposing a wider API.
|
||||
@visibleForTesting
|
||||
List<BangData> buildFrequentBangDisplayList({
|
||||
required List<BangData> frequentBangs,
|
||||
BangData? selectedBang,
|
||||
BangData? defaultBang,
|
||||
}) {
|
||||
final selectedKey = selectedBang?.toKey();
|
||||
final defaultKey = defaultBang?.toKey();
|
||||
|
||||
return [
|
||||
if (selectedBang != null) selectedBang,
|
||||
...frequentBangs.where(
|
||||
(bang) => bang.toKey() != selectedKey && bang.toKey() != defaultKey,
|
||||
),
|
||||
if (defaultBang != null && defaultKey != selectedKey) defaultBang,
|
||||
];
|
||||
}
|
||||
|
||||
/// Whether the chip's delete affordance (selection-clear or
|
||||
/// frequency-reset) should be enabled for [bang].
|
||||
///
|
||||
/// Two distinct actions hide behind the single delete affordance:
|
||||
/// 1. Selection clear — always offered for the currently-selected bang.
|
||||
/// 2. Frequency reset — offered for any bang that isn't the user's
|
||||
/// default search bang. The default is protected to keep the
|
||||
/// frecency-ranked list anchored on a stable provider.
|
||||
///
|
||||
/// When no default is set yet (onboarding / explicitly cleared), no bang
|
||||
/// is "protected" and frequency reset is offered for any non-selected
|
||||
/// bang.
|
||||
@visibleForTesting
|
||||
bool canDeleteFrequentBang({
|
||||
required BangData bang,
|
||||
BangData? selectedBang,
|
||||
BangData? defaultBang,
|
||||
}) {
|
||||
final isSelected = selectedBang?.toKey() == bang.toKey();
|
||||
if (isSelected) return true;
|
||||
final isProtectedDefault =
|
||||
defaultBang != null && defaultBang.toKey() == bang.toKey();
|
||||
return !isProtectedDefault;
|
||||
}
|
||||
|
||||
class FrequentBangsSection extends ConsumerWidget {
|
||||
const FrequentBangsSection({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final frequentBangs = ref.watch(
|
||||
frequentBangListProvider.select((v) => v.value ?? const <BangData>[]),
|
||||
);
|
||||
|
||||
final selectedBang = ref.watch(selectedBangDataProvider());
|
||||
final defaultBang = ref.watch(
|
||||
defaultSearchBangDataProvider.select((v) => v.value),
|
||||
);
|
||||
final activeBang = selectedBang ?? defaultBang;
|
||||
|
||||
final bangs = buildFrequentBangDisplayList(
|
||||
frequentBangs: frequentBangs,
|
||||
selectedBang: selectedBang,
|
||||
defaultBang: defaultBang,
|
||||
);
|
||||
|
||||
void selectBang(BangData bang) {
|
||||
ref.read(selectedBangTriggerProvider().notifier).setTrigger(bang.toKey());
|
||||
}
|
||||
|
||||
Future<void> handleDeletion(BangData bang) async {
|
||||
final currentSelection = ref.read(selectedBangTriggerProvider());
|
||||
|
||||
if (currentSelection == bang.toKey()) {
|
||||
ref.read(selectedBangTriggerProvider().notifier).clearTrigger();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!canDeleteFrequentBang(
|
||||
bang: bang,
|
||||
selectedBang: selectedBang,
|
||||
defaultBang: defaultBang,
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
|
||||
final dialogResult = await showResetBangDialog(
|
||||
context,
|
||||
triggerName: bang.trigger,
|
||||
);
|
||||
|
||||
if (dialogResult == true) {
|
||||
await ref
|
||||
.read(bangDataRepositoryProvider.notifier)
|
||||
.resetFrequency(bang.trigger);
|
||||
}
|
||||
}
|
||||
|
||||
return SearchModuleSection(
|
||||
title: 'Frequent Bangs',
|
||||
moduleType: SearchModuleType.frequentBangs,
|
||||
totalCount: bangs.length,
|
||||
contentSliverBuilder:
|
||||
({required bool isCollapsed, required int visibleCount}) => [
|
||||
if (!isCollapsed)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
child: BangChipStrip(
|
||||
bangs: bangs,
|
||||
selectedBang: activeBang,
|
||||
deletableSelectedBang: selectedBang,
|
||||
canDeleteBang: (bang) => canDeleteFrequentBang(
|
||||
bang: bang,
|
||||
selectedBang: selectedBang,
|
||||
defaultBang: defaultBang,
|
||||
),
|
||||
maxCount: visibleCount >= bangs.length
|
||||
? null
|
||||
: visibleCount,
|
||||
sortSelectedFirst: false,
|
||||
allowFrequencyResetAction: true,
|
||||
onSelected: selectBang,
|
||||
onDeleted: handleDeletion,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:weblibre/features/bangs/domain/providers/bangs.dart';
|
||||
import 'package:weblibre/features/bangs/domain/providers/search.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/domain/providers/search_modules_view.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/search_module_section.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/search_query_chips.dart';
|
||||
|
||||
class RecentSearchesSection extends ConsumerWidget {
|
||||
final TextEditingController searchTextController;
|
||||
final Future<void> Function(String query) submitSearch;
|
||||
|
||||
const RecentSearchesSection({
|
||||
required this.searchTextController,
|
||||
required this.submitSearch,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final searchHistory = ref.watch(
|
||||
searchHistoryProvider.select((value) => value.value ?? const []),
|
||||
);
|
||||
final queries = searchHistory.map((entry) => entry.searchQuery).toList();
|
||||
|
||||
return SearchModuleSection(
|
||||
title: 'Recent Searches',
|
||||
moduleType: SearchModuleType.recentSearches,
|
||||
totalCount: queries.length,
|
||||
contentSliverBuilder:
|
||||
({required bool isCollapsed, required int visibleCount}) => [
|
||||
SliverToBoxAdapter(
|
||||
child: SearchQueryChips(
|
||||
queries: queries,
|
||||
showHistory: true,
|
||||
visibleCount: visibleCount,
|
||||
searchTextController: searchTextController,
|
||||
submitSearch: submitSearch,
|
||||
onDeleteHistory: (query) => ref
|
||||
.read(bangSearchProvider.notifier)
|
||||
.removeSearchEntry(query),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
+12
-3
@@ -23,6 +23,7 @@ import 'package:weblibre/features/geckoview/features/browser/presentation/widget
|
||||
import 'package:weblibre/features/geckoview/features/search/domain/providers/empty_state_content.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/domain/providers/search_modules_view.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/search_module_section.dart';
|
||||
import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_controller.dart';
|
||||
import 'package:weblibre/presentation/widgets/url_list_tile.dart';
|
||||
|
||||
class RecentTabsSection extends ConsumerWidget {
|
||||
@@ -49,15 +50,23 @@ class RecentTabsSection extends ConsumerWidget {
|
||||
itemCount: visibleCount,
|
||||
itemBuilder: (context, index) {
|
||||
final (tabState, containerData) = tabs[index];
|
||||
final sandboxSourceUri = ref.watch(
|
||||
sandboxSourceUriForTabProvider(tabId: tabState.id),
|
||||
);
|
||||
final displayUrl = sandboxSourceUri ?? tabState.url;
|
||||
final displayTitle =
|
||||
sandboxSourceUri != null && tabState.title.isEmpty
|
||||
? sandboxSourceUri.authority
|
||||
: tabState.titleOrAuthority;
|
||||
|
||||
return UrlListTile(
|
||||
title: tabState.titleOrAuthority,
|
||||
uri: tabState.url,
|
||||
title: displayTitle,
|
||||
uri: displayUrl,
|
||||
leading: TabIcon(
|
||||
tabState: tabState,
|
||||
iconSize: UrlListTile.iconSize,
|
||||
),
|
||||
borderColor: containerData?.color,
|
||||
containerColor: containerData?.color,
|
||||
onTap: () => onTabSelected(tabState.id),
|
||||
);
|
||||
},
|
||||
|
||||
+8
-1
@@ -26,6 +26,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:weblibre/features/bangs/data/models/bang_data.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/domain/providers/engine_suggestions.dart';
|
||||
import 'package:weblibre/features/user/domain/providers.dart';
|
||||
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
|
||||
import 'package:weblibre/presentation/hooks/on_listenable_change_selector.dart';
|
||||
import 'package:weblibre/presentation/widgets/auto_suggest_text_field.dart';
|
||||
import 'package:weblibre/presentation/widgets/qr_scanner_button.dart';
|
||||
@@ -68,6 +69,11 @@ class SearchField extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final incognitoEnabled = ref.watch(incognitoModeEnabledProvider);
|
||||
final acceptSuggestionOnSubmit = ref.watch(
|
||||
generalSettingsWithDefaultsProvider.select(
|
||||
(s) => s.acceptSuggestionOnSubmit,
|
||||
),
|
||||
);
|
||||
|
||||
final hasText = useListenableSelector(
|
||||
textEditingController,
|
||||
@@ -120,13 +126,14 @@ class SearchField extends HookConsumerWidget {
|
||||
child: AutoSuggestTextField(
|
||||
controller: textEditingController,
|
||||
suggestion: suggestion.value,
|
||||
acceptSuggestionOnSubmit: acceptSuggestionOnSubmit,
|
||||
enableIMEPersonalizedLearning: !incognitoEnabled,
|
||||
focusNode: safeFocusNode,
|
||||
maxLines: maxLines,
|
||||
textFieldKey: textFieldKey,
|
||||
textInputAction: (maxLines == null || maxLines! > 1)
|
||||
? TextInputAction.done
|
||||
: null,
|
||||
: TextInputAction.search,
|
||||
minLines: minLines,
|
||||
autofocus: autofocus,
|
||||
onSuggestionDismiss: () {
|
||||
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:nullability/nullability.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/domain/providers/combined_history.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/domain/providers/engine_suggestions.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/domain/providers/search_modules_view.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/history_row_icon.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/search_module_section.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/history_search.dart';
|
||||
import 'package:weblibre/presentation/hooks/on_listenable_change_selector.dart';
|
||||
import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart';
|
||||
import 'package:weblibre/presentation/widgets/url_icon.dart';
|
||||
import 'package:weblibre/utils/text_highlight.dart';
|
||||
|
||||
/// Combined history view: engine frecency-ranked suggestions augmented with
|
||||
/// local content snippets, plus local-only content matches appended at the
|
||||
/// end. Replaces the separate "History" + "Local content" sections in the
|
||||
/// default search module ordering.
|
||||
class CombinedHistorySuggestions extends HookConsumerWidget {
|
||||
final ValueListenable<TextEditingValue> searchTextListenable;
|
||||
|
||||
/// Tap handler for a row's leading URL.
|
||||
///
|
||||
/// [findInPageQuery] is non-null only for rows whose snippet was an FTS
|
||||
/// content match — the search screen forwards it to the engine so the
|
||||
/// landed page opens with a find-in-page query pre-filled. UI-level
|
||||
/// concern bleeding into the callback signature is deliberate so the
|
||||
/// host doesn't have to re-derive "did this row come from an FTS match"
|
||||
/// when opening the URL.
|
||||
final void Function(Uri uri, {String? findInPageQuery}) onUriSelected;
|
||||
|
||||
const CombinedHistorySuggestions({
|
||||
super.key,
|
||||
required this.onUriSelected,
|
||||
required this.searchTextListenable,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// Both upstreams need their own kick. Engine sends the suggestion
|
||||
// request; local FTS runs the query against the history index.
|
||||
useOnListenableChangeSelector(
|
||||
searchTextListenable,
|
||||
() => searchTextListenable.value.text,
|
||||
() async {
|
||||
final text = searchTextListenable.value.text;
|
||||
if (ref.exists(engineSuggestionsProvider)) {
|
||||
await ref.read(engineSuggestionsProvider.notifier).addQuery(text);
|
||||
}
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
// matchPrefix/matchSuffix default to historyHighlightPrefix/Suffix,
|
||||
// which is the same constant the row builder scans for below.
|
||||
// Don't override the defaults here unless those scan constants
|
||||
// are kept in sync.
|
||||
await ref
|
||||
.read(historySearchRepositoryProvider.notifier)
|
||||
.addQuery(text);
|
||||
},
|
||||
);
|
||||
|
||||
final items = ref.watch(combinedHistorySuggestionsProvider);
|
||||
|
||||
// Pre-build the four text styles used per row. Both `bodyLarge` and
|
||||
// `bodyMedium` come with a "base" variant and a bold one for the
|
||||
// highlighted-substring spans. Doing this once per build() keeps the
|
||||
// per-row itemBuilder from re-running four copyWith()s per visible
|
||||
// item — small but completely avoidable allocation on every scroll.
|
||||
final theme = Theme.of(context);
|
||||
final titleBase = theme.textTheme.bodyLarge?.copyWith(
|
||||
color: theme.colorScheme.onSurface,
|
||||
);
|
||||
final titleHighlight = titleBase?.copyWith(fontWeight: FontWeight.bold);
|
||||
final snippetBase = theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
);
|
||||
final snippetHighlight = snippetBase?.copyWith(fontWeight: FontWeight.bold);
|
||||
|
||||
return SearchModuleSection(
|
||||
title: 'History',
|
||||
moduleType: SearchModuleType.combinedHistory,
|
||||
totalCount: items.length,
|
||||
contentSliverBuilder:
|
||||
({required bool isCollapsed, required int visibleCount}) => [
|
||||
SliverList.builder(
|
||||
itemCount: visibleCount,
|
||||
itemBuilder: (context, index) {
|
||||
final item = items[index];
|
||||
final titleHasMatch =
|
||||
item.highlightedTitle?.contains(historyHighlightPrefix) ??
|
||||
false;
|
||||
final snippetHasMatch =
|
||||
item.snippet?.contains(historyHighlightPrefix) ?? false;
|
||||
|
||||
return ListTile(
|
||||
key: ValueKey(item.uri.toString()),
|
||||
leading: HistoryRowIcon(
|
||||
iconBytes: item.engineIcon,
|
||||
fallback: UrlIcon([item.uri], iconSize: 24),
|
||||
),
|
||||
title: titleHasMatch
|
||||
? Text.rich(
|
||||
buildHighlightedText(
|
||||
item.highlightedTitle!,
|
||||
titleBase,
|
||||
titleHighlight,
|
||||
historyHighlightPrefix,
|
||||
historyHighlightSuffix,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
)
|
||||
: item.title.mapNotNull(
|
||||
(title) => Text(
|
||||
title,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
subtitle: snippetHasMatch
|
||||
? Text.rich(
|
||||
buildHighlightedText(
|
||||
item.snippet!,
|
||||
snippetBase,
|
||||
snippetHighlight,
|
||||
historyHighlightPrefix,
|
||||
historyHighlightSuffix,
|
||||
normalizeWhitespaces: true,
|
||||
),
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
)
|
||||
: UriBreadcrumb(uri: item.uri, showHttpScheme: false),
|
||||
trailing: item.source == CombinedHistorySource.local
|
||||
? Tooltip(
|
||||
message: 'Content match',
|
||||
child: Icon(
|
||||
MdiIcons.textBoxSearchOutline,
|
||||
size: 16,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
onTap: () => onUriSelected(
|
||||
item.uri,
|
||||
findInPageQuery: snippetHasMatch
|
||||
? searchTextListenable.value.text
|
||||
: null,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
-251
@@ -1,251 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:fading_scroll/fading_scroll.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:nullability/nullability.dart';
|
||||
import 'package:sliver_tools/sliver_tools.dart';
|
||||
import 'package:weblibre/core/providers/persisted_bool.dart';
|
||||
import 'package:weblibre/features/bangs/data/models/bang_data.dart';
|
||||
import 'package:weblibre/features/bangs/domain/providers/bangs.dart';
|
||||
import 'package:weblibre/features/bangs/domain/repositories/data.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/domain/providers/search_suggestions.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/smart_bang_selector.dart';
|
||||
import 'package:weblibre/presentation/hooks/on_listenable_change_selector.dart';
|
||||
|
||||
class FullSearchTermSuggestions extends HookConsumerWidget {
|
||||
final TextEditingController searchTextController;
|
||||
final Future<void> Function(String query) submitSearch;
|
||||
final BangData? activeBang;
|
||||
|
||||
/// The domain to scope site-specific bangs to.
|
||||
/// When null, only global bangs are shown (new tab mode).
|
||||
final String? domain;
|
||||
|
||||
const FullSearchTermSuggestions({
|
||||
required this.searchTextController,
|
||||
required this.submitSearch,
|
||||
required this.activeBang,
|
||||
this.domain,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final searchText = useListenableSelector(
|
||||
searchTextController,
|
||||
() => searchTextController.text,
|
||||
);
|
||||
final searchTextIsNotEmpty = searchText.isNotEmpty;
|
||||
|
||||
final searchSuggestions = ref.watch(searchSuggestionsProvider());
|
||||
final searchHistory = ref.watch(searchHistoryProvider);
|
||||
final expanded = ref.watch(
|
||||
persistedBoolProvider(PersistedBoolKey.searchSuggestionsExpanded),
|
||||
);
|
||||
|
||||
useOnListenableChangeSelector(
|
||||
searchTextController,
|
||||
() => searchTextController.text,
|
||||
() {
|
||||
ref
|
||||
.read(searchSuggestionsProvider().notifier)
|
||||
.addQuery(searchTextController.text);
|
||||
},
|
||||
);
|
||||
|
||||
final showHistory =
|
||||
!searchTextIsNotEmpty && (searchHistory.value.isNotEmpty);
|
||||
|
||||
final suggestionQueries = useMemoized(
|
||||
() => showHistory
|
||||
? searchHistory.value!.map((e) => e.searchQuery).toList()
|
||||
: [
|
||||
if (searchTextIsNotEmpty) searchText,
|
||||
if (searchSuggestions.value != null)
|
||||
...searchSuggestions.value!.whereNot((s) => s == searchText),
|
||||
],
|
||||
[showHistory, searchText, searchHistory.value, searchSuggestions.value],
|
||||
);
|
||||
|
||||
return MultiSliver(
|
||||
children: [
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 12.0, top: 12.0),
|
||||
child: SmartBangSelector(
|
||||
domain: domain,
|
||||
searchTextController: searchTextController,
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: _SuggestionsContent(
|
||||
expanded: expanded,
|
||||
queries: suggestionQueries,
|
||||
showHistory: showHistory,
|
||||
searchTextController: searchTextController,
|
||||
submitSearch: submitSearch,
|
||||
onDeleteHistory: (query) => ref
|
||||
.read(bangDataRepositoryProvider.notifier)
|
||||
.removeSearchEntry(query),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6.0),
|
||||
child: IconButton(
|
||||
onPressed: ref
|
||||
.read(
|
||||
persistedBoolProvider(
|
||||
PersistedBoolKey.searchSuggestionsExpanded,
|
||||
).notifier,
|
||||
)
|
||||
.toggle,
|
||||
icon: Icon(expanded ? Icons.unfold_less : Icons.unfold_more),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SuggestionsContent extends StatelessWidget {
|
||||
final bool expanded;
|
||||
final List<String> queries;
|
||||
final bool showHistory;
|
||||
final TextEditingController searchTextController;
|
||||
final Future<void> Function(String query) submitSearch;
|
||||
final Future<void> Function(String query) onDeleteHistory;
|
||||
|
||||
const _SuggestionsContent({
|
||||
required this.expanded,
|
||||
required this.queries,
|
||||
required this.showHistory,
|
||||
required this.searchTextController,
|
||||
required this.submitSearch,
|
||||
required this.onDeleteHistory,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (expanded) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 150),
|
||||
child: FadingScroll(
|
||||
fadingSize: 25,
|
||||
builder: (context, controller) {
|
||||
return CustomScrollView(
|
||||
shrinkWrap: true,
|
||||
controller: controller,
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 12.0),
|
||||
child: Wrap(
|
||||
spacing: 8.0,
|
||||
children: [
|
||||
for (final query in queries)
|
||||
_SuggestionChip(
|
||||
query: query,
|
||||
showHistory: showHistory,
|
||||
searchTextController: searchTextController,
|
||||
submitSearch: submitSearch,
|
||||
onDeleteHistory: onDeleteHistory,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 12.0, top: 8.0),
|
||||
child: SizedBox(
|
||||
height: 44,
|
||||
child: FadingScroll(
|
||||
fadingSize: 25,
|
||||
builder: (context, controller) {
|
||||
return ListView.separated(
|
||||
controller: controller,
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: queries.length,
|
||||
separatorBuilder: (context, index) => const SizedBox(width: 8),
|
||||
itemBuilder: (context, index) => _SuggestionChip(
|
||||
query: queries[index],
|
||||
showHistory: showHistory,
|
||||
searchTextController: searchTextController,
|
||||
submitSearch: submitSearch,
|
||||
onDeleteHistory: onDeleteHistory,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SuggestionChip extends StatelessWidget {
|
||||
final String query;
|
||||
final bool showHistory;
|
||||
final TextEditingController searchTextController;
|
||||
final Future<void> Function(String query) submitSearch;
|
||||
final Future<void> Function(String query) onDeleteHistory;
|
||||
|
||||
const _SuggestionChip({
|
||||
required this.query,
|
||||
required this.showHistory,
|
||||
required this.searchTextController,
|
||||
required this.submitSearch,
|
||||
required this.onDeleteHistory,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onLongPress: () => searchTextController.text = query,
|
||||
child: InputChip(
|
||||
avatar: Icon(showHistory ? Icons.history : Icons.search),
|
||||
label: Text(query),
|
||||
onSelected: (value) async {
|
||||
if (value) await submitSearch(query);
|
||||
},
|
||||
onDeleted: showHistory ? () => onDeleteHistory(query) : null,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:nullability/nullability.dart';
|
||||
import 'package:weblibre/features/geckoview/utils/image_helper.dart';
|
||||
import 'package:weblibre/presentation/hooks/cached_future.dart';
|
||||
import 'package:weblibre/presentation/widgets/safe_raw_image.dart';
|
||||
|
||||
/// Leading icon for a history-style ListTile row.
|
||||
///
|
||||
/// Wraps the boilerplate that `combined_history_suggestions`,
|
||||
/// `local_history_suggestions`, and `history_suggestions` would otherwise
|
||||
/// each repeat: decode `engineIcon` bytes through the shared image
|
||||
/// helper's LRU, render via [SafeRawImage], fall back to [fallback] when
|
||||
/// either the bytes are null or decoding produced nothing. The
|
||||
/// [RepaintBoundary] isolates per-row image swaps from the surrounding
|
||||
/// list's paint cost.
|
||||
class HistoryRowIcon extends HookWidget {
|
||||
const HistoryRowIcon({
|
||||
super.key,
|
||||
required this.iconBytes,
|
||||
required this.fallback,
|
||||
this.size = 24,
|
||||
});
|
||||
|
||||
final Uint8List? iconBytes;
|
||||
final Widget fallback;
|
||||
final double size;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cachedIcon = useCachedFuture(
|
||||
() async => iconBytes.mapNotNull(tryDecodeImage),
|
||||
[iconBytes],
|
||||
);
|
||||
|
||||
return RepaintBoundary(
|
||||
child: SafeRawImage(
|
||||
image: cachedIcon.data,
|
||||
height: size,
|
||||
width: size,
|
||||
fallback: fallback,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+25
-44
@@ -19,19 +19,16 @@
|
||||
*/
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:nullability/nullability.dart';
|
||||
import 'package:skeletonizer/skeletonizer.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/domain/providers/engine_suggestions.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/domain/providers/search_modules_view.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/history_row_icon.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/search_module_section.dart';
|
||||
import 'package:weblibre/features/geckoview/utils/image_helper.dart';
|
||||
import 'package:weblibre/presentation/hooks/cached_future.dart';
|
||||
import 'package:weblibre/presentation/hooks/on_listenable_change_selector.dart';
|
||||
import 'package:weblibre/presentation/widgets/failure_widget.dart';
|
||||
import 'package:weblibre/presentation/widgets/safe_raw_image.dart';
|
||||
import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart';
|
||||
|
||||
class HistorySuggestions extends HookConsumerWidget {
|
||||
@@ -85,51 +82,35 @@ class HistorySuggestions extends HookConsumerWidget {
|
||||
Uri.tryParse,
|
||||
);
|
||||
|
||||
return HookBuilder(
|
||||
return ListTile(
|
||||
key: ValueKey(suggestion.id),
|
||||
builder: (context) {
|
||||
final icon = useCachedFuture(
|
||||
() async =>
|
||||
suggestion.icon.mapNotNull(tryDecodeImage),
|
||||
[suggestion.description, suggestion.icon],
|
||||
);
|
||||
|
||||
return ListTile(
|
||||
leading: RepaintBoundary(
|
||||
child: SafeRawImage(
|
||||
image: icon.data,
|
||||
height: 24,
|
||||
width: 24,
|
||||
fallback: const Icon(MdiIcons.web, size: 24),
|
||||
),
|
||||
),
|
||||
title: suggestion.title.mapNotNull(
|
||||
(title) => Text(
|
||||
title,
|
||||
leading: HistoryRowIcon(
|
||||
iconBytes: suggestion.icon,
|
||||
fallback: const Icon(MdiIcons.web, size: 24),
|
||||
),
|
||||
title: suggestion.title.mapNotNull(
|
||||
(title) => Text(
|
||||
title,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
subtitle:
|
||||
uri.mapNotNull(
|
||||
(uri) =>
|
||||
UriBreadcrumb(uri: uri, showHttpScheme: false),
|
||||
) ??
|
||||
suggestion.description.mapNotNull(
|
||||
(description) => Text(
|
||||
description,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
subtitle:
|
||||
uri.mapNotNull(
|
||||
(uri) => UriBreadcrumb(
|
||||
uri: uri,
|
||||
showHttpScheme: false,
|
||||
),
|
||||
) ??
|
||||
suggestion.description.mapNotNull(
|
||||
(description) => Text(
|
||||
description,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
if (uri != null) {
|
||||
onUriSelected(uri);
|
||||
}
|
||||
},
|
||||
);
|
||||
onTap: () {
|
||||
if (uri != null) {
|
||||
onUriSelected(uri);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:nullability/nullability.dart';
|
||||
import 'package:skeletonizer/skeletonizer.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/domain/providers/search_modules_view.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/search_module_section.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/history_search.dart';
|
||||
import 'package:weblibre/presentation/hooks/on_listenable_change_selector.dart';
|
||||
import 'package:weblibre/presentation/widgets/failure_widget.dart';
|
||||
import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart';
|
||||
import 'package:weblibre/presentation/widgets/url_icon.dart';
|
||||
import 'package:weblibre/utils/text_highlight.dart';
|
||||
|
||||
/// FTS5-backed search over the local history index. Sits next to the engine
|
||||
/// "History" module (which is frecency-ranked from Places). Future iteration
|
||||
/// will merge the two into a single ranked list once weights are tuned.
|
||||
class LocalHistorySuggestions extends HookConsumerWidget {
|
||||
final ValueListenable<TextEditingValue> searchTextListenable;
|
||||
final void Function(Uri uri, {String? findInPageQuery}) onUriSelected;
|
||||
|
||||
const LocalHistorySuggestions({
|
||||
super.key,
|
||||
required this.onUriSelected,
|
||||
required this.searchTextListenable,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final searchAsync = ref.watch(historySearchRepositoryProvider);
|
||||
final results = searchAsync.value?.results ?? const [];
|
||||
|
||||
useOnListenableChangeSelector(
|
||||
searchTextListenable,
|
||||
() => searchTextListenable.value.text,
|
||||
() async {
|
||||
// matchPrefix/matchSuffix default to historyHighlightPrefix/Suffix
|
||||
// — the same constants used below for highlight scanning.
|
||||
await ref
|
||||
.read(historySearchRepositoryProvider.notifier)
|
||||
.addQuery(searchTextListenable.value.text);
|
||||
},
|
||||
);
|
||||
|
||||
return SearchModuleSection(
|
||||
title: 'Local content',
|
||||
moduleType: SearchModuleType.localHistory,
|
||||
totalCount: results.length,
|
||||
contentSliverBuilder:
|
||||
({required bool isCollapsed, required int visibleCount}) => [
|
||||
SliverSkeletonizer(
|
||||
enabled: searchAsync.isLoading,
|
||||
child: searchAsync.when(
|
||||
skipLoadingOnReload: true,
|
||||
data: (data) {
|
||||
if (data == null || data.results.isEmpty) {
|
||||
return const SliverToBoxAdapter(child: SizedBox.shrink());
|
||||
}
|
||||
return SliverList.builder(
|
||||
itemCount: visibleCount,
|
||||
itemBuilder: (context, index) {
|
||||
final result = data.results[index];
|
||||
final uri = Uri.tryParse(result.urlCanonical);
|
||||
|
||||
final content =
|
||||
(result.extractedContent?.contains(historyHighlightPrefix) ==
|
||||
true)
|
||||
? result.extractedContent
|
||||
: result.fullContent;
|
||||
final titleHasMatch =
|
||||
result.title?.contains(historyHighlightPrefix) ?? false;
|
||||
final bodyHasMatch =
|
||||
content?.contains(historyHighlightPrefix) ?? false;
|
||||
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return ListTile(
|
||||
leading: RepaintBoundary(
|
||||
child: uri != null
|
||||
? UrlIcon([uri], iconSize: 24)
|
||||
: const Icon(MdiIcons.history, size: 24),
|
||||
),
|
||||
title: result.title.mapNotNull(
|
||||
(title) => Text.rich(
|
||||
buildHighlightedText(
|
||||
title,
|
||||
theme.textTheme.bodyLarge?.copyWith(
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
theme.textTheme.bodyLarge?.copyWith(
|
||||
color: theme.colorScheme.onSurface,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
historyHighlightPrefix,
|
||||
historyHighlightSuffix,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
subtitle: bodyHasMatch
|
||||
? Text.rich(
|
||||
buildHighlightedText(
|
||||
content!,
|
||||
theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
historyHighlightPrefix,
|
||||
historyHighlightSuffix,
|
||||
normalizeWhitespaces: true,
|
||||
),
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
)
|
||||
: (uri != null && !titleHasMatch
|
||||
? UriBreadcrumb(
|
||||
uri: uri,
|
||||
showHttpScheme: false,
|
||||
)
|
||||
: null),
|
||||
onTap: () {
|
||||
if (uri != null) {
|
||||
onUriSelected(
|
||||
uri,
|
||||
findInPageQuery: bodyHasMatch
|
||||
? searchTextListenable.value.text
|
||||
: null,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
error: (error, stackTrace) => SliverToBoxAdapter(
|
||||
child: FailureWidget(
|
||||
title: 'Could not load local content',
|
||||
exception: error,
|
||||
),
|
||||
),
|
||||
loading: () => SliverList.builder(
|
||||
itemCount: isCollapsed ? 0 : 5,
|
||||
itemBuilder: (context, index) =>
|
||||
const ListTile(title: Bone.text()),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
+10
-2
@@ -34,9 +34,15 @@ class SearchModuleHeader extends StatelessWidget {
|
||||
final Widget? headerTrailing;
|
||||
|
||||
/// The maximum number of items shown in preview mode.
|
||||
/// The trailing button is hidden when totalCount <= this value.
|
||||
/// The trailing "Show all / Show less" button is hidden when
|
||||
/// totalCount <= this value (or when [showPagination] is false).
|
||||
final int previewLimit;
|
||||
|
||||
/// Set false for modules that render a single non-paginated body
|
||||
/// (e.g. a chip strip with its own scrolling). Hides the
|
||||
/// "Show all N / Show less" affordance regardless of [totalCount].
|
||||
final bool showPagination;
|
||||
|
||||
/// Called when the header is long-pressed (e.g. to enter reorder mode).
|
||||
final VoidCallback? onLongPress;
|
||||
|
||||
@@ -49,6 +55,7 @@ class SearchModuleHeader extends StatelessWidget {
|
||||
required this.onToggleExpansion,
|
||||
this.headerTrailing,
|
||||
this.previewLimit = 3,
|
||||
this.showPagination = true,
|
||||
this.onLongPress,
|
||||
});
|
||||
|
||||
@@ -57,7 +64,8 @@ class SearchModuleHeader extends StatelessWidget {
|
||||
final disableAnimations = MediaQuery.disableAnimationsOf(context);
|
||||
final isCollapsed = displayState == SearchModuleDisplayState.collapsed;
|
||||
final isExpanded = displayState == SearchModuleDisplayState.expanded;
|
||||
final showTrailing = !isCollapsed && totalCount > previewLimit;
|
||||
final showTrailing =
|
||||
showPagination && !isCollapsed && totalCount > previewLimit;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8.0),
|
||||
|
||||
+31
@@ -30,6 +30,13 @@ const previewItemsPerModule = 3;
|
||||
/// - Display state management (preview/expanded/collapsed)
|
||||
/// - Pinned header with collapse/expand and show-all/show-less controls
|
||||
/// - Visible item count calculation
|
||||
///
|
||||
/// **Always render this widget, even when [totalCount] is 0.** The header
|
||||
/// carries the long-press affordance that activates reorder mode and the
|
||||
/// visibility toggle — short-circuiting to `SizedBox.shrink()` at the call
|
||||
/// site means the user can lose their only entry-point to module
|
||||
/// configuration. Set [hideWhenEmpty] explicitly if the section should
|
||||
/// collapse silently.
|
||||
class SearchModuleSection extends ConsumerWidget {
|
||||
final String title;
|
||||
final SearchModuleType moduleType;
|
||||
@@ -46,6 +53,22 @@ class SearchModuleSection extends ConsumerWidget {
|
||||
/// The maximum number of items shown in preview mode for this section.
|
||||
final int previewLimit;
|
||||
|
||||
/// If true and [totalCount] is 0, the entire section (header included)
|
||||
/// is omitted. Use sparingly — see the class doc for why hiding the
|
||||
/// header is usually undesirable.
|
||||
final bool hideWhenEmpty;
|
||||
|
||||
/// Set false for modules whose body is a single non-paginated widget
|
||||
/// (e.g. a chip strip with its own scrolling). When false:
|
||||
/// - the "Show all N / Show less" affordance is suppressed,
|
||||
/// - [visibleCount] passed to [contentSliverBuilder] is always
|
||||
/// [totalCount] (the section can't be partially shown),
|
||||
/// - the section can still be fully collapsed via the header chevron.
|
||||
///
|
||||
/// This replaces the prior workaround of passing `totalCount: 0,
|
||||
/// previewLimit: 0` to suppress pagination.
|
||||
final bool showPagination;
|
||||
|
||||
final List<Widget> Function({
|
||||
required bool isCollapsed,
|
||||
required int visibleCount,
|
||||
@@ -60,6 +83,8 @@ class SearchModuleSection extends ConsumerWidget {
|
||||
required this.contentSliverBuilder,
|
||||
this.headerTrailing,
|
||||
this.previewLimit = previewItemsPerModule,
|
||||
this.hideWhenEmpty = false,
|
||||
this.showPagination = true,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -70,12 +95,17 @@ class SearchModuleSection extends ConsumerWidget {
|
||||
return MultiSliver(children: const []);
|
||||
}
|
||||
|
||||
if (hideWhenEmpty && totalCount == 0) {
|
||||
return MultiSliver(children: const []);
|
||||
}
|
||||
|
||||
final displayState = ref.watch(
|
||||
searchModuleDisplayStateControllerProvider(moduleType),
|
||||
);
|
||||
|
||||
final isCollapsed = displayState == SearchModuleDisplayState.collapsed;
|
||||
final showAllItems =
|
||||
!showPagination ||
|
||||
displayState == SearchModuleDisplayState.expanded ||
|
||||
totalCount <= previewLimit;
|
||||
final visibleCount = isCollapsed
|
||||
@@ -95,6 +125,7 @@ class SearchModuleSection extends ConsumerWidget {
|
||||
displayState: displayState,
|
||||
headerTrailing: isCollapsed ? null : headerTrailing,
|
||||
previewLimit: previewLimit,
|
||||
showPagination: showPagination,
|
||||
onToggleCollapse: () => ref
|
||||
.read(
|
||||
searchModuleDisplayStateControllerProvider(
|
||||
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/domain/providers/search_modules_view.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/search_module_section.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/smart_bang_selector.dart';
|
||||
|
||||
/// Hosts the [SmartBangSelector] inside the standard collapsible/reorderable
|
||||
/// search module header. The selector renders its own empty/default state and
|
||||
/// scrolls horizontally on its own, so this section runs with
|
||||
/// `showPagination: false` — the header keeps its collapse / reorder
|
||||
/// affordances but the "Show all N / Show less" button is suppressed.
|
||||
class SearchProvidersSection extends ConsumerWidget {
|
||||
final TextEditingController searchTextController;
|
||||
final String? domain;
|
||||
|
||||
const SearchProvidersSection({
|
||||
required this.searchTextController,
|
||||
required this.domain,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return SearchModuleSection(
|
||||
title: 'Search Providers',
|
||||
moduleType: SearchModuleType.searchProviders,
|
||||
totalCount: 0,
|
||||
showPagination: false,
|
||||
contentSliverBuilder:
|
||||
({required bool isCollapsed, required int visibleCount}) => [
|
||||
if (!isCollapsed)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 12.0, top: 8.0),
|
||||
child: SmartBangSelector(
|
||||
domain: domain,
|
||||
searchTextController: searchTextController,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'package:fading_scroll/fading_scroll.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class SearchQueryChips extends StatelessWidget {
|
||||
final List<String> queries;
|
||||
final bool showHistory;
|
||||
final int visibleCount;
|
||||
final TextEditingController searchTextController;
|
||||
final Future<void> Function(String query) submitSearch;
|
||||
final Future<void> Function(String query)? onDeleteHistory;
|
||||
|
||||
const SearchQueryChips({
|
||||
required this.queries,
|
||||
required this.showHistory,
|
||||
required this.visibleCount,
|
||||
required this.searchTextController,
|
||||
required this.submitSearch,
|
||||
this.onDeleteHistory,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (queries.isEmpty || visibleCount == 0) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final visibleQueries = queries.take(visibleCount).toList();
|
||||
final showExpandedLayout = visibleCount >= queries.length;
|
||||
|
||||
if (showExpandedLayout) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 12.0, top: 8.0),
|
||||
child: Wrap(
|
||||
spacing: 8.0,
|
||||
runSpacing: 8.0,
|
||||
children: [
|
||||
for (final query in visibleQueries)
|
||||
_SearchQueryChip(
|
||||
query: query,
|
||||
showHistory: showHistory,
|
||||
searchTextController: searchTextController,
|
||||
submitSearch: submitSearch,
|
||||
onDeleteHistory: onDeleteHistory,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 12.0, top: 8.0),
|
||||
child: SizedBox(
|
||||
height: 44,
|
||||
child: FadingScroll(
|
||||
fadingSize: 15,
|
||||
builder: (context, controller) {
|
||||
return ListView.separated(
|
||||
controller: controller,
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: visibleQueries.length,
|
||||
separatorBuilder: (context, index) => const SizedBox(width: 8),
|
||||
itemBuilder: (context, index) => _SearchQueryChip(
|
||||
query: visibleQueries[index],
|
||||
showHistory: showHistory,
|
||||
searchTextController: searchTextController,
|
||||
submitSearch: submitSearch,
|
||||
onDeleteHistory: onDeleteHistory,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SearchQueryChip extends StatelessWidget {
|
||||
final String query;
|
||||
final bool showHistory;
|
||||
final TextEditingController searchTextController;
|
||||
final Future<void> Function(String query) submitSearch;
|
||||
final Future<void> Function(String query)? onDeleteHistory;
|
||||
|
||||
const _SearchQueryChip({
|
||||
required this.query,
|
||||
required this.showHistory,
|
||||
required this.searchTextController,
|
||||
required this.submitSearch,
|
||||
required this.onDeleteHistory,
|
||||
});
|
||||
|
||||
void _fillField() {
|
||||
// Set the controller's value (not just `.text`) so the caret lands at
|
||||
// the end of the inserted query — bare `.text =` resets the selection
|
||||
// to offset 0, which feels broken when the user immediately tries to
|
||||
// continue typing.
|
||||
searchTextController.value = TextEditingValue(
|
||||
text: query,
|
||||
selection: TextSelection.collapsed(offset: query.length),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Tap submits the query, long-press only fills the field so the user
|
||||
// can edit it before submitting. The GestureDetector and InputChip's
|
||||
// own tap detector both end up in Flutter's gesture arena: after
|
||||
// kLongPressTimeout the long-press wins and the tap is cancelled, so
|
||||
// a single long-press never double-fires the submit. A quick tap
|
||||
// resolves to onPressed before the long-press timer expires.
|
||||
return GestureDetector(
|
||||
onLongPress: _fillField,
|
||||
child: InputChip(
|
||||
avatar: Icon(showHistory ? Icons.history : Icons.search),
|
||||
label: Text(query),
|
||||
onPressed: () async {
|
||||
_fillField();
|
||||
await submitSearch(query);
|
||||
},
|
||||
onDeleted: showHistory && onDeleteHistory != null
|
||||
? () => onDeleteHistory!(query)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/domain/providers/search_modules_view.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/domain/providers/search_suggestions.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/search_module_section.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/search_query_chips.dart';
|
||||
import 'package:weblibre/presentation/hooks/on_listenable_change_selector.dart';
|
||||
|
||||
class SearchTermSuggestionsSection extends HookConsumerWidget {
|
||||
final TextEditingController searchTextController;
|
||||
final Future<void> Function(String query) submitSearch;
|
||||
|
||||
const SearchTermSuggestionsSection({
|
||||
required this.searchTextController,
|
||||
required this.submitSearch,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final searchText = useListenableSelector(
|
||||
searchTextController,
|
||||
() => searchTextController.text,
|
||||
);
|
||||
final searchSuggestions = ref.watch(
|
||||
searchSuggestionsProvider().select((value) => value.value ?? const []),
|
||||
);
|
||||
|
||||
useOnListenableChangeSelector(
|
||||
searchTextController,
|
||||
() => searchTextController.text,
|
||||
() {
|
||||
ref
|
||||
.read(searchSuggestionsProvider().notifier)
|
||||
.addQuery(searchTextController.text);
|
||||
},
|
||||
);
|
||||
|
||||
final queries = [
|
||||
if (searchText.isNotEmpty) searchText,
|
||||
...searchSuggestions.where((suggestion) => suggestion != searchText),
|
||||
];
|
||||
|
||||
return SearchModuleSection(
|
||||
title: 'Suggestions',
|
||||
moduleType: SearchModuleType.searchSuggestions,
|
||||
totalCount: queries.length,
|
||||
contentSliverBuilder:
|
||||
({required bool isCollapsed, required int visibleCount}) => [
|
||||
SliverToBoxAdapter(
|
||||
child: SearchQueryChips(
|
||||
queries: queries,
|
||||
showHistory: false,
|
||||
visibleCount: visibleCount,
|
||||
searchTextController: searchTextController,
|
||||
submitSearch: submitSearch,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
+26
-46
@@ -30,8 +30,8 @@ import 'package:weblibre/features/bangs/domain/providers/search.dart';
|
||||
import 'package:weblibre/features/bangs/domain/repositories/data.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/domain/providers.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/presentation/dialogs/reset_bang_dialog.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/bang_chip_strip.dart';
|
||||
import 'package:weblibre/presentation/hooks/on_listenable_change_selector.dart';
|
||||
import 'package:weblibre/presentation/widgets/selectable_chips.dart';
|
||||
import 'package:weblibre/presentation/widgets/sliding_pill_toggle.dart';
|
||||
import 'package:weblibre/presentation/widgets/url_icon.dart';
|
||||
import 'package:weblibre/utils/uri_parser.dart' as uri_parser;
|
||||
@@ -92,7 +92,6 @@ class SmartBangSelector extends HookConsumerWidget {
|
||||
frequentBangListProvider.select((v) => v.value ?? const []),
|
||||
);
|
||||
|
||||
// Use search results if available, otherwise fall back to frequent bangs
|
||||
final globalBangs = searchBangs.isNotEmpty ? searchBangs : frequentBangs;
|
||||
|
||||
// Trigger search when text changes
|
||||
@@ -164,8 +163,11 @@ class _TabbedBangSelector extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final tabController = useTabController(initialLength: 2);
|
||||
final tabIndex = useState(0);
|
||||
final tabController = useTabController(
|
||||
initialLength: 2,
|
||||
initialIndex: isSiteSelected ? 1 : 0,
|
||||
);
|
||||
final tabIndex = useState(isSiteSelected ? 1 : 0);
|
||||
|
||||
useEffect(() {
|
||||
void listener() => tabIndex.value = tabController.index;
|
||||
@@ -181,7 +183,7 @@ class _TabbedBangSelector extends HookConsumerWidget {
|
||||
padding: const EdgeInsets.only(right: 12.0),
|
||||
child: SlidingPillToggle(
|
||||
selectedIndex: tabIndex.value,
|
||||
labels: const ['Search On This Site', 'All Providers'],
|
||||
labels: const ['All Providers', 'Search On This Site'],
|
||||
onChanged: (index) => tabController.animateTo(index),
|
||||
),
|
||||
),
|
||||
@@ -191,15 +193,6 @@ class _TabbedBangSelector extends HookConsumerWidget {
|
||||
child: TabBarView(
|
||||
controller: tabController,
|
||||
children: [
|
||||
// Site tab - uses domain-scoped provider, clears global on select
|
||||
_BangChipsList(
|
||||
domain: domain,
|
||||
siteDomain: domain, // Pass for mutual exclusion
|
||||
bangs: siteBangs,
|
||||
selectedBang: isSiteSelected ? activeBang : null,
|
||||
searchTextController: searchTextController,
|
||||
displayMenu: displayMenu,
|
||||
),
|
||||
// All tab - uses global provider, clears site on select
|
||||
_BangChipsList(
|
||||
domain: null,
|
||||
@@ -209,6 +202,15 @@ class _TabbedBangSelector extends HookConsumerWidget {
|
||||
searchTextController: searchTextController,
|
||||
displayMenu: displayMenu,
|
||||
),
|
||||
// Site tab - uses domain-scoped provider, clears global on select
|
||||
_BangChipsList(
|
||||
domain: domain,
|
||||
siteDomain: domain, // Pass for mutual exclusion
|
||||
bangs: siteBangs,
|
||||
selectedBang: isSiteSelected ? activeBang : null,
|
||||
searchTextController: searchTextController,
|
||||
displayMenu: displayMenu,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -273,38 +275,16 @@ class _BangChipsList extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final hasContent = selectedBang != null || bangs.isNotEmpty;
|
||||
|
||||
return SizedBox(
|
||||
height: 48,
|
||||
child: Row(
|
||||
children: [
|
||||
if (hasContent)
|
||||
Expanded(
|
||||
child: SelectableChips(
|
||||
itemId: (bang) => bang.trigger,
|
||||
itemAvatar: (bang) =>
|
||||
UrlIcon([bang.getDefaultUrl()], iconSize: 20),
|
||||
itemLabel: (bang) => Text(bang.websiteName),
|
||||
itemTooltip: (bang) => bang.trigger,
|
||||
availableItems: bangs,
|
||||
selectedItem: selectedBang,
|
||||
onSelected: (bang) => _handleSelection(context, ref, bang),
|
||||
onDeleted: (bang) => _handleDeletion(context, ref, bang),
|
||||
),
|
||||
)
|
||||
else if (displayMenu) ...[
|
||||
const _DefaultSearchProviderChip(),
|
||||
const Spacer(),
|
||||
] else
|
||||
const Spacer(),
|
||||
if (displayMenu)
|
||||
IconButton(
|
||||
onPressed: () => _openBangSearch(context, ref),
|
||||
icon: const Icon(Icons.chevron_right),
|
||||
),
|
||||
],
|
||||
),
|
||||
return BangChipStrip(
|
||||
bangs: bangs,
|
||||
selectedBang: selectedBang,
|
||||
prefixItems: displayMenu
|
||||
? const [_DefaultSearchProviderChip()]
|
||||
: const [],
|
||||
showTrailingMenu: displayMenu,
|
||||
onMenuPressed: displayMenu ? () => _openBangSearch(context, ref) : null,
|
||||
onSelected: (bang) => _handleSelection(context, ref, bang),
|
||||
onDeleted: (bang) => _handleDeletion(context, ref, bang),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/database/daos/capture_tab.drift.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/database/database.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/database/definitions.drift.dart';
|
||||
|
||||
enum CaptureTabStatus { pending, ready, failed }
|
||||
|
||||
String _statusToDb(CaptureTabStatus status) => status.name;
|
||||
|
||||
CaptureTabStatus _statusFromDb(String raw) => CaptureTabStatus.values
|
||||
.firstWhere((s) => s.name == raw, orElse: () => CaptureTabStatus.pending);
|
||||
|
||||
@DriftAccessor()
|
||||
class CaptureTabDao extends DatabaseAccessor<TabDatabase>
|
||||
with $CaptureTabDaoMixin {
|
||||
CaptureTabDao(super.db);
|
||||
|
||||
Future<void> upsert({
|
||||
required String tabId,
|
||||
required String captureId,
|
||||
required String sourceUrl,
|
||||
required CaptureTabStatus status,
|
||||
DateTime? createdAt,
|
||||
}) {
|
||||
return into(db.captureTab).insert(
|
||||
CaptureTabCompanion.insert(
|
||||
tabId: tabId,
|
||||
captureId: captureId,
|
||||
sourceUrl: sourceUrl,
|
||||
status: Value(_statusToDb(status)),
|
||||
createdAt: createdAt ?? DateTime.now(),
|
||||
),
|
||||
mode: InsertMode.insertOrReplace,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> updateStatus(String tabId, CaptureTabStatus status) {
|
||||
return (update(db.captureTab)..where((t) => t.tabId.equals(tabId))).write(
|
||||
CaptureTabCompanion(status: Value(_statusToDb(status))),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> updateCaptureId(String tabId, String captureId) {
|
||||
return (update(db.captureTab)..where((t) => t.tabId.equals(tabId))).write(
|
||||
CaptureTabCompanion(captureId: Value(captureId)),
|
||||
);
|
||||
}
|
||||
|
||||
Future<int> deleteByTabId(String tabId) {
|
||||
return (delete(db.captureTab)..where((t) => t.tabId.equals(tabId))).go();
|
||||
}
|
||||
|
||||
Future<CaptureTabData?> findByTabId(String tabId) {
|
||||
return (select(
|
||||
db.captureTab,
|
||||
)..where((t) => t.tabId.equals(tabId))).getSingleOrNull();
|
||||
}
|
||||
|
||||
Future<List<CaptureTabData>> findAll() {
|
||||
return select(db.captureTab).get();
|
||||
}
|
||||
|
||||
Stream<List<CaptureTabData>> watchAll() {
|
||||
return select(db.captureTab).watch();
|
||||
}
|
||||
|
||||
CaptureTabStatus readStatus(CaptureTabData row) => _statusFromDb(row.status);
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
// dart format width=80
|
||||
// ignore_for_file: type=lint
|
||||
import 'package:drift/drift.dart' as i0;
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/database/database.dart'
|
||||
as i1;
|
||||
|
||||
mixin $CaptureTabDaoMixin on i0.DatabaseAccessor<i1.TabDatabase> {
|
||||
CaptureTabDaoManager get managers => CaptureTabDaoManager(this);
|
||||
}
|
||||
|
||||
class CaptureTabDaoManager {
|
||||
final $CaptureTabDaoMixin _db;
|
||||
CaptureTabDaoManager(this._db);
|
||||
}
|
||||
@@ -39,6 +39,8 @@ class ContainerDao extends DatabaseAccessor<TabDatabase>
|
||||
id: container.id,
|
||||
name: Value(container.name),
|
||||
color: container.color,
|
||||
orderKey: container.orderKey,
|
||||
isPinned: Value(container.isPinned),
|
||||
metadata: Value(container.metadata),
|
||||
),
|
||||
);
|
||||
@@ -50,11 +52,29 @@ class ContainerDao extends DatabaseAccessor<TabDatabase>
|
||||
id: Value(container.id),
|
||||
name: Value(container.name),
|
||||
color: Value(container.color),
|
||||
orderKey: Value(container.orderKey),
|
||||
isPinned: Value(container.isPinned),
|
||||
metadata: Value(container.metadata),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> assignOrderKey(String id, {required String orderKey}) {
|
||||
return (update(db.container)..where((c) => c.id.equals(id))).write(
|
||||
ContainerCompanion(orderKey: Value(orderKey)),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> assignPinned(
|
||||
String id, {
|
||||
required bool isPinned,
|
||||
required String orderKey,
|
||||
}) {
|
||||
return (update(db.container)..where((c) => c.id.equals(id))).write(
|
||||
ContainerCompanion(isPinned: Value(isPinned), orderKey: Value(orderKey)),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> deleteContainer(String id) {
|
||||
return db.container.deleteOne(ContainerCompanion(id: Value(id)));
|
||||
}
|
||||
@@ -151,6 +171,46 @@ class ContainerDao extends DatabaseAccessor<TabDatabase>
|
||||
);
|
||||
}
|
||||
|
||||
SingleSelectable<String> generateLeadingContainerOrderKey({
|
||||
required bool isPinned,
|
||||
int bucket = 0,
|
||||
}) {
|
||||
return db.definitionsDrift.leadingContainerOrderKey(
|
||||
isPinned: isPinned,
|
||||
bucket: bucket,
|
||||
);
|
||||
}
|
||||
|
||||
SingleSelectable<String> generateTrailingContainerOrderKey({
|
||||
required bool isPinned,
|
||||
int bucket = 0,
|
||||
}) {
|
||||
return db.definitionsDrift.trailingContainerOrderKey(
|
||||
isPinned: isPinned,
|
||||
bucket: bucket,
|
||||
);
|
||||
}
|
||||
|
||||
SingleOrNullSelectable<String> generateOrderKeyAfterContainerId(
|
||||
String containerId, {
|
||||
required bool isPinned,
|
||||
}) {
|
||||
return db.definitionsDrift.containerOrderKeyAfter(
|
||||
containerId: containerId,
|
||||
isPinned: isPinned,
|
||||
);
|
||||
}
|
||||
|
||||
SingleSelectable<String> generateOrderKeyBeforeContainerId(
|
||||
String containerId, {
|
||||
required bool isPinned,
|
||||
}) {
|
||||
return db.definitionsDrift.containerOrderKeyBefore(
|
||||
containerId: containerId,
|
||||
isPinned: isPinned,
|
||||
);
|
||||
}
|
||||
|
||||
SingleOrNullSelectable<String> getLastChildTabId(
|
||||
String? containerId,
|
||||
String parentId,
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/database/daos/history.drift.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/database/database.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/models/history_query_result.dart';
|
||||
|
||||
@DriftAccessor()
|
||||
class HistoryDao extends DatabaseAccessor<TabDatabase> with $HistoryDaoMixin {
|
||||
HistoryDao(super.db);
|
||||
|
||||
/// Search the local history index. If the FTS query is empty (e.g. all
|
||||
/// tokens were below the minimum trigram length), falls back to a host
|
||||
/// prefix scan so very short user input still returns something useful.
|
||||
Selectable<HistoryQueryResult> queryHistory({
|
||||
required String searchString,
|
||||
required String matchPrefix,
|
||||
required String matchSuffix,
|
||||
required String ellipsis,
|
||||
required int snippetLength,
|
||||
int limit = 25,
|
||||
}) {
|
||||
final ftsQuery = db.buildFtsQuery(searchString);
|
||||
|
||||
if (ftsQuery.isNotEmpty) {
|
||||
return db.definitionsDrift.queryHistoryFullContent(
|
||||
query: ftsQuery,
|
||||
snippetLength: snippetLength,
|
||||
beforeMatch: matchPrefix,
|
||||
afterMatch: matchSuffix,
|
||||
ellipsis: ellipsis,
|
||||
limit: limit,
|
||||
);
|
||||
}
|
||||
|
||||
final trimmed = searchString.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
// Empty query → no results. Caller should not invoke us in this case
|
||||
// but the guard avoids surfacing every host on the device.
|
||||
return _emptyHistorySelectable();
|
||||
}
|
||||
|
||||
return db.definitionsDrift.queryHistoryByHostPrefix(
|
||||
hostPrefix: '$trimmed%',
|
||||
limit: limit,
|
||||
);
|
||||
}
|
||||
|
||||
/// Hydrate canonical URLs (e.g. from Places' `getSuggestions`) with the
|
||||
/// local content rows. Returns rows in arbitrary order; callers are
|
||||
/// expected to preserve their own ordering.
|
||||
Selectable<HistoryQueryResult> hydrateByCanonicalUrls(
|
||||
Iterable<String> canonicalUrls,
|
||||
) {
|
||||
final urls = canonicalUrls.toList(growable: false);
|
||||
if (urls.isEmpty) {
|
||||
return _emptyHistorySelectable();
|
||||
}
|
||||
return db.definitionsDrift.historyByCanonicalUrls(canonicalUrls: urls);
|
||||
}
|
||||
|
||||
Future<int> countRows() {
|
||||
return db.definitionsDrift.countHistoryRows().getSingle();
|
||||
}
|
||||
|
||||
Future<void> clear() {
|
||||
return db.definitionsDrift.clearHistory();
|
||||
}
|
||||
|
||||
/// Returns a stable page over the local index, ordered oldest-first.
|
||||
///
|
||||
/// The pruner advances [offset] by the number of rows that survived each
|
||||
/// batch, so it can delete rows while still scanning the full table exactly
|
||||
/// once.
|
||||
Future<List<String>> urlsPage({int limit = 200, int offset = 0}) {
|
||||
return db.definitionsDrift
|
||||
.historyUrlsPage(limit: limit, offset: offset)
|
||||
.get();
|
||||
}
|
||||
|
||||
Future<int> deleteByCanonicalUrls(Iterable<String> canonicalUrls) {
|
||||
final urls = canonicalUrls.toList(growable: false);
|
||||
if (urls.isEmpty) return Future.value(0);
|
||||
return db.definitionsDrift.deleteHistoryByCanonicalUrls(
|
||||
canonicalUrls: urls,
|
||||
);
|
||||
}
|
||||
|
||||
/// Mirror a `local_index_setting` value from the user-facing settings.
|
||||
Future<void> upsertSetting(String key, bool value) {
|
||||
return db.definitionsDrift.upsertLocalIndexSetting(
|
||||
key: key,
|
||||
value: value ? 1 : 0,
|
||||
);
|
||||
}
|
||||
|
||||
/// Sentinel that cannot match any real row in `history.url_canonical`.
|
||||
///
|
||||
/// Canonical URLs come from `canonicalizeUrl()` / `url_canonical()` which
|
||||
/// require `uri.hasScheme` — every canonical form therefore contains a
|
||||
/// `:`. The sentinel below starts with a space and contains no `:`, so
|
||||
/// no canonicalization output can collide with it. Used by
|
||||
/// [_emptyHistorySelectable] to coerce the IN-list query into a
|
||||
/// guaranteed-empty result without inventing a new query shape.
|
||||
///
|
||||
/// Note: the schema is `url_canonical TEXT PRIMARY KEY NOT NULL` with no
|
||||
/// CHECK constraint, so the empty string `''` is technically a valid
|
||||
/// value at the SQL level. The write path's `url_indexable()` gate
|
||||
/// rejects schemeless input (including `''`), so in practice we never
|
||||
/// see one — but this sentinel doesn't rely on that invariant.
|
||||
static const _impossibleCanonicalUrlSentinel = ' __no_match__ ';
|
||||
|
||||
Selectable<HistoryQueryResult> _emptyHistorySelectable() =>
|
||||
// `IN ()` is not valid SQL — substitute a sentinel that cannot
|
||||
// match any real row. See [_impossibleCanonicalUrlSentinel].
|
||||
db.definitionsDrift.historyByCanonicalUrls(
|
||||
canonicalUrls: const [_impossibleCanonicalUrlSentinel],
|
||||
);
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
// dart format width=80
|
||||
// ignore_for_file: type=lint
|
||||
import 'package:drift/drift.dart' as i0;
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/database/database.dart'
|
||||
as i1;
|
||||
|
||||
mixin $HistoryDaoMixin on i0.DatabaseAccessor<i1.TabDatabase> {
|
||||
HistoryDaoManager get managers => HistoryDaoManager(this);
|
||||
}
|
||||
|
||||
class HistoryDaoManager {
|
||||
final $HistoryDaoMixin _db;
|
||||
HistoryDaoManager(this._db);
|
||||
}
|
||||
@@ -49,6 +49,19 @@ class TabDao extends DatabaseAccessor<TabDatabase> with $TabDaoMixin {
|
||||
|
||||
static const closedTabTombstoneTtl = Duration(hours: 24);
|
||||
|
||||
/// Soft cap for stored extracted/full content bytes (UTF-16 code units).
|
||||
/// The trigram FTS index expansion is roughly 5–10x the source size, so an
|
||||
/// uncapped multi-MB page DOM blows up the shadow tables disproportionately.
|
||||
/// Anything above the cap is truncated at write time; the tail is rarely
|
||||
/// useful for in-page text search anyway.
|
||||
static const _contentSizeCap = 256 * 1024;
|
||||
|
||||
static String? _capContent(String? value) {
|
||||
if (value == null) return null;
|
||||
if (value.length <= _contentSizeCap) return value;
|
||||
return value.substring(0, _contentSizeCap);
|
||||
}
|
||||
|
||||
TabDao(super.db);
|
||||
|
||||
UpdateStatement<Tab, TabData> _updateByIdStatement(String id) =>
|
||||
@@ -695,10 +708,10 @@ class TabDao extends DatabaseAccessor<TabDatabase> with $TabDaoMixin {
|
||||
await statement.write(
|
||||
TabCompanion(
|
||||
isProbablyReaderable: Value(isProbablyReaderable),
|
||||
extractedContentMarkdown: Value(extractedContentMarkdown),
|
||||
extractedContentPlain: Value(extractedContentPlain),
|
||||
fullContentMarkdown: Value(fullContentMarkdown),
|
||||
fullContentPlain: Value(fullContentPlain),
|
||||
extractedContentMarkdown: Value(_capContent(extractedContentMarkdown)),
|
||||
extractedContentPlain: Value(_capContent(extractedContentPlain)),
|
||||
fullContentMarkdown: Value(_capContent(fullContentMarkdown)),
|
||||
fullContentPlain: Value(_capContent(fullContentPlain)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,17 +21,26 @@ import 'package:drift/drift.dart';
|
||||
import 'package:drift/internal/versioned_schema.dart';
|
||||
import 'package:drift_dev/api/migrations_native.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:lexo_rank/lexo_rank.dart';
|
||||
import 'package:weblibre/data/database/functions/lexo_rank_functions.dart';
|
||||
import 'package:weblibre/data/database/functions/url_functions.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/database/daos/capture_tab.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/database/daos/container.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/database/daos/history.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/database/daos/tab.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/database/database.drift.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/database/database.steps.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/database/definitions.drift.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_source.dart';
|
||||
import 'package:weblibre/features/search/domain/fts_tokenizer.dart';
|
||||
|
||||
@DriftDatabase(include: {'definitions.drift'}, daos: [ContainerDao, TabDao])
|
||||
@DriftDatabase(
|
||||
include: {'definitions.drift'},
|
||||
daos: [ContainerDao, TabDao, CaptureTabDao, HistoryDao],
|
||||
)
|
||||
class TabDatabase extends $TabDatabase with TrigramQueryBuilderMixin {
|
||||
@override
|
||||
final int schemaVersion = 9;
|
||||
final int schemaVersion = 13;
|
||||
|
||||
@override
|
||||
final int ftsTokenLimit = 10;
|
||||
@@ -44,11 +53,17 @@ class TabDatabase extends $TabDatabase with TrigramQueryBuilderMixin {
|
||||
if (kDebugMode) {
|
||||
// This check pulls in a fair amount of code that's not needed
|
||||
// anywhere else, so we recommend only doing it in debug builds.
|
||||
await validateDatabaseSchema();
|
||||
await validateDatabaseSchema(
|
||||
setup: (database) {
|
||||
registerLexorankFunctions(database);
|
||||
registerUrlFunctions(database);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
await customStatement('PRAGMA foreign_keys = ON');
|
||||
await definitionsDrift.optimizeFtsIndex();
|
||||
await definitionsDrift.optimizeHistoryFtsIndex();
|
||||
},
|
||||
onUpgrade: (m, from, to) async {
|
||||
// Following the advice from https://drift.simonbinder.eu/Migrations/api/#general-tips
|
||||
@@ -140,5 +155,84 @@ class TabDatabase extends $TabDatabase with TrigramQueryBuilderMixin {
|
||||
from8To9: (m, schema) async {
|
||||
await m.create(schema.closedTabTombstone);
|
||||
},
|
||||
from9To10: (m, schema) async {
|
||||
await m.create(schema.captureTab);
|
||||
await m.create(schema.idxCaptureTabCaptureId);
|
||||
},
|
||||
from10To11: (m, schema) async {
|
||||
// Re-scope `tab_after_update` to fire only when FTS-relevant columns
|
||||
// change. Previously every tab UPDATE (order_key reshuffle, container
|
||||
// reassignment, pin toggle, timestamp touch, ...) rewrote the trigram
|
||||
// shadow rows for free.
|
||||
await m.drop(schema.tabAfterUpdate);
|
||||
await m.create(schema.tabAfterUpdate);
|
||||
},
|
||||
from11To12: (m, schema) async {
|
||||
// Local search index — content companion to Mozilla Places (which
|
||||
// remains SoT for visit metadata). See definitions.drift for layout.
|
||||
//
|
||||
// Order matters: the generated `tab.content_hash` column references
|
||||
// the `content_hash()` SQL function, which is registered in the
|
||||
// tab.db `setup` callback. The migration runs after `setup`, so the
|
||||
// function is available here.
|
||||
//
|
||||
// `tab.content_hash` requires recreating the tab table because
|
||||
// SQLite's ALTER TABLE only supports adding VIRTUAL generated
|
||||
// columns when the column is not part of any existing index/trigger
|
||||
// dependency chain — drift's TableMigration handles the rebuild.
|
||||
await m.alterTable(TableMigration(schema.tab));
|
||||
|
||||
// Settings table seeded with defaults: index enabled, private tabs
|
||||
// not indexed.
|
||||
await m.create(schema.localIndexSetting);
|
||||
await m.database.customStatement(
|
||||
"INSERT INTO local_index_setting (key, value) VALUES ('enabled', 1)",
|
||||
);
|
||||
await m.database.customStatement(
|
||||
"INSERT INTO local_index_setting (key, value) VALUES ('index_private', 0)",
|
||||
);
|
||||
|
||||
// History content table + FTS5 + maintenance triggers.
|
||||
await m.create(schema.history);
|
||||
await m.create(schema.idxHistoryHost);
|
||||
await m.create(schema.idxHistoryObserved);
|
||||
await m.create(schema.historyFts);
|
||||
await m.create(schema.historyAfterInsert);
|
||||
await m.create(schema.historyAfterDelete);
|
||||
await m.create(schema.historyAfterUpdate);
|
||||
|
||||
// tab_after_update gains a content_hash WHEN guard. Recreate.
|
||||
await m.drop(schema.tabAfterUpdate);
|
||||
await m.create(schema.tabAfterUpdate);
|
||||
|
||||
// Tab → history fan-out triggers.
|
||||
await m.create(schema.tabToHistoryOnInsert);
|
||||
await m.create(schema.tabToHistoryOnUpdate);
|
||||
},
|
||||
from12To13: (m, schema) async {
|
||||
await m.alterTable(
|
||||
TableMigration(
|
||||
schema.container,
|
||||
newColumns: [schema.container.orderKey, schema.container.isPinned],
|
||||
columnTransformer: {
|
||||
schema.container.orderKey: Constant(LexoRank.middle().value),
|
||||
schema.container.isPinned: const Constant(false),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final database = m.database as TabDatabase;
|
||||
final containerIds = await database.definitionsDrift
|
||||
.containerIdsByLastUpdated()
|
||||
.get();
|
||||
|
||||
var rank = LexoRank.middle();
|
||||
for (final containerId in containerIds) {
|
||||
await (database.update(database.container)
|
||||
..where((container) => container.id.equals(containerId)))
|
||||
.write(ContainerCompanion(orderKey: Value(rank.value)));
|
||||
rank = rank.genNext();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
+128
-8
@@ -9,8 +9,12 @@ import 'package:weblibre/features/geckoview/features/tabs/data/database/database
|
||||
as i3;
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/database/daos/tab.dart'
|
||||
as i4;
|
||||
import 'package:drift/internal/modular.dart' as i5;
|
||||
import 'package:sqlite3/common.dart' as i6;
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/database/daos/capture_tab.dart'
|
||||
as i5;
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/database/daos/history.dart'
|
||||
as i6;
|
||||
import 'package:drift/internal/modular.dart' as i7;
|
||||
import 'package:sqlite3/common.dart' as i8;
|
||||
|
||||
abstract class $TabDatabase extends i0.GeneratedDatabase {
|
||||
$TabDatabase(i0.QueryExecutor e) : super(e);
|
||||
@@ -20,12 +24,22 @@ abstract class $TabDatabase extends i0.GeneratedDatabase {
|
||||
late final i1.ClosedTabTombstone closedTabTombstone = i1.ClosedTabTombstone(
|
||||
this,
|
||||
);
|
||||
late final i1.CaptureTab captureTab = i1.CaptureTab(this);
|
||||
late final i1.TabFts tabFts = i1.TabFts(this);
|
||||
late final i1.LocalIndexSetting localIndexSetting = i1.LocalIndexSetting(
|
||||
this,
|
||||
);
|
||||
late final i1.History history = i1.History(this);
|
||||
late final i1.HistoryFts historyFts = i1.HistoryFts(this);
|
||||
late final i2.ContainerDao containerDao = i2.ContainerDao(
|
||||
this as i3.TabDatabase,
|
||||
);
|
||||
late final i4.TabDao tabDao = i4.TabDao(this as i3.TabDatabase);
|
||||
i1.DefinitionsDrift get definitionsDrift => i5.ReadDatabaseContainer(
|
||||
late final i5.CaptureTabDao captureTabDao = i5.CaptureTabDao(
|
||||
this as i3.TabDatabase,
|
||||
);
|
||||
late final i6.HistoryDao historyDao = i6.HistoryDao(this as i3.TabDatabase);
|
||||
i1.DefinitionsDrift get definitionsDrift => i7.ReadDatabaseContainer(
|
||||
this,
|
||||
).accessor<i1.DefinitionsDrift>(i1.DefinitionsDrift.new);
|
||||
@override
|
||||
@@ -37,11 +51,23 @@ abstract class $TabDatabase extends i0.GeneratedDatabase {
|
||||
tab,
|
||||
closedTabTombstone,
|
||||
i1.idxTabParentContainer,
|
||||
captureTab,
|
||||
i1.idxCaptureTabCaptureId,
|
||||
tabFts,
|
||||
i1.tabMaintainParentChainOnDelete,
|
||||
i1.tabAfterInsert,
|
||||
i1.tabAfterDelete,
|
||||
i1.tabAfterUpdate,
|
||||
localIndexSetting,
|
||||
history,
|
||||
i1.idxHistoryHost,
|
||||
i1.idxHistoryObserved,
|
||||
historyFts,
|
||||
i1.historyAfterInsert,
|
||||
i1.historyAfterDelete,
|
||||
i1.historyAfterUpdate,
|
||||
i1.tabToHistoryOnInsert,
|
||||
i1.tabToHistoryOnUpdate,
|
||||
];
|
||||
@override
|
||||
i0.StreamQueryUpdateRules get streamUpdateRules =>
|
||||
@@ -53,6 +79,13 @@ abstract class $TabDatabase extends i0.GeneratedDatabase {
|
||||
),
|
||||
result: [i0.TableUpdate('tab', kind: i0.UpdateKind.delete)],
|
||||
),
|
||||
i0.WritePropagation(
|
||||
on: i0.TableUpdateQuery.onTableName(
|
||||
'tab',
|
||||
limitUpdateKind: i0.UpdateKind.delete,
|
||||
),
|
||||
result: [i0.TableUpdate('capture_tab', kind: i0.UpdateKind.delete)],
|
||||
),
|
||||
i0.WritePropagation(
|
||||
on: i0.TableUpdateQuery.onTableName(
|
||||
'tab',
|
||||
@@ -81,6 +114,41 @@ abstract class $TabDatabase extends i0.GeneratedDatabase {
|
||||
),
|
||||
result: [i0.TableUpdate('tab_fts', kind: i0.UpdateKind.insert)],
|
||||
),
|
||||
i0.WritePropagation(
|
||||
on: i0.TableUpdateQuery.onTableName(
|
||||
'history',
|
||||
limitUpdateKind: i0.UpdateKind.insert,
|
||||
),
|
||||
result: [i0.TableUpdate('history_fts', kind: i0.UpdateKind.insert)],
|
||||
),
|
||||
i0.WritePropagation(
|
||||
on: i0.TableUpdateQuery.onTableName(
|
||||
'history',
|
||||
limitUpdateKind: i0.UpdateKind.delete,
|
||||
),
|
||||
result: [i0.TableUpdate('history_fts', kind: i0.UpdateKind.insert)],
|
||||
),
|
||||
i0.WritePropagation(
|
||||
on: i0.TableUpdateQuery.onTableName(
|
||||
'history',
|
||||
limitUpdateKind: i0.UpdateKind.update,
|
||||
),
|
||||
result: [i0.TableUpdate('history_fts', kind: i0.UpdateKind.insert)],
|
||||
),
|
||||
i0.WritePropagation(
|
||||
on: i0.TableUpdateQuery.onTableName(
|
||||
'tab',
|
||||
limitUpdateKind: i0.UpdateKind.insert,
|
||||
),
|
||||
result: [i0.TableUpdate('history', kind: i0.UpdateKind.insert)],
|
||||
),
|
||||
i0.WritePropagation(
|
||||
on: i0.TableUpdateQuery.onTableName(
|
||||
'tab',
|
||||
limitUpdateKind: i0.UpdateKind.update,
|
||||
),
|
||||
result: [i0.TableUpdate('history', kind: i0.UpdateKind.insert)],
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -92,19 +160,32 @@ class $TabDatabaseManager {
|
||||
i1.$TabTableManager get tab => i1.$TabTableManager(_db, _db.tab);
|
||||
i1.$ClosedTabTombstoneTableManager get closedTabTombstone =>
|
||||
i1.$ClosedTabTombstoneTableManager(_db, _db.closedTabTombstone);
|
||||
i1.$CaptureTabTableManager get captureTab =>
|
||||
i1.$CaptureTabTableManager(_db, _db.captureTab);
|
||||
i1.$TabFtsTableManager get tabFts => i1.$TabFtsTableManager(_db, _db.tabFts);
|
||||
i1.$LocalIndexSettingTableManager get localIndexSetting =>
|
||||
i1.$LocalIndexSettingTableManager(_db, _db.localIndexSetting);
|
||||
i1.$HistoryTableManager get history =>
|
||||
i1.$HistoryTableManager(_db, _db.history);
|
||||
i1.$HistoryFtsTableManager get historyFts =>
|
||||
i1.$HistoryFtsTableManager(_db, _db.historyFts);
|
||||
}
|
||||
|
||||
extension DefineFunctions on i6.CommonDatabase {
|
||||
extension DefineFunctions on i8.CommonDatabase {
|
||||
void defineFunctions({
|
||||
required String Function(int, String?) lexoRankNext,
|
||||
required String Function(int, String?) lexoRankPrevious,
|
||||
required String Function(String?, String?) lexoRankReorderAfter,
|
||||
required String Function(String?, String?) lexoRankReorderBefore,
|
||||
required int Function() generateContentHash,
|
||||
required bool Function(String?) urlIndexable,
|
||||
required String Function(String?) urlCanonical,
|
||||
required String Function(String?) urlHost,
|
||||
required String Function(String?) urlPath,
|
||||
}) {
|
||||
createFunction(
|
||||
functionName: 'lexo_rank_next',
|
||||
argumentCount: const i6.AllowedArgumentCount(2),
|
||||
argumentCount: const i8.AllowedArgumentCount(2),
|
||||
function: (args) {
|
||||
final arg0 = args[0] as int;
|
||||
final arg1 = args[1] as String?;
|
||||
@@ -113,7 +194,7 @@ extension DefineFunctions on i6.CommonDatabase {
|
||||
);
|
||||
createFunction(
|
||||
functionName: 'lexo_rank_previous',
|
||||
argumentCount: const i6.AllowedArgumentCount(2),
|
||||
argumentCount: const i8.AllowedArgumentCount(2),
|
||||
function: (args) {
|
||||
final arg0 = args[0] as int;
|
||||
final arg1 = args[1] as String?;
|
||||
@@ -122,7 +203,7 @@ extension DefineFunctions on i6.CommonDatabase {
|
||||
);
|
||||
createFunction(
|
||||
functionName: 'lexo_rank_reorder_after',
|
||||
argumentCount: const i6.AllowedArgumentCount(2),
|
||||
argumentCount: const i8.AllowedArgumentCount(2),
|
||||
function: (args) {
|
||||
final arg0 = args[0] as String?;
|
||||
final arg1 = args[1] as String?;
|
||||
@@ -131,12 +212,51 @@ extension DefineFunctions on i6.CommonDatabase {
|
||||
);
|
||||
createFunction(
|
||||
functionName: 'lexo_rank_reorder_before',
|
||||
argumentCount: const i6.AllowedArgumentCount(2),
|
||||
argumentCount: const i8.AllowedArgumentCount(2),
|
||||
function: (args) {
|
||||
final arg0 = args[0] as String?;
|
||||
final arg1 = args[1] as String?;
|
||||
return lexoRankReorderBefore(arg0, arg1);
|
||||
},
|
||||
);
|
||||
createFunction(
|
||||
functionName: 'generate_content_hash',
|
||||
argumentCount: const i8.AllowedArgumentCount(0),
|
||||
function: (args) {
|
||||
return generateContentHash();
|
||||
},
|
||||
);
|
||||
createFunction(
|
||||
functionName: 'url_indexable',
|
||||
argumentCount: const i8.AllowedArgumentCount(1),
|
||||
function: (args) {
|
||||
final arg0 = args[0] as String?;
|
||||
return urlIndexable(arg0);
|
||||
},
|
||||
);
|
||||
createFunction(
|
||||
functionName: 'url_canonical',
|
||||
argumentCount: const i8.AllowedArgumentCount(1),
|
||||
function: (args) {
|
||||
final arg0 = args[0] as String?;
|
||||
return urlCanonical(arg0);
|
||||
},
|
||||
);
|
||||
createFunction(
|
||||
functionName: 'url_host',
|
||||
argumentCount: const i8.AllowedArgumentCount(1),
|
||||
function: (args) {
|
||||
final arg0 = args[0] as String?;
|
||||
return urlHost(arg0);
|
||||
},
|
||||
);
|
||||
createFunction(
|
||||
functionName: 'url_path',
|
||||
argumentCount: const i8.AllowedArgumentCount(1),
|
||||
function: (args) {
|
||||
final arg0 = args[0] as String?;
|
||||
return urlPath(arg0);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -931,6 +931,898 @@ i1.GeneratedColumn<int> _column_21(String aliasedName) =>
|
||||
type: i1.DriftSqlType.int,
|
||||
$customConstraints: 'NOT NULL',
|
||||
);
|
||||
|
||||
final class Schema10 extends i0.VersionedSchema {
|
||||
Schema10({required super.database}) : super(version: 10);
|
||||
@override
|
||||
late final List<i1.DatabaseSchemaEntity> entities = [
|
||||
container,
|
||||
tab,
|
||||
closedTabTombstone,
|
||||
idxTabParentContainer,
|
||||
captureTab,
|
||||
idxCaptureTabCaptureId,
|
||||
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 Shape6 closedTabTombstone = Shape6(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'closed_tab_tombstone',
|
||||
withoutRowId: false,
|
||||
isStrict: false,
|
||||
tableConstraints: [],
|
||||
columns: [_column_20, _column_21],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
final i1.Index idxTabParentContainer = i1.Index(
|
||||
'idx_tab_parent_container',
|
||||
'CREATE INDEX idx_tab_parent_container ON tab (parent_id, container_id)',
|
||||
);
|
||||
late final Shape7 captureTab = Shape7(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'capture_tab',
|
||||
withoutRowId: false,
|
||||
isStrict: false,
|
||||
tableConstraints: [],
|
||||
columns: [_column_22, _column_23, _column_24, _column_25, _column_26],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
final i1.Index idxCaptureTabCaptureId = i1.Index(
|
||||
'idx_capture_tab_capture_id',
|
||||
'CREATE INDEX idx_capture_tab_capture_id ON capture_tab (capture_id)',
|
||||
);
|
||||
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 Shape7 extends i0.VersionedTable {
|
||||
Shape7({required super.source, required super.alias}) : super.aliased();
|
||||
i1.GeneratedColumn<String> get tabId =>
|
||||
columnsByName['tab_id']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get captureId =>
|
||||
columnsByName['capture_id']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get sourceUrl =>
|
||||
columnsByName['source_url']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get status =>
|
||||
columnsByName['status']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<int> get createdAt =>
|
||||
columnsByName['created_at']! as i1.GeneratedColumn<int>;
|
||||
}
|
||||
|
||||
i1.GeneratedColumn<String> _column_22(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>(
|
||||
'tab_id',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i1.DriftSqlType.string,
|
||||
$customConstraints:
|
||||
'NOT NULL PRIMARY KEY REFERENCES tab(id)ON DELETE CASCADE',
|
||||
);
|
||||
i1.GeneratedColumn<String> _column_23(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>(
|
||||
'capture_id',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i1.DriftSqlType.string,
|
||||
$customConstraints: 'NOT NULL',
|
||||
);
|
||||
i1.GeneratedColumn<String> _column_24(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>(
|
||||
'source_url',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i1.DriftSqlType.string,
|
||||
$customConstraints: 'NOT NULL',
|
||||
);
|
||||
i1.GeneratedColumn<String> _column_25(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>(
|
||||
'status',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i1.DriftSqlType.string,
|
||||
$customConstraints: 'NOT NULL DEFAULT \'pending\'',
|
||||
defaultValue: const i1.CustomExpression('\'pending\''),
|
||||
);
|
||||
i1.GeneratedColumn<int> _column_26(String aliasedName) =>
|
||||
i1.GeneratedColumn<int>(
|
||||
'created_at',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i1.DriftSqlType.int,
|
||||
$customConstraints: 'NOT NULL',
|
||||
);
|
||||
|
||||
final class Schema11 extends i0.VersionedSchema {
|
||||
Schema11({required super.database}) : super(version: 11);
|
||||
@override
|
||||
late final List<i1.DatabaseSchemaEntity> entities = [
|
||||
container,
|
||||
tab,
|
||||
closedTabTombstone,
|
||||
idxTabParentContainer,
|
||||
captureTab,
|
||||
idxCaptureTabCaptureId,
|
||||
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 Shape6 closedTabTombstone = Shape6(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'closed_tab_tombstone',
|
||||
withoutRowId: false,
|
||||
isStrict: false,
|
||||
tableConstraints: [],
|
||||
columns: [_column_20, _column_21],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
final i1.Index idxTabParentContainer = i1.Index(
|
||||
'idx_tab_parent_container',
|
||||
'CREATE INDEX idx_tab_parent_container ON tab (parent_id, container_id)',
|
||||
);
|
||||
late final Shape7 captureTab = Shape7(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'capture_tab',
|
||||
withoutRowId: false,
|
||||
isStrict: false,
|
||||
tableConstraints: [],
|
||||
columns: [_column_22, _column_23, _column_24, _column_25, _column_26],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
final i1.Index idxCaptureTabCaptureId = i1.Index(
|
||||
'idx_capture_tab_capture_id',
|
||||
'CREATE INDEX idx_capture_tab_capture_id ON capture_tab (capture_id)',
|
||||
);
|
||||
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 OF title, url, extracted_content_plain, full_content_plain 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',
|
||||
);
|
||||
}
|
||||
|
||||
final class Schema12 extends i0.VersionedSchema {
|
||||
Schema12({required super.database}) : super(version: 12);
|
||||
@override
|
||||
late final List<i1.DatabaseSchemaEntity> entities = [
|
||||
container,
|
||||
tab,
|
||||
closedTabTombstone,
|
||||
idxTabParentContainer,
|
||||
captureTab,
|
||||
idxCaptureTabCaptureId,
|
||||
tabFts,
|
||||
tabMaintainParentChainOnDelete,
|
||||
tabAfterInsert,
|
||||
tabAfterDelete,
|
||||
tabAfterUpdate,
|
||||
localIndexSetting,
|
||||
history,
|
||||
idxHistoryHost,
|
||||
idxHistoryObserved,
|
||||
historyFts,
|
||||
historyAfterInsert,
|
||||
historyAfterDelete,
|
||||
historyAfterUpdate,
|
||||
tabToHistoryOnInsert,
|
||||
tabToHistoryOnUpdate,
|
||||
];
|
||||
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 Shape8 tab = Shape8(
|
||||
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,
|
||||
_column_27,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape6 closedTabTombstone = Shape6(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'closed_tab_tombstone',
|
||||
withoutRowId: false,
|
||||
isStrict: false,
|
||||
tableConstraints: [],
|
||||
columns: [_column_20, _column_21],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
final i1.Index idxTabParentContainer = i1.Index(
|
||||
'idx_tab_parent_container',
|
||||
'CREATE INDEX idx_tab_parent_container ON tab (parent_id, container_id)',
|
||||
);
|
||||
late final Shape7 captureTab = Shape7(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'capture_tab',
|
||||
withoutRowId: false,
|
||||
isStrict: false,
|
||||
tableConstraints: [],
|
||||
columns: [_column_22, _column_23, _column_24, _column_25, _column_26],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
final i1.Index idxCaptureTabCaptureId = i1.Index(
|
||||
'idx_capture_tab_capture_id',
|
||||
'CREATE INDEX idx_capture_tab_capture_id ON capture_tab (capture_id)',
|
||||
);
|
||||
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 OF title, url, extracted_content_plain, full_content_plain ON tab WHEN OLD.content_hash IS NOT NEW.content_hash OR OLD.url IS NOT NEW.url 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',
|
||||
);
|
||||
late final Shape9 localIndexSetting = Shape9(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'local_index_setting',
|
||||
withoutRowId: false,
|
||||
isStrict: true,
|
||||
tableConstraints: [],
|
||||
columns: [_column_28, _column_29],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape10 history = Shape10(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'history',
|
||||
withoutRowId: false,
|
||||
isStrict: false,
|
||||
tableConstraints: [],
|
||||
columns: [
|
||||
_column_30,
|
||||
_column_31,
|
||||
_column_32,
|
||||
_column_8,
|
||||
_column_10,
|
||||
_column_11,
|
||||
_column_12,
|
||||
_column_13,
|
||||
_column_14,
|
||||
_column_33,
|
||||
_column_34,
|
||||
_column_35,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
final i1.Index idxHistoryHost = i1.Index(
|
||||
'idx_history_host',
|
||||
'CREATE INDEX idx_history_host ON history (url_host)',
|
||||
);
|
||||
final i1.Index idxHistoryObserved = i1.Index(
|
||||
'idx_history_observed',
|
||||
'CREATE INDEX idx_history_observed ON history (observed_at DESC)',
|
||||
);
|
||||
late final Shape11 historyFts = Shape11(
|
||||
source: i0.VersionedVirtualTable(
|
||||
entityName: 'history_fts',
|
||||
moduleAndArgs:
|
||||
'fts5(title, url_host, url_path, extracted_content_plain, full_content_plain, content=history, tokenize="trigram")',
|
||||
columns: [_column_8, _column_36, _column_32, _column_12, _column_14],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
final i1.Trigger historyAfterInsert = i1.Trigger(
|
||||
'CREATE TRIGGER history_after_insert AFTER INSERT ON history BEGIN INSERT INTO history_fts ("rowid", title, url_host, url_path, extracted_content_plain, full_content_plain) VALUES (new."rowid", new.title, new.url_host, new.url_path, new.extracted_content_plain, new.full_content_plain);END',
|
||||
'history_after_insert',
|
||||
);
|
||||
final i1.Trigger historyAfterDelete = i1.Trigger(
|
||||
'CREATE TRIGGER history_after_delete AFTER DELETE ON history BEGIN INSERT INTO history_fts (history_fts, "rowid", title, url_host, url_path, extracted_content_plain, full_content_plain) VALUES (\'delete\', old."rowid", old.title, old.url_host, old.url_path, old.extracted_content_plain, old.full_content_plain);END',
|
||||
'history_after_delete',
|
||||
);
|
||||
final i1.Trigger historyAfterUpdate = i1.Trigger(
|
||||
'CREATE TRIGGER history_after_update AFTER UPDATE OF title, url_host, url_path, extracted_content_plain, full_content_plain ON history BEGIN INSERT INTO history_fts (history_fts, "rowid", title, url_host, url_path, extracted_content_plain, full_content_plain) VALUES (\'delete\', old."rowid", old.title, old.url_host, old.url_path, old.extracted_content_plain, old.full_content_plain);INSERT INTO history_fts ("rowid", title, url_host, url_path, extracted_content_plain, full_content_plain) VALUES (new."rowid", new.title, new.url_host, new.url_path, new.extracted_content_plain, new.full_content_plain);END',
|
||||
'history_after_update',
|
||||
);
|
||||
final i1.Trigger tabToHistoryOnInsert = i1.Trigger(
|
||||
'CREATE TRIGGER tab_to_history_on_insert AFTER INSERT ON tab WHEN NEW.url IS NOT NULL AND url_indexable(CAST(NEW.url AS TEXT)) = 1 AND (SELECT value FROM local_index_setting WHERE "key" = \'enabled\') = 1 AND(NEW.tab_mode != 1 OR (SELECT value FROM local_index_setting WHERE "key" = \'index_private\') = 1)BEGIN INSERT INTO history (url_canonical, url_host, url_path, title, is_probably_readerable, extracted_content_markdown, extracted_content_plain, full_content_markdown, full_content_plain, content_hash, observed_at, observed_count) VALUES (url_canonical(CAST(NEW.url AS TEXT)), url_host(CAST(NEW.url AS TEXT)), url_path(CAST(NEW.url AS TEXT)), NEW.title, NEW.is_probably_readerable, NEW.extracted_content_markdown, NEW.extracted_content_plain, NEW.full_content_markdown, NEW.full_content_plain, NEW.content_hash, strftime(\'%s\', \'now\') * 1000, 1) ON CONFLICT (url_canonical) DO UPDATE SET title = COALESCE(excluded.title, history.title), is_probably_readerable = excluded.is_probably_readerable, extracted_content_markdown = excluded.extracted_content_markdown, extracted_content_plain = excluded.extracted_content_plain, full_content_markdown = excluded.full_content_markdown, full_content_plain = excluded.full_content_plain, content_hash = excluded.content_hash, observed_at = excluded.observed_at, observed_count = history.observed_count + 1 WHERE history.content_hash IS NOT excluded.content_hash;END',
|
||||
'tab_to_history_on_insert',
|
||||
);
|
||||
final i1.Trigger tabToHistoryOnUpdate = i1.Trigger(
|
||||
'CREATE TRIGGER tab_to_history_on_update AFTER UPDATE OF title, url, extracted_content_plain, extracted_content_markdown, full_content_plain, full_content_markdown, is_probably_readerable ON tab WHEN NEW.url IS NOT NULL AND url_indexable(CAST(NEW.url AS TEXT)) = 1 AND(OLD.content_hash IS NOT NEW.content_hash OR OLD.url IS NOT NEW.url)AND (SELECT value FROM local_index_setting WHERE "key" = \'enabled\') = 1 AND(NEW.tab_mode != 1 OR (SELECT value FROM local_index_setting WHERE "key" = \'index_private\') = 1)BEGIN INSERT INTO history (url_canonical, url_host, url_path, title, is_probably_readerable, extracted_content_markdown, extracted_content_plain, full_content_markdown, full_content_plain, content_hash, observed_at, observed_count) VALUES (url_canonical(CAST(NEW.url AS TEXT)), url_host(CAST(NEW.url AS TEXT)), url_path(CAST(NEW.url AS TEXT)), NEW.title, NEW.is_probably_readerable, NEW.extracted_content_markdown, NEW.extracted_content_plain, NEW.full_content_markdown, NEW.full_content_plain, NEW.content_hash, strftime(\'%s\', \'now\') * 1000, 1) ON CONFLICT (url_canonical) DO UPDATE SET title = COALESCE(excluded.title, history.title), is_probably_readerable = excluded.is_probably_readerable, extracted_content_markdown = excluded.extracted_content_markdown, extracted_content_plain = excluded.extracted_content_plain, full_content_markdown = excluded.full_content_markdown, full_content_plain = excluded.full_content_plain, content_hash = excluded.content_hash, observed_at = excluded.observed_at, observed_count = history.observed_count + 1 WHERE history.content_hash IS NOT excluded.content_hash;END',
|
||||
'tab_to_history_on_update',
|
||||
);
|
||||
}
|
||||
|
||||
class Shape8 extends i0.VersionedTable {
|
||||
Shape8({required super.source, required super.alias}) : super.aliased();
|
||||
i1.GeneratedColumn<String> get id =>
|
||||
columnsByName['id']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<int> get source =>
|
||||
columnsByName['source']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<String> get parentId =>
|
||||
columnsByName['parent_id']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get containerId =>
|
||||
columnsByName['container_id']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get orderKey =>
|
||||
columnsByName['order_key']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get url =>
|
||||
columnsByName['url']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get title =>
|
||||
columnsByName['title']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<int> get tabMode =>
|
||||
columnsByName['tab_mode']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<String> get isolationContextId =>
|
||||
columnsByName['isolation_context_id']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<int> get isPinned =>
|
||||
columnsByName['is_pinned']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<int> get isProbablyReaderable =>
|
||||
columnsByName['is_probably_readerable']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<String> get extractedContentMarkdown =>
|
||||
columnsByName['extracted_content_markdown']!
|
||||
as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get extractedContentPlain =>
|
||||
columnsByName['extracted_content_plain']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get fullContentMarkdown =>
|
||||
columnsByName['full_content_markdown']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get fullContentPlain =>
|
||||
columnsByName['full_content_plain']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<int> get timestamp =>
|
||||
columnsByName['timestamp']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<int> get contentHash =>
|
||||
columnsByName['content_hash']! as i1.GeneratedColumn<int>;
|
||||
}
|
||||
|
||||
i1.GeneratedColumn<int> _column_27(
|
||||
String aliasedName,
|
||||
) => i1.GeneratedColumn<int>(
|
||||
'content_hash',
|
||||
aliasedName,
|
||||
true,
|
||||
generatedAs: i1.GeneratedAs(
|
||||
const i1.CustomExpression(
|
||||
'generate_content_hash(title, extracted_content_plain, full_content_plain)',
|
||||
),
|
||||
false,
|
||||
),
|
||||
type: i1.DriftSqlType.int,
|
||||
$customConstraints:
|
||||
'GENERATED ALWAYS AS (generate_content_hash(title, extracted_content_plain, full_content_plain)) VIRTUAL',
|
||||
);
|
||||
|
||||
class Shape9 extends i0.VersionedTable {
|
||||
Shape9({required super.source, required super.alias}) : super.aliased();
|
||||
i1.GeneratedColumn<String> get key =>
|
||||
columnsByName['key']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<int> get value =>
|
||||
columnsByName['value']! as i1.GeneratedColumn<int>;
|
||||
}
|
||||
|
||||
i1.GeneratedColumn<String> _column_28(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>(
|
||||
'key',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i1.DriftSqlType.string,
|
||||
$customConstraints: 'PRIMARY KEY NOT NULL',
|
||||
);
|
||||
i1.GeneratedColumn<int> _column_29(String aliasedName) =>
|
||||
i1.GeneratedColumn<int>(
|
||||
'value',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i1.DriftSqlType.int,
|
||||
$customConstraints: 'NOT NULL',
|
||||
);
|
||||
|
||||
class Shape10 extends i0.VersionedTable {
|
||||
Shape10({required super.source, required super.alias}) : super.aliased();
|
||||
i1.GeneratedColumn<String> get urlCanonical =>
|
||||
columnsByName['url_canonical']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get urlHost =>
|
||||
columnsByName['url_host']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get urlPath =>
|
||||
columnsByName['url_path']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get title =>
|
||||
columnsByName['title']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<int> get isProbablyReaderable =>
|
||||
columnsByName['is_probably_readerable']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<String> get extractedContentMarkdown =>
|
||||
columnsByName['extracted_content_markdown']!
|
||||
as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get extractedContentPlain =>
|
||||
columnsByName['extracted_content_plain']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get fullContentMarkdown =>
|
||||
columnsByName['full_content_markdown']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get fullContentPlain =>
|
||||
columnsByName['full_content_plain']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<int> get contentHash =>
|
||||
columnsByName['content_hash']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<int> get observedAt =>
|
||||
columnsByName['observed_at']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<int> get observedCount =>
|
||||
columnsByName['observed_count']! as i1.GeneratedColumn<int>;
|
||||
}
|
||||
|
||||
i1.GeneratedColumn<String> _column_30(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>(
|
||||
'url_canonical',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i1.DriftSqlType.string,
|
||||
$customConstraints: 'PRIMARY KEY NOT NULL',
|
||||
);
|
||||
i1.GeneratedColumn<String> _column_31(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>(
|
||||
'url_host',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i1.DriftSqlType.string,
|
||||
$customConstraints: 'NOT NULL',
|
||||
);
|
||||
i1.GeneratedColumn<String> _column_32(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>(
|
||||
'url_path',
|
||||
aliasedName,
|
||||
true,
|
||||
type: i1.DriftSqlType.string,
|
||||
$customConstraints: '',
|
||||
);
|
||||
i1.GeneratedColumn<int> _column_33(String aliasedName) =>
|
||||
i1.GeneratedColumn<int>(
|
||||
'content_hash',
|
||||
aliasedName,
|
||||
true,
|
||||
type: i1.DriftSqlType.int,
|
||||
$customConstraints: '',
|
||||
);
|
||||
i1.GeneratedColumn<int> _column_34(String aliasedName) =>
|
||||
i1.GeneratedColumn<int>(
|
||||
'observed_at',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i1.DriftSqlType.int,
|
||||
$customConstraints: 'NOT NULL',
|
||||
);
|
||||
i1.GeneratedColumn<int> _column_35(String aliasedName) =>
|
||||
i1.GeneratedColumn<int>(
|
||||
'observed_count',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i1.DriftSqlType.int,
|
||||
$customConstraints: 'NOT NULL DEFAULT 1',
|
||||
defaultValue: const i1.CustomExpression('1'),
|
||||
);
|
||||
|
||||
class Shape11 extends i0.VersionedVirtualTable {
|
||||
Shape11({required super.source, required super.alias}) : super.aliased();
|
||||
i1.GeneratedColumn<String> get title =>
|
||||
columnsByName['title']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get urlHost =>
|
||||
columnsByName['url_host']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get urlPath =>
|
||||
columnsByName['url_path']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get extractedContentPlain =>
|
||||
columnsByName['extracted_content_plain']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get fullContentPlain =>
|
||||
columnsByName['full_content_plain']! as i1.GeneratedColumn<String>;
|
||||
}
|
||||
|
||||
i1.GeneratedColumn<String> _column_36(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>(
|
||||
'url_host',
|
||||
aliasedName,
|
||||
true,
|
||||
type: i1.DriftSqlType.string,
|
||||
$customConstraints: '',
|
||||
);
|
||||
|
||||
final class Schema13 extends i0.VersionedSchema {
|
||||
Schema13({required super.database}) : super(version: 13);
|
||||
@override
|
||||
late final List<i1.DatabaseSchemaEntity> entities = [
|
||||
container,
|
||||
tab,
|
||||
closedTabTombstone,
|
||||
idxTabParentContainer,
|
||||
captureTab,
|
||||
idxCaptureTabCaptureId,
|
||||
tabFts,
|
||||
tabMaintainParentChainOnDelete,
|
||||
tabAfterInsert,
|
||||
tabAfterDelete,
|
||||
tabAfterUpdate,
|
||||
localIndexSetting,
|
||||
history,
|
||||
idxHistoryHost,
|
||||
idxHistoryObserved,
|
||||
historyFts,
|
||||
historyAfterInsert,
|
||||
historyAfterDelete,
|
||||
historyAfterUpdate,
|
||||
tabToHistoryOnInsert,
|
||||
tabToHistoryOnUpdate,
|
||||
];
|
||||
late final Shape12 container = Shape12(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'container',
|
||||
withoutRowId: false,
|
||||
isStrict: false,
|
||||
tableConstraints: [],
|
||||
columns: [
|
||||
_column_0,
|
||||
_column_1,
|
||||
_column_2,
|
||||
_column_6,
|
||||
_column_19,
|
||||
_column_3,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape8 tab = Shape8(
|
||||
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,
|
||||
_column_27,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape6 closedTabTombstone = Shape6(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'closed_tab_tombstone',
|
||||
withoutRowId: false,
|
||||
isStrict: false,
|
||||
tableConstraints: [],
|
||||
columns: [_column_20, _column_21],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
final i1.Index idxTabParentContainer = i1.Index(
|
||||
'idx_tab_parent_container',
|
||||
'CREATE INDEX idx_tab_parent_container ON tab (parent_id, container_id)',
|
||||
);
|
||||
late final Shape7 captureTab = Shape7(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'capture_tab',
|
||||
withoutRowId: false,
|
||||
isStrict: false,
|
||||
tableConstraints: [],
|
||||
columns: [_column_22, _column_23, _column_24, _column_25, _column_26],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
final i1.Index idxCaptureTabCaptureId = i1.Index(
|
||||
'idx_capture_tab_capture_id',
|
||||
'CREATE INDEX idx_capture_tab_capture_id ON capture_tab (capture_id)',
|
||||
);
|
||||
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 OF title, url, extracted_content_plain, full_content_plain ON tab WHEN OLD.content_hash IS NOT NEW.content_hash OR OLD.url IS NOT NEW.url 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',
|
||||
);
|
||||
late final Shape9 localIndexSetting = Shape9(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'local_index_setting',
|
||||
withoutRowId: false,
|
||||
isStrict: true,
|
||||
tableConstraints: [],
|
||||
columns: [_column_28, _column_29],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape10 history = Shape10(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'history',
|
||||
withoutRowId: false,
|
||||
isStrict: false,
|
||||
tableConstraints: [],
|
||||
columns: [
|
||||
_column_30,
|
||||
_column_31,
|
||||
_column_32,
|
||||
_column_8,
|
||||
_column_10,
|
||||
_column_11,
|
||||
_column_12,
|
||||
_column_13,
|
||||
_column_14,
|
||||
_column_33,
|
||||
_column_34,
|
||||
_column_35,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
final i1.Index idxHistoryHost = i1.Index(
|
||||
'idx_history_host',
|
||||
'CREATE INDEX idx_history_host ON history (url_host)',
|
||||
);
|
||||
final i1.Index idxHistoryObserved = i1.Index(
|
||||
'idx_history_observed',
|
||||
'CREATE INDEX idx_history_observed ON history (observed_at DESC)',
|
||||
);
|
||||
late final Shape11 historyFts = Shape11(
|
||||
source: i0.VersionedVirtualTable(
|
||||
entityName: 'history_fts',
|
||||
moduleAndArgs:
|
||||
'fts5(title, url_host, url_path, extracted_content_plain, full_content_plain, content=history, tokenize="trigram")',
|
||||
columns: [_column_8, _column_36, _column_32, _column_12, _column_14],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
final i1.Trigger historyAfterInsert = i1.Trigger(
|
||||
'CREATE TRIGGER history_after_insert AFTER INSERT ON history BEGIN INSERT INTO history_fts ("rowid", title, url_host, url_path, extracted_content_plain, full_content_plain) VALUES (new."rowid", new.title, new.url_host, new.url_path, new.extracted_content_plain, new.full_content_plain);END',
|
||||
'history_after_insert',
|
||||
);
|
||||
final i1.Trigger historyAfterDelete = i1.Trigger(
|
||||
'CREATE TRIGGER history_after_delete AFTER DELETE ON history BEGIN INSERT INTO history_fts (history_fts, "rowid", title, url_host, url_path, extracted_content_plain, full_content_plain) VALUES (\'delete\', old."rowid", old.title, old.url_host, old.url_path, old.extracted_content_plain, old.full_content_plain);END',
|
||||
'history_after_delete',
|
||||
);
|
||||
final i1.Trigger historyAfterUpdate = i1.Trigger(
|
||||
'CREATE TRIGGER history_after_update AFTER UPDATE OF title, url_host, url_path, extracted_content_plain, full_content_plain ON history BEGIN INSERT INTO history_fts (history_fts, "rowid", title, url_host, url_path, extracted_content_plain, full_content_plain) VALUES (\'delete\', old."rowid", old.title, old.url_host, old.url_path, old.extracted_content_plain, old.full_content_plain);INSERT INTO history_fts ("rowid", title, url_host, url_path, extracted_content_plain, full_content_plain) VALUES (new."rowid", new.title, new.url_host, new.url_path, new.extracted_content_plain, new.full_content_plain);END',
|
||||
'history_after_update',
|
||||
);
|
||||
final i1.Trigger tabToHistoryOnInsert = i1.Trigger(
|
||||
'CREATE TRIGGER tab_to_history_on_insert AFTER INSERT ON tab WHEN NEW.url IS NOT NULL AND url_indexable(CAST(NEW.url AS TEXT)) = 1 AND (SELECT value FROM local_index_setting WHERE "key" = \'enabled\') = 1 AND(NEW.tab_mode != 1 OR (SELECT value FROM local_index_setting WHERE "key" = \'index_private\') = 1)BEGIN INSERT INTO history (url_canonical, url_host, url_path, title, is_probably_readerable, extracted_content_markdown, extracted_content_plain, full_content_markdown, full_content_plain, content_hash, observed_at, observed_count) VALUES (url_canonical(CAST(NEW.url AS TEXT)), url_host(CAST(NEW.url AS TEXT)), url_path(CAST(NEW.url AS TEXT)), NEW.title, NEW.is_probably_readerable, NEW.extracted_content_markdown, NEW.extracted_content_plain, NEW.full_content_markdown, NEW.full_content_plain, NEW.content_hash, strftime(\'%s\', \'now\') * 1000, 1) ON CONFLICT (url_canonical) DO UPDATE SET title = COALESCE(excluded.title, history.title), is_probably_readerable = excluded.is_probably_readerable, extracted_content_markdown = excluded.extracted_content_markdown, extracted_content_plain = excluded.extracted_content_plain, full_content_markdown = excluded.full_content_markdown, full_content_plain = excluded.full_content_plain, content_hash = excluded.content_hash, observed_at = excluded.observed_at, observed_count = history.observed_count + 1 WHERE history.content_hash IS NOT excluded.content_hash;END',
|
||||
'tab_to_history_on_insert',
|
||||
);
|
||||
final i1.Trigger tabToHistoryOnUpdate = i1.Trigger(
|
||||
'CREATE TRIGGER tab_to_history_on_update AFTER UPDATE OF title, url, extracted_content_plain, extracted_content_markdown, full_content_plain, full_content_markdown, is_probably_readerable ON tab WHEN NEW.url IS NOT NULL AND url_indexable(CAST(NEW.url AS TEXT)) = 1 AND(OLD.content_hash IS NOT NEW.content_hash OR OLD.url IS NOT NEW.url)AND (SELECT value FROM local_index_setting WHERE "key" = \'enabled\') = 1 AND(NEW.tab_mode != 1 OR (SELECT value FROM local_index_setting WHERE "key" = \'index_private\') = 1)BEGIN INSERT INTO history (url_canonical, url_host, url_path, title, is_probably_readerable, extracted_content_markdown, extracted_content_plain, full_content_markdown, full_content_plain, content_hash, observed_at, observed_count) VALUES (url_canonical(CAST(NEW.url AS TEXT)), url_host(CAST(NEW.url AS TEXT)), url_path(CAST(NEW.url AS TEXT)), NEW.title, NEW.is_probably_readerable, NEW.extracted_content_markdown, NEW.extracted_content_plain, NEW.full_content_markdown, NEW.full_content_plain, NEW.content_hash, strftime(\'%s\', \'now\') * 1000, 1) ON CONFLICT (url_canonical) DO UPDATE SET title = COALESCE(excluded.title, history.title), is_probably_readerable = excluded.is_probably_readerable, extracted_content_markdown = excluded.extracted_content_markdown, extracted_content_plain = excluded.extracted_content_plain, full_content_markdown = excluded.full_content_markdown, full_content_plain = excluded.full_content_plain, content_hash = excluded.content_hash, observed_at = excluded.observed_at, observed_count = history.observed_count + 1 WHERE history.content_hash IS NOT excluded.content_hash;END',
|
||||
'tab_to_history_on_update',
|
||||
);
|
||||
}
|
||||
|
||||
class Shape12 extends i0.VersionedTable {
|
||||
Shape12({required super.source, required super.alias}) : super.aliased();
|
||||
i1.GeneratedColumn<String> get id =>
|
||||
columnsByName['id']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get name =>
|
||||
columnsByName['name']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<int> get color =>
|
||||
columnsByName['color']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<String> get orderKey =>
|
||||
columnsByName['order_key']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<int> get isPinned =>
|
||||
columnsByName['is_pinned']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<String> get metadata =>
|
||||
columnsByName['metadata']! as i1.GeneratedColumn<String>;
|
||||
}
|
||||
|
||||
i0.MigrationStepWithVersion migrationSteps({
|
||||
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
|
||||
required Future<void> Function(i1.Migrator m, Schema4 schema) from3To4,
|
||||
@@ -939,6 +1831,10 @@ i0.MigrationStepWithVersion migrationSteps({
|
||||
required Future<void> Function(i1.Migrator m, Schema7 schema) from6To7,
|
||||
required Future<void> Function(i1.Migrator m, Schema8 schema) from7To8,
|
||||
required Future<void> Function(i1.Migrator m, Schema9 schema) from8To9,
|
||||
required Future<void> Function(i1.Migrator m, Schema10 schema) from9To10,
|
||||
required Future<void> Function(i1.Migrator m, Schema11 schema) from10To11,
|
||||
required Future<void> Function(i1.Migrator m, Schema12 schema) from11To12,
|
||||
required Future<void> Function(i1.Migrator m, Schema13 schema) from12To13,
|
||||
}) {
|
||||
return (currentVersion, database) async {
|
||||
switch (currentVersion) {
|
||||
@@ -977,6 +1873,26 @@ i0.MigrationStepWithVersion migrationSteps({
|
||||
final migrator = i1.Migrator(database, schema);
|
||||
await from8To9(migrator, schema);
|
||||
return 9;
|
||||
case 9:
|
||||
final schema = Schema10(database: database);
|
||||
final migrator = i1.Migrator(database, schema);
|
||||
await from9To10(migrator, schema);
|
||||
return 10;
|
||||
case 10:
|
||||
final schema = Schema11(database: database);
|
||||
final migrator = i1.Migrator(database, schema);
|
||||
await from10To11(migrator, schema);
|
||||
return 11;
|
||||
case 11:
|
||||
final schema = Schema12(database: database);
|
||||
final migrator = i1.Migrator(database, schema);
|
||||
await from11To12(migrator, schema);
|
||||
return 12;
|
||||
case 12:
|
||||
final schema = Schema13(database: database);
|
||||
final migrator = i1.Migrator(database, schema);
|
||||
await from12To13(migrator, schema);
|
||||
return 13;
|
||||
default:
|
||||
throw ArgumentError.value('Unknown migration from $currentVersion');
|
||||
}
|
||||
@@ -991,6 +1907,10 @@ i1.OnUpgrade stepByStep({
|
||||
required Future<void> Function(i1.Migrator m, Schema7 schema) from6To7,
|
||||
required Future<void> Function(i1.Migrator m, Schema8 schema) from7To8,
|
||||
required Future<void> Function(i1.Migrator m, Schema9 schema) from8To9,
|
||||
required Future<void> Function(i1.Migrator m, Schema10 schema) from9To10,
|
||||
required Future<void> Function(i1.Migrator m, Schema11 schema) from10To11,
|
||||
required Future<void> Function(i1.Migrator m, Schema12 schema) from11To12,
|
||||
required Future<void> Function(i1.Migrator m, Schema13 schema) from12To13,
|
||||
}) => i0.VersionedSchema.stepByStepHelper(
|
||||
step: migrationSteps(
|
||||
from2To3: from2To3,
|
||||
@@ -1000,5 +1920,9 @@ i1.OnUpgrade stepByStep({
|
||||
from6To7: from6To7,
|
||||
from7To8: from7To8,
|
||||
from8To9: from8To9,
|
||||
from9To10: from9To10,
|
||||
from10To11: from10To11,
|
||||
from11To12: from11To12,
|
||||
from12To13: from12To13,
|
||||
),
|
||||
);
|
||||
|
||||
+355
-6
@@ -2,6 +2,7 @@ import 'package:weblibre/data/database/converters/color.dart';
|
||||
import 'package:weblibre/data/database/converters/uri.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/models/site_assignment.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/models/history_query_result.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/models/tab_query_result.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/database/converters/container_metadata_converter.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
|
||||
@@ -11,6 +12,8 @@ CREATE TABLE container (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
name TEXT,
|
||||
color INTEGER NOT NULL MAPPED BY `const ColorConverter()`,
|
||||
order_key TEXT NOT NULL,
|
||||
is_pinned BOOL NOT NULL DEFAULT 0,
|
||||
metadata TEXT MAPPED BY `const ContainerMetadataConverter()`
|
||||
) WITH ContainerData;
|
||||
|
||||
@@ -31,6 +34,14 @@ CREATE TABLE tab(
|
||||
full_content_markdown TEXT,
|
||||
full_content_plain TEXT,
|
||||
timestamp DATETIME NOT NULL,
|
||||
-- xxh3-64 over (title, extracted_content_plain, full_content_plain).
|
||||
-- Used by the FTS update trigger and the tab→history fan-out trigger to
|
||||
-- short-circuit when the row's UPDATE didn't actually change content.
|
||||
-- VIRTUAL (computed on read) to avoid recreating the table on migration;
|
||||
-- recomputation cost is negligible vs. the FTS rewrite it skips.
|
||||
content_hash INTEGER GENERATED ALWAYS AS (
|
||||
generate_content_hash(title, extracted_content_plain, full_content_plain)
|
||||
) VIRTUAL,
|
||||
CHECK (
|
||||
(tab_mode = 2 AND isolation_context_id IS NOT NULL) OR
|
||||
(tab_mode != 2 AND isolation_context_id IS NULL)
|
||||
@@ -49,6 +60,16 @@ CREATE TABLE closed_tab_tombstone(
|
||||
-- degrade to per-row scans on large containers.
|
||||
CREATE INDEX idx_tab_parent_container ON tab(parent_id, container_id);
|
||||
|
||||
CREATE TABLE capture_tab (
|
||||
tab_id TEXT NOT NULL PRIMARY KEY REFERENCES tab (id) ON DELETE CASCADE,
|
||||
capture_id TEXT NOT NULL,
|
||||
source_url TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
created_at DATETIME NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_capture_tab_capture_id ON capture_tab(capture_id);
|
||||
|
||||
CREATE VIRTUAL TABLE tab_fts
|
||||
USING fts5(
|
||||
title,
|
||||
@@ -84,18 +105,286 @@ CREATE TRIGGER tab_after_delete AFTER DELETE ON tab BEGIN
|
||||
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;
|
||||
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)
|
||||
-- Scoped to FTS-relevant columns so unrelated tab updates (order_key,
|
||||
-- parent_id, container_id, is_pinned, timestamp, ...) don't trigger a
|
||||
-- full trigram reindex of the row. Additionally gated on content_hash so
|
||||
-- rewrites of identical content (page reload re-emitting the same extract)
|
||||
-- skip the FTS rewrite. URL changes are caught separately because the hash
|
||||
-- doesn't include URL.
|
||||
CREATE TRIGGER tab_after_update AFTER UPDATE OF
|
||||
title, url, extracted_content_plain, full_content_plain
|
||||
ON tab
|
||||
WHEN OLD.content_hash IS NOT NEW.content_hash
|
||||
OR OLD.url IS NOT NEW.url
|
||||
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)
|
||||
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;
|
||||
|
||||
optimizeFtsIndex:
|
||||
INSERT INTO tab_fts(tab_fts) VALUES ('optimize');
|
||||
|
||||
-- Single-row settings table used as the SQL-side gate for the local search
|
||||
-- index. Mirrors the user-facing toggles in general settings. Updated by the
|
||||
-- settings repository; read by `tab_to_history_on_*` triggers.
|
||||
--
|
||||
-- Keys: 'enabled' (0/1), 'index_private' (0/1).
|
||||
CREATE TABLE local_index_setting (
|
||||
"key" TEXT PRIMARY KEY NOT NULL,
|
||||
value INTEGER NOT NULL
|
||||
) STRICT;
|
||||
|
||||
-- Local content index. Keyed by canonical URL. Visit metadata (visit_count,
|
||||
-- last_visit, frecency, view_time, sync) lives in Mozilla Places — this
|
||||
-- table only carries the content needed for FTS and snippet/highlight
|
||||
-- rendering. `observed_*` are diagnostic counters tracking how often we
|
||||
-- (re)wrote content, NOT visit count.
|
||||
CREATE TABLE history (
|
||||
url_canonical TEXT PRIMARY KEY NOT NULL,
|
||||
url_host TEXT NOT NULL,
|
||||
url_path TEXT,
|
||||
title TEXT,
|
||||
is_probably_readerable BOOL,
|
||||
extracted_content_markdown TEXT,
|
||||
extracted_content_plain TEXT,
|
||||
full_content_markdown TEXT,
|
||||
full_content_plain TEXT,
|
||||
content_hash INTEGER,
|
||||
observed_at DATETIME NOT NULL,
|
||||
observed_count INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
CREATE INDEX idx_history_host ON history(url_host);
|
||||
CREATE INDEX idx_history_observed ON history(observed_at DESC);
|
||||
|
||||
CREATE VIRTUAL TABLE history_fts
|
||||
USING fts5(
|
||||
title,
|
||||
url_host,
|
||||
url_path,
|
||||
extracted_content_plain,
|
||||
full_content_plain,
|
||||
content=history,
|
||||
tokenize="trigram"
|
||||
);
|
||||
|
||||
-- Mirror of the tab_fts trigger pattern, scoped to FTS-relevant columns so
|
||||
-- non-content updates (observed_at touches, observed_count bumps) skip the
|
||||
-- trigram reindex.
|
||||
CREATE TRIGGER history_after_insert AFTER INSERT ON history BEGIN
|
||||
INSERT INTO
|
||||
history_fts(rowid, title, url_host, url_path, extracted_content_plain, full_content_plain)
|
||||
VALUES (new.rowid, new.title, new.url_host, new.url_path, new.extracted_content_plain, new.full_content_plain);
|
||||
END;
|
||||
CREATE TRIGGER history_after_delete AFTER DELETE ON history BEGIN
|
||||
INSERT INTO
|
||||
history_fts(history_fts, rowid, title, url_host, url_path, extracted_content_plain, full_content_plain)
|
||||
VALUES('delete', old.rowid, old.title, old.url_host, old.url_path, old.extracted_content_plain, old.full_content_plain);
|
||||
END;
|
||||
CREATE TRIGGER history_after_update AFTER UPDATE OF
|
||||
title, url_host, url_path, extracted_content_plain, full_content_plain
|
||||
ON history BEGIN
|
||||
INSERT INTO
|
||||
history_fts(history_fts, rowid, title, url_host, url_path, extracted_content_plain, full_content_plain)
|
||||
VALUES('delete', old.rowid, old.title, old.url_host, old.url_path, old.extracted_content_plain, old.full_content_plain);
|
||||
INSERT INTO
|
||||
history_fts(rowid, title, url_host, url_path, extracted_content_plain, full_content_plain)
|
||||
VALUES (new.rowid, new.title, new.url_host, new.url_path, new.extracted_content_plain, new.full_content_plain);
|
||||
END;
|
||||
|
||||
optimizeHistoryFtsIndex:
|
||||
INSERT INTO history_fts(history_fts) VALUES ('optimize');
|
||||
|
||||
-- Local content FTS query against the history table. Mirrors
|
||||
-- `queryTabsFullContent` but with weights tuned for history semantics:
|
||||
-- title and host dominate, path adds a smaller boost, content rounds out
|
||||
-- recall. Frecency / recency from Places is layered on at the call site
|
||||
-- because Places (not us) owns visit metadata.
|
||||
queryHistoryFullContent WITH HistoryQueryResult:
|
||||
WITH weights AS (
|
||||
SELECT
|
||||
10.0 as title_weight, -- Title matches dominate.
|
||||
8.0 as host_weight, -- Domain is the next strongest signal.
|
||||
2.0 as path_weight, -- URL path; small boost.
|
||||
3.0 as extracted_weight, -- Extracted (reader) content.
|
||||
1.0 as full_weight -- Full plain content.
|
||||
)
|
||||
SELECT
|
||||
h.url_canonical,
|
||||
h.url_host,
|
||||
h.url_path,
|
||||
highlight(history_fts, 0, :beforeMatch, :afterMatch) AS title,
|
||||
snippet(history_fts, 3, :beforeMatch, :afterMatch, :ellipsis, :snippetLength) AS extracted_content,
|
||||
snippet(history_fts, 4, :beforeMatch, :afterMatch, :ellipsis, :snippetLength) AS full_content,
|
||||
h.observed_at,
|
||||
bm25(history_fts,
|
||||
weights.title_weight,
|
||||
weights.host_weight,
|
||||
weights.path_weight,
|
||||
weights.extracted_weight,
|
||||
weights.full_weight) AS weighted_rank
|
||||
FROM history_fts(:query) fts
|
||||
INNER JOIN history h ON h.rowid = fts.rowid
|
||||
CROSS JOIN weights
|
||||
ORDER BY
|
||||
weighted_rank ASC,
|
||||
h.observed_at DESC
|
||||
LIMIT :limit;
|
||||
|
||||
-- Sub-3-char fallback (FTS query empty after tokenization). Hits the host
|
||||
-- index instead of doing a LIKE-on-trigram scan. Returns rows ordered by
|
||||
-- recency.
|
||||
queryHistoryByHostPrefix WITH HistoryQueryResult:
|
||||
SELECT
|
||||
h.url_canonical,
|
||||
h.url_host,
|
||||
h.url_path,
|
||||
h.title,
|
||||
NULL AS extracted_content,
|
||||
NULL AS full_content,
|
||||
h.observed_at,
|
||||
0.0 AS weighted_rank
|
||||
FROM history h
|
||||
WHERE h.url_host LIKE :hostPrefix
|
||||
ORDER BY h.observed_at DESC
|
||||
LIMIT :limit;
|
||||
|
||||
-- Bulk hydration: returns content rows for the given canonical URLs.
|
||||
-- Used after Places' frecency-ranked autocomplete so we can attach local
|
||||
-- titles/snippets without re-running FTS.
|
||||
historyByCanonicalUrls WITH HistoryQueryResult:
|
||||
SELECT
|
||||
h.url_canonical,
|
||||
h.url_host,
|
||||
h.url_path,
|
||||
h.title,
|
||||
NULL AS extracted_content,
|
||||
NULL AS full_content,
|
||||
h.observed_at,
|
||||
0.0 AS weighted_rank
|
||||
FROM history h
|
||||
WHERE h.url_canonical IN :canonicalUrls;
|
||||
|
||||
-- Settings sync. Called from Dart whenever the user toggles either flag.
|
||||
upsertLocalIndexSetting:
|
||||
INSERT INTO local_index_setting ("key", value) VALUES (:key, :value)
|
||||
ON CONFLICT("key") DO UPDATE SET value = excluded.value;
|
||||
|
||||
-- Diagnostic: count of indexed pages (settings UI shows this).
|
||||
countHistoryRows:
|
||||
SELECT COUNT(*) AS count FROM history;
|
||||
|
||||
-- Manual reset path; settings UI offers a "Clear local search index" button.
|
||||
clearHistory:
|
||||
DELETE FROM history;
|
||||
|
||||
-- Pruner: stable oldest-first pagination over the local index.
|
||||
-- The `url_canonical` tiebreaker is critical: `observed_at` is a
|
||||
-- coarse timestamp (multiple rows commonly share the same value), so
|
||||
-- without a deterministic secondary sort, ties can land in different
|
||||
-- orders across two queries — and the pruner's offset arithmetic
|
||||
-- (advance by candidates.length - deleted) would then either skip
|
||||
-- rows or re-scan ones it already examined.
|
||||
historyUrlsPage:
|
||||
SELECT url_canonical FROM history
|
||||
ORDER BY observed_at ASC, url_canonical ASC
|
||||
LIMIT :limit OFFSET :offset;
|
||||
|
||||
deleteHistoryByCanonicalUrls:
|
||||
DELETE FROM history WHERE url_canonical IN :canonicalUrls;
|
||||
|
||||
-- Tab → history fan-out. Fires when content-bearing columns change AND the
|
||||
-- content actually changed (content_hash gate). Settings are read from
|
||||
-- local_index_setting at SQL level so the trigger is the single source of
|
||||
-- truth for indexing eligibility. tab_mode = 1 is the private tab marker;
|
||||
-- private tabs are skipped unless `index_private` is enabled.
|
||||
CREATE TRIGGER tab_to_history_on_insert AFTER INSERT ON tab
|
||||
WHEN NEW.url IS NOT NULL
|
||||
AND url_indexable(CAST(NEW.url AS TEXT)) = 1
|
||||
AND (SELECT value FROM local_index_setting WHERE "key" = 'enabled') = 1
|
||||
AND (NEW.tab_mode != 1
|
||||
OR (SELECT value FROM local_index_setting WHERE "key" = 'index_private') = 1)
|
||||
BEGIN
|
||||
INSERT INTO history (
|
||||
url_canonical, url_host, url_path, title, is_probably_readerable,
|
||||
extracted_content_markdown, extracted_content_plain,
|
||||
full_content_markdown, full_content_plain,
|
||||
content_hash, observed_at, observed_count
|
||||
) VALUES (
|
||||
url_canonical(CAST(NEW.url AS TEXT)),
|
||||
url_host(CAST(NEW.url AS TEXT)),
|
||||
url_path(CAST(NEW.url AS TEXT)),
|
||||
NEW.title,
|
||||
NEW.is_probably_readerable,
|
||||
NEW.extracted_content_markdown,
|
||||
NEW.extracted_content_plain,
|
||||
NEW.full_content_markdown,
|
||||
NEW.full_content_plain,
|
||||
NEW.content_hash,
|
||||
strftime('%s','now') * 1000,
|
||||
1
|
||||
)
|
||||
ON CONFLICT(url_canonical) DO UPDATE SET
|
||||
title = COALESCE(excluded.title, history.title),
|
||||
is_probably_readerable = excluded.is_probably_readerable,
|
||||
extracted_content_markdown = excluded.extracted_content_markdown,
|
||||
extracted_content_plain = excluded.extracted_content_plain,
|
||||
full_content_markdown = excluded.full_content_markdown,
|
||||
full_content_plain = excluded.full_content_plain,
|
||||
content_hash = excluded.content_hash,
|
||||
observed_at = excluded.observed_at,
|
||||
observed_count = history.observed_count + 1
|
||||
WHERE history.content_hash IS NOT excluded.content_hash;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER tab_to_history_on_update AFTER UPDATE OF
|
||||
title, url, extracted_content_plain, extracted_content_markdown,
|
||||
full_content_plain, full_content_markdown, is_probably_readerable
|
||||
ON tab
|
||||
WHEN NEW.url IS NOT NULL
|
||||
AND url_indexable(CAST(NEW.url AS TEXT)) = 1
|
||||
AND (OLD.content_hash IS NOT NEW.content_hash
|
||||
OR OLD.url IS NOT NEW.url)
|
||||
AND (SELECT value FROM local_index_setting WHERE "key" = 'enabled') = 1
|
||||
AND (NEW.tab_mode != 1
|
||||
OR (SELECT value FROM local_index_setting WHERE "key" = 'index_private') = 1)
|
||||
BEGIN
|
||||
INSERT INTO history (
|
||||
url_canonical, url_host, url_path, title, is_probably_readerable,
|
||||
extracted_content_markdown, extracted_content_plain,
|
||||
full_content_markdown, full_content_plain,
|
||||
content_hash, observed_at, observed_count
|
||||
) VALUES (
|
||||
url_canonical(CAST(NEW.url AS TEXT)),
|
||||
url_host(CAST(NEW.url AS TEXT)),
|
||||
url_path(CAST(NEW.url AS TEXT)),
|
||||
NEW.title,
|
||||
NEW.is_probably_readerable,
|
||||
NEW.extracted_content_markdown,
|
||||
NEW.extracted_content_plain,
|
||||
NEW.full_content_markdown,
|
||||
NEW.full_content_plain,
|
||||
NEW.content_hash,
|
||||
strftime('%s','now') * 1000,
|
||||
1
|
||||
)
|
||||
ON CONFLICT(url_canonical) DO UPDATE SET
|
||||
title = COALESCE(excluded.title, history.title),
|
||||
is_probably_readerable = excluded.is_probably_readerable,
|
||||
extracted_content_markdown = excluded.extracted_content_markdown,
|
||||
extracted_content_plain = excluded.extracted_content_plain,
|
||||
full_content_markdown = excluded.full_content_markdown,
|
||||
full_content_plain = excluded.full_content_plain,
|
||||
content_hash = excluded.content_hash,
|
||||
observed_at = excluded.observed_at,
|
||||
observed_count = history.observed_count + 1
|
||||
WHERE history.content_hash IS NOT excluded.content_hash;
|
||||
END;
|
||||
|
||||
containersWithCount WITH ContainerDataWithCount:
|
||||
SELECT
|
||||
container.*,
|
||||
@@ -109,7 +398,67 @@ containersWithCount WITH ContainerDataWithCount:
|
||||
FROM tab
|
||||
GROUP BY container_id
|
||||
) AS tab_agg ON container.id = tab_agg.container_id
|
||||
ORDER BY tab_agg.last_updated DESC NULLS LAST;
|
||||
ORDER BY container.is_pinned DESC, container.order_key ASC;
|
||||
|
||||
containerIdsByLastUpdated:
|
||||
SELECT container.id
|
||||
FROM container
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
container_id,
|
||||
MAX(timestamp) AS last_updated
|
||||
FROM tab
|
||||
GROUP BY container_id
|
||||
) AS tab_agg ON container.id = tab_agg.container_id
|
||||
ORDER BY tab_agg.last_updated DESC NULLS LAST, container.rowid ASC;
|
||||
|
||||
leadingContainerOrderKey(:is_pinned AS BOOL, :bucket AS INTEGER):
|
||||
SELECT lexo_rank_previous(
|
||||
:bucket,
|
||||
(
|
||||
SELECT order_key
|
||||
FROM container
|
||||
WHERE is_pinned = :is_pinned
|
||||
ORDER BY order_key
|
||||
LIMIT 1
|
||||
)
|
||||
);
|
||||
|
||||
trailingContainerOrderKey(:is_pinned AS BOOL, :bucket AS INTEGER):
|
||||
SELECT lexo_rank_next(
|
||||
:bucket,
|
||||
(
|
||||
SELECT order_key
|
||||
FROM container
|
||||
WHERE is_pinned = :is_pinned
|
||||
ORDER BY order_key DESC
|
||||
LIMIT 1
|
||||
)
|
||||
);
|
||||
|
||||
containerOrderKeyAfter(:container_id AS TEXT, :is_pinned AS BOOL):
|
||||
WITH ordered_table AS (
|
||||
SELECT id,
|
||||
order_key,
|
||||
LEAD(order_key) OVER (ORDER BY order_key) AS next_order_key
|
||||
FROM container
|
||||
WHERE is_pinned = :is_pinned
|
||||
)
|
||||
SELECT lexo_rank_reorder_after(order_key, next_order_key)
|
||||
FROM ordered_table
|
||||
WHERE id = :container_id;
|
||||
|
||||
containerOrderKeyBefore(:container_id AS TEXT, :is_pinned AS BOOL):
|
||||
WITH ordered_table AS (
|
||||
SELECT id,
|
||||
order_key,
|
||||
LAG(order_key) OVER (ORDER BY order_key) AS prev_order_key
|
||||
FROM container
|
||||
WHERE is_pinned = :is_pinned
|
||||
)
|
||||
SELECT lexo_rank_reorder_before(order_key, prev_order_key)
|
||||
FROM ordered_table
|
||||
WHERE id = :container_id;
|
||||
|
||||
leadingOrderKey(REQUIRED :container_id AS TEXT OR NULL, :bucket AS INTEGER):
|
||||
SELECT lexo_rank_previous(
|
||||
|
||||
+3045
-89
File diff suppressed because it is too large
Load Diff
@@ -85,12 +85,20 @@ class ContainerData with FastEquatable {
|
||||
final String? name;
|
||||
@ColorJsonConverter()
|
||||
final Color color;
|
||||
|
||||
final String orderKey;
|
||||
|
||||
@JsonKey(defaultValue: false)
|
||||
final bool isPinned;
|
||||
|
||||
final ContainerMetadata metadata;
|
||||
|
||||
ContainerData({
|
||||
required this.id,
|
||||
this.name,
|
||||
required this.color,
|
||||
required this.orderKey,
|
||||
this.isPinned = false,
|
||||
ContainerMetadata? metadata,
|
||||
}) : metadata = metadata ?? ContainerMetadata.withDefaults();
|
||||
|
||||
@@ -100,7 +108,14 @@ class ContainerData with FastEquatable {
|
||||
Map<String, dynamic> toJson() => _$ContainerDataToJson(this);
|
||||
|
||||
@override
|
||||
List<Object?> get hashParameters => [id, name, color, metadata];
|
||||
List<Object?> get hashParameters => [
|
||||
id,
|
||||
name,
|
||||
color,
|
||||
orderKey,
|
||||
isPinned,
|
||||
metadata,
|
||||
];
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
@@ -111,6 +126,8 @@ class ContainerDataWithCount extends ContainerData {
|
||||
required super.id,
|
||||
super.name,
|
||||
required super.color,
|
||||
required super.orderKey,
|
||||
super.isPinned,
|
||||
super.metadata,
|
||||
required this.tabCount,
|
||||
});
|
||||
|
||||
@@ -115,6 +115,10 @@ abstract class _$ContainerDataCWProxy {
|
||||
|
||||
ContainerData color(Color color);
|
||||
|
||||
ContainerData orderKey(String orderKey);
|
||||
|
||||
ContainerData isPinned(bool isPinned);
|
||||
|
||||
ContainerData metadata(ContainerMetadata? metadata);
|
||||
|
||||
/// Creates a new instance with the provided field values.
|
||||
@@ -128,6 +132,8 @@ abstract class _$ContainerDataCWProxy {
|
||||
String id,
|
||||
String? name,
|
||||
Color color,
|
||||
String orderKey,
|
||||
bool isPinned,
|
||||
ContainerMetadata? metadata,
|
||||
});
|
||||
}
|
||||
@@ -148,6 +154,12 @@ class _$ContainerDataCWProxyImpl implements _$ContainerDataCWProxy {
|
||||
@override
|
||||
ContainerData color(Color color) => call(color: color);
|
||||
|
||||
@override
|
||||
ContainerData orderKey(String orderKey) => call(orderKey: orderKey);
|
||||
|
||||
@override
|
||||
ContainerData isPinned(bool isPinned) => call(isPinned: isPinned);
|
||||
|
||||
@override
|
||||
ContainerData metadata(ContainerMetadata? metadata) =>
|
||||
call(metadata: metadata);
|
||||
@@ -164,6 +176,8 @@ class _$ContainerDataCWProxyImpl implements _$ContainerDataCWProxy {
|
||||
Object? id = const $CopyWithPlaceholder(),
|
||||
Object? name = const $CopyWithPlaceholder(),
|
||||
Object? color = const $CopyWithPlaceholder(),
|
||||
Object? orderKey = const $CopyWithPlaceholder(),
|
||||
Object? isPinned = const $CopyWithPlaceholder(),
|
||||
Object? metadata = const $CopyWithPlaceholder(),
|
||||
}) {
|
||||
return ContainerData(
|
||||
@@ -179,6 +193,14 @@ class _$ContainerDataCWProxyImpl implements _$ContainerDataCWProxy {
|
||||
? _value.color
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: color as Color,
|
||||
orderKey: orderKey == const $CopyWithPlaceholder() || orderKey == null
|
||||
? _value.orderKey
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: orderKey as String,
|
||||
isPinned: isPinned == const $CopyWithPlaceholder() || isPinned == null
|
||||
? _value.isPinned
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: isPinned as bool,
|
||||
metadata: metadata == const $CopyWithPlaceholder()
|
||||
? _value.metadata
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
@@ -241,6 +263,8 @@ ContainerData _$ContainerDataFromJson(
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String?,
|
||||
color: const ColorJsonConverter().fromJson((json['color'] as num).toInt()),
|
||||
orderKey: json['orderKey'] as String,
|
||||
isPinned: json['isPinned'] as bool? ?? false,
|
||||
metadata: json['metadata'] == null
|
||||
? null
|
||||
: ContainerMetadata.fromJson(json['metadata'] as Map<String, dynamic>),
|
||||
@@ -251,6 +275,8 @@ Map<String, dynamic> _$ContainerDataToJson(ContainerData instance) =>
|
||||
'id': instance.id,
|
||||
'name': instance.name,
|
||||
'color': const ColorJsonConverter().toJson(instance.color),
|
||||
'orderKey': instance.orderKey,
|
||||
'isPinned': instance.isPinned,
|
||||
'metadata': instance.metadata.toJson(),
|
||||
};
|
||||
|
||||
@@ -260,6 +286,8 @@ ContainerDataWithCount _$ContainerDataWithCountFromJson(
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String?,
|
||||
color: const ColorJsonConverter().fromJson((json['color'] as num).toInt()),
|
||||
orderKey: json['orderKey'] as String,
|
||||
isPinned: json['isPinned'] as bool? ?? false,
|
||||
metadata: json['metadata'] == null
|
||||
? null
|
||||
: ContainerMetadata.fromJson(json['metadata'] as Map<String, dynamic>),
|
||||
@@ -272,6 +300,8 @@ Map<String, dynamic> _$ContainerDataWithCountToJson(
|
||||
'id': instance.id,
|
||||
'name': instance.name,
|
||||
'color': const ColorJsonConverter().toJson(instance.color),
|
||||
'orderKey': instance.orderKey,
|
||||
'isPinned': instance.isPinned,
|
||||
'metadata': instance.metadata.toJson(),
|
||||
'tabCount': instance.tabCount,
|
||||
};
|
||||
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'package:fast_equatable/fast_equatable.dart';
|
||||
|
||||
/// FTS5 hit against the local history index. Highlight/snippet markers are
|
||||
/// embedded inline (the same `***`-style markers used for tab results) so the
|
||||
/// renderer in `utils/text_highlight.dart` can be reused as-is.
|
||||
class HistoryQueryResult with FastEquatable {
|
||||
final String urlCanonical;
|
||||
final String urlHost;
|
||||
final String? urlPath;
|
||||
final String? title;
|
||||
|
||||
/// `snippet()` over `extracted_content_plain`. May contain highlight
|
||||
/// markers if the FTS query matched within the extracted content.
|
||||
final String? extractedContent;
|
||||
|
||||
/// `snippet()` over `full_content_plain`.
|
||||
final String? fullContent;
|
||||
|
||||
/// Smaller is more relevant (BM25 convention). Combine with frecency from
|
||||
/// Places at the call site to produce the final ranking.
|
||||
final double weightedRank;
|
||||
|
||||
final DateTime observedAt;
|
||||
|
||||
HistoryQueryResult({
|
||||
required this.urlCanonical,
|
||||
required this.urlHost,
|
||||
required this.urlPath,
|
||||
required this.title,
|
||||
required this.extractedContent,
|
||||
required this.fullContent,
|
||||
required this.weightedRank,
|
||||
required this.observedAt,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get hashParameters => [
|
||||
urlCanonical,
|
||||
urlHost,
|
||||
urlPath,
|
||||
title,
|
||||
extractedContent,
|
||||
fullContent,
|
||||
weightedRank,
|
||||
observedAt,
|
||||
];
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import 'package:sqlite3_flutter_libs/sqlite3_flutter_libs.dart';
|
||||
import 'package:weblibre/core/database_registry.dart';
|
||||
import 'package:weblibre/core/filesystem.dart';
|
||||
import 'package:weblibre/data/database/functions/lexo_rank_functions.dart';
|
||||
import 'package:weblibre/data/database/functions/url_functions.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/database/database.dart';
|
||||
|
||||
part 'providers.g.dart';
|
||||
@@ -46,6 +47,7 @@ TabDatabase tabDatabase(Ref ref) {
|
||||
file,
|
||||
setup: (database) {
|
||||
registerLexorankFunctions(database);
|
||||
registerUrlFunctions(database);
|
||||
},
|
||||
);
|
||||
}),
|
||||
|
||||
@@ -48,4 +48,4 @@ final class TabDatabaseProvider
|
||||
}
|
||||
}
|
||||
|
||||
String _$tabDatabaseHash() => r'62466f063f5eae2a32c37dce0496b8e47e7cb953';
|
||||
String _$tabDatabaseHash() => r'983f15b87d8d8bdd52b45d79fe78a1cb4b241f6e';
|
||||
|
||||
+145
-3
@@ -43,6 +43,14 @@ class ContainerRepository extends _$ContainerRepository {
|
||||
}
|
||||
}
|
||||
|
||||
if (container.orderKey.isEmpty) {
|
||||
throw ArgumentError.value(
|
||||
container.orderKey,
|
||||
'container.orderKey',
|
||||
'Containers require an explicit order key',
|
||||
);
|
||||
}
|
||||
|
||||
return ref.read(tabDatabaseProvider).containerDao.addContainer(container);
|
||||
}
|
||||
|
||||
@@ -70,6 +78,21 @@ class ContainerRepository extends _$ContainerRepository {
|
||||
.replaceContainer(container);
|
||||
}
|
||||
|
||||
Future<void> assignContainerOrderKey(String id, {required String orderKey}) {
|
||||
return ref
|
||||
.read(tabDatabaseProvider)
|
||||
.containerDao
|
||||
.assignOrderKey(id, orderKey: orderKey);
|
||||
}
|
||||
|
||||
Future<void> setContainerPinned(String id, {required bool isPinned}) async {
|
||||
final orderKey = await getTrailingContainerOrderKey(isPinned: isPinned);
|
||||
return ref
|
||||
.read(tabDatabaseProvider)
|
||||
.containerDao
|
||||
.assignPinned(id, isPinned: isPinned, orderKey: orderKey);
|
||||
}
|
||||
|
||||
Future<ContainerData?> getContainerData(String id) {
|
||||
return ref
|
||||
.read(tabDatabaseProvider)
|
||||
@@ -125,6 +148,113 @@ class ContainerRepository extends _$ContainerRepository {
|
||||
.getSingle();
|
||||
}
|
||||
|
||||
Future<String> getLeadingContainerOrderKey({required bool isPinned}) {
|
||||
return ref
|
||||
.read(tabDatabaseProvider)
|
||||
.containerDao
|
||||
.generateLeadingContainerOrderKey(isPinned: isPinned)
|
||||
.getSingle();
|
||||
}
|
||||
|
||||
Future<String> getTrailingContainerOrderKey({required bool isPinned}) {
|
||||
return ref
|
||||
.read(tabDatabaseProvider)
|
||||
.containerDao
|
||||
.generateTrailingContainerOrderKey(isPinned: isPinned)
|
||||
.getSingle();
|
||||
}
|
||||
|
||||
Future<String?> getOrderKeyAfterContainer(
|
||||
String containerId, {
|
||||
required bool isPinned,
|
||||
}) {
|
||||
return ref
|
||||
.read(tabDatabaseProvider)
|
||||
.containerDao
|
||||
.generateOrderKeyAfterContainerId(containerId, isPinned: isPinned)
|
||||
.getSingleOrNull();
|
||||
}
|
||||
|
||||
Future<String> getOrderKeyBeforeContainer(
|
||||
String containerId, {
|
||||
required bool isPinned,
|
||||
}) {
|
||||
return ref
|
||||
.read(tabDatabaseProvider)
|
||||
.containerDao
|
||||
.generateOrderKeyBeforeContainerId(containerId, isPinned: isPinned)
|
||||
.getSingle();
|
||||
}
|
||||
|
||||
Future<void> reorderContainer(
|
||||
List<ContainerData> containers,
|
||||
int oldIndex,
|
||||
int newIndex,
|
||||
) async {
|
||||
if (containers.isEmpty || oldIndex == newIndex) return;
|
||||
|
||||
var targetIndex = newIndex;
|
||||
if (targetIndex > oldIndex) {
|
||||
targetIndex -= 1;
|
||||
}
|
||||
targetIndex = targetIndex.clamp(0, containers.length - 1);
|
||||
if (targetIndex == oldIndex) return;
|
||||
|
||||
final movingContainer = containers[oldIndex];
|
||||
final scopedContainers = containers
|
||||
.where((container) => container.isPinned == movingContainer.isPinned)
|
||||
.toList();
|
||||
final scopedOldIndex = scopedContainers.indexWhere(
|
||||
(container) => container.id == movingContainer.id,
|
||||
);
|
||||
if (scopedOldIndex < 0) return;
|
||||
|
||||
final containersWithoutMoving = containers.toList()..removeAt(oldIndex);
|
||||
final scopedTargetIndex = containersWithoutMoving
|
||||
.take(targetIndex)
|
||||
.where((container) => container.isPinned == movingContainer.isPinned)
|
||||
.length
|
||||
.clamp(0, scopedContainers.length - 1)
|
||||
.toInt();
|
||||
if (scopedTargetIndex == scopedOldIndex) return;
|
||||
|
||||
final orderKey = await _orderKeyForReorder(
|
||||
scopedContainers,
|
||||
scopedOldIndex,
|
||||
scopedTargetIndex,
|
||||
movingContainer.isPinned,
|
||||
);
|
||||
await assignContainerOrderKey(movingContainer.id, orderKey: orderKey);
|
||||
}
|
||||
|
||||
Future<String> _orderKeyForReorder(
|
||||
List<ContainerData> containers,
|
||||
int oldIndex,
|
||||
int targetIndex,
|
||||
bool isPinned,
|
||||
) async {
|
||||
if (targetIndex <= 0) {
|
||||
return getLeadingContainerOrderKey(isPinned: isPinned);
|
||||
}
|
||||
|
||||
if (targetIndex >= containers.length - 1) {
|
||||
return getTrailingContainerOrderKey(isPinned: isPinned);
|
||||
}
|
||||
|
||||
if (targetIndex < oldIndex) {
|
||||
return await getOrderKeyAfterContainer(
|
||||
containers[targetIndex - 1].id,
|
||||
isPinned: isPinned,
|
||||
) ??
|
||||
await getLeadingContainerOrderKey(isPinned: isPinned);
|
||||
}
|
||||
|
||||
return getOrderKeyBeforeContainer(
|
||||
containers[targetIndex + 1].id,
|
||||
isPinned: isPinned,
|
||||
);
|
||||
}
|
||||
|
||||
Future<String?> getOrderKeyAfterTab(String tabId, String? containerId) {
|
||||
return ref
|
||||
.read(tabDatabaseProvider)
|
||||
@@ -142,12 +272,19 @@ class ContainerRepository extends _$ContainerRepository {
|
||||
}
|
||||
|
||||
Future<Color> unusedRandomContainerColor() async {
|
||||
final allColors = colorTypes.flattened.toList();
|
||||
final usedColors = await getDistinctColors();
|
||||
final unusedColorTypes = colorTypes.where((colors) {
|
||||
return !shadingTypes(
|
||||
colors,
|
||||
).any((shade) => usedColors.contains(shade.keys.first));
|
||||
}).toList();
|
||||
final availableColors =
|
||||
(unusedColorTypes.isNotEmpty ? unusedColorTypes : colorTypes).flattened
|
||||
.toList();
|
||||
|
||||
Color randomColor;
|
||||
do {
|
||||
randomColor = randomColorShade(allColors);
|
||||
randomColor = randomColorShade(availableColors);
|
||||
} while (usedColors.contains(randomColor));
|
||||
|
||||
return randomColor;
|
||||
@@ -155,8 +292,13 @@ class ContainerRepository extends _$ContainerRepository {
|
||||
|
||||
Future<ContainerData> createNewContainer() async {
|
||||
final initialColor = await unusedRandomContainerColor();
|
||||
final orderKey = await getTrailingContainerOrderKey(isPinned: false);
|
||||
|
||||
return ContainerData(id: uuid.v7(), color: initialColor);
|
||||
return ContainerData(
|
||||
id: uuid.v7(),
|
||||
color: initialColor,
|
||||
orderKey: orderKey,
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> isSiteAssignedToContainer(Uri uri) async {
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@ final class ContainerRepositoryProvider
|
||||
}
|
||||
|
||||
String _$containerRepositoryHash() =>
|
||||
r'55c3e897f4f42d01dab43114ef00075536b74cf7';
|
||||
r'a708f298d7bd50f1823029e00f8ed295b9f81f4e';
|
||||
|
||||
abstract class _$ContainerRepository extends $Notifier<void> {
|
||||
void build();
|
||||
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'
|
||||
show HistoryMetadata;
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:weblibre/core/logger.dart';
|
||||
import 'package:weblibre/features/geckoview/features/history/domain/repositories/history.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/models/history_query_result.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/providers.dart';
|
||||
|
||||
part 'history_search.g.dart';
|
||||
|
||||
/// Canonical highlight markers wrapped around FTS5 snippet matches.
|
||||
///
|
||||
/// `historyHighlightPrefix` and `historyHighlightSuffix` are baked into the
|
||||
/// `snippet()` text returned by [HistorySearchRepository.addQuery]; the
|
||||
/// search UI scans for these exact strings to convert them into styled
|
||||
/// spans (see `utils/text_highlight.dart`). Producer and consumers MUST
|
||||
/// reference the same constants — if the strings ever diverge, highlight
|
||||
/// rendering silently breaks (no error, just plain text).
|
||||
///
|
||||
/// The two are intentionally identical today (`***` on both sides). They
|
||||
/// are kept as two constants so a future change can use distinct
|
||||
/// open/close markers without rewriting every call site.
|
||||
const historyHighlightPrefix = '***';
|
||||
const historyHighlightSuffix = '***';
|
||||
|
||||
// Tunables for the frecency-aware re-rank. Smaller `weighted_rank` is more
|
||||
// relevant (BM25 convention). Frecency boosts subtract from the rank so a
|
||||
// frequently-/recently-visited page floats up.
|
||||
|
||||
/// Weight on log-scaled total view time (seconds).
|
||||
const double _viewTimeRankWeight = 0.5;
|
||||
|
||||
/// Weight on the recency decay term.
|
||||
const double _recencyRankWeight = 0.5;
|
||||
|
||||
/// Half-life for the recency decay (days).
|
||||
const double _recencyHalfLifeDays = 14;
|
||||
|
||||
/// Search the local history content index. Mirrors `TabSearchRepository`'s
|
||||
/// shape so the two integrate symmetrically into the search UI.
|
||||
///
|
||||
/// Visit metadata is fetched from Places at query time and folded into the
|
||||
/// score before emitting state. The DAO returns rows ordered by raw BM25;
|
||||
/// Places adds the frecency / recency signal we don't track locally.
|
||||
@Riverpod()
|
||||
class HistorySearchRepository extends _$HistorySearchRepository {
|
||||
Future<void> addQuery(
|
||||
String input, {
|
||||
int snippetLength = 120,
|
||||
int maxResults = 25,
|
||||
String matchPrefix = historyHighlightPrefix,
|
||||
String matchSuffix = historyHighlightSuffix,
|
||||
String ellipsis = '…',
|
||||
}) async {
|
||||
if (input.isEmpty) {
|
||||
state = const AsyncValue.data(null);
|
||||
return;
|
||||
}
|
||||
|
||||
final result = await AsyncValue.guard(() async {
|
||||
final localHits = await ref
|
||||
.read(tabDatabaseProvider)
|
||||
.historyDao
|
||||
.queryHistory(
|
||||
searchString: input,
|
||||
matchPrefix: matchPrefix,
|
||||
matchSuffix: matchSuffix,
|
||||
ellipsis: ellipsis,
|
||||
snippetLength: snippetLength,
|
||||
limit: maxResults,
|
||||
)
|
||||
.get();
|
||||
|
||||
// Drop rows Places has forgotten (deleted via "Clear browsing data",
|
||||
// expired by the engine's pruner, etc.). Local index can outlive
|
||||
// Places' record for the same URL; this keeps the visible results
|
||||
// aligned with what the engine considers "visited".
|
||||
final filtered = await _filterByPlacesVisited(localHits);
|
||||
|
||||
// Hydrate per-URL frecency signal from Places and re-rank in place.
|
||||
final reranked = await _rerankWithPlacesMetadata(filtered);
|
||||
|
||||
return (query: input, results: reranked);
|
||||
});
|
||||
|
||||
if (!ref.mounted) return;
|
||||
|
||||
state = result;
|
||||
}
|
||||
|
||||
Future<List<HistoryQueryResult>> _filterByPlacesVisited(
|
||||
List<HistoryQueryResult> hits,
|
||||
) async {
|
||||
if (hits.isEmpty) return hits;
|
||||
final urls = hits.map((h) => h.urlCanonical).toList(growable: false);
|
||||
final visited = await ref
|
||||
.read(historyRepositoryProvider.notifier)
|
||||
.getVisited(urls);
|
||||
if (visited.length != hits.length) {
|
||||
// Bridge contract violation: getVisited is supposed to return
|
||||
// exactly one bool per input URL. Surface all hits rather than
|
||||
// dropping them silently (better UX = the user still sees results),
|
||||
// but log loudly so the regression doesn't go unnoticed in the
|
||||
// wild — searches would otherwise stop being filtered against the
|
||||
// engine's view of "did I visit this?" with no symptom.
|
||||
logger.e(
|
||||
'PlacesHistory.getVisited contract violation: returned '
|
||||
'${visited.length} bools for ${hits.length} URLs; falling back '
|
||||
'to unfiltered results.',
|
||||
);
|
||||
return hits;
|
||||
}
|
||||
return [
|
||||
for (var i = 0; i < hits.length; i++)
|
||||
if (visited[i]) hits[i],
|
||||
];
|
||||
}
|
||||
|
||||
/// For each hit, fetches the latest Places metadata, computes a combined
|
||||
/// score, and returns hits ordered ascending (smallest = most relevant).
|
||||
/// Metadata is fetched in a single bulk call to keep the IPC cost flat.
|
||||
/// On bridge error, falls back to the raw BM25 ordering for the whole set.
|
||||
Future<List<HistoryQueryResult>> _rerankWithPlacesMetadata(
|
||||
List<HistoryQueryResult> hits,
|
||||
) async {
|
||||
if (hits.isEmpty) return hits;
|
||||
|
||||
final urls = hits.map((h) => h.urlCanonical).toList(growable: false);
|
||||
final List<HistoryMetadata?> metadata;
|
||||
try {
|
||||
metadata = await ref
|
||||
.read(historyRepositoryProvider.notifier)
|
||||
.getLatestHistoryMetadataForUrls(urls);
|
||||
} catch (e, s) {
|
||||
// Places bridge failure (IPC error, etc.) — fall back to the raw
|
||||
// BM25 ordering rather than blanking the search results. Log
|
||||
// loudly so a transport-level regression doesn't quietly silence
|
||||
// every frecency re-rank in the app.
|
||||
logger.e(
|
||||
'PlacesHistory.getLatestHistoryMetadataForUrls threw; '
|
||||
'falling back to BM25-only ordering.',
|
||||
error: e,
|
||||
stackTrace: s,
|
||||
);
|
||||
return hits;
|
||||
}
|
||||
if (metadata.length != hits.length) {
|
||||
logger.e(
|
||||
'PlacesHistory.getLatestHistoryMetadataForUrls contract '
|
||||
'violation: returned ${metadata.length} entries for '
|
||||
'${hits.length} URLs; falling back to BM25-only ordering.',
|
||||
);
|
||||
return hits;
|
||||
}
|
||||
|
||||
final now = DateTime.now();
|
||||
final scored = <(double, HistoryQueryResult)>[
|
||||
for (var i = 0; i < hits.length; i++)
|
||||
(_combinedScore(hits[i], metadata[i], now), hits[i]),
|
||||
];
|
||||
scored.sort((a, b) => a.$1.compareTo(b.$1));
|
||||
return [for (final entry in scored) entry.$2];
|
||||
}
|
||||
|
||||
double _combinedScore(
|
||||
HistoryQueryResult hit,
|
||||
HistoryMetadata? metadata,
|
||||
DateTime now,
|
||||
) {
|
||||
var score = hit.weightedRank;
|
||||
|
||||
if (metadata != null) {
|
||||
// ln(1 + viewTime_seconds): heavy reading dominates lookup-style hits.
|
||||
final viewSeconds = metadata.totalViewTime / 1000.0;
|
||||
score -= _viewTimeRankWeight * math.log(1 + viewSeconds);
|
||||
|
||||
// Exponential recency decay anchored on Places' updatedAt — i.e. the
|
||||
// last time the engine observed *anything* about this URL.
|
||||
final updatedAt = DateTime.fromMillisecondsSinceEpoch(metadata.updatedAt);
|
||||
final ageDays = now.difference(updatedAt).inHours / 24.0;
|
||||
final decay = math.exp(
|
||||
-math.max(0.0, ageDays) / _recencyHalfLifeDays,
|
||||
);
|
||||
score -= _recencyRankWeight * decay;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<({String query, List<HistoryQueryResult> results})?> build() {
|
||||
return Future.value();
|
||||
}
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'history_search.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
/// Search the local history content index. Mirrors `TabSearchRepository`'s
|
||||
/// shape so the two integrate symmetrically into the search UI.
|
||||
///
|
||||
/// Visit metadata is fetched from Places at query time and folded into the
|
||||
/// score before emitting state. The DAO returns rows ordered by raw BM25;
|
||||
/// Places adds the frecency / recency signal we don't track locally.
|
||||
|
||||
@ProviderFor(HistorySearchRepository)
|
||||
final historySearchRepositoryProvider = HistorySearchRepositoryProvider._();
|
||||
|
||||
/// Search the local history content index. Mirrors `TabSearchRepository`'s
|
||||
/// shape so the two integrate symmetrically into the search UI.
|
||||
///
|
||||
/// Visit metadata is fetched from Places at query time and folded into the
|
||||
/// score before emitting state. The DAO returns rows ordered by raw BM25;
|
||||
/// Places adds the frecency / recency signal we don't track locally.
|
||||
final class HistorySearchRepositoryProvider
|
||||
extends
|
||||
$AsyncNotifierProvider<
|
||||
HistorySearchRepository,
|
||||
({String query, List<HistoryQueryResult> results})?
|
||||
> {
|
||||
/// Search the local history content index. Mirrors `TabSearchRepository`'s
|
||||
/// shape so the two integrate symmetrically into the search UI.
|
||||
///
|
||||
/// Visit metadata is fetched from Places at query time and folded into the
|
||||
/// score before emitting state. The DAO returns rows ordered by raw BM25;
|
||||
/// Places adds the frecency / recency signal we don't track locally.
|
||||
HistorySearchRepositoryProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'historySearchRepositoryProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$historySearchRepositoryHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
HistorySearchRepository create() => HistorySearchRepository();
|
||||
}
|
||||
|
||||
String _$historySearchRepositoryHash() =>
|
||||
r'd999bfaf32268363f51a3c6831fffa1b14420b07';
|
||||
|
||||
/// Search the local history content index. Mirrors `TabSearchRepository`'s
|
||||
/// shape so the two integrate symmetrically into the search UI.
|
||||
///
|
||||
/// Visit metadata is fetched from Places at query time and folded into the
|
||||
/// score before emitting state. The DAO returns rows ordered by raw BM25;
|
||||
/// Places adds the frecency / recency signal we don't track locally.
|
||||
|
||||
abstract class _$HistorySearchRepository
|
||||
extends
|
||||
$AsyncNotifier<({String query, List<HistoryQueryResult> results})?> {
|
||||
FutureOr<({String query, List<HistoryQueryResult> results})?> build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final ref =
|
||||
this.ref
|
||||
as $Ref<
|
||||
AsyncValue<({String query, List<HistoryQueryResult> results})?>,
|
||||
({String query, List<HistoryQueryResult> results})?
|
||||
>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<
|
||||
AsyncValue<({String query, List<HistoryQueryResult> results})?>,
|
||||
({String query, List<HistoryQueryResult> results})?
|
||||
>,
|
||||
AsyncValue<({String query, List<HistoryQueryResult> results})?>,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleCreate(ref, build);
|
||||
}
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:weblibre/core/logger.dart';
|
||||
import 'package:weblibre/features/geckoview/features/history/domain/repositories/history.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/providers.dart';
|
||||
|
||||
part 'local_index_pruner.g.dart';
|
||||
|
||||
/// Drops local history rows that the engine no longer remembers (the user
|
||||
/// cleared browsing data, the engine's own retention pruned them, etc.).
|
||||
///
|
||||
/// Triggered manually — call `prune()` from a periodic timer (cold-start
|
||||
/// once per app launch is enough). The sweep walks the whole table in batches
|
||||
/// so stale rows don't get stranded behind an always-valid oldest page.
|
||||
@Riverpod(keepAlive: true)
|
||||
class LocalIndexPruner extends _$LocalIndexPruner {
|
||||
/// Examines the whole local index in [batchSize] chunks. Anything Places
|
||||
/// returns `false` for via `getVisited` is removed. Returns the total
|
||||
/// number of deleted rows.
|
||||
///
|
||||
/// A single batch failing (DB hiccup, Places bridge transient) is
|
||||
/// logged and skipped — the sweep advances by `batchSize` so a stuck
|
||||
/// page can't block later pages from being pruned. A contract
|
||||
/// violation (getVisited returning a different list length than asked
|
||||
/// for) stops the sweep, because under that condition we can no
|
||||
/// longer trust the per-row visited bits.
|
||||
Future<int> prune({int batchSize = 200}) async {
|
||||
final dao = ref.read(tabDatabaseProvider).historyDao;
|
||||
var totalDeleted = 0;
|
||||
var offset = 0;
|
||||
|
||||
while (true) {
|
||||
final List<String> candidates;
|
||||
try {
|
||||
candidates = await dao.urlsPage(limit: batchSize, offset: offset);
|
||||
} catch (e, s) {
|
||||
logger.e(
|
||||
'Local index pruner: urlsPage failed at offset=$offset',
|
||||
error: e,
|
||||
stackTrace: s,
|
||||
);
|
||||
return totalDeleted;
|
||||
}
|
||||
|
||||
if (candidates.isEmpty) {
|
||||
return totalDeleted;
|
||||
}
|
||||
|
||||
final int deleted;
|
||||
try {
|
||||
final visited = await ref
|
||||
.read(historyRepositoryProvider.notifier)
|
||||
.getVisited(candidates);
|
||||
if (visited.length != candidates.length) {
|
||||
// Bridge contract violation — see history_search.dart for the
|
||||
// same shape. Bail instead of advancing offset: the visited
|
||||
// bits no longer line up with `candidates`.
|
||||
logger.e(
|
||||
'Local index pruner: getVisited returned ${visited.length} '
|
||||
'for ${candidates.length} URLs — stopping after deleting '
|
||||
'$totalDeleted rows',
|
||||
);
|
||||
return totalDeleted;
|
||||
}
|
||||
|
||||
final toDelete = <String>[
|
||||
for (var i = 0; i < candidates.length; i++)
|
||||
if (!visited[i]) candidates[i],
|
||||
];
|
||||
|
||||
deleted = toDelete.isEmpty
|
||||
? 0
|
||||
: await dao.deleteByCanonicalUrls(toDelete);
|
||||
totalDeleted += deleted;
|
||||
} catch (e, s) {
|
||||
// Transient failure for this batch (Places bridge IPC error,
|
||||
// SQLite hiccup, etc.). Skip the batch and continue — advance by
|
||||
// a full `batchSize` so we don't re-examine the same page on
|
||||
// the next iteration.
|
||||
logger.e(
|
||||
'Local index pruner: batch at offset=$offset failed; skipping',
|
||||
error: e,
|
||||
stackTrace: s,
|
||||
);
|
||||
offset += batchSize;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (candidates.length < batchSize) {
|
||||
return totalDeleted;
|
||||
}
|
||||
|
||||
// We processed `candidates.length` rows; `deleted` of them were
|
||||
// removed, so the table shrunk by `deleted`. The next page should
|
||||
// start at `offset + candidates.length - deleted` to skip exactly
|
||||
// the rows we kept. SQLite re-numbers OFFSET against the
|
||||
// post-delete row positions, so this lands at the first row we
|
||||
// haven't seen yet.
|
||||
offset += candidates.length - deleted;
|
||||
|
||||
// Yield to the event loop between batches so a large sweep
|
||||
// doesn't monopolise the main isolate.
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void build() {}
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'local_index_pruner.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
/// Drops local history rows that the engine no longer remembers (the user
|
||||
/// cleared browsing data, the engine's own retention pruned them, etc.).
|
||||
///
|
||||
/// Triggered manually — call `prune()` from a periodic timer (cold-start
|
||||
/// once per app launch is enough). The sweep walks the whole table in batches
|
||||
/// so stale rows don't get stranded behind an always-valid oldest page.
|
||||
|
||||
@ProviderFor(LocalIndexPruner)
|
||||
final localIndexPrunerProvider = LocalIndexPrunerProvider._();
|
||||
|
||||
/// Drops local history rows that the engine no longer remembers (the user
|
||||
/// cleared browsing data, the engine's own retention pruned them, etc.).
|
||||
///
|
||||
/// Triggered manually — call `prune()` from a periodic timer (cold-start
|
||||
/// once per app launch is enough). The sweep walks the whole table in batches
|
||||
/// so stale rows don't get stranded behind an always-valid oldest page.
|
||||
final class LocalIndexPrunerProvider
|
||||
extends $NotifierProvider<LocalIndexPruner, void> {
|
||||
/// Drops local history rows that the engine no longer remembers (the user
|
||||
/// cleared browsing data, the engine's own retention pruned them, etc.).
|
||||
///
|
||||
/// Triggered manually — call `prune()` from a periodic timer (cold-start
|
||||
/// once per app launch is enough). The sweep walks the whole table in batches
|
||||
/// so stale rows don't get stranded behind an always-valid oldest page.
|
||||
LocalIndexPrunerProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'localIndexPrunerProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$localIndexPrunerHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
LocalIndexPruner create() => LocalIndexPruner();
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(void value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<void>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$localIndexPrunerHash() => r'471b82b87fdc9e075d7afed042682c9592c8735a';
|
||||
|
||||
/// Drops local history rows that the engine no longer remembers (the user
|
||||
/// cleared browsing data, the engine's own retention pruned them, etc.).
|
||||
///
|
||||
/// Triggered manually — call `prune()` from a periodic timer (cold-start
|
||||
/// once per app launch is enough). The sweep walks the whole table in batches
|
||||
/// so stale rows don't get stranded behind an always-valid oldest page.
|
||||
|
||||
abstract class _$LocalIndexPruner extends $Notifier<void> {
|
||||
void build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final ref = this.ref as $Ref<void, void>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<void, void>,
|
||||
void,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleCreate(ref, build);
|
||||
}
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'dart:async';
|
||||
|
||||
// `riverpod/riverpod.dart` is what carries the `.select` extension on
|
||||
// providers; `riverpod_annotation` re-exports `Ref` but not the
|
||||
// ProviderListenable extensions, so this import isn't redundant despite
|
||||
// the unused-import lint's opinion.
|
||||
import 'package:riverpod/riverpod.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:synchronized/synchronized.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/providers.dart';
|
||||
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
|
||||
|
||||
part 'local_index_settings_sync.g.dart';
|
||||
|
||||
/// Setting keys in `local_index_setting`. Kept as constants here because
|
||||
/// the SQL triggers in `definitions.drift` reference the same literal
|
||||
/// strings (`'enabled'`, `'index_private'`) — a divergence between the
|
||||
/// Dart writer and the SQL reader would silently disable the trigger
|
||||
/// gate.
|
||||
const _kEnabledKey = 'enabled';
|
||||
const _kIndexPrivateKey = 'index_private';
|
||||
|
||||
/// Mirrors `enableLocalSearchIndex` / `indexPrivateTabs` from user.db
|
||||
/// settings into tab.db's `local_index_setting` rows. The trigger reads
|
||||
/// from `local_index_setting` exclusively, so this provider is the bridge
|
||||
/// keeping both in sync.
|
||||
///
|
||||
/// Scheduled as a `keepAlive` provider that's read at app start
|
||||
/// (`main.dart`) so the listener is wired before the first tab event
|
||||
/// reaches the database.
|
||||
@Riverpod(keepAlive: true)
|
||||
class LocalIndexSettingsSync extends _$LocalIndexSettingsSync {
|
||||
/// Serializes the two-row upsert per settings change. Without this, two
|
||||
/// rapid setting flips could interleave their `enabled` / `index_private`
|
||||
/// writes such that the final state on disk doesn't match the user's
|
||||
/// last click. The lock is fine-grained per provider instance.
|
||||
final _writeLock = Lock();
|
||||
|
||||
Future<void> _push({
|
||||
required bool enabled,
|
||||
required bool indexPrivate,
|
||||
}) => _writeLock.synchronized(() async {
|
||||
final dao = ref.read(tabDatabaseProvider).historyDao;
|
||||
await dao.upsertSetting(_kEnabledKey, enabled);
|
||||
await dao.upsertSetting(_kIndexPrivateKey, indexPrivate);
|
||||
});
|
||||
|
||||
@override
|
||||
void build() {
|
||||
ref.listen(
|
||||
generalSettingsWithDefaultsProvider.select(
|
||||
(s) => (
|
||||
enabled: s.enableLocalSearchIndex,
|
||||
indexPrivate: s.indexPrivateTabs,
|
||||
),
|
||||
),
|
||||
(previous, next) {
|
||||
if (previous == next) return;
|
||||
// Fire-and-forget: `_writeLock` ensures the second flip queues
|
||||
// behind the first instead of racing it.
|
||||
unawaited(_push(enabled: next.enabled, indexPrivate: next.indexPrivate));
|
||||
},
|
||||
fireImmediately: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'local_index_settings_sync.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
/// Mirrors `enableLocalSearchIndex` / `indexPrivateTabs` from user.db
|
||||
/// settings into tab.db's `local_index_setting` rows. The trigger reads
|
||||
/// from `local_index_setting` exclusively, so this provider is the bridge
|
||||
/// keeping both in sync.
|
||||
///
|
||||
/// Scheduled as a `keepAlive` provider that's read at app start
|
||||
/// (`main.dart`) so the listener is wired before the first tab event
|
||||
/// reaches the database.
|
||||
|
||||
@ProviderFor(LocalIndexSettingsSync)
|
||||
final localIndexSettingsSyncProvider = LocalIndexSettingsSyncProvider._();
|
||||
|
||||
/// Mirrors `enableLocalSearchIndex` / `indexPrivateTabs` from user.db
|
||||
/// settings into tab.db's `local_index_setting` rows. The trigger reads
|
||||
/// from `local_index_setting` exclusively, so this provider is the bridge
|
||||
/// keeping both in sync.
|
||||
///
|
||||
/// Scheduled as a `keepAlive` provider that's read at app start
|
||||
/// (`main.dart`) so the listener is wired before the first tab event
|
||||
/// reaches the database.
|
||||
final class LocalIndexSettingsSyncProvider
|
||||
extends $NotifierProvider<LocalIndexSettingsSync, void> {
|
||||
/// Mirrors `enableLocalSearchIndex` / `indexPrivateTabs` from user.db
|
||||
/// settings into tab.db's `local_index_setting` rows. The trigger reads
|
||||
/// from `local_index_setting` exclusively, so this provider is the bridge
|
||||
/// keeping both in sync.
|
||||
///
|
||||
/// Scheduled as a `keepAlive` provider that's read at app start
|
||||
/// (`main.dart`) so the listener is wired before the first tab event
|
||||
/// reaches the database.
|
||||
LocalIndexSettingsSyncProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'localIndexSettingsSyncProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$localIndexSettingsSyncHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
LocalIndexSettingsSync create() => LocalIndexSettingsSync();
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(void value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<void>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$localIndexSettingsSyncHash() =>
|
||||
r'f1e1878741020de80640da72905fce4e56d13789';
|
||||
|
||||
/// Mirrors `enableLocalSearchIndex` / `indexPrivateTabs` from user.db
|
||||
/// settings into tab.db's `local_index_setting` rows. The trigger reads
|
||||
/// from `local_index_setting` exclusively, so this provider is the bridge
|
||||
/// keeping both in sync.
|
||||
///
|
||||
/// Scheduled as a `keepAlive` provider that's read at app start
|
||||
/// (`main.dart`) so the listener is wired before the first tab event
|
||||
/// reaches the database.
|
||||
|
||||
abstract class _$LocalIndexSettingsSync extends $Notifier<void> {
|
||||
void build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final ref = this.ref as $Ref<void, void>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<void, void>,
|
||||
void,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleCreate(ref, build);
|
||||
}
|
||||
}
|
||||
+316
-162
@@ -31,8 +31,10 @@ import 'package:weblibre/features/geckoview/features/tabs/presentation/controlle
|
||||
import 'package:weblibre/features/geckoview/features/tabs/presentation/dialogs/delete_container_dialog.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/presentation/dialogs/discard_changes_dialog.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/presentation/screens/container_sites.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_icon_picker_sheet.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/color_picker_dialog.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/utils/container_colors.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/utils/container_icons.dart';
|
||||
import 'package:weblibre/presentation/icons/tor_icons.dart';
|
||||
|
||||
enum _DialogMode { create, edit }
|
||||
@@ -72,7 +74,11 @@ class ContainerEditScreen extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final disableAnimations = MediaQuery.disableAnimationsOf(context);
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
final selectedColor = useState(initialContainer.color);
|
||||
final selectedIcon = useState(initialContainer.metadata.iconData);
|
||||
final contextualIdentity = useState(
|
||||
initialContainer.metadata.contextualIdentity,
|
||||
);
|
||||
@@ -92,6 +98,7 @@ class ContainerEditScreen extends HookConsumerWidget {
|
||||
color: selectedColor.value,
|
||||
metadata: initialContainer.metadata.copyWith(
|
||||
contextualIdentity: contextualIdentity.value,
|
||||
iconData: selectedIcon.value,
|
||||
useProxy: useProxy.value && contextualIdentity.value != null,
|
||||
clearDataOnExit:
|
||||
clearDataOnExit.value && contextualIdentity.value != null,
|
||||
@@ -115,9 +122,101 @@ class ContainerEditScreen extends HookConsumerWidget {
|
||||
return container;
|
||||
}
|
||||
|
||||
Future<void> saveAndClose() async {
|
||||
final container = await saveContainer();
|
||||
if (context.mounted) {
|
||||
context.pop(container);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> openColorPicker() async {
|
||||
final color = await showDialog<Color?>(
|
||||
context: context,
|
||||
builder: (context) => ColorPickerDialog(selectedColor.value),
|
||||
);
|
||||
|
||||
if (color != null) {
|
||||
selectedColor.value = color;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> openIconPicker() async {
|
||||
final icon = await showModalBottomSheet<IconData>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
builder: (context) => FractionallySizedBox(
|
||||
heightFactor: 0.92,
|
||||
child: ContainerIconPickerSheet(
|
||||
selectedColor: selectedColor.value,
|
||||
selectedIcon: resolveContainerIcon(selectedIcon.value),
|
||||
onSelected: (iconData) => Navigator.of(context).pop(iconData),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (icon != null) {
|
||||
selectedIcon.value = icon;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> openAppearanceMenu() async {
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
useSafeArea: true,
|
||||
builder: (context) {
|
||||
return SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.palette_outlined),
|
||||
title: const Text('Change Color'),
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
openColorPicker();
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(resolveContainerIcon(selectedIcon.value)),
|
||||
title: const Text('Change Icon'),
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
openIconPicker();
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> deleteContainer() async {
|
||||
final result = await showDeleteContainerDialog(context);
|
||||
|
||||
if (result == true) {
|
||||
await ref
|
||||
.read(containerRepositoryProvider.notifier)
|
||||
.deleteContainer(initialContainer.id);
|
||||
|
||||
if (context.mounted) {
|
||||
context.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final container = buildContainer();
|
||||
//Empty copy to create comparable container with same type
|
||||
final comparison = initialContainer.copyWith();
|
||||
final previewIcon = resolveContainerIcon(selectedIcon.value);
|
||||
final previewPalette = ContainerColors.palette(
|
||||
context,
|
||||
selectedColor.value,
|
||||
);
|
||||
final assignedSiteCount = assignedSites.value?.length ?? 0;
|
||||
|
||||
return PopScope(
|
||||
canPop: container == comparison,
|
||||
@@ -133,10 +232,7 @@ class ContainerEditScreen extends HookConsumerWidget {
|
||||
context.pop();
|
||||
}
|
||||
case DiscardChangesChoice.save:
|
||||
final container = await saveContainer();
|
||||
if (context.mounted) {
|
||||
context.pop(container);
|
||||
}
|
||||
await saveAndClose();
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
@@ -146,180 +242,238 @@ class ContainerEditScreen extends HookConsumerWidget {
|
||||
_DialogMode.edit => 'Edit Container',
|
||||
}),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: () async {
|
||||
final container = await saveContainer();
|
||||
if (context.mounted) {
|
||||
context.pop(container);
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.check),
|
||||
),
|
||||
IconButton(onPressed: saveAndClose, icon: const Icon(Icons.check)),
|
||||
],
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ListView(
|
||||
children: [
|
||||
TextField(
|
||||
decoration: InputDecoration(
|
||||
prefixIcon: Padding(
|
||||
padding: const EdgeInsets.all(10.0),
|
||||
child: AnimatedContainer(
|
||||
duration: disableAnimations
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 300),
|
||||
height: 24,
|
||||
width: 24,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: ContainerColors.preview(
|
||||
selectedColor.value,
|
||||
body: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
children: [
|
||||
Card.filled(
|
||||
margin: EdgeInsets.zero,
|
||||
color: colorScheme.surfaceContainer,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: openAppearanceMenu,
|
||||
child: Stack(
|
||||
alignment: Alignment.bottomRight,
|
||||
children: [
|
||||
AnimatedContainer(
|
||||
duration: disableAnimations
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeInOut,
|
||||
width: 72,
|
||||
height: 72,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: previewPalette.avatarBackgroundColor,
|
||||
border: Border.all(
|
||||
color: previewPalette.outlineColor,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
previewIcon,
|
||||
color: previewPalette.avatarForegroundColor,
|
||||
size: 34,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primary,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: theme.scaffoldBackgroundColor,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.edit,
|
||||
size: 14,
|
||||
color: colorScheme.onPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 20),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: textController,
|
||||
style: theme.textTheme.titleLarge,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Container Name',
|
||||
filled: true,
|
||||
fillColor: colorScheme.surfaceContainerHighest
|
||||
.withValues(alpha: 0.6),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
suffixIcon: _buildMagicWandButton(
|
||||
context,
|
||||
ref,
|
||||
textController,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
label: const Text('Name'),
|
||||
suffixIcon: _buildMagicWandButton(
|
||||
context,
|
||||
ref,
|
||||
textController,
|
||||
),
|
||||
),
|
||||
controller: textController,
|
||||
],
|
||||
),
|
||||
TextButton.icon(
|
||||
label: const Text('Select Color'),
|
||||
icon: const Icon(Icons.colorize),
|
||||
onPressed: () async {
|
||||
final color = await showDialog<Color?>(
|
||||
context: context,
|
||||
builder: (context) =>
|
||||
ColorPickerDialog(selectedColor.value),
|
||||
);
|
||||
|
||||
if (color != null) {
|
||||
selectedColor.value = color;
|
||||
}
|
||||
},
|
||||
),
|
||||
SwitchListTile.adaptive(
|
||||
value: contextualIdentity.value != null,
|
||||
title: const Text('Cookie Isolation'),
|
||||
secondary: const Icon(MdiIcons.cookieLock),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
onChanged: (_mode == _DialogMode.create)
|
||||
? (value) {
|
||||
contextualIdentity.value = value
|
||||
? initialContainer
|
||||
.metadata
|
||||
.contextualIdentity ??
|
||||
uuid.v4()
|
||||
: null;
|
||||
|
||||
if (!value && useProxy.value) {
|
||||
useProxy.value = false;
|
||||
}
|
||||
}
|
||||
: null,
|
||||
),
|
||||
SwitchListTile.adaptive(
|
||||
value: useProxy.value,
|
||||
title: const Text('Use Tor™ Proxy'),
|
||||
secondary: const Icon(TorIcons.onionAlt),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
onChanged: switch (_mode) {
|
||||
_DialogMode.create => (value) {
|
||||
if (value && contextualIdentity.value == null) {
|
||||
contextualIdentity.value =
|
||||
initialContainer
|
||||
.metadata
|
||||
.contextualIdentity ??
|
||||
uuid.v4();
|
||||
}
|
||||
|
||||
useProxy.value = value;
|
||||
},
|
||||
_DialogMode.edit =>
|
||||
(contextualIdentity.value != null)
|
||||
? (value) {
|
||||
useProxy.value = value;
|
||||
}
|
||||
: null,
|
||||
},
|
||||
),
|
||||
SwitchListTile.adaptive(
|
||||
value: clearDataOnExit.value,
|
||||
title: const Text('Clear Data on Exit'),
|
||||
subtitle: const Text(
|
||||
'Clear cookies and site data when app closes',
|
||||
),
|
||||
secondary: const Icon(MdiIcons.databaseRemove),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
onChanged: (contextualIdentity.value != null)
|
||||
? (value) {
|
||||
clearDataOnExit.value = value;
|
||||
}
|
||||
: null,
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.web),
|
||||
title: const Text('Assigned Sites'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
onTap: () async {
|
||||
final result = await showDialog<Set<Uri>>(
|
||||
context: context,
|
||||
builder: (context) => ContainerSitesScreen(
|
||||
initialSites: assignedSites.value?.toSet() ?? {},
|
||||
),
|
||||
);
|
||||
|
||||
if (result.isEmpty) {
|
||||
assignedSites.value = null;
|
||||
} else {
|
||||
assignedSites.value = result!.toList();
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_mode == _DialogMode.edit)
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: BorderSide(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
const SizedBox(height: 28),
|
||||
Text(
|
||||
'Privacy & Security',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.primary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Card.filled(
|
||||
margin: EdgeInsets.zero,
|
||||
color: colorScheme.surfaceContainer,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
children: [
|
||||
SwitchListTile.adaptive(
|
||||
value: contextualIdentity.value != null,
|
||||
title: const Text('Cookie Isolation'),
|
||||
secondary: const Icon(MdiIcons.cookieLock),
|
||||
onChanged: (_mode == _DialogMode.create)
|
||||
? (value) {
|
||||
contextualIdentity.value = value
|
||||
? initialContainer
|
||||
.metadata
|
||||
.contextualIdentity ??
|
||||
uuid.v4()
|
||||
: null;
|
||||
|
||||
if (!value && useProxy.value) {
|
||||
useProxy.value = false;
|
||||
}
|
||||
|
||||
if (!value && clearDataOnExit.value) {
|
||||
clearDataOnExit.value = false;
|
||||
}
|
||||
}
|
||||
: null,
|
||||
),
|
||||
foregroundColor: Theme.of(context).colorScheme.error,
|
||||
iconColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
label: const Text('Delete'),
|
||||
icon: const Icon(Icons.delete),
|
||||
onPressed: () async {
|
||||
final result = await showDeleteContainerDialog(context);
|
||||
const Divider(height: 1, indent: 56),
|
||||
SwitchListTile.adaptive(
|
||||
value: useProxy.value,
|
||||
title: const Text('Use Tor™ Proxy'),
|
||||
secondary: const Icon(TorIcons.onionAlt),
|
||||
onChanged: switch (_mode) {
|
||||
_DialogMode.create => (value) {
|
||||
if (value && contextualIdentity.value == null) {
|
||||
contextualIdentity.value =
|
||||
initialContainer
|
||||
.metadata
|
||||
.contextualIdentity ??
|
||||
uuid.v4();
|
||||
}
|
||||
|
||||
if (result == true) {
|
||||
await ref
|
||||
.read(containerRepositoryProvider.notifier)
|
||||
.deleteContainer(initialContainer.id);
|
||||
useProxy.value = value;
|
||||
},
|
||||
_DialogMode.edit =>
|
||||
(contextualIdentity.value != null)
|
||||
? (value) {
|
||||
useProxy.value = value;
|
||||
}
|
||||
: null,
|
||||
},
|
||||
),
|
||||
const Divider(height: 1, indent: 56),
|
||||
SwitchListTile.adaptive(
|
||||
value: clearDataOnExit.value,
|
||||
title: const Text('Clear Data on Exit'),
|
||||
subtitle: const Text(
|
||||
'Clear cookies and site data when app closes',
|
||||
),
|
||||
secondary: const Icon(MdiIcons.databaseRemove),
|
||||
onChanged: (contextualIdentity.value != null)
|
||||
? (value) {
|
||||
clearDataOnExit.value = value;
|
||||
}
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Assignments',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.primary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Card.filled(
|
||||
margin: EdgeInsets.zero,
|
||||
color: colorScheme.surfaceContainer,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.web),
|
||||
title: const Text('Assigned Sites'),
|
||||
subtitle: assignedSiteCount > 0
|
||||
? Text(
|
||||
'$assignedSiteCount ${assignedSiteCount == 1 ? 'rule' : 'rules'} configured',
|
||||
)
|
||||
: const Text(
|
||||
'Route matching origins into this container',
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () async {
|
||||
final result = await showDialog<Set<Uri>>(
|
||||
context: context,
|
||||
builder: (context) => ContainerSitesScreen(
|
||||
initialSites: assignedSites.value?.toSet() ?? {},
|
||||
),
|
||||
);
|
||||
|
||||
if (context.mounted) {
|
||||
context.pop();
|
||||
}
|
||||
if (result == null || result.isEmpty) {
|
||||
assignedSites.value = null;
|
||||
} else {
|
||||
assignedSites.value = result.toList();
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_mode == _DialogMode.edit)
|
||||
SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
height: 52,
|
||||
child: FilledButton.tonalIcon(
|
||||
onPressed: deleteContainer,
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
label: const Text('Delete Container'),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: colorScheme.errorContainer,
|
||||
foregroundColor: colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
+292
-127
@@ -17,11 +17,10 @@
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:fading_scroll/fading_scroll.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_slidable/flutter_slidable.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:skeletonizer/skeletonizer.dart';
|
||||
import 'package:weblibre/core/routing/routes.dart';
|
||||
@@ -29,7 +28,9 @@ import 'package:weblibre/features/geckoview/features/tabs/data/models/container_
|
||||
import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selected_container.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_list_tile.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_title.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/utils/container_colors.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/utils/container_icons.dart';
|
||||
import 'package:weblibre/features/tor/presentation/controllers/start_tor_proxy.dart';
|
||||
import 'package:weblibre/features/tor/presentation/widgets/tor_dialog.dart';
|
||||
import 'package:weblibre/presentation/widgets/failure_widget.dart';
|
||||
@@ -41,137 +42,118 @@ class ContainerListScreen extends HookConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final containersAsync = ref.watch(watchContainersWithCountProvider);
|
||||
final selectedContainer = ref.watch(selectedContainerProvider);
|
||||
final repository = ref.watch(containerRepositoryProvider.notifier);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Containers')),
|
||||
body: SafeArea(
|
||||
child: Skeletonizer(
|
||||
enabled: containersAsync.isLoading,
|
||||
child: containersAsync.when(
|
||||
skipLoadingOnReload: true,
|
||||
data: (containers) => FadingScroll(
|
||||
fadingSize: 25,
|
||||
builder: (context, controller) {
|
||||
return ListView.builder(
|
||||
controller: controller,
|
||||
itemCount: containers.length,
|
||||
itemBuilder: (context, index) {
|
||||
final container = containers[index];
|
||||
return Slidable(
|
||||
key: ValueKey(container.id),
|
||||
startActionPane: ActionPane(
|
||||
motion: const ScrollMotion(),
|
||||
children: [
|
||||
if (container.id != selectedContainer)
|
||||
SlidableAction(
|
||||
onPressed: (context) async {
|
||||
final result = await ref
|
||||
.read(selectedContainerProvider.notifier)
|
||||
.setContainerId(container.id);
|
||||
Future<void> setSelectedContainer(ContainerDataWithCount container) async {
|
||||
final result = await ref
|
||||
.read(selectedContainerProvider.notifier)
|
||||
.setContainerId(container.id);
|
||||
|
||||
if (context.mounted &&
|
||||
result ==
|
||||
SetContainerResult.successHasProxy) {
|
||||
final shouldStartProxy = await ref
|
||||
.read(
|
||||
startProxyControllerProvider.notifier,
|
||||
)
|
||||
.shouldPromptProxyStart();
|
||||
if (context.mounted && result == SetContainerResult.successHasProxy) {
|
||||
final shouldStartProxy = await ref
|
||||
.read(startProxyControllerProvider.notifier)
|
||||
.shouldPromptProxyStart();
|
||||
|
||||
if (!context.mounted || !shouldStartProxy) {
|
||||
return;
|
||||
}
|
||||
if (!context.mounted || !shouldStartProxy) {
|
||||
return;
|
||||
}
|
||||
|
||||
final dialogResult = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return const TorDialog();
|
||||
},
|
||||
);
|
||||
final dialogResult = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return const TorDialog();
|
||||
},
|
||||
);
|
||||
|
||||
if (dialogResult == true) {
|
||||
await ref
|
||||
.read(
|
||||
startProxyControllerProvider.notifier,
|
||||
)
|
||||
.startProxy();
|
||||
}
|
||||
}
|
||||
},
|
||||
foregroundColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.onPrimaryContainer,
|
||||
backgroundColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.primaryContainer,
|
||||
icon: Icons.check,
|
||||
label: 'Select',
|
||||
)
|
||||
else
|
||||
SlidableAction(
|
||||
onPressed: (context) {
|
||||
ref
|
||||
.read(selectedContainerProvider.notifier)
|
||||
.clearContainer();
|
||||
},
|
||||
foregroundColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.onPrimaryContainer,
|
||||
backgroundColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.primaryContainer,
|
||||
icon: Icons.close,
|
||||
label: 'Unselect',
|
||||
),
|
||||
],
|
||||
),
|
||||
endActionPane: ActionPane(
|
||||
motion: const ScrollMotion(),
|
||||
children: [
|
||||
SlidableAction(
|
||||
onPressed: (context) async {
|
||||
await ref
|
||||
.read(containerRepositoryProvider.notifier)
|
||||
.deleteContainer(container.id);
|
||||
},
|
||||
backgroundColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.errorContainer,
|
||||
foregroundColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.onErrorContainer,
|
||||
icon: Icons.delete,
|
||||
label: 'Delete',
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ContainerListTile(
|
||||
container,
|
||||
isSelected: container.id == selectedContainer,
|
||||
onTap: () async {
|
||||
await ContainerEditRoute(
|
||||
containerData: jsonEncode(container.toJson()),
|
||||
).push(context);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
error: (error, stackTrace) => Center(
|
||||
child: FailureWidget(
|
||||
title: 'Failed to load containers',
|
||||
exception: error,
|
||||
onRetry: () => ref.invalidate(watchContainersWithCountProvider),
|
||||
if (dialogResult == true) {
|
||||
await ref.read(startProxyControllerProvider.notifier).startProxy();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> editContainer(ContainerDataWithCount container) async {
|
||||
await ContainerEditRoute(
|
||||
containerData: jsonEncode(container.toJson()),
|
||||
).push(context);
|
||||
}
|
||||
|
||||
Widget buildList(List<ContainerDataWithCount> containers) {
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
const SliverAppBar.large(title: Text('Containers')),
|
||||
if (containers.isEmpty)
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Text(
|
||||
'No containers yet.',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 96),
|
||||
sliver: SliverReorderableList(
|
||||
itemCount: containers.length,
|
||||
onReorder: (oldIndex, newIndex) {
|
||||
unawaited(
|
||||
repository.reorderContainer(containers, oldIndex, newIndex),
|
||||
);
|
||||
},
|
||||
itemBuilder: (context, index) {
|
||||
final container = containers[index];
|
||||
final isSelected = container.id == selectedContainer;
|
||||
return Padding(
|
||||
key: ValueKey(container.id),
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: _ContainerCard(
|
||||
container: container,
|
||||
index: index,
|
||||
isSelected: isSelected,
|
||||
onTap: () => editContainer(container),
|
||||
onSelect: isSelected
|
||||
? () => ref
|
||||
.read(selectedContainerProvider.notifier)
|
||||
.clearContainer()
|
||||
: () => setSelectedContainer(container),
|
||||
// onDelete: () => repository.deleteContainer(container.id),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
loading: () => ListView.builder(
|
||||
itemCount: 3,
|
||||
itemBuilder: (context, index) => ContainerListTile(
|
||||
ContainerData(id: 'null', color: Colors.transparent),
|
||||
onTap: null,
|
||||
isSelected: false,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
body: Skeletonizer(
|
||||
enabled: containersAsync.isLoading,
|
||||
child: containersAsync.when(
|
||||
skipLoadingOnReload: true,
|
||||
data: buildList,
|
||||
error: (error, stackTrace) => Center(
|
||||
child: FailureWidget(
|
||||
title: 'Failed to load containers',
|
||||
exception: error,
|
||||
onRetry: () => ref.invalidate(watchContainersWithCountProvider),
|
||||
),
|
||||
),
|
||||
loading: () => buildList(
|
||||
List.generate(
|
||||
3,
|
||||
(index) => ContainerDataWithCount(
|
||||
id: 'loading-$index',
|
||||
name: 'Container',
|
||||
color: Colors.transparent,
|
||||
orderKey: '',
|
||||
tabCount: 0,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -195,3 +177,186 @@ class ContainerListScreen extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ContainerCard extends HookConsumerWidget {
|
||||
const _ContainerCard({
|
||||
required this.container,
|
||||
required this.index,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
required this.onSelect,
|
||||
this.onDelete,
|
||||
});
|
||||
|
||||
final ContainerDataWithCount container;
|
||||
final int index;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback onSelect;
|
||||
final VoidCallback? onDelete;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
final containerColor = container.color;
|
||||
final tabCount = container.tabCount ?? 0;
|
||||
final palette = ContainerColors.palette(context, containerColor);
|
||||
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeInOut,
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? palette.surfaceHighColor
|
||||
: colorScheme.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: isSelected
|
||||
? Border.fromBorderSide(palette.borderSide)
|
||||
: Border.all(
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.4),
|
||||
),
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 22,
|
||||
backgroundColor: palette.avatarBackgroundColor,
|
||||
foregroundColor: palette.avatarForegroundColor,
|
||||
child: Icon(
|
||||
resolveContainerIcon(container.metadata.iconData),
|
||||
size: 22,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
DefaultTextStyle.merge(
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
child: ContainerTitle(container: container),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 6,
|
||||
children: [
|
||||
_ContainerInfoChip(
|
||||
icon: Icons.tab_outlined,
|
||||
label:
|
||||
'$tabCount ${tabCount == 1 ? 'tab' : 'tabs'}',
|
||||
),
|
||||
if (container.metadata.contextualIdentity != null)
|
||||
const _ContainerInfoChip(
|
||||
icon: Icons.cookie_outlined,
|
||||
label: 'Isolated',
|
||||
),
|
||||
if (container.metadata.useProxy)
|
||||
const _ContainerInfoChip(
|
||||
icon: Icons.route_outlined,
|
||||
label: 'Proxy',
|
||||
),
|
||||
if (container.metadata.clearDataOnExit)
|
||||
const _ContainerInfoChip(
|
||||
icon: Icons.cleaning_services_outlined,
|
||||
label: 'Clear on exit',
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
ReorderableDragStartListener(
|
||||
index: index,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 8, bottom: 8),
|
||||
child: Icon(
|
||||
Icons.drag_indicator,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Row(
|
||||
children: [
|
||||
if (isSelected)
|
||||
Chip(
|
||||
avatar: Icon(
|
||||
Icons.check,
|
||||
size: 16,
|
||||
color: palette.onContainerColor,
|
||||
),
|
||||
label: const Text('Active'),
|
||||
side: BorderSide.none,
|
||||
visualDensity: VisualDensity.compact,
|
||||
backgroundColor: palette.containerColor,
|
||||
labelStyle: TextStyle(color: palette.onContainerColor),
|
||||
)
|
||||
else
|
||||
const SizedBox.shrink(),
|
||||
const Spacer(),
|
||||
if (onDelete != null) ...[
|
||||
IconButton(
|
||||
tooltip: 'Delete',
|
||||
color: colorScheme.error,
|
||||
onPressed: onDelete,
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: onSelect,
|
||||
icon: Icon(isSelected ? Icons.close : Icons.check),
|
||||
label: Text(isSelected ? 'Unselect' : 'Select'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ContainerInfoChip extends StatelessWidget {
|
||||
const _ContainerInfoChip({required this.icon, required this.label});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return IconTheme.merge(
|
||||
data: IconThemeData(size: 14, color: theme.colorScheme.onSurfaceVariant),
|
||||
child: DefaultTextStyle.merge(
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [Icon(icon), const SizedBox(width: 4), Text(label)],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+297
-65
@@ -19,7 +19,6 @@
|
||||
*/
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:fading_scroll/fading_scroll.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
@@ -32,7 +31,9 @@ import 'package:weblibre/features/geckoview/features/tabs/domain/entities/contai
|
||||
import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selected_container.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_list_tile.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_title.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/utils/container_colors.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/utils/container_icons.dart';
|
||||
import 'package:weblibre/presentation/widgets/failure_widget.dart';
|
||||
|
||||
class ContainerSelectionScreen extends HookConsumerWidget {
|
||||
@@ -43,76 +44,70 @@ class ContainerSelectionScreen extends HookConsumerWidget {
|
||||
final containersAsync = ref.watch(watchContainersWithCountProvider);
|
||||
final selectedContainerId = ref.watch(selectedContainerProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Select Container')),
|
||||
body: SafeArea(
|
||||
child: Skeletonizer(
|
||||
enabled: containersAsync.isLoading,
|
||||
child: containersAsync.when(
|
||||
skipLoadingOnReload: true,
|
||||
data: (containers) => FadingScroll(
|
||||
fadingSize: 25,
|
||||
builder: (context, controller) {
|
||||
return ListView.builder(
|
||||
controller: controller,
|
||||
itemCount: containers.length + 1,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == 0) {
|
||||
return ListTileTheme(
|
||||
selectedColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.onPrimaryContainer,
|
||||
selectedTileColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.primaryContainer,
|
||||
child: ListTile(
|
||||
selected: selectedContainerId == null,
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest,
|
||||
child: const Icon(MdiIcons.folderHidden),
|
||||
),
|
||||
title: const Text('Unassigned'),
|
||||
onTap: () {
|
||||
context.pop<ContainerSelectionResult>(
|
||||
const ContainerSelectionResult.unassigned(),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final container = containers[index - 1];
|
||||
return ContainerListTile(
|
||||
container,
|
||||
isSelected: container.id == selectedContainerId,
|
||||
Widget buildList(List<ContainerDataWithCount> containers) {
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
const SliverAppBar.large(title: Text('Select Container')),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 96),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
if (index == 0) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: _UnassignedSelectionCard(
|
||||
isSelected: selectedContainerId == null,
|
||||
onTap: () {
|
||||
context.pop<ContainerSelectionResult>(
|
||||
ContainerSelectionResult.selected(container.id),
|
||||
const ContainerSelectionResult.unassigned(),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final container = containers[index - 1];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: _SelectionContainerCard(
|
||||
container: container,
|
||||
isSelected: container.id == selectedContainerId,
|
||||
onTap: () {
|
||||
context.pop<ContainerSelectionResult>(
|
||||
ContainerSelectionResult.selected(container.id),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
}, childCount: containers.length + 1),
|
||||
),
|
||||
error: (error, stackTrace) => Center(
|
||||
child: FailureWidget(
|
||||
title: 'Failed to load containers',
|
||||
exception: error,
|
||||
onRetry: () => ref.invalidate(watchContainersWithCountProvider),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
body: Skeletonizer(
|
||||
enabled: containersAsync.isLoading,
|
||||
child: containersAsync.when(
|
||||
skipLoadingOnReload: true,
|
||||
data: buildList,
|
||||
error: (error, stackTrace) => Center(
|
||||
child: FailureWidget(
|
||||
title: 'Failed to load containers',
|
||||
exception: error,
|
||||
onRetry: () => ref.invalidate(watchContainersWithCountProvider),
|
||||
),
|
||||
loading: () => ListView.builder(
|
||||
itemCount: 3,
|
||||
itemBuilder: (context, index) => ContainerListTile(
|
||||
ContainerData(
|
||||
id: Namespace.nil.value,
|
||||
color: Colors.transparent,
|
||||
),
|
||||
isSelected: false,
|
||||
onTap: null,
|
||||
),
|
||||
loading: () => buildList(
|
||||
List.generate(
|
||||
3,
|
||||
(index) => ContainerDataWithCount(
|
||||
id: Namespace.nil.value,
|
||||
name: 'Container',
|
||||
color: Colors.transparent,
|
||||
orderKey: '',
|
||||
tabCount: 0,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -136,3 +131,240 @@ class ContainerSelectionScreen extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _UnassignedSelectionCard extends StatelessWidget {
|
||||
const _UnassignedSelectionCard({
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final accentColor = colorScheme.primary;
|
||||
final palette = ContainerColors.palette(context, accentColor);
|
||||
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeInOut,
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? palette.surfaceHighColor
|
||||
: colorScheme.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: isSelected
|
||||
? Border.fromBorderSide(palette.borderSide)
|
||||
: Border.all(
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.4),
|
||||
),
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 22,
|
||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||
foregroundColor: colorScheme.onSurfaceVariant,
|
||||
child: const Icon(MdiIcons.folderHidden, size: 22),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Unassigned',
|
||||
style: Theme.of(context).textTheme.titleMedium
|
||||
?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Tabs without a container',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (isSelected)
|
||||
Chip(
|
||||
avatar: Icon(
|
||||
Icons.check,
|
||||
size: 16,
|
||||
color: palette.onContainerColor,
|
||||
),
|
||||
label: const Text('Active'),
|
||||
side: BorderSide.none,
|
||||
visualDensity: VisualDensity.compact,
|
||||
backgroundColor: palette.containerColor,
|
||||
labelStyle: TextStyle(color: palette.onContainerColor),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SelectionContainerCard extends StatelessWidget {
|
||||
const _SelectionContainerCard({
|
||||
required this.container,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final ContainerDataWithCount container;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
final containerColor = container.color;
|
||||
final tabCount = container.tabCount ?? 0;
|
||||
final palette = ContainerColors.palette(context, containerColor);
|
||||
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeInOut,
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? palette.surfaceHighColor
|
||||
: colorScheme.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: isSelected
|
||||
? Border.fromBorderSide(palette.borderSide)
|
||||
: Border.all(
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.4),
|
||||
),
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 22,
|
||||
backgroundColor: palette.avatarBackgroundColor,
|
||||
foregroundColor: palette.avatarForegroundColor,
|
||||
child: Icon(
|
||||
resolveContainerIcon(container.metadata.iconData),
|
||||
size: 22,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
DefaultTextStyle.merge(
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
child: IconTheme.merge(
|
||||
data: IconThemeData(color: colorScheme.onSurface),
|
||||
child: ContainerTitle(container: container),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 6,
|
||||
children: [
|
||||
_SelectionInfoChip(
|
||||
icon: Icons.tab_outlined,
|
||||
label:
|
||||
'$tabCount ${tabCount == 1 ? 'tab' : 'tabs'}',
|
||||
),
|
||||
if (container.metadata.contextualIdentity != null)
|
||||
const _SelectionInfoChip(
|
||||
icon: Icons.cookie_outlined,
|
||||
label: 'Isolated',
|
||||
),
|
||||
if (container.metadata.useProxy)
|
||||
const _SelectionInfoChip(
|
||||
icon: Icons.route_outlined,
|
||||
label: 'Proxy',
|
||||
),
|
||||
if (container.metadata.clearDataOnExit)
|
||||
const _SelectionInfoChip(
|
||||
icon: Icons.cleaning_services_outlined,
|
||||
label: 'Clear on exit',
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (isSelected)
|
||||
Chip(
|
||||
avatar: Icon(
|
||||
Icons.check,
|
||||
size: 16,
|
||||
color: palette.onContainerColor,
|
||||
),
|
||||
label: const Text('Active'),
|
||||
side: BorderSide.none,
|
||||
visualDensity: VisualDensity.compact,
|
||||
backgroundColor: palette.containerColor,
|
||||
labelStyle: TextStyle(color: palette.onContainerColor),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SelectionInfoChip extends StatelessWidget {
|
||||
const _SelectionInfoChip({required this.icon, required this.label});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return IconTheme.merge(
|
||||
data: IconThemeData(size: 14, color: theme.colorScheme.onSurfaceVariant),
|
||||
child: DefaultTextStyle.merge(
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [Icon(icon), const SizedBox(width: 4), Text(label)],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -48,7 +48,9 @@ class ColorPickerDialog extends HookWidget {
|
||||
onColorChanged: (value) {
|
||||
selectedColor.value = value;
|
||||
},
|
||||
displayAlpha: ContainerColors.defaultAlpha,
|
||||
displayColorBuilder: (context, color) {
|
||||
return ContainerColors.palette(context, color).containerColor;
|
||||
},
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
|
||||
+39
-11
@@ -22,11 +22,12 @@ import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:nullability/nullability.dart';
|
||||
import 'package:weblibre/core/routing/routes.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/domain/entities/container_selection_result.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selected_container.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_title.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_chip_content.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/utils/container_colors.dart';
|
||||
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
|
||||
|
||||
@@ -38,11 +39,13 @@ class CompactContainerSelector extends ConsumerWidget {
|
||||
final ContainerData? selectedContainer;
|
||||
final Future<void> Function(ContainerSelectionResult selection)?
|
||||
onSelectionChanged;
|
||||
final bool emphasizeSelection;
|
||||
|
||||
const CompactContainerSelector({
|
||||
super.key,
|
||||
this.selectedContainer,
|
||||
this.onSelectionChanged,
|
||||
this.emphasizeSelection = true,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -56,8 +59,11 @@ class CompactContainerSelector extends ConsumerWidget {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isSelected = selectedContainer != null;
|
||||
final accentColor = selectedContainer?.color ?? colorScheme.primary;
|
||||
final showSelectedHighlight = isSelected && emphasizeSelection;
|
||||
final palette = ContainerColors.palette(context, accentColor);
|
||||
|
||||
return GestureDetector(
|
||||
onLongPress: isSelected
|
||||
@@ -67,16 +73,38 @@ class CompactContainerSelector extends ConsumerWidget {
|
||||
).push(context);
|
||||
}
|
||||
: null,
|
||||
child: ActionChip(
|
||||
avatar: isSelected ? null : const Icon(MdiIcons.folderHidden),
|
||||
label: isSelected
|
||||
? ContainerTitle(container: selectedContainer!)
|
||||
: const Text('Unassigned'),
|
||||
backgroundColor: isSelected
|
||||
? ContainerColors.forChip(selectedContainer!.color)
|
||||
child: FilterChip(
|
||||
avatar: isSelected
|
||||
? buildContainerChipAvatar(
|
||||
context,
|
||||
selectedContainer!,
|
||||
showSelectedHighlight,
|
||||
)
|
||||
: null,
|
||||
side: isSelected ? BorderSide(color: theme.colorScheme.primary) : null,
|
||||
onPressed: () async {
|
||||
label: isSelected
|
||||
? buildContainerChipLabel(
|
||||
context,
|
||||
selectedContainer!,
|
||||
showSelectedHighlight,
|
||||
)
|
||||
: const Text('Unassigned'),
|
||||
color: WidgetStatePropertyAll(
|
||||
isSelected
|
||||
? (showSelectedHighlight
|
||||
? palette.selectedBackgroundColor
|
||||
: palette.backgroundColor)
|
||||
: colorScheme.surfaceContainer,
|
||||
),
|
||||
side: isSelected
|
||||
? (showSelectedHighlight
|
||||
? palette.selectedBorderSide
|
||||
: palette.borderSide)
|
||||
: BorderSide(
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.4),
|
||||
),
|
||||
selected: false,
|
||||
showCheckmark: false,
|
||||
onSelected: (_) async {
|
||||
final selection = await const ContainerSelectionRoute()
|
||||
.push<ContainerSelectionResult?>(context);
|
||||
if (selection == null) {
|
||||
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:nullability/nullability.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_title.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/utils/container_colors.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/utils/container_icons.dart';
|
||||
|
||||
Widget? buildContainerChipAvatar(
|
||||
BuildContext context,
|
||||
ContainerData container,
|
||||
bool isSelected, {
|
||||
double size = 18,
|
||||
}) {
|
||||
final palette = ContainerColors.palette(context, container.color);
|
||||
|
||||
return chipContainerIcon(container.metadata.iconData).mapNotNull(
|
||||
(iconData) => Icon(
|
||||
iconData,
|
||||
size: size,
|
||||
color: isSelected ? palette.selectedAvatarColor : palette.avatarColor,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildContainerChipLabel(
|
||||
BuildContext context,
|
||||
ContainerData container,
|
||||
bool isSelected, {
|
||||
Widget? trailing,
|
||||
}) {
|
||||
final palette = ContainerColors.palette(context, container.color);
|
||||
final foregroundColor = isSelected
|
||||
? palette.selectedForegroundColor
|
||||
: palette.foregroundColor;
|
||||
|
||||
return DefaultTextStyle.merge(
|
||||
style: TextStyle(
|
||||
color: foregroundColor,
|
||||
fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500,
|
||||
),
|
||||
child: IconTheme.merge(
|
||||
data: IconThemeData(color: foregroundColor),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(child: ContainerTitle(container: container)),
|
||||
if (trailing != null) ...[const SizedBox(width: 6), trailing],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
+251
-81
@@ -17,8 +17,11 @@
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
@@ -31,12 +34,37 @@ import 'package:weblibre/features/geckoview/features/tabs/data/entities/containe
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/gecko_inference.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_chip_content.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_title.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/tab_drag_container_target.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/utils/container_colors.dart';
|
||||
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
|
||||
import 'package:weblibre/presentation/widgets/inline_count_badge.dart';
|
||||
import 'package:weblibre/presentation/widgets/selectable_chips.dart';
|
||||
|
||||
ContainerColorPalette _palette(BuildContext context, Color color) {
|
||||
return ContainerColors.palette(context, color);
|
||||
}
|
||||
|
||||
Color _chipColor(BuildContext context, Color color, bool isSelected) {
|
||||
final palette = _palette(context, color);
|
||||
return isSelected ? palette.selectedBackgroundColor : palette.backgroundColor;
|
||||
}
|
||||
|
||||
BorderSide _chipSide(BuildContext context, Color color, bool isSelected) {
|
||||
final palette = _palette(context, color);
|
||||
return isSelected ? palette.selectedBorderSide : palette.borderSide;
|
||||
}
|
||||
|
||||
InlineCountBadge _countBadge(BuildContext context, Color color, int count) {
|
||||
final palette = _palette(context, color);
|
||||
return InlineCountBadge(
|
||||
count: count,
|
||||
backgroundColor: palette.badgeBackgroundColor,
|
||||
foregroundColor: palette.badgeForegroundColor,
|
||||
);
|
||||
}
|
||||
|
||||
class _UnassignedContainerChip extends ConsumerWidget {
|
||||
final int? Function()? containerBadgeCount;
|
||||
final bool selected;
|
||||
@@ -61,11 +89,22 @@ class _UnassignedContainerChip extends ConsumerWidget {
|
||||
|
||||
return FilterChip(
|
||||
avatar: const Icon(MdiIcons.folderHidden),
|
||||
labelPadding: (tabCount > 0) ? null : const EdgeInsets.only(right: 2.0),
|
||||
label: (tabCount > 0)
|
||||
? Text(tabCount.toString())
|
||||
: const SizedBox.shrink(),
|
||||
labelPadding: const EdgeInsets.only(left: 6),
|
||||
label: SizedBox(
|
||||
height: 20,
|
||||
child: Center(
|
||||
child: _countBadge(
|
||||
context,
|
||||
Theme.of(context).colorScheme.primary,
|
||||
tabCount,
|
||||
),
|
||||
),
|
||||
),
|
||||
color: WidgetStatePropertyAll(
|
||||
_chipColor(context, Theme.of(context).colorScheme.primary, selected),
|
||||
),
|
||||
selected: selected,
|
||||
side: _chipSide(context, Theme.of(context).colorScheme.primary, selected),
|
||||
showCheckmark: false,
|
||||
onSelected: (value) {
|
||||
if (value) {
|
||||
@@ -91,9 +130,14 @@ class _SyncedTabsChip extends ConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return FilterChip(
|
||||
avatar: const Icon(Icons.devices_other),
|
||||
labelPadding: count > 0 ? null : const EdgeInsets.only(right: 2.0),
|
||||
label: count > 0 ? Text(count.toString()) : const SizedBox.shrink(),
|
||||
label: count > 0
|
||||
? _countBadge(context, Theme.of(context).colorScheme.primary, count)
|
||||
: const SizedBox.shrink(),
|
||||
color: WidgetStatePropertyAll(
|
||||
_chipColor(context, Theme.of(context).colorScheme.primary, selected),
|
||||
),
|
||||
selected: selected,
|
||||
side: _chipSide(context, Theme.of(context).colorScheme.primary, selected),
|
||||
showCheckmark: false,
|
||||
onSelected: (value) {
|
||||
if (value) {
|
||||
@@ -134,6 +178,14 @@ class _ContainerSuggestionsChip extends ConsumerWidget {
|
||||
return FilterChip(
|
||||
avatar: const Icon(MdiIcons.autoFix),
|
||||
label: Text(data!.length.toString()),
|
||||
color: WidgetStatePropertyAll(
|
||||
_chipColor(context, Theme.of(context).colorScheme.primary, false),
|
||||
),
|
||||
side: _chipSide(
|
||||
context,
|
||||
Theme.of(context).colorScheme.primary,
|
||||
false,
|
||||
),
|
||||
showCheckmark: false,
|
||||
onSelected: (_) async {
|
||||
await const ContainerDraftRoute().push(context);
|
||||
@@ -149,9 +201,17 @@ class _ContainerSuggestionsChip extends ConsumerWidget {
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
loading: () {
|
||||
return const FilterChip(
|
||||
avatar: Icon(MdiIcons.autoFix),
|
||||
label: Skeletonizer(child: Text('0')),
|
||||
return FilterChip(
|
||||
avatar: const Icon(MdiIcons.autoFix),
|
||||
label: const Skeletonizer(child: Text('0')),
|
||||
color: WidgetStatePropertyAll(
|
||||
_chipColor(context, Theme.of(context).colorScheme.primary, false),
|
||||
),
|
||||
side: _chipSide(
|
||||
context,
|
||||
Theme.of(context).colorScheme.primary,
|
||||
false,
|
||||
),
|
||||
showCheckmark: false,
|
||||
onSelected: null,
|
||||
);
|
||||
@@ -215,11 +275,80 @@ class ContainerChips extends HookConsumerWidget {
|
||||
searchTextListenable,
|
||||
() => searchTextListenable?.value.text,
|
||||
);
|
||||
final chipScrollController = useScrollController();
|
||||
final activeItemKey = useRef(GlobalKey());
|
||||
final isUserScrolling = useRef(false);
|
||||
final userScrollTimer = useRef<Timer?>(null);
|
||||
|
||||
useEffect(() {
|
||||
return userScrollTimer.value?.cancel;
|
||||
}, []);
|
||||
|
||||
final containersAsync = ref.watch(
|
||||
matchSortedContainersWithCountProvider(searchText),
|
||||
);
|
||||
|
||||
useEffect(() {
|
||||
final selectedId = selectedContainer?.id;
|
||||
if (selectedId == null || isUserScrolling.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final renderedContainers =
|
||||
containerFilter.mapNotNull(
|
||||
(filter) => containersAsync.value?.where(filter).toList(),
|
||||
) ??
|
||||
containersAsync.value;
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final activeContext = activeItemKey.value.currentContext;
|
||||
if (activeContext != null) {
|
||||
Scrollable.ensureVisible(
|
||||
activeContext,
|
||||
alignment: 0.5,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!chipScrollController.hasClients || renderedContainers == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final activeIndex = renderedContainers.indexWhere(
|
||||
(container) => container.id == selectedId,
|
||||
);
|
||||
if (activeIndex < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
final totalItems = renderedContainers.length;
|
||||
final maxExtent = chipScrollController.position.maxScrollExtent;
|
||||
if (totalItems <= 0 || maxExtent <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
chipScrollController.jumpTo(
|
||||
(activeIndex / totalItems * maxExtent).clamp(0.0, maxExtent),
|
||||
);
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final retryContext = activeItemKey.value.currentContext;
|
||||
if (retryContext != null) {
|
||||
Scrollable.ensureVisible(
|
||||
retryContext,
|
||||
alignment: 0.5,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return null;
|
||||
}, [selectedContainer?.id, containersAsync.value]);
|
||||
|
||||
return containersAsync.when(
|
||||
skipLoadingOnReload: true,
|
||||
data: (containers) {
|
||||
@@ -235,50 +364,100 @@ class ContainerChips extends HookConsumerWidget {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return SizedBox(
|
||||
height: 48,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child:
|
||||
SelectableChips<
|
||||
ContainerDataWithCount,
|
||||
ContainerData,
|
||||
String
|
||||
>(
|
||||
enableDelete: false,
|
||||
maxCount: null,
|
||||
itemId: (container) => container.id,
|
||||
itemBackgroundColor: (container) =>
|
||||
ContainerColors.forChip(container.color),
|
||||
selectedBorderColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary,
|
||||
itemLabel: (container) =>
|
||||
ContainerTitle(container: container),
|
||||
itemBadgeCount: (container) =>
|
||||
containerBadgeCount?.call(container) ??
|
||||
container.tabCount,
|
||||
itemWrap: enableDragAndDrop
|
||||
? (child, container) {
|
||||
return TabDragContainerTarget(
|
||||
container: container,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
: null,
|
||||
prefixListItems: [
|
||||
if (showSyncedChip)
|
||||
_SyncedTabsChip(
|
||||
selected: syncedChipSelected,
|
||||
count: syncedTabCount,
|
||||
onSelected: onSyncedChipSelected ?? () {},
|
||||
),
|
||||
if (showUnassignedChip)
|
||||
enableDragAndDrop
|
||||
? TabDragContainerTarget(
|
||||
container: null,
|
||||
child: _UnassignedContainerChip(
|
||||
return NotificationListener<UserScrollNotification>(
|
||||
onNotification: (notification) {
|
||||
userScrollTimer.value?.cancel();
|
||||
if (notification.direction != ScrollDirection.idle) {
|
||||
isUserScrolling.value = true;
|
||||
}
|
||||
userScrollTimer.value = Timer(
|
||||
const Duration(milliseconds: 1500),
|
||||
() {
|
||||
isUserScrolling.value = false;
|
||||
},
|
||||
);
|
||||
return false;
|
||||
},
|
||||
child: SizedBox(
|
||||
height: 48,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child:
|
||||
SelectableChips<
|
||||
ContainerDataWithCount,
|
||||
ContainerData,
|
||||
String
|
||||
>(
|
||||
enableDelete: false,
|
||||
maxCount: null,
|
||||
scrollController: chipScrollController,
|
||||
activeItemKey: activeItemKey.value,
|
||||
cacheExtent: 500,
|
||||
itemId: (container) => container.id,
|
||||
decoration: SelectableChipDecoration(
|
||||
color: (container, isSelected) =>
|
||||
_chipColor(context, container.color, isSelected),
|
||||
side: (container, isSelected) =>
|
||||
_chipSide(context, container.color, isSelected),
|
||||
),
|
||||
itemAvatar: (container) {
|
||||
final isSelected =
|
||||
selectedContainer?.id == container.id;
|
||||
return buildContainerChipAvatar(
|
||||
context,
|
||||
container,
|
||||
isSelected,
|
||||
);
|
||||
},
|
||||
selectedBorderColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary,
|
||||
itemLabel: (container) {
|
||||
final isSelected =
|
||||
selectedContainer?.id == container.id;
|
||||
final count =
|
||||
containerBadgeCount?.call(container) ??
|
||||
container.tabCount;
|
||||
return buildContainerChipLabel(
|
||||
context,
|
||||
container,
|
||||
isSelected,
|
||||
trailing: count != null && count > 0
|
||||
? _countBadge(context, container.color, count)
|
||||
: null,
|
||||
);
|
||||
},
|
||||
itemWrap: enableDragAndDrop
|
||||
? (child, container) {
|
||||
return TabDragContainerTarget(
|
||||
container: container,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
: null,
|
||||
prefixListItems: [
|
||||
if (showSyncedChip)
|
||||
_SyncedTabsChip(
|
||||
selected: syncedChipSelected,
|
||||
count: syncedTabCount,
|
||||
onSelected: onSyncedChipSelected ?? () {},
|
||||
),
|
||||
if (showUnassignedChip)
|
||||
enableDragAndDrop
|
||||
? TabDragContainerTarget(
|
||||
container: null,
|
||||
child: _UnassignedContainerChip(
|
||||
containerBadgeCount: () =>
|
||||
containerBadgeCount?.call(null),
|
||||
selected:
|
||||
unassignedChipSelected ??
|
||||
(selectedContainer == null &&
|
||||
!syncedChipSelected),
|
||||
onSelected: onSelected,
|
||||
),
|
||||
)
|
||||
: _UnassignedContainerChip(
|
||||
containerBadgeCount: () =>
|
||||
containerBadgeCount?.call(null),
|
||||
selected:
|
||||
@@ -287,35 +466,26 @@ class ContainerChips extends HookConsumerWidget {
|
||||
!syncedChipSelected),
|
||||
onSelected: onSelected,
|
||||
),
|
||||
)
|
||||
: _UnassignedContainerChip(
|
||||
containerBadgeCount: () =>
|
||||
containerBadgeCount?.call(null),
|
||||
selected:
|
||||
unassignedChipSelected ??
|
||||
(selectedContainer == null &&
|
||||
!syncedChipSelected),
|
||||
onSelected: onSelected,
|
||||
),
|
||||
if (showGroupSuggestions)
|
||||
const _ContainerSuggestionsChip(),
|
||||
],
|
||||
availableItems: availableContainers,
|
||||
selectedItem: selectedContainer,
|
||||
onSelected: onSelected,
|
||||
onDeleted: onDeleted,
|
||||
onLongPress: onLongPress,
|
||||
),
|
||||
),
|
||||
if (displayMenu)
|
||||
IconButton(
|
||||
// visualDensity: VisualDensity.compact,
|
||||
onPressed: () async {
|
||||
await const ContainerListRoute().push(context);
|
||||
},
|
||||
icon: const Icon(Icons.chevron_right),
|
||||
if (showGroupSuggestions)
|
||||
const _ContainerSuggestionsChip(),
|
||||
],
|
||||
availableItems: availableContainers,
|
||||
selectedItem: selectedContainer,
|
||||
onSelected: onSelected,
|
||||
onDeleted: onDeleted,
|
||||
onLongPress: onLongPress,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (displayMenu)
|
||||
IconButton(
|
||||
// visualDensity: VisualDensity.compact,
|
||||
onPressed: () async {
|
||||
await const ContainerListRoute().push(context);
|
||||
},
|
||||
icon: const Icon(Icons.chevron_right),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
+234
@@ -0,0 +1,234 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/utils/container_colors.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/utils/container_icons.dart';
|
||||
|
||||
final List<ContainerIconOption> _mdiContainerIconOptions = List.unmodifiable([
|
||||
for (final iconData in MdiIcons.values)
|
||||
if (iconData.mdiMetadata case final metadata?)
|
||||
ContainerIconOption(
|
||||
iconData: iconData,
|
||||
name: metadata.name,
|
||||
searchText: [
|
||||
metadata.name,
|
||||
...?metadata.tags,
|
||||
...?metadata.styles,
|
||||
].join(' ').toLowerCase(),
|
||||
),
|
||||
]);
|
||||
|
||||
class ContainerIconPickerSheet extends HookWidget {
|
||||
const ContainerIconPickerSheet({
|
||||
required this.selectedColor,
|
||||
required this.selectedIcon,
|
||||
required this.onSelected,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final Color selectedColor;
|
||||
final IconData selectedIcon;
|
||||
final ValueChanged<IconData> onSelected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final palette = ContainerColors.palette(context, selectedColor);
|
||||
final searchController = useTextEditingController();
|
||||
useListenable(searchController);
|
||||
|
||||
final query = searchController.text.trim().toLowerCase();
|
||||
final filteredIcons = useMemoized(() {
|
||||
if (query.isEmpty) {
|
||||
return _mdiContainerIconOptions;
|
||||
}
|
||||
|
||||
return _mdiContainerIconOptions
|
||||
.where((icon) => icon.searchText.contains(query))
|
||||
.toList(growable: false);
|
||||
}, [query]);
|
||||
|
||||
return Material(
|
||||
color: theme.colorScheme.surface,
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.viewInsetsOf(context).bottom,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
width: 32,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.onSurfaceVariant.withValues(
|
||||
alpha: 0.4,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 20, 20, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Choose Icon',
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
Text(
|
||||
'${filteredIcons.length} mdi icons',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: palette.avatarBackgroundColor,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(
|
||||
color: palette.outlineColor,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.all(10),
|
||||
child: Icon(
|
||||
selectedIcon,
|
||||
color: palette.avatarForegroundColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: SearchBar(
|
||||
controller: searchController,
|
||||
hintText: 'Search MDI icons',
|
||||
leading: const Icon(Icons.search),
|
||||
trailing: [
|
||||
if (searchController.text.isNotEmpty)
|
||||
IconButton(
|
||||
onPressed: searchController.clear,
|
||||
icon: const Icon(Icons.close),
|
||||
),
|
||||
],
|
||||
elevation: const WidgetStatePropertyAll(0),
|
||||
backgroundColor: WidgetStatePropertyAll(
|
||||
theme.colorScheme.surfaceContainerHigh,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: filteredIcons.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
'No icons found.',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
)
|
||||
: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final columnCount = (constraints.maxWidth / 76)
|
||||
.floor()
|
||||
.clamp(4, 7);
|
||||
|
||||
return GridView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 24),
|
||||
keyboardDismissBehavior:
|
||||
ScrollViewKeyboardDismissBehavior.onDrag,
|
||||
gridDelegate:
|
||||
SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: columnCount,
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisSpacing: 12,
|
||||
),
|
||||
itemCount: filteredIcons.length,
|
||||
itemBuilder: (context, index) {
|
||||
final option = filteredIcons[index];
|
||||
final isSelected =
|
||||
option.iconData == selectedIcon;
|
||||
|
||||
return Tooltip(
|
||||
message: option.name,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
onTap: () => onSelected(option.iconData),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(
|
||||
milliseconds: 160,
|
||||
),
|
||||
curve: Curves.easeInOut,
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? palette.surfaceHighColor
|
||||
: theme
|
||||
.colorScheme
|
||||
.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
border: isSelected
|
||||
? Border.all(
|
||||
color: palette.outlineColor,
|
||||
width: 2,
|
||||
)
|
||||
: Border.all(
|
||||
color: theme
|
||||
.colorScheme
|
||||
.outlineVariant
|
||||
.withValues(alpha: 0.35),
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
option.iconData,
|
||||
color: isSelected
|
||||
? palette.avatarForegroundColor
|
||||
: theme
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+8
-3
@@ -22,6 +22,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_title.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/utils/container_colors.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/utils/container_icons.dart';
|
||||
|
||||
class ContainerListTile extends HookWidget {
|
||||
final ContainerData container;
|
||||
@@ -37,13 +38,17 @@ class ContainerListTile extends HookWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final palette = ContainerColors.palette(context, container.color);
|
||||
|
||||
return ListTileTheme(
|
||||
selectedColor: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
selectedTileColor: Theme.of(context).colorScheme.primaryContainer,
|
||||
selectedColor: palette.onContainerColor,
|
||||
selectedTileColor: palette.containerColor,
|
||||
child: ListTile(
|
||||
selected: isSelected,
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: ContainerColors.preview(container.color),
|
||||
backgroundColor: palette.avatarBackgroundColor,
|
||||
foregroundColor: palette.avatarForegroundColor,
|
||||
child: Icon(resolveContainerIcon(container.metadata.iconData)),
|
||||
),
|
||||
title: ContainerTitle(container: container),
|
||||
onTap: onTap,
|
||||
|
||||
+17
-6
@@ -33,6 +33,7 @@ class MaterialPicker extends StatefulWidget {
|
||||
this.enableLabel = false,
|
||||
this.portraitOnly = false,
|
||||
this.displayAlpha,
|
||||
this.displayColorBuilder,
|
||||
});
|
||||
|
||||
final Color pickerColor;
|
||||
@@ -41,6 +42,7 @@ class MaterialPicker extends StatefulWidget {
|
||||
final bool enableLabel;
|
||||
final bool portraitOnly;
|
||||
final double? displayAlpha;
|
||||
final Color Function(BuildContext context, Color color)? displayColorBuilder;
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => _MaterialPickerState();
|
||||
@@ -72,6 +74,17 @@ class _MaterialPickerState extends State<MaterialPicker> {
|
||||
MediaQuery.of(context).orientation == Orientation.portrait ||
|
||||
widget.portraitOnly;
|
||||
|
||||
Color resolveDisplayColor(Color color) {
|
||||
final displayColorBuilder = widget.displayColorBuilder;
|
||||
if (displayColorBuilder != null) {
|
||||
return displayColorBuilder(context, color);
|
||||
}
|
||||
|
||||
return widget.displayAlpha != null
|
||||
? color.withValues(alpha: widget.displayAlpha)
|
||||
: color;
|
||||
}
|
||||
|
||||
Widget colorList() {
|
||||
return Container(
|
||||
clipBehavior: Clip.hardEdge,
|
||||
@@ -127,9 +140,9 @@ class _MaterialPickerState extends State<MaterialPicker> {
|
||||
const Padding(padding: EdgeInsets.only(left: 7)),
|
||||
...colorTypes.map((List<Color> colors) {
|
||||
final Color colorType = colors[0];
|
||||
final Color displayColorType = widget.displayAlpha != null
|
||||
? colorType.withValues(alpha: widget.displayAlpha)
|
||||
: colorType;
|
||||
final Color displayColorType = resolveDisplayColor(
|
||||
colorType,
|
||||
);
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
if (widget.onPrimaryChanged != null) {
|
||||
@@ -219,9 +232,7 @@ class _MaterialPickerState extends State<MaterialPicker> {
|
||||
Map<Color, String> colors,
|
||||
) {
|
||||
final Color color = colors.keys.first;
|
||||
final Color displayColor = widget.displayAlpha != null
|
||||
? color.withValues(alpha: widget.displayAlpha)
|
||||
: color;
|
||||
final Color displayColor = resolveDisplayColor(color);
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
setState(() => _currentShading = color);
|
||||
|
||||
+16
-15
@@ -38,6 +38,7 @@ class TabDragContainerTarget extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final overlayController = useOverlayPortalController();
|
||||
final layerLink = useMemoized(LayerLink.new);
|
||||
|
||||
return DragTarget<TabDragData>(
|
||||
onMove: (details) {
|
||||
@@ -67,28 +68,28 @@ class TabDragContainerTarget extends HookConsumerWidget {
|
||||
}
|
||||
},
|
||||
builder: (context, candidateData, rejectedData) {
|
||||
final renderBox = context.findRenderObject() as RenderBox?;
|
||||
final position = renderBox?.localToGlobal(Offset.zero) ?? Offset.zero;
|
||||
|
||||
return OverlayPortal(
|
||||
controller: overlayController,
|
||||
overlayChildBuilder: (context) => Positioned(
|
||||
top: position.dy,
|
||||
left: position.dx,
|
||||
overlayChildBuilder: (context) => CompositedTransformFollower(
|
||||
link: layerLink,
|
||||
showWhenUnlinked: false,
|
||||
child: IgnorePointer(
|
||||
child: Transform.scale(scale: 1.1, child: child),
|
||||
),
|
||||
),
|
||||
child: Consumer(
|
||||
child: child,
|
||||
builder: (context, ref, child) {
|
||||
final dragTabId = ref.watch(willAcceptDropProvider);
|
||||
child: CompositedTransformTarget(
|
||||
link: layerLink,
|
||||
child: Consumer(
|
||||
child: child,
|
||||
builder: (context, ref, child) {
|
||||
final dragTabId = ref.watch(willAcceptDropProvider);
|
||||
|
||||
return Opacity(
|
||||
opacity: (dragTabId == null) ? 1.0 : 0.0,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
return Opacity(
|
||||
opacity: (dragTabId == null) ? 1.0 : 0.0,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -19,55 +19,101 @@
|
||||
*/
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ContainerColorPalette {
|
||||
const ContainerColorPalette({
|
||||
required this.accentColor,
|
||||
required this.onAccentColor,
|
||||
required this.containerColor,
|
||||
required this.onContainerColor,
|
||||
required this.surfaceColor,
|
||||
required this.surfaceHighColor,
|
||||
required this.outlineColor,
|
||||
required this.backgroundColor,
|
||||
required this.selectedBackgroundColor,
|
||||
required this.borderSide,
|
||||
required this.selectedBorderSide,
|
||||
required this.foregroundColor,
|
||||
required this.selectedForegroundColor,
|
||||
required this.badgeBackgroundColor,
|
||||
required this.badgeForegroundColor,
|
||||
required this.avatarColor,
|
||||
required this.selectedAvatarColor,
|
||||
required this.avatarBackgroundColor,
|
||||
required this.avatarForegroundColor,
|
||||
});
|
||||
|
||||
final Color accentColor;
|
||||
final Color onAccentColor;
|
||||
final Color containerColor;
|
||||
final Color onContainerColor;
|
||||
final Color surfaceColor;
|
||||
final Color surfaceHighColor;
|
||||
final Color outlineColor;
|
||||
final Color backgroundColor;
|
||||
final Color selectedBackgroundColor;
|
||||
final BorderSide borderSide;
|
||||
final BorderSide selectedBorderSide;
|
||||
final Color foregroundColor;
|
||||
final Color selectedForegroundColor;
|
||||
final Color badgeBackgroundColor;
|
||||
final Color badgeForegroundColor;
|
||||
final Color avatarColor;
|
||||
final Color selectedAvatarColor;
|
||||
final Color avatarBackgroundColor;
|
||||
final Color avatarForegroundColor;
|
||||
}
|
||||
|
||||
/// Centralized helper for container color display and theming.
|
||||
///
|
||||
/// This class handles the conversion of stored container colors (full opacity)
|
||||
/// to their display variants (with transparency) for consistent appearance
|
||||
/// across the application.
|
||||
/// This class converts the stored container seed color into Material 3 roles
|
||||
/// used consistently across the application.
|
||||
class ContainerColors {
|
||||
ContainerColors._();
|
||||
|
||||
/// Default alpha value for container color display (33% opacity)
|
||||
static const double defaultAlpha = 0.33;
|
||||
static const double surfaceAlpha = 0.18;
|
||||
static const double surfaceHighAlpha = 0.28;
|
||||
static const double outlineBorderAlpha = 0.5;
|
||||
|
||||
/// Returns the display color for container chips and backgrounds.
|
||||
///
|
||||
/// Applies semi-transparent overlay that works well for backgrounds
|
||||
/// while maintaining color distinction between containers.
|
||||
///
|
||||
/// [baseColor] The stored container color (typically full opacity)
|
||||
static Color forChip(Color baseColor) {
|
||||
return baseColor.withValues(alpha: defaultAlpha);
|
||||
}
|
||||
static ContainerColorPalette palette(BuildContext context, Color seedColor) {
|
||||
final theme = Theme.of(context);
|
||||
final appScheme = theme.colorScheme;
|
||||
final containerScheme = ColorScheme.fromSeed(
|
||||
seedColor: fullOpacity(seedColor),
|
||||
brightness: theme.brightness,
|
||||
);
|
||||
final surfaceColor = Color.alphaBlend(
|
||||
containerScheme.primaryContainer.withValues(alpha: surfaceAlpha),
|
||||
appScheme.surfaceContainer,
|
||||
);
|
||||
final surfaceHighColor = Color.alphaBlend(
|
||||
containerScheme.primaryContainer.withValues(alpha: surfaceHighAlpha),
|
||||
appScheme.surfaceContainerHighest,
|
||||
);
|
||||
final outlineColor = containerScheme.primary.withValues(
|
||||
alpha: outlineBorderAlpha,
|
||||
);
|
||||
|
||||
/// Returns the display color for container app bar backgrounds.
|
||||
///
|
||||
/// Uses the same transparency as chips for visual consistency.
|
||||
///
|
||||
/// [baseColor] The stored container color (typically full opacity)
|
||||
static Color forAppBar(Color baseColor) {
|
||||
return baseColor.withValues(alpha: defaultAlpha);
|
||||
}
|
||||
|
||||
/// Returns the display color with theme-aware blending.
|
||||
///
|
||||
/// Uses Material Design's color blending algorithm for proper color mixing
|
||||
/// with the surface color, ensuring better visual appearance across themes.
|
||||
///
|
||||
/// [baseColor] The stored container color (typically full opacity)
|
||||
/// [surface] The surface color to blend with (typically from theme)
|
||||
static Color forSurface(Color baseColor, Color surface) {
|
||||
return Color.alphaBlend(baseColor.withValues(alpha: defaultAlpha), surface);
|
||||
}
|
||||
|
||||
/// Returns the preview color showing how the color will appear in the UI.
|
||||
///
|
||||
/// This is useful in color pickers to show users the actual appearance
|
||||
/// before they confirm their selection.
|
||||
///
|
||||
/// [baseColor] The color being previewed
|
||||
static Color preview(Color baseColor) {
|
||||
return baseColor.withValues(alpha: defaultAlpha);
|
||||
return ContainerColorPalette(
|
||||
accentColor: containerScheme.primary,
|
||||
onAccentColor: containerScheme.onPrimary,
|
||||
containerColor: containerScheme.primaryContainer,
|
||||
onContainerColor: containerScheme.onPrimaryContainer,
|
||||
surfaceColor: surfaceColor,
|
||||
surfaceHighColor: surfaceHighColor,
|
||||
outlineColor: outlineColor,
|
||||
backgroundColor: surfaceColor,
|
||||
selectedBackgroundColor: containerScheme.primaryContainer,
|
||||
borderSide: BorderSide(color: outlineColor),
|
||||
selectedBorderSide: const BorderSide(color: Colors.transparent),
|
||||
foregroundColor: appScheme.onSurfaceVariant,
|
||||
selectedForegroundColor: containerScheme.onPrimaryContainer,
|
||||
badgeBackgroundColor: containerScheme.primary,
|
||||
badgeForegroundColor: containerScheme.onPrimary,
|
||||
avatarColor: containerScheme.primary,
|
||||
selectedAvatarColor: containerScheme.onPrimaryContainer,
|
||||
avatarBackgroundColor: surfaceHighColor,
|
||||
avatarForegroundColor: containerScheme.primary,
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns the full opacity version of a container color.
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
|
||||
|
||||
const IconData defaultContainerIcon = MdiIcons.folderOutline;
|
||||
|
||||
/// Always returns a non-null icon, defaulting to [defaultContainerIcon]
|
||||
/// when the container has no icon set. Use this for any rendering surface
|
||||
/// where the slot can't be empty (settings rows, full-size container
|
||||
/// displays, the icon-picker preview).
|
||||
IconData resolveContainerIcon(IconData? iconData) {
|
||||
return iconData ?? defaultContainerIcon;
|
||||
}
|
||||
|
||||
bool isDefaultContainerIcon(IconData? iconData) {
|
||||
return resolveContainerIcon(iconData) == defaultContainerIcon;
|
||||
}
|
||||
|
||||
/// Returns null when the container is using the default folder icon, and
|
||||
/// the resolved icon otherwise. Use this for compact surfaces (chips,
|
||||
/// breadcrumb-style strips) that should omit the icon slot rather than
|
||||
/// render the generic folder placeholder.
|
||||
IconData? chipContainerIcon(IconData? iconData) {
|
||||
return isDefaultContainerIcon(iconData)
|
||||
? null
|
||||
: resolveContainerIcon(iconData);
|
||||
}
|
||||
|
||||
class ContainerIconOption {
|
||||
const ContainerIconOption({
|
||||
required this.iconData,
|
||||
required this.name,
|
||||
required this.searchText,
|
||||
});
|
||||
|
||||
final IconData iconData;
|
||||
final String name;
|
||||
final String searchText;
|
||||
}
|
||||
+44
@@ -52,6 +52,11 @@ extension DefineFunctions on i6.CommonDatabase {
|
||||
required String Function(int, String?) lexoRankPrevious,
|
||||
required String Function(String?, String?) lexoRankReorderAfter,
|
||||
required String Function(String?, String?) lexoRankReorderBefore,
|
||||
required int Function() generateContentHash,
|
||||
required bool Function(String?) urlIndexable,
|
||||
required String Function(String?) urlCanonical,
|
||||
required String Function(String?) urlHost,
|
||||
required String Function(String?) urlPath,
|
||||
}) {
|
||||
createFunction(
|
||||
functionName: 'lexo_rank_next',
|
||||
@@ -89,5 +94,44 @@ extension DefineFunctions on i6.CommonDatabase {
|
||||
return lexoRankReorderBefore(arg0, arg1);
|
||||
},
|
||||
);
|
||||
createFunction(
|
||||
functionName: 'generate_content_hash',
|
||||
argumentCount: const i6.AllowedArgumentCount(0),
|
||||
function: (args) {
|
||||
return generateContentHash();
|
||||
},
|
||||
);
|
||||
createFunction(
|
||||
functionName: 'url_indexable',
|
||||
argumentCount: const i6.AllowedArgumentCount(1),
|
||||
function: (args) {
|
||||
final arg0 = args[0] as String?;
|
||||
return urlIndexable(arg0);
|
||||
},
|
||||
);
|
||||
createFunction(
|
||||
functionName: 'url_canonical',
|
||||
argumentCount: const i6.AllowedArgumentCount(1),
|
||||
function: (args) {
|
||||
final arg0 = args[0] as String?;
|
||||
return urlCanonical(arg0);
|
||||
},
|
||||
);
|
||||
createFunction(
|
||||
functionName: 'url_host',
|
||||
argumentCount: const i6.AllowedArgumentCount(1),
|
||||
function: (args) {
|
||||
final arg0 = args[0] as String?;
|
||||
return urlHost(arg0);
|
||||
},
|
||||
);
|
||||
createFunction(
|
||||
functionName: 'url_path',
|
||||
argumentCount: const i6.AllowedArgumentCount(1),
|
||||
function: (args) {
|
||||
final arg0 = args[0] as String?;
|
||||
return urlPath(arg0);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user