Add Supa account and search changes

This commit is contained in:
Fabian Freund
2026-05-22 18:10:22 +02:00
parent 3a19865b2e
commit 51289f1266
374 changed files with 54061 additions and 5013 deletions
@@ -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
@@ -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:
@@ -42,7 +42,7 @@ final class BrowserDataServiceProvider
}
String _$browserDataServiceHash() =>
r'861943be10c4dea325484b6a28ba21f3ff0d29fa';
r'b6586d14ef13ab2905072e1e26cabc17846eaf73';
abstract class _$BrowserDataService extends $Notifier<void> {
void build();
@@ -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 {
@@ -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),
@@ -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),
@@ -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),
@@ -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,
@@ -53,4 +53,4 @@ final class ShowSiteSettingsBadgeProvider
}
String _$showSiteSettingsBadgeHash() =>
r'fbfe409cdd6656d5cd26e377a68acde6ff4294fa';
r'6345920e4391786e47a89964c7ffa75818732584';
@@ -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;
}
@@ -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),
@@ -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()),
],
),
],
@@ -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;
}
@@ -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,
@@ -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
@@ -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(
@@ -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,
);
@@ -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,
);
@@ -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);
},
@@ -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(
@@ -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()
@@ -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'),
@@ -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],
);
},
),
);
},
@@ -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,
@@ -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,
);
@@ -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) =>
@@ -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));
@@ -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() {}
}
@@ -41,7 +41,7 @@ final class HistoryRepositoryProvider
}
}
String _$historyRepositoryHash() => r'414b68ec6be3cc2eca3681cbc00990e53e6127ce';
String _$historyRepositoryHash() => r'4fec6cf4ef7cdfcabf3ccfd4a43fbf634bfe377f';
abstract class _$HistoryRepository extends $Notifier<void> {
void build();
@@ -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) {
@@ -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,
);
},
);
},
),
@@ -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,
);
}
}
@@ -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.
@@ -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;
}
@@ -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';
@@ -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;
}
}
@@ -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);
}
}
@@ -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;
@@ -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',
};
// **************************************************************************
@@ -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,
};
}
@@ -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(),
],
),
);
},
);
}
}
@@ -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),
),
],
),
);
}
}
@@ -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,
),
),
),
],
);
}
}
@@ -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),
),
),
],
);
}
}
@@ -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),
);
},
@@ -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: () {
@@ -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,
),
);
},
),
],
);
}
}
@@ -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,
),
);
}
}
@@ -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,
),
);
}
}
@@ -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);
}
},
);
},
@@ -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()),
),
),
),
],
);
}
}
@@ -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),
@@ -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(
@@ -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,
),
),
),
],
);
}
}
@@ -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,
),
);
}
}
@@ -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,
),
),
],
);
}
}
@@ -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),
);
}
@@ -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);
}
@@ -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],
);
}
@@ -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 510x 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();
}
},
);
}
@@ -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,
),
);
@@ -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(
@@ -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,
};
@@ -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';
@@ -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 {
@@ -42,7 +42,7 @@ final class ContainerRepositoryProvider
}
String _$containerRepositoryHash() =>
r'55c3e897f4f42d01dab43114ef00075536b74cf7';
r'a708f298d7bd50f1823029e00f8ed295b9f81f4e';
abstract class _$ContainerRepository extends $Notifier<void> {
void build();
@@ -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();
}
}
@@ -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);
}
}
@@ -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() {}
}
@@ -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);
}
}
@@ -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,
);
}
}
@@ -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);
}
}
@@ -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,
),
),
),
),
),
],
),
),
);
@@ -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)],
),
),
);
}
}
@@ -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)],
),
),
);
}
}
@@ -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(
@@ -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) {
@@ -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],
],
),
),
);
}
@@ -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),
),
],
),
),
);
},
@@ -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,
),
),
),
),
);
},
);
},
),
),
],
),
),
),
);
}
}
@@ -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,
@@ -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);
@@ -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;
}
@@ -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);
},
);
}
}