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
@@ -20,6 +20,7 @@
// ignore_for_file: deprecated_member_use
import 'package:fading_scroll/fading_scroll.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:google_fonts/google_fonts.dart';
@@ -122,57 +123,63 @@ class LogDetailsDialog extends StatelessWidget {
),
content: SizedBox(
width: double.maxFinite,
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (message.isNotEmpty) ...[
Text(
'Message:',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(height: 4),
SelectableText(
message,
style: GoogleFonts.robotoMono(fontSize: 12),
),
const SizedBox(height: 16),
],
if (error != null) ...[
Text(
'Error:',
style: TextStyle(
fontWeight: FontWeight.bold,
color: _levelColor(level),
),
),
const SizedBox(height: 4),
SelectableText(
error!,
style: GoogleFonts.robotoMono(fontSize: 11),
),
const SizedBox(height: 16),
],
if (stackTrace != null) ...[
Text(
'Stack Trace:',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(height: 4),
SelectableText(
stackTrace!,
style: GoogleFonts.robotoMono(fontSize: 9),
),
],
],
),
child: FadingScroll(
fadingSize: 25,
builder: (context, controller) {
return SingleChildScrollView(
controller: controller,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (message.isNotEmpty) ...[
Text(
'Message:',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(height: 4),
SelectableText(
message,
style: GoogleFonts.robotoMono(fontSize: 12),
),
const SizedBox(height: 16),
],
if (error != null) ...[
Text(
'Error:',
style: TextStyle(
fontWeight: FontWeight.bold,
color: _levelColor(level),
),
),
const SizedBox(height: 4),
SelectableText(
error!,
style: GoogleFonts.robotoMono(fontSize: 11),
),
const SizedBox(height: 16),
],
if (stackTrace != null) ...[
Text(
'Stack Trace:',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(height: 4),
SelectableText(
stackTrace!,
style: GoogleFonts.robotoMono(fontSize: 9),
),
],
],
),
);
},
),
),
actions: [
@@ -22,6 +22,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart';
import 'package:weblibre/features/user/data/models/engine_settings.dart';
import 'package:weblibre/features/user/domain/repositories/engine_settings.dart';
import 'package:weblibre/utils/exit_app.dart';
@@ -57,95 +58,141 @@ class AddonCollectionScreen extends HookConsumerWidget {
keys: [addonCollectionSetting],
);
return Scaffold(
appBar: AppBar(
title: const Text('Custom Extension Collection'),
actions: [
if (addonCollectionSetting != null)
IconButton(
onPressed: () async {
await ref
.read(engineSettingsRepositoryProvider.notifier)
.updateSettings(
(currentSettings) =>
currentSettings.copyWith.addonCollection(null),
);
return SettingsCustomScrollScaffold(
title: 'Custom Extension Collection',
actions: [
if (addonCollectionSetting != null)
IconButton(
onPressed: () async {
await ref
.read(engineSettingsRepositoryProvider.notifier)
.updateSettings(
(currentSettings) =>
currentSettings.copyWith.addonCollection(null),
);
await exitApp(ref.container);
},
icon: const Icon(Icons.delete),
),
],
),
body: Form(
key: formKey,
child: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: ListView(
children: [
TextFormField(
controller: serverURLController,
decoration: const InputDecoration(
label: Text('Server URL'),
hintText: _defaultServerUrl,
floatingLabelBehavior: FloatingLabelBehavior.always,
),
keyboardType: TextInputType.url,
validator: (value) {
return validateUrl(
value,
onlyHttpProtocol: true,
eagerParsing: false,
);
},
),
const SizedBox(height: 8),
TextFormField(
controller: collectionUserController,
decoration: const InputDecoration(
label: Text('Collection User'),
floatingLabelBehavior: FloatingLabelBehavior.always,
),
validator: validateRequired,
),
const SizedBox(height: 8),
TextFormField(
controller: collectionNameController,
decoration: const InputDecoration(
label: Text('Collection Name'),
floatingLabelBehavior: FloatingLabelBehavior.always,
),
validator: validateRequired,
),
const SizedBox(height: 32),
FilledButton(
onPressed: () async {
if (formKey.currentState?.validate() == true) {
await ref
.read(engineSettingsRepositoryProvider.notifier)
.updateSettings(
(
currentSettings,
) => currentSettings.copyWith.addonCollection(
AddonCollection(
serverURL: serverURLController.text,
collectionUser: collectionUserController.text,
collectionName: collectionNameController.text,
await exitApp(ref.container);
},
icon: const Icon(Icons.delete),
),
],
slivers: [
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 24, 16, 20),
sliver: SliverToBoxAdapter(
child: Form(
key: formKey,
child: SettingsSectionList(
sections: [
SettingsSectionDefinition(
title: 'Collection Source',
entries: [
SettingsEntryDefinition(
title: 'Collection configuration',
subtitle:
'Mozilla server, collection owner, and collection name',
keywords: const ['addons', 'collection'],
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
TextFormField(
controller: serverURLController,
decoration: const InputDecoration(
label: Text('Server URL'),
hintText: _defaultServerUrl,
floatingLabelBehavior:
FloatingLabelBehavior.always,
),
keyboardType: TextInputType.url,
validator: (value) {
return validateUrl(
value,
onlyHttpProtocol: true,
eagerParsing: false,
);
},
),
),
);
const SizedBox(height: 8),
TextFormField(
controller: collectionUserController,
decoration: const InputDecoration(
label: Text('Collection User'),
floatingLabelBehavior:
FloatingLabelBehavior.always,
),
validator: validateRequired,
),
const SizedBox(height: 8),
TextFormField(
controller: collectionNameController,
decoration: const InputDecoration(
label: Text('Collection Name'),
floatingLabelBehavior:
FloatingLabelBehavior.always,
),
validator: validateRequired,
),
],
),
),
),
],
),
SettingsSectionDefinition(
title: 'Actions',
entries: [
SettingsEntryDefinition(
title: 'Save & Restart Browser',
subtitle:
'Apply the custom collection and restart the browser',
keywords: const ['restart'],
child: Padding(
padding: const EdgeInsets.all(16.0),
child: SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: () async {
if (formKey.currentState?.validate() == true) {
await ref
.read(
engineSettingsRepositoryProvider
.notifier,
)
.updateSettings(
(currentSettings) => currentSettings
.copyWith
.addonCollection(
AddonCollection(
serverURL:
serverURLController.text,
collectionUser:
collectionUserController
.text,
collectionName:
collectionNameController
.text,
),
),
);
await exitApp(ref.container);
}
},
child: const Text('Save & Restart Browser'),
),
],
await exitApp(ref.container);
}
},
child: const Text('Save & Restart Browser'),
),
),
),
),
],
),
],
query: '',
),
),
),
),
),
],
);
}
}
@@ -19,7 +19,6 @@
*/
import 'dart:developer';
import 'package:fading_scroll/fading_scroll.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@@ -32,7 +31,7 @@ import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart';
import 'package:weblibre/features/settings/presentation/dialogs/user_agent_restart_dialog.dart';
import 'package:weblibre/features/settings/presentation/widgets/custom_list_tile.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/engine_settings.dart';
import 'package:weblibre/features/user/domain/providers.dart';
import 'package:weblibre/features/user/domain/repositories/cache.dart';
@@ -40,76 +39,84 @@ import 'package:weblibre/features/user/domain/repositories/engine_settings.dart'
import 'package:weblibre/utils/exit_app.dart';
import 'package:weblibre/utils/ui_helper.dart';
const List<SettingsSectionDefinition> advancedSettingsSections = [
SettingsSectionDefinition(
title: 'Content & Identity',
keywords: ['engine'],
entries: [
SettingsEntryDefinition(
title: 'Enable JavaScript',
subtitle: 'Turn website scripting on or off',
keywords: ['javascript'],
child: _JavaScriptTile(),
),
SettingsEntryDefinition(
title: 'Custom User Agent',
subtitle: 'Override the browser user agent string',
keywords: ['ua'],
child: _UserAgentTile(),
),
SettingsEntryDefinition(
title: 'Use third party CA certificates',
subtitle: 'Allow Android CA store certificates',
keywords: ['certificates', 'enterprise roots', 'ca'],
child: _EnterpriseRootsTile(),
),
],
),
SettingsSectionDefinition(
title: 'Experimental',
entries: [
SettingsEntryDefinition(
title: 'Experimental Features',
subtitle: 'Low-level runtime features and startup behavior',
keywords: ['runtime', 'startup'],
child: _ExperimentalSettingsTile(),
),
],
),
SettingsSectionDefinition(
title: 'Developer Tools',
keywords: ['debug'],
entries: [
SettingsEntryDefinition(
title: 'Icon Cache',
subtitle: 'Stored favicons',
keywords: ['favicons', 'cache'],
child: _IconCacheTile(),
),
SettingsEntryDefinition(
title: 'Error Logs',
subtitle: 'View and copy logs for issue reporting',
keywords: ['logs'],
child: _ErrorLogsTile(),
),
SettingsEntryDefinition(
title: 'Dart VM',
subtitle: 'Copy Dart VM service URL',
keywords: ['service url'],
child: _DartVmTile(),
),
SettingsEntryDefinition(
title: 'Reset UI',
subtitle: 'Rebuild the entire browser UI',
keywords: ['refresh ui'],
child: _ResetUITile(),
),
],
),
];
class AdvancedSettingsScreen extends StatelessWidget {
const AdvancedSettingsScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Advanced')),
body: SafeArea(
child: FadingScroll(
fadingSize: 25,
builder: (context, controller) {
return ListView(
controller: controller,
padding: const EdgeInsets.symmetric(horizontal: 12.0),
children: const [
_ContentIdentitySection(),
_ExperimentalSection(),
_DeveloperToolsSection(),
],
);
},
),
),
);
}
}
class _ContentIdentitySection extends StatelessWidget {
const _ContentIdentitySection();
@override
Widget build(BuildContext context) {
return const Column(
children: [
SettingSection(name: 'Content & Identity'),
_JavaScriptTile(),
_UserAgentTile(),
_EnterpriseRootsTile(),
],
);
}
}
class _ExperimentalSection extends StatelessWidget {
const _ExperimentalSection();
@override
Widget build(BuildContext context) {
return const Column(
children: [
SettingSection(name: 'Experimental'),
_ExperimentalSettingsTile(),
],
);
}
}
class _DeveloperToolsSection extends StatelessWidget {
const _DeveloperToolsSection();
@override
Widget build(BuildContext context) {
return const Column(
children: [
SettingSection(name: 'Developer Tools'),
_IconCacheTile(),
_ErrorLogsTile(),
_DartVmTile(),
_ResetUITile(),
],
return const SettingsDetailScaffold(
title: 'Advanced',
subtitle: 'Engine behavior, runtime overrides, and developer tools.',
icon: MdiIcons.tuneVertical,
sections: advancedSettingsSections,
);
}
}
@@ -17,57 +17,84 @@
* 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_material_design_icons/flutter_material_design_icons.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/features/bangs/data/models/bang_group.dart';
import 'package:weblibre/features/bangs/domain/repositories/data.dart';
import 'package:weblibre/features/settings/presentation/widgets/bang_group_list_tile.dart';
import 'package:weblibre/features/settings/presentation/widgets/custom_list_tile.dart';
import 'package:weblibre/features/settings/presentation/widgets/sections.dart';
import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart';
const List<SettingsSectionDefinition> bangSettingsSections = [
SettingsSectionDefinition(
title: 'Usage Data',
entries: [
SettingsEntryDefinition(
title: 'Bang Frequencies',
subtitle: 'Tracked usage for bang recommendations',
keywords: ['usage', 'recommendations'],
child: _BangFrequenciesTile(),
),
],
),
SettingsSectionDefinition(
title: 'Repositories',
entries: [
SettingsEntryDefinition(
title: 'General Bangs',
subtitle: 'Sync on demand from GitHub',
keywords: ['repository'],
child: BangGroupListTile(
group: BangGroup.general,
title: 'General Bangs',
subtitle: 'Sync on demand from GitHub',
),
),
SettingsEntryDefinition(
title: 'Kagi Bangs',
subtitle: 'Sync on demand from GitHub',
keywords: ['repository'],
child: BangGroupListTile(
group: BangGroup.kagi,
title: 'Kagi Bangs',
subtitle: 'Sync on-demand from GitHub',
),
),
],
),
];
class BangSettingsScreen extends HookConsumerWidget {
const BangSettingsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
appBar: AppBar(title: const Text('Bang Settings')),
body: SafeArea(
child: FadingScroll(
fadingSize: 25,
builder: (context, controller) {
return ListView(
controller: controller,
children: [
CustomListTile(
title: 'Bang Frequencies',
subtitle: 'Tracked usage for Bang recommendations',
suffix: FilledButton.icon(
onPressed: () async {
await ref
.read(bangDataRepositoryProvider.notifier)
.resetFrequencies();
},
icon: const Icon(Icons.delete),
label: const Text('Clear'),
),
),
const SettingSubSection(name: 'Repositories'),
const BangGroupListTile(
group: BangGroup.general,
title: 'General Bangs',
subtitle: 'Sync on demand from GitHub',
),
const BangGroupListTile(
group: BangGroup.kagi,
title: 'Kagi Bangs',
subtitle: 'Sync on-demand from GitHub',
),
],
);
},
),
return const SettingsDetailScaffold(
title: 'Bang Settings',
subtitle: 'Bang shortcuts usage, repositories, and on-demand sync.',
icon: MdiIcons.exclamationThick,
sections: bangSettingsSections,
);
}
}
class _BangFrequenciesTile extends HookConsumerWidget {
const _BangFrequenciesTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
return CustomListTile(
title: 'Bang Frequencies',
subtitle: 'Tracked usage for bang recommendations',
suffix: FilledButton.icon(
onPressed: () async {
await ref
.read(bangDataRepositoryProvider.notifier)
.resetFrequencies();
},
icon: const Icon(Icons.delete),
label: const Text('Clear'),
),
);
}
@@ -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_material_design_icons/flutter_material_design_icons.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
@@ -27,87 +26,133 @@ import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/providers.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selected_container.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> browsingSettingsSections = [
SettingsSectionDefinition(
title: 'Tabs',
entries: [
SettingsEntryDefinition(
title: 'New Tab Default',
subtitle: 'Choose the default type for manually created tabs',
keywords: ['regular', 'private', 'isolated'],
child: _NewTabDefaultSection(),
),
SettingsEntryDefinition(
title: 'Small Web Tab Default',
subtitle: 'Choose the tab type used when entering Small Web',
keywords: ['regular', 'private', 'isolated'],
child: _SmallWebTabDefaultSection(),
),
SettingsEntryDefinition(
title: 'Tab List Direction',
subtitle: 'Choose how tabs are ordered in the list view',
keywords: ['sorting', 'order'],
child: _TabListDirectionSection(),
),
SettingsEntryDefinition(
title: 'Tab Bar Direction',
subtitle: 'Choose how tabs are ordered in the tab bar',
keywords: ['sorting', 'order'],
child: _TabBarDirectionSection(),
),
SettingsEntryDefinition(
title: 'Show Container UI',
subtitle: 'Show container selectors, menus, and management',
keywords: ['containers'],
child: _ShowContainerUiTile(),
),
SettingsEntryDefinition(
title: 'Show Isolated Tab UI',
subtitle: 'Show isolated-tab creation options in the UI',
keywords: ['isolated tabs'],
child: _ShowIsolatedTabUiTile(),
),
SettingsEntryDefinition(
title: 'Create Child Tabs',
subtitle: 'Open links from tabs in the same container context',
keywords: ['child tabs'],
child: _CreateChildTabsTile(),
),
],
),
SettingsSectionDefinition(
title: 'Navigation',
entries: [
SettingsEntryDefinition(
title: 'Pull to Refresh',
subtitle: 'Swipe down on pages to reload them',
keywords: ['reload'],
child: _PullToRefreshTile(),
),
SettingsEntryDefinition(
title: 'Double Back to Close Tab',
subtitle: 'Require double back press before closing the current tab',
keywords: ['back button'],
child: _DoubleBackCloseTabTile(),
),
SettingsEntryDefinition(
title: 'Tab Bar Swipe Behavior',
subtitle: 'Choose what horizontal swipes on the tab bar do',
keywords: ['gestures', 'swipe'],
child: _TabBarSwipeBehaviorSection(),
),
SettingsEntryDefinition(
title: 'Open Links in Apps',
subtitle: 'Choose how external app links open',
keywords: ['app links', 'external apps'],
child: _AppLinksModeSection(),
),
],
),
SettingsSectionDefinition(
title: 'Home Screen',
entries: [
SettingsEntryDefinition(
title: 'Install Sites as Apps',
subtitle: 'Allow websites without a manifest to be installed as apps',
keywords: ['pwa', 'web apps'],
child: _AllowNonManifestPwaInstallTile(),
),
],
),
SettingsSectionDefinition(
title: 'External Links',
entries: [
SettingsEntryDefinition(
title: 'External Link Handling',
subtitle: 'Choose how external links open in WebLibre',
keywords: ['intents'],
child: _ExternalLinkHandlingSection(),
),
SettingsEntryDefinition(
title: 'URL Cleaner',
subtitle: 'Tracking removal rules and catalog updates',
keywords: ['utm', 'tracking parameters'],
child: _UrlCleanerSettingsTile(),
),
SettingsEntryDefinition(
title: 'Unshortener',
subtitle: 'Short link resolver and API token',
keywords: ['short links', 'redirects'],
child: _UnshortenerSettingsTile(),
),
],
),
];
class BrowsingSettingsScreen extends StatelessWidget {
const BrowsingSettingsScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Browsing')),
body: SafeArea(
child: FadingScroll(
fadingSize: 25,
builder: (context, controller) {
return ListView(
controller: controller,
padding: const EdgeInsets.symmetric(horizontal: 12.0),
children: const [
_TabsSection(),
_NavigationSection(),
_HomeScreenSection(),
_ExternalLinksSection(),
],
);
},
),
),
);
}
}
class _TabsSection extends StatelessWidget {
const _TabsSection();
@override
Widget build(BuildContext context) {
return const Column(
children: [
SettingSection(name: 'Tabs'),
_NewTabDefaultSection(),
_SmallWebTabDefaultSection(),
_TabListDirectionSection(),
_TabBarDirectionSection(),
_ShowContainerUiTile(),
_ShowIsolatedTabUiTile(),
_CreateChildTabsTile(),
],
);
}
}
class _NavigationSection extends StatelessWidget {
const _NavigationSection();
@override
Widget build(BuildContext context) {
return const Column(
children: [
SettingSection(name: 'Navigation'),
_PullToRefreshTile(),
_DoubleBackCloseTabTile(),
_TabBarSwipeBehaviorSection(),
_AppLinksModeSection(),
],
);
}
}
class _ExternalLinksSection extends StatelessWidget {
const _ExternalLinksSection();
@override
Widget build(BuildContext context) {
return const Column(
children: [
SettingSection(name: 'External Links'),
_ExternalLinkHandlingSection(),
_UrlCleanerSettingsTile(),
_UnshortenerSettingsTile(),
],
return const SettingsDetailScaffold(
title: 'Browsing',
subtitle: 'Tabs, navigation, app links, and Small Web behavior.',
icon: MdiIcons.compassOutline,
sections: browsingSettingsSections,
);
}
}
@@ -275,7 +320,6 @@ class _ExternalLinkHandlingSection extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final appColors = AppColors.of(context);
final settings = ref.watch(generalSettingsWithDefaultsProvider);
final tabIntentOpenSetting = settings.tabIntentOpenSetting;
@@ -291,62 +335,54 @@ class _ExternalLinkHandlingSection extends HookConsumerWidget {
leading: Icon(MdiIcons.tabPlus),
contentPadding: EdgeInsets.zero,
),
Center(
child: SegmentedButton(
showSelectedIcon: false,
segments: [
const ButtonSegment(
value: TabIntentOpenSetting.ask,
label: Text('Prompt'),
icon: Icon(MdiIcons.messageQuestion),
),
const ButtonSegment(
value: TabIntentOpenSetting.regular,
label: Text('Regular'),
icon: Icon(MdiIcons.tab),
),
ButtonSegment(
value: TabIntentOpenSetting.private,
label: const Text('Private'),
icon: Icon(
MdiIcons.dominoMask,
color: tabIntentOpenSetting == TabIntentOpenSetting.private
? null
: appColors.privateTabPurple,
),
),
if (settings.showIsolatedTabUi)
ButtonSegment(
value: TabIntentOpenSetting.isolated,
label: const Text('Isolated'),
icon: Icon(
MdiIcons.snowflake,
color:
tabIntentOpenSetting == TabIntentOpenSetting.isolated
? null
: appColors.isolatedTabTeal,
),
),
],
selected: {tabIntentOpenSetting},
onSelectionChanged: (value) async {
RadioGroup(
groupValue: tabIntentOpenSetting,
onChanged: (value) async {
if (value != null) {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(currentSettings) => currentSettings.copyWith
.tabIntentOpenSetting(value.first),
(currentSettings) =>
currentSettings.copyWith.tabIntentOpenSetting(value),
);
},
style: switch (tabIntentOpenSetting) {
TabIntentOpenSetting.regular => null,
TabIntentOpenSetting.private => SegmentedButton.styleFrom(
selectedBackgroundColor: appColors.privateSelectionOverlay,
}
},
child: Column(
children: [
const RadioListTile.adaptive(
value: TabIntentOpenSetting.ask,
title: Text('Prompt'),
subtitle: Text('Ask how external links should open'),
secondary: Icon(MdiIcons.messageQuestion),
),
TabIntentOpenSetting.isolated => SegmentedButton.styleFrom(
selectedBackgroundColor: appColors.isolatedSelectionOverlay,
const RadioListTile.adaptive(
value: TabIntentOpenSetting.regular,
title: Text('Regular'),
subtitle: Text('Open external links in a regular tab'),
secondary: Icon(MdiIcons.tab),
),
TabIntentOpenSetting.ask => null,
},
RadioListTile.adaptive(
value: TabIntentOpenSetting.private,
title: const Text('Private'),
subtitle: const Text('Open external links in a private tab'),
secondary: Icon(
MdiIcons.dominoMask,
color: AppColors.of(context).privateTabPurple,
),
),
if (settings.showIsolatedTabUi)
RadioListTile.adaptive(
value: TabIntentOpenSetting.isolated,
title: const Text('Isolated'),
subtitle: const Text(
'Open external links in an isolated tab',
),
secondary: Icon(
MdiIcons.snowflake,
color: AppColors.of(context).isolatedTabTeal,
),
),
],
),
),
],
@@ -747,20 +783,6 @@ class _DoubleBackCloseTabTile extends HookConsumerWidget {
}
}
class _HomeScreenSection extends StatelessWidget {
const _HomeScreenSection();
@override
Widget build(BuildContext context) {
return const Column(
children: [
SettingSection(name: 'Home Screen'),
_AllowNonManifestPwaInstallTile(),
],
);
}
}
class _AllowNonManifestPwaInstallTile extends HookConsumerWidget {
const _AllowNonManifestPwaInstallTile();
@@ -29,6 +29,7 @@ import 'package:weblibre/features/geckoview/features/browser/features/contextual
import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/presentation/toolbar_button_registry.dart';
import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/presentation/widgets/contextual_toolbar.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart';
import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart';
import 'package:weblibre/features/user/data/database/definitions.drift.dart';
class ContextualToolbarSettingsScreen extends HookConsumerWidget {
@@ -38,138 +39,144 @@ class ContextualToolbarSettingsScreen extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final configs = ref.watch(effectiveToolbarButtonConfigsProvider);
final repository = ref.watch(contextualToolbarConfigRepositoryProvider);
final search = useSettingsSearch();
final query = search.normalizedQuery;
bool matchesQuery(ToolbarButtonConfig config) {
if (query.isEmpty) return true;
final def = toolbarButtonRegistryById[config.buttonId];
if (def == null) return false;
return matchesSettingsSearch(query, [
def.label,
...def.longPressActions,
config.buttonId,
]);
}
final visibleConfigs = useMemoized(
() => configs.value.where((c) => c.isVisible).toList(growable: false),
[configs],
() => configs.value
.where((c) => c.isVisible)
.where(matchesQuery)
.toList(growable: false),
[configs, query],
);
final hiddenConfigs = useMemoized(
() => configs.value.where((c) => !c.isVisible).toList(growable: false),
[configs],
() => configs.value
.where((c) => !c.isVisible)
.where(matchesQuery)
.toList(growable: false),
[configs, query],
);
return Scaffold(
appBar: AppBar(
title: const Text('Customize Toolbar'),
actions: [
MenuAnchor(
builder: (context, controller, child) => IconButton(
onPressed: () {
if (controller.isOpen) {
controller.close();
} else {
controller.open();
}
},
icon: const Icon(Icons.more_vert),
),
menuChildren: [
MenuItemButton(
leadingIcon: const Icon(Icons.restore),
onPressed: () => _resetToDefaults(ref),
child: const Text('Reset to Defaults'),
),
],
return SettingsCustomScrollScaffold(
title: 'Customize Toolbar',
searchController: search.controller,
searchHintText: 'Search toolbar buttons',
actions: [
MenuAnchor(
builder: (context, controller, child) => IconButton(
onPressed: () {
if (controller.isOpen) {
controller.close();
} else {
controller.open();
}
},
icon: const Icon(Icons.more_vert),
),
],
),
body: SafeArea(
child: CustomScrollView(
slivers: [
SliverPersistentHeader(
pinned: true,
delegate: _ToolbarPreviewDelegate(configs: configs.value),
menuChildren: [
MenuItemButton(
leadingIcon: const Icon(Icons.restore),
onPressed: () => _resetToDefaults(ref),
child: const Text('Reset to Defaults'),
),
SliverToBoxAdapter(
child: ListTile(
title: Text(
'Enabled',
style: Theme.of(context).textTheme.labelLarge,
),
),
),
if (visibleConfigs.isEmpty)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: Text(
'No enabled buttons. Toggle a button below to enable it.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
)
else
SliverReorderableList(
itemCount: visibleConfigs.length,
onReorder: (oldIndex, newIndex) => _onReorder(
visibleConfigs,
oldIndex,
newIndex,
repository,
isVisible: true,
),
itemBuilder: (context, index) {
final config = visibleConfigs[index];
return _ToolbarButtonConfigTile(
key: ValueKey(config.buttonId),
index: index,
config: config,
repository: repository,
);
},
),
const SliverToBoxAdapter(child: Divider(height: 1)),
SliverToBoxAdapter(
child: ListTile(
title: Text(
'Disabled',
style: Theme.of(context).textTheme.labelLarge,
),
),
),
if (hiddenConfigs.isEmpty)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: Text(
'All buttons are enabled.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
)
else
SliverReorderableList(
itemCount: hiddenConfigs.length,
onReorder: (oldIndex, newIndex) => _onReorder(
hiddenConfigs,
oldIndex,
newIndex,
repository,
isVisible: false,
),
itemBuilder: (context, index) {
final config = hiddenConfigs[index];
return _ToolbarButtonConfigTile(
key: ValueKey(config.buttonId),
index: index,
config: config,
repository: repository,
);
},
),
],
),
),
],
slivers: [
const SliverToBoxAdapter(child: SizedBox(height: 16)),
SliverPersistentHeader(
pinned: true,
delegate: _ToolbarPreviewDelegate(configs: configs.value),
),
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 20, 16, 0),
sliver: SliverToBoxAdapter(child: _SectionLabel(label: 'Enabled')),
),
if (visibleConfigs.isEmpty)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(
query.isEmpty
? 'No enabled buttons. Toggle a button below to enable it.'
: 'No enabled buttons match "${search.rawQuery}".',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
)
else
SliverReorderableList(
itemCount: visibleConfigs.length,
onReorder: (oldIndex, newIndex) => _onReorder(
visibleConfigs,
oldIndex,
newIndex,
repository,
isVisible: true,
),
itemBuilder: (context, index) {
final config = visibleConfigs[index];
return _ToolbarButtonConfigTile(
key: ValueKey(config.buttonId),
index: index,
config: config,
repository: repository,
);
},
),
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 20, 16, 0),
sliver: SliverToBoxAdapter(child: _SectionLabel(label: 'Disabled')),
),
if (hiddenConfigs.isEmpty)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(
query.isEmpty
? 'All buttons are enabled.'
: 'No disabled buttons match "${search.rawQuery}".',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
)
else
SliverReorderableList(
itemCount: hiddenConfigs.length,
onReorder: (oldIndex, newIndex) => _onReorder(
hiddenConfigs,
oldIndex,
newIndex,
repository,
isVisible: false,
),
itemBuilder: (context, index) {
final config = hiddenConfigs[index];
return _ToolbarButtonConfigTile(
key: ValueKey(config.buttonId),
index: index,
config: config,
repository: repository,
);
},
),
const SliverToBoxAdapter(child: SizedBox(height: 20)),
],
);
}
@@ -251,6 +258,26 @@ class ContextualToolbarSettingsScreen extends HookConsumerWidget {
}
}
class _SectionLabel extends StatelessWidget {
const _SectionLabel({required this.label});
final String label;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(
label,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w700,
),
),
);
}
}
class _ToolbarButtonConfigTile extends HookConsumerWidget {
const _ToolbarButtonConfigTile({
super.key,
@@ -17,41 +17,78 @@
* 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_material_design_icons/flutter_material_design_icons.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/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/engine_settings.dart';
import 'package:weblibre/features/user/domain/repositories/engine_settings.dart';
const List<SettingsSectionDefinition> customTrackingProtectionSections = [
SettingsSectionDefinition(
title: 'Allowlist Exceptions',
entries: [
SettingsEntryDefinition(
title: 'Allowlist exceptions',
subtitle: 'Compatibility exceptions for major and minor website issues',
child: _AllowlistSection(),
),
],
),
SettingsSectionDefinition(
title: 'Cookies',
entries: [
SettingsEntryDefinition(
title: 'Cookies',
subtitle: 'Cookie blocking mode and policy selection',
child: _CookiesSection(),
),
],
),
SettingsSectionDefinition(
title: 'Tracking Content',
entries: [
SettingsEntryDefinition(
title: 'Tracking content',
subtitle: 'Tracking scripts and scope for blocking',
child: _TrackingContentSection(),
),
],
),
SettingsSectionDefinition(
title: 'Trackers',
entries: [
SettingsEntryDefinition(
title: 'Trackers',
subtitle: 'Cryptominers, known fingerprinters, and redirect trackers',
child: _TrackersSection(),
),
],
),
SettingsSectionDefinition(
title: 'Advanced Fingerprinting Protection',
entries: [
SettingsEntryDefinition(
title: 'Advanced fingerprinting protection',
subtitle: 'Suspected fingerprinters and tab scope',
child: _AdvancedFingerprintingSection(),
),
],
),
];
class CustomTrackingProtectionScreen extends StatelessWidget {
const CustomTrackingProtectionScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Custom Tracking Protection')),
body: SafeArea(
child: FadingScroll(
fadingSize: 25,
builder: (context, controller) {
return ListView(
controller: controller,
padding: const EdgeInsets.symmetric(horizontal: 12.0),
children: const [
_AllowlistSection(),
_CookiesSection(),
_TrackingContentSection(),
_TrackersSection(),
_AdvancedFingerprintingSection(),
],
);
},
),
),
return const SettingsDetailScaffold(
title: 'Custom Tracking Protection',
subtitle: 'Custom cookie, content, tracker, and fingerprinting controls.',
icon: MdiIcons.shieldEditOutline,
sections: customTrackingProtectionSections,
);
}
}
@@ -70,7 +107,6 @@ class _AllowlistSection extends HookConsumerWidget {
return Column(
children: [
const SettingSection(name: 'Allowlist Exceptions'),
SwitchListTile.adaptive(
title: const Text('Fix website major issues'),
subtitle: const Text(
@@ -116,7 +152,6 @@ class _CookiesSection extends HookConsumerWidget {
return Column(
children: [
const SettingSection(name: 'Cookies'),
SwitchListTile.adaptive(
title: const Text('Block Cookies'),
subtitle: const Text('Block cookies based on the policy below'),
@@ -198,7 +233,6 @@ class _TrackingContentSection extends HookConsumerWidget {
return Column(
children: [
const SettingSection(name: 'Tracking Content'),
SwitchListTile.adaptive(
title: const Text('Block Tracking Content'),
subtitle: const Text(
@@ -270,7 +304,6 @@ class _TrackersSection extends HookConsumerWidget {
return Column(
children: [
const SettingSection(name: 'Trackers'),
const ListTile(
title: Text('Always Blocked'),
subtitle: Text(
@@ -340,7 +373,6 @@ class _AdvancedFingerprintingSection extends HookConsumerWidget {
return Column(
children: [
const SettingSection(name: 'Advanced Fingerprinting Protection'),
SwitchListTile.adaptive(
title: const Text('Suspected Fingerprinters'),
subtitle: const Text(
@@ -20,17 +20,32 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/features/settings/presentation/widgets/doh_settings_content.dart';
import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart';
const List<SettingsSectionDefinition> dohSettingsSections = [
SettingsSectionDefinition(
title: 'Resolver Settings',
entries: [
SettingsEntryDefinition(
title: 'DNS over HTTPS',
subtitle: 'Protection level, provider choice, and custom resolver URL',
keywords: ['doh', 'resolver', 'dns provider'],
child: DohSettingsContent(),
),
],
),
];
class DohSettingsScreen extends HookConsumerWidget {
const DohSettingsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
appBar: AppBar(title: const Text('DNS over HTTPS')),
body: const SafeArea(
child: SingleChildScrollView(child: DohSettingsContent()),
),
return const SettingsDetailScaffold(
title: 'DNS over HTTPS',
subtitle: 'Encrypted DNS protection level and resolver selection.',
icon: Icons.dns_outlined,
sections: dohSettingsSections,
);
}
}
@@ -30,6 +30,7 @@ import 'package:timeago/timeago.dart' as timeago;
import 'package:weblibre/core/logger.dart';
import 'package:weblibre/features/settings/domain/providers/log_filter.dart';
import 'package:weblibre/features/settings/presentation/dialogs/log_details_dialog.dart';
import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart';
import 'package:weblibre/utils/ui_helper.dart';
IconData _levelIcon(Level level) {
@@ -98,62 +99,75 @@ class ErrorLogsScreen extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final minLogLevel = ref.watch(logFilterProvider);
final search = useSettingsSearch();
final query = search.normalizedQuery;
final allLogs = useMemoized(() => loggerMemory.buffer.toList());
final sortedLogs = useMemoized(
() => allLogs.reversed
.where((e) => e.level.value >= minLogLevel.value)
.where((e) {
if (query.isEmpty) return true;
return matchesSettingsSearch(query, [
if (e.origin.message != null) e.origin.message.toString(),
if (e.origin.error != null) e.origin.error.toString(),
]);
})
.toList(),
[allLogs, minLogLevel.value],
[allLogs, minLogLevel.value, query],
);
return Scaffold(
appBar: AppBar(
title: const Text('Error Logs'),
actions: [
MenuAnchor(
builder: (context, controller, childAnchor) {
return TextButton.icon(
onPressed: controller.open,
icon: const Icon(Icons.filter_list),
label: Text(minLogLevel.name.toUpperCase()),
);
},
menuChildren: [
const Divider(height: 0),
_buildLevelFilterMenuItem(context, ref, minLogLevel, Level.trace),
_buildLevelFilterMenuItem(context, ref, minLogLevel, Level.debug),
_buildLevelFilterMenuItem(context, ref, minLogLevel, Level.info),
_buildLevelFilterMenuItem(
context,
ref,
minLogLevel,
Level.warning,
),
_buildLevelFilterMenuItem(context, ref, minLogLevel, Level.error),
_buildLevelFilterMenuItem(context, ref, minLogLevel, Level.fatal),
],
return SettingsCustomScrollScaffold(
title: 'Error Logs',
searchController: search.controller,
searchHintText: 'Search log messages',
actions: [
MenuAnchor(
builder: (context, controller, childAnchor) {
return TextButton.icon(
onPressed: controller.open,
icon: const Icon(Icons.filter_list),
label: Text(minLogLevel.name.toUpperCase()),
);
},
menuChildren: [
const Divider(height: 0),
_buildLevelFilterMenuItem(context, ref, minLogLevel, Level.trace),
_buildLevelFilterMenuItem(context, ref, minLogLevel, Level.debug),
_buildLevelFilterMenuItem(context, ref, minLogLevel, Level.info),
_buildLevelFilterMenuItem(context, ref, minLogLevel, Level.warning),
_buildLevelFilterMenuItem(context, ref, minLogLevel, Level.error),
_buildLevelFilterMenuItem(context, ref, minLogLevel, Level.fatal),
],
),
IconButton(
onPressed: () => _copyToClipboard(context),
icon: const Icon(Icons.copy),
tooltip: 'Copy logs',
),
],
slivers: [
if (sortedLogs.isEmpty)
const SliverFillRemaining(
hasScrollBody: false,
child: Center(child: Text('No logs available')),
)
else
SliverPadding(
padding: const EdgeInsets.fromLTRB(8, 16, 8, 20),
// Log lines render as their own _LogEntryTile cards rather than
// settings-section entries because they have nothing to do with
// settings semantically — the SettingsSection abstraction was
// overkill here. We keep the SettingsSearchField + filtering for
// free via the shared scaffold.
sliver: SliverList.builder(
itemCount: sortedLogs.length,
itemBuilder: (context, index) =>
_LogEntryTile(event: sortedLogs[index]),
),
),
IconButton(
onPressed: () => _copyToClipboard(context),
icon: const Icon(Icons.copy),
tooltip: 'Copy logs',
),
],
),
body: SafeArea(
child: sortedLogs.isEmpty
? const Center(child: Text('No logs available'))
: ListView.builder(
padding: const EdgeInsets.all(8.0),
itemCount: sortedLogs.length,
itemBuilder: (context, index) {
final logEntry = sortedLogs[index];
return _LogEntryTile(event: logEntry);
},
),
),
],
);
}
@@ -17,43 +17,58 @@
* 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_material_design_icons/flutter_material_design_icons.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/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/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/utils/exit_app.dart';
const List<SettingsSectionDefinition> experimentalSettingsSections = [
SettingsSectionDefinition(
title: 'Web Push',
entries: [
SettingsEntryDefinition(
title: 'Choose UnifiedPush Distributor',
subtitle: 'Select the app that delivers website push notifications',
keywords: ['notifications', 'push'],
child: _UnifiedPushDistributorTile(),
),
],
),
SettingsSectionDefinition(
title: 'Runtime & Startup',
entries: [
SettingsEntryDefinition(
title: 'Isolated Content Process',
subtitle: 'Run web content in an isolated process',
keywords: ['restart'],
child: _IsolatedProcessEnabledTile(),
),
SettingsEntryDefinition(
title: 'App Zygote Process',
subtitle: 'Preload the content service for faster isolated startup',
keywords: ['restart', 'android 10'],
child: _AppZygoteProcessEnabledTile(),
),
],
),
];
class ExperimentalSettingsScreen extends StatelessWidget {
const ExperimentalSettingsScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Experimental')),
body: SafeArea(
child: FadingScroll(
fadingSize: 25,
builder: (context, controller) {
return ListView(
controller: controller,
padding: const EdgeInsets.symmetric(horizontal: 12.0),
children: const [
SettingSection(name: 'Web Push'),
_UnifiedPushDistributorTile(),
SettingSection(name: 'Runtime & Startup'),
_IsolatedProcessEnabledTile(),
_AppZygoteProcessEnabledTile(),
],
);
},
),
),
return const SettingsDetailScaffold(
title: 'Experimental',
subtitle: 'Push delivery, runtime isolation, and startup behavior.',
icon: MdiIcons.flaskOutline,
sections: experimentalSettingsSections,
);
}
}
@@ -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,35 +26,62 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/services/browser_addon.dart';
import 'package:weblibre/features/settings/presentation/widgets/custom_list_tile.dart';
import 'package:weblibre/features/settings/presentation/widgets/sections.dart';
import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart';
const List<SettingsSectionDefinition> extensionsSettingsSections = [
SettingsSectionDefinition(
title: 'Extensions',
entries: [
SettingsEntryDefinition(
title: 'Manage Extensions',
subtitle:
'Browse installed, disabled, available, and unsupported extensions',
keywords: ['addons', 'browser extensions'],
child: _ManageExtensionsTile(),
),
SettingsEntryDefinition(
title: 'Custom Collection',
subtitle: 'Use a custom Mozilla addon collection',
keywords: ['addons'],
child: _AddonCollectionTile(),
),
],
),
SettingsSectionDefinition(
title: 'Updates',
entries: [
SettingsEntryDefinition(
title: 'Automatic updates',
subtitle:
'Automatically check for and install extension updates every 12 hours',
keywords: ['addons'],
child: _AutoUpdateTile(),
),
],
),
SettingsSectionDefinition(
title: 'Security',
entries: [
SettingsEntryDefinition(
title: 'Allow unsigned extensions',
subtitle: 'Unsigned extensions have not been verified by Mozilla',
keywords: ['addons'],
child: _AllowUnsignedExtensionsTile(),
),
],
),
];
class ExtensionsSettingsScreen extends StatelessWidget {
const ExtensionsSettingsScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Extensions')),
body: SafeArea(
child: FadingScroll(
fadingSize: 25,
builder: (context, controller) {
return ListView(
controller: controller,
padding: const EdgeInsets.symmetric(horizontal: 12.0),
children: const [
SettingSection(name: 'Extensions'),
_ManageExtensionsTile(),
_AddonCollectionTile(),
SettingSection(name: 'Updates'),
_AutoUpdateTile(),
SettingSection(name: 'Security'),
_AllowUnsignedExtensionsTile(),
],
);
},
),
),
return const SettingsDetailScaffold(
title: 'Extensions',
subtitle: 'Manage add-ons, update behavior, and extension security.',
icon: MdiIcons.puzzleOutline,
sections: extensionsSettingsSections,
);
}
}
@@ -22,6 +22,7 @@ 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/settings/presentation/controllers/save_settings.dart';
import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart';
import 'package:weblibre/features/user/data/models/engine_settings.dart';
import 'package:weblibre/features/user/domain/entities/fingerprint_overrides.dart';
import 'package:weblibre/features/user/domain/providers.dart';
@@ -35,146 +36,172 @@ class FingerprintSettingsScreen extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final targetsAsync = ref.watch(fingerprintTargetsProvider);
final settingsAsync = ref.watch(fingerprintOverrideSettingsProvider);
final search = useSettingsSearch();
return Scaffold(
appBar: AppBar(
title: const Text('Fingerprint Protection'),
actions: [
MenuAnchor(
builder: (context, controller, child) {
return IconButton(
onPressed: () {
if (controller.isOpen) {
controller.close();
} else {
controller.open();
}
},
icon: const Icon(Icons.more_vert),
);
},
menuChildren: [
MenuItemButton(
leadingIcon: const Icon(MdiIcons.restore),
child: const Text('Load Defaults'),
onPressed: () async {
await ref
.read(saveEngineSettingsControllerProvider.notifier)
.save(
(currentSettings) => currentSettings.copyWith
.fingerprintingProtectionOverrides(
FingerprintOverrides.defaults().toString(),
),
);
},
),
MenuItemButton(
leadingIcon: const Icon(MdiIcons.restore),
child: const Text('Load Hardened Defaults'),
onPressed: () async {
await ref
.read(saveEngineSettingsControllerProvider.notifier)
.save(
(currentSettings) => currentSettings.copyWith
.fingerprintingProtectionOverrides(
FingerprintOverrides.hardenedDefaults()
.toString(),
),
);
},
),
],
),
],
),
body: SafeArea(
child: settingsAsync.when(
skipLoadingOnReload: true,
data: (result) {
return result.fold(
(overrides) {
return targetsAsync.when(
skipLoadingOnReload: true,
data: (targets) {
return ListView.builder(
itemCount: targets.length,
itemBuilder: (context, index) {
final target = targets[index];
final state = overrides.targets[target.name];
return CheckboxListTile.adaptive(
value:
(overrides.allTargets == true &&
state != false) ||
state == true,
onChanged: (value) async {
if (value != null) {
final newOverrides = overrides
.copyWithTarget(target.name, value)
.toString();
await ref
.read(
saveEngineSettingsControllerProvider
.notifier,
)
.save(
(currentSettings) => currentSettings
.copyWith
.fingerprintingProtectionOverrides(
newOverrides,
),
);
}
},
title: Text(target.name),
subtitle: target.description.mapNotNull(
(desc) => Text(desc),
return SettingsCustomScrollScaffold(
title: 'Fingerprint Protection',
searchController: search.controller,
searchHintText: 'Search fingerprint override targets',
actions: [
MenuAnchor(
builder: (context, controller, child) {
return IconButton(
onPressed: () {
if (controller.isOpen) {
controller.close();
} else {
controller.open();
}
},
icon: const Icon(Icons.more_vert),
);
},
menuChildren: [
MenuItemButton(
leadingIcon: const Icon(MdiIcons.restore),
child: const Text('Load Defaults'),
onPressed: () async {
await ref
.read(saveEngineSettingsControllerProvider.notifier)
.save(
(currentSettings) => currentSettings.copyWith
.fingerprintingProtectionOverrides(
FingerprintOverrides.defaults().toString(),
),
);
},
),
MenuItemButton(
leadingIcon: const Icon(MdiIcons.restore),
child: const Text('Load Hardened Defaults'),
onPressed: () async {
await ref
.read(saveEngineSettingsControllerProvider.notifier)
.save(
(currentSettings) => currentSettings.copyWith
.fingerprintingProtectionOverrides(
FingerprintOverrides.hardenedDefaults().toString(),
),
);
},
),
],
),
],
slivers: [
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 24, 16, 20),
sliver: SliverToBoxAdapter(
child: settingsAsync.when(
skipLoadingOnReload: true,
data: (result) {
return result.fold(
(overrides) {
return targetsAsync.when(
skipLoadingOnReload: true,
data: (targets) {
final filteredTargets = targets.where((target) {
if (search.normalizedQuery.isEmpty) return true;
return matchesSettingsSearch(search.normalizedQuery, [
target.name,
if (target.description != null) target.description!,
]);
}).toList();
return SettingsSectionList(
sections: filteredTargets.isEmpty
? const []
: [
SettingsSectionDefinition(
title: 'Override Targets',
entries: [
for (final target in filteredTargets)
SettingsEntryDefinition(
title: target.name,
subtitle: target.description,
child: CheckboxListTile.adaptive(
value:
(overrides.allTargets == true &&
overrides.targets[target
.name] !=
false) ||
overrides.targets[target
.name] ==
true,
onChanged: (value) async {
if (value != null) {
final newOverrides = overrides
.copyWithTarget(
target.name,
value,
)
.toString();
await ref
.read(
saveEngineSettingsControllerProvider
.notifier,
)
.save(
(
currentSettings,
) => currentSettings
.copyWith
.fingerprintingProtectionOverrides(
newOverrides,
),
);
}
},
title: Text(target.name),
subtitle: target.description
.mapNotNull(
(desc) => Text(desc),
),
),
),
],
),
],
query: search.rawQuery,
);
},
error: (error, stackTrace) {
return FailureWidget(
exception: error,
onRetry: () {
ref.invalidate(fingerprintTargetsProvider);
},
);
},
loading: () =>
const Center(child: CircularProgressIndicator()),
);
},
onFailure: (errorMessage) {
return FailureWidget(
title: errorMessage.message,
exception: errorMessage.details,
onRetry: () {
ref.invalidate(fingerprintOverrideSettingsProvider);
},
);
},
error: (error, stackTrace) {
return Center(
child: FailureWidget(
exception: error,
onRetry: () {
ref.invalidate(fingerprintTargetsProvider);
},
),
);
);
},
error: (error, stackTrace) {
return FailureWidget(
exception: error,
onRetry: () {
ref.invalidate(fingerprintOverrideSettingsProvider);
},
loading: () =>
const Center(child: CircularProgressIndicator()),
);
},
onFailure: (errorMessage) {
return Center(
child: FailureWidget(
title: errorMessage.message,
exception: errorMessage.details,
onRetry: () {
ref.invalidate(fingerprintOverrideSettingsProvider);
},
),
);
},
);
},
error: (error, stackTrace) {
return Center(
child: FailureWidget(
exception: error,
onRetry: () {
ref.invalidate(fingerprintOverrideSettingsProvider);
},
),
);
},
loading: () => const Center(child: CircularProgressIndicator()),
loading: () => const Center(child: CircularProgressIndicator()),
),
),
),
),
],
);
}
}
@@ -25,48 +25,76 @@ import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart';
import 'package:weblibre/features/settings/presentation/widgets/custom_list_tile.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/presentation/hooks/cached_future.dart';
const List<SettingsSectionDefinition> generalSettingsSections = [
SettingsSectionDefinition(
title: 'Default Browser',
keywords: ['browser defaults'],
entries: [
SettingsEntryDefinition(
title: 'Default Browser',
subtitle: 'Set WebLibre as your default browser',
keywords: ['system browser'],
child: _DefaultBrowserTile(),
),
],
),
SettingsSectionDefinition(
title: 'Appearance',
entries: [
SettingsEntryDefinition(
title: 'Theme',
subtitle: 'Choose system, light, or dark mode',
keywords: ['light', 'dark', 'theme mode'],
child: _ThemeSection(),
),
SettingsEntryDefinition(
title: 'User Interface Zoom',
subtitle: 'Make the user interface smaller or larger',
keywords: ['ui scale', 'zoom'],
child: _UiZoomSection(),
),
SettingsEntryDefinition(
title: 'Disable Animations',
subtitle: 'Reduce motion and turn off app animations',
keywords: ['motion'],
child: _DisableAnimationsTile(),
),
SettingsEntryDefinition(
title: 'Show Modal Barrier',
subtitle: 'Dim the background behind dialogs and bottom sheets',
keywords: ['dialogs', 'bottom sheets', 'overlay'],
child: _ShowModalBarrierTile(),
),
],
),
SettingsSectionDefinition(
title: 'Downloads',
entries: [
SettingsEntryDefinition(
title: 'Use external download manager',
subtitle: 'Manage downloads with another app',
keywords: ['downloads'],
child: _ExternalDownloadManagerTile(),
),
],
),
];
class GeneralSettingsScreen extends StatelessWidget {
const GeneralSettingsScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('General')),
body: SafeArea(
child: FadingScroll(
fadingSize: 25,
builder: (context, controller) {
return ListView(
controller: controller,
padding: const EdgeInsets.symmetric(horizontal: 12.0),
children: const [
_DefaultBrowserSection(),
_AppearanceSection(),
_DownloadsSection(),
],
);
},
),
),
);
}
}
class _DefaultBrowserSection extends StatelessWidget {
const _DefaultBrowserSection();
@override
Widget build(BuildContext context) {
return const Column(
children: [
SettingSection(name: 'Default Browser'),
_DefaultBrowserTile(),
],
return const SettingsDetailScaffold(
title: 'General',
subtitle: 'Appearance, language, downloads, and browser defaults.',
icon: Icons.tune,
sections: generalSettingsSections,
);
}
}
@@ -118,37 +146,6 @@ class _DefaultBrowserTile extends HookConsumerWidget {
}
}
class _AppearanceSection extends StatelessWidget {
const _AppearanceSection();
@override
Widget build(BuildContext context) {
return const Column(
children: [
SettingSection(name: 'Appearance'),
_ThemeSection(),
_UiZoomSection(),
_DisableAnimationsTile(),
_ShowModalBarrierTile(),
],
);
}
}
class _DownloadsSection extends StatelessWidget {
const _DownloadsSection();
@override
Widget build(BuildContext context) {
return const Column(
children: [
SettingSection(name: 'Downloads'),
_ExternalDownloadManagerTile(),
],
);
}
}
class _UiZoomSection extends HookConsumerWidget {
const _UiZoomSection();
@@ -27,6 +27,7 @@ import 'package:nullability/nullability.dart';
import 'package:weblibre/domain/repositories/locale_resolver.dart';
import 'package:weblibre/extensions/locale.dart';
import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart';
import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart';
import 'package:weblibre/features/user/data/models/engine_settings.dart';
import 'package:weblibre/features/user/domain/repositories/engine_settings.dart';
@@ -51,117 +52,174 @@ class LocaleSettingsScreen extends HookConsumerWidget {
),
);
final availableLocales = {
...systemLocales,
...userLocales.value,
Locale.fromSubtags(languageCode: 'en', countryCode: 'US'),
};
final availableLocales = useMemoized(
() =>
{
...systemLocales,
...userLocales.value,
Locale.fromSubtags(languageCode: 'en', countryCode: 'US'),
}.toList()..sort(
(a, b) => a.toLanguageTag().compareTo(b.toLanguageTag()),
),
[systemLocales, userLocales],
);
final customLocaleController = useTextEditingController();
final search = useSettingsSearch();
return Scaffold(
appBar: AppBar(title: const Text('Browser Languages')),
body: SafeArea(
child: ListView(
children: [
...availableLocales.map((locale) {
return CheckboxListTile.adaptive(
value: userLocales.value.contains(locale),
onChanged: (value) async {
if (value != null) {
await ref
.read(saveEngineSettingsControllerProvider.notifier)
.save(
(currentSettings) => currentSettings.copyWith.locales(
value
? {
...currentSettings.locales,
locale.toLanguageTag(),
}.toList()
: ([
...currentSettings.locales,
]..remove(locale.toLanguageTag())).toList(),
),
);
}
},
title: Consumer(
builder: (context, ref, child) {
final resolvedAsync = ref.watch(
resolveLocaleProvider(locale),
);
final filteredLocales = availableLocales.where((locale) {
if (search.normalizedQuery.isEmpty) return true;
return locale
.toLanguageTag()
.toLowerCase()
.contains(search.normalizedQuery);
}).toList();
return Text(
resolvedAsync.maybeWhen(
data: (data) =>
data.mapNotNull(
(data) =>
'${data.languageName} ${data.countryName.mapNotNull((country) => '($country)') ?? ''}'
.trim(),
) ??
locale.toLanguageTag(),
orElse: () => locale.toLanguageTag(),
),
);
},
),
subtitle: Text(locale.toLanguageTag()),
secondary: CountryFlag.fromLanguageCode(
locale.languageCode,
theme: const ImageTheme(shape: RoundedRectangle(8.0)),
),
);
}),
const Divider(),
Padding(
padding: const EdgeInsets.only(left: 16.0, right: 20),
child: Form(
key: formKey,
child: TextFormField(
controller: customLocaleController,
decoration: InputDecoration(
label: const Text('Custom Locale'),
hint: const Text('en-US'),
floatingLabelBehavior: FloatingLabelBehavior.always,
suffixIcon: IconButton(
onPressed: () {
if (formKey.currentState?.validate() == true) {
formKey.currentState?.save();
}
},
icon: const Icon(Icons.add),
),
),
validator: (value) {
if (value != null && Locale.tryParse(value) == null) {
return 'Invalid locale identifier';
}
return SettingsCustomScrollScaffold(
title: 'Browser Languages',
searchController: search.controller,
searchHintText: 'Search locales by tag',
slivers: [
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 24, 16, 20),
sliver: SliverToBoxAdapter(
child: SettingsSectionList(
sections: [
SettingsSectionDefinition(
title: 'Language & Region Settings',
entries: [
for (final locale in filteredLocales)
SettingsEntryDefinition(
title: locale.toLanguageTag(),
subtitle: 'Browser language preference',
keywords: [locale.languageCode],
child: CheckboxListTile.adaptive(
value: userLocales.value.contains(locale),
onChanged: (value) async {
if (value != null) {
await ref
.read(
saveEngineSettingsControllerProvider
.notifier,
)
.save(
(currentSettings) =>
currentSettings.copyWith.locales(
value
? {
...currentSettings.locales,
locale.toLanguageTag(),
}.toList()
: ([...currentSettings.locales]
..remove(
locale.toLanguageTag(),
))
.toList(),
),
);
}
},
title: Consumer(
builder: (context, ref, child) {
final resolvedAsync = ref.watch(
resolveLocaleProvider(locale),
);
return null;
},
onSaved: (newValue) async {
if (newValue != null) {
await ref
.read(saveEngineSettingsControllerProvider.notifier)
.save(
(currentSettings) =>
currentSettings.copyWith.locales(
{
...currentSettings.locales,
Locale.parse(newValue).toLanguageTag(),
}.toList(),
return Text(
resolvedAsync.maybeWhen(
data: (data) =>
data.mapNotNull(
(data) =>
'${data.languageName} ${data.countryName.mapNotNull((country) => '($country)') ?? ''}'
.trim(),
) ??
locale.toLanguageTag(),
orElse: () => locale.toLanguageTag(),
),
);
customLocaleController.clear();
}
},
);
},
),
subtitle: Text(locale.toLanguageTag()),
secondary: CountryFlag.fromLanguageCode(
locale.languageCode,
theme: const ImageTheme(
shape: RoundedRectangle(8.0),
),
),
),
),
],
),
),
SettingsSectionDefinition(
title: 'Custom Locale',
entries: [
SettingsEntryDefinition(
title: 'Add custom locale',
subtitle: 'Enter a locale tag such as en-US',
keywords: const ['locale tag'],
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
child: Form(
key: formKey,
child: TextFormField(
controller: customLocaleController,
decoration: InputDecoration(
label: const Text('Custom Locale'),
hint: const Text('en-US'),
floatingLabelBehavior:
FloatingLabelBehavior.always,
suffixIcon: IconButton(
onPressed: () {
if (formKey.currentState?.validate() ==
true) {
formKey.currentState?.save();
}
},
icon: const Icon(Icons.add),
),
),
validator: (value) {
if (value != null &&
Locale.tryParse(value) == null) {
return 'Invalid locale identifier';
}
return null;
},
onSaved: (newValue) async {
if (newValue != null) {
await ref
.read(
saveEngineSettingsControllerProvider
.notifier,
)
.save(
(currentSettings) =>
currentSettings.copyWith.locales(
{
...currentSettings.locales,
Locale.parse(
newValue,
).toLanguageTag(),
}.toList(),
),
);
customLocaleController.clear();
}
},
),
),
),
),
],
),
],
query: search.rawQuery,
),
],
),
),
),
],
);
}
}
@@ -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_material_design_icons/flutter_material_design_icons.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
@@ -29,6 +28,7 @@ import 'package:weblibre/features/intent_gatekeeper/domain/entities/intent_sourc
import 'package:weblibre/features/intent_gatekeeper/domain/services/package_label_resolver.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/engine_settings.dart';
import 'package:weblibre/features/user/data/models/general_settings.dart';
import 'package:weblibre/features/user/domain/presentation/dialogs/quit_browser_dialog.dart';
@@ -36,69 +36,216 @@ import 'package:weblibre/features/user/domain/repositories/engine_settings.dart'
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
import 'package:weblibre/utils/exit_app.dart';
const List<SettingsSectionDefinition> privacySecuritySettingsSections = [
SettingsSectionDefinition(
title: 'Tracking Protection',
keywords: ['privacy'],
entries: [
SettingsEntryDefinition(
title: 'Enhanced Tracking Protection',
subtitle: 'Choose how aggressively trackers are blocked',
keywords: ['etp', 'standard', 'strict', 'custom'],
child: _EnhancedTrackingProtectionSection(),
),
SettingsEntryDefinition(
title: 'Bounce Tracking Protection',
subtitle: 'Remove tracking state left by redirect-based trackers',
keywords: ['redirect trackers'],
child: _BounceTrackingProtectionTile(),
),
SettingsEntryDefinition(
title: 'Query Parameter Stripping',
subtitle: 'Remove tracking parameters from URLs',
keywords: ['utm'],
child: _QueryParameterStrippingSection(),
),
SettingsEntryDefinition(
title: 'Tracking Protection Exceptions',
subtitle: 'Sites where tracking protection is disabled',
keywords: ['exceptions'],
child: _TrackingProtectionExceptionsTile(),
),
SettingsEntryDefinition(
title: 'uBlock Filter Lists & Hardenings',
subtitle: 'Manage filter lists and apply WebLibre hardenings',
keywords: ['ublock', 'filters'],
child: _UBlockFilterListsTile(),
),
],
),
SettingsSectionDefinition(
title: 'Fingerprinting',
entries: [
SettingsEntryDefinition(
title: 'Browser Languages',
subtitle: 'Choose which languages websites can see',
keywords: ['locale'],
child: _BrowserLanguagesTile(),
),
SettingsEntryDefinition(
title: 'Fingerprint Protection',
subtitle: 'Granular control over browser fingerprinting',
keywords: ['privacy'],
child: _FingerprintProtectionTile(),
),
SettingsEntryDefinition(
title: 'Resist Fingerprinting',
subtitle: 'Advanced fingerprinting protection hardening',
keywords: ['rfp'],
child: _ResistFingerprintingTile(),
),
],
),
SettingsSectionDefinition(
title: 'Connection Security',
entries: [
SettingsEntryDefinition(
title: 'Block insecure HTTP connections',
subtitle: 'Prefer HTTPS and block insecure connections',
keywords: ['https only'],
child: _HttpsOnlyModeSection(),
),
SettingsEntryDefinition(
title: 'DNS over HTTPS',
subtitle: 'Encrypt DNS lookups',
keywords: ['doh'],
child: _DnsTile(),
),
],
),
SettingsSectionDefinition(
title: 'Network Protection',
entries: [
SettingsEntryDefinition(
title: 'Local Network Access',
subtitle: 'Enable local network and device access blocking',
keywords: ['lan'],
child: _LnaEnabledTile(),
),
SettingsEntryDefinition(
title: 'Block Local Network Requests',
subtitle: 'Block requests to local network devices and services',
keywords: ['lan'],
child: _LnaBlockingTile(),
),
SettingsEntryDefinition(
title: 'Block Local Network Trackers',
subtitle: 'Block tracker-like local network requests',
keywords: ['lan'],
child: _LnaBlockTrackersTile(),
),
],
),
SettingsSectionDefinition(
title: 'Privacy Signals & Modes',
entries: [
SettingsEntryDefinition(
title: 'Incognito Mode',
subtitle: 'Delete selected browsing data on app restart',
keywords: ['private mode'],
child: _IncognitoModeSection(),
),
SettingsEntryDefinition(
title: 'Screenshot protection',
subtitle: 'Prevent app content from appearing in screenshots',
keywords: ['screenshots'],
child: _ScreenshotProtectionTile(),
),
SettingsEntryDefinition(
title: 'Global Privacy Control (GPC)',
subtitle: 'Send a privacy preference signal to websites',
keywords: ['gpc'],
child: _GlobalPrivacyControlTile(),
),
],
),
SettingsSectionDefinition(
title: 'App-Opening Protection',
entries: [
SettingsEntryDefinition(
title: 'Block apps from opening your browser',
subtitle: 'Control which apps may launch WebLibre directly',
keywords: ['intent gatekeeper', 'external apps'],
child: _AppOpeningProtectionSection(),
),
],
),
SettingsSectionDefinition(
title: 'Data Management',
entries: [
SettingsEntryDefinition(
title: 'Delete Browsing Data',
subtitle: 'Clear history, cookies, and other browsing data',
keywords: ['clear data'],
child: _DeleteBrowsingDataTile(),
),
SettingsEntryDefinition(
title: 'Auto-Clear History',
subtitle: 'Automatically clear history after a chosen duration',
keywords: ['history retention'],
child: _AutoClearHistorySection(),
),
SettingsEntryDefinition(
title: 'Auto-Clear Unassigned Tabs',
subtitle: 'Automatically close tabs not assigned to a container',
keywords: ['cleanup tabs'],
child: _AutoClearUnassignedTabsSection(),
),
],
),
SettingsSectionDefinition(
title: 'Google Safe Browsing',
entries: [
SettingsEntryDefinition(
title: 'Safe Browsing Malware Protection',
subtitle: 'Warn about malware and harmful downloads',
keywords: ['google safe browsing'],
child: _SafeBrowsingMalwareTile(),
),
SettingsEntryDefinition(
title: 'Safe Browsing Phishing Protection',
subtitle: 'Warn about deceptive websites and login pages',
keywords: ['google safe browsing'],
child: _SafeBrowsingPhishingTile(),
),
],
),
SettingsSectionDefinition(
title: 'Advanced Security',
entries: [
SettingsEntryDefinition(
title: 'Web Engine Hardening',
subtitle: 'Harden browser engine behavior and defaults',
keywords: ['hardening'],
child: _WebEngineHardeningTile(),
),
SettingsEntryDefinition(
title: 'Fission (Site Isolation)',
subtitle: 'Use stronger site isolation between origins',
keywords: ['site isolation'],
child: _FissionEnabledTile(),
),
SettingsEntryDefinition(
title: 'Extensions Web API',
subtitle: 'Allow extensions to expose web APIs to pages',
keywords: ['extension api'],
child: _ExtensionsWebAPIEnabledTile(),
),
],
),
];
class PrivacySecuritySettingsScreen extends StatelessWidget {
const PrivacySecuritySettingsScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Privacy & Security')),
body: SafeArea(
child: FadingScroll(
fadingSize: 25,
builder: (context, controller) {
return ListView(
controller: controller,
padding: const EdgeInsets.symmetric(horizontal: 12.0),
children: const [
_TrackingProtectionSection(),
_FingerprintingSection(),
_ConnectionSecuritySection(),
_NetworkProtectionSection(),
_PrivacySignalsSection(),
_AppOpeningProtectionSection(),
_DataManagementSection(),
_SafeBrowsingSection(),
_AdvancedSecuritySection(),
],
);
},
),
),
);
}
}
class _FingerprintingSection extends StatelessWidget {
const _FingerprintingSection();
@override
Widget build(BuildContext context) {
return const Column(
children: [
SettingSection(name: 'Fingerprinting'),
_BrowserLanguagesTile(),
_FingerprintProtectionTile(),
_ResistFingerprintingTile(),
],
);
}
}
class _TrackingProtectionSection extends StatelessWidget {
const _TrackingProtectionSection();
@override
Widget build(BuildContext context) {
return const Column(
children: [
SettingSection(name: 'Tracking Protection'),
_EnhancedTrackingProtectionSection(),
_BounceTrackingProtectionTile(),
_QueryParameterStrippingSection(),
_TrackingProtectionExceptionsTile(),
_UBlockFilterListsTile(),
],
return const SettingsDetailScaffold(
title: 'Privacy & Security',
subtitle:
'Tracking protection, fingerprinting, browsing data, and network hardening.',
icon: MdiIcons.shieldLock,
sections: privacySecuritySettingsSections,
);
}
}
@@ -120,84 +267,6 @@ class _TrackingProtectionExceptionsTile extends StatelessWidget {
}
}
class _ConnectionSecuritySection extends StatelessWidget {
const _ConnectionSecuritySection();
@override
Widget build(BuildContext context) {
return const Column(
children: [
SettingSection(name: 'Connection Security'),
_HttpsOnlyModeSection(),
_DnsTile(),
],
);
}
}
class _PrivacySignalsSection extends StatelessWidget {
const _PrivacySignalsSection();
@override
Widget build(BuildContext context) {
return const Column(
children: [
SettingSection(name: 'Privacy Signals & Modes'),
_IncognitoModeSection(),
_ScreenshotProtectionTile(),
_GlobalPrivacyControlTile(),
],
);
}
}
class _SafeBrowsingSection extends StatelessWidget {
const _SafeBrowsingSection();
@override
Widget build(BuildContext context) {
return const Column(
children: [
SettingSection(name: 'Google Safe Browsing'),
_SafeBrowsingMalwareTile(),
_SafeBrowsingPhishingTile(),
],
);
}
}
class _DataManagementSection extends StatelessWidget {
const _DataManagementSection();
@override
Widget build(BuildContext context) {
return const Column(
children: [
SettingSection(name: 'Data Management'),
_DeleteBrowsingDataTile(),
_AutoClearHistorySection(),
_AutoClearUnassignedTabsSection(),
],
);
}
}
class _AdvancedSecuritySection extends StatelessWidget {
const _AdvancedSecuritySection();
@override
Widget build(BuildContext context) {
return const Column(
children: [
SettingSection(name: 'Advanced Security'),
_WebEngineHardeningTile(),
_FissionEnabledTile(),
_ExtensionsWebAPIEnabledTile(),
],
);
}
}
class _IncognitoModeSection extends HookConsumerWidget {
const _IncognitoModeSection();
@@ -1052,22 +1121,6 @@ class _ManagedAppPolicyTile extends HookConsumerWidget {
enum _PolicyAction { allow, block, remove }
class _NetworkProtectionSection extends StatelessWidget {
const _NetworkProtectionSection();
@override
Widget build(BuildContext context) {
return const Column(
children: [
SettingSection(name: 'Network Protection'),
_LnaEnabledTile(),
_LnaBlockingTile(),
_LnaBlockTrackersTile(),
],
);
}
}
class _BrowserLanguagesTile extends StatelessWidget {
const _BrowserLanguagesTile();
@@ -17,89 +17,117 @@
* 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';
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/providers.dart';
import 'package:weblibre/features/search/domain/entities/abstract/i_search_suggestion_provider.dart';
import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart';
import 'package:weblibre/features/settings/presentation/widgets/bang_icon.dart';
import 'package:weblibre/features/settings/presentation/widgets/default_search_selector.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> searchSettingsSections = [
SettingsSectionDefinition(
title: 'Providers',
keywords: ['engines'],
entries: [
SettingsEntryDefinition(
title: 'Default Search Provider',
subtitle: 'Choose the default engine for searches',
keywords: ['search engine'],
child: _DefaultSearchProviderSection(),
),
SettingsEntryDefinition(
title: 'Default Autocomplete Provider',
subtitle: 'Choose the provider for search suggestions',
keywords: ['suggestions'],
child: _AutocompleteProviderSection(),
),
SettingsEntryDefinition(
title: 'Custom Search Engines',
subtitle: 'Add and manage your own search providers',
keywords: ['user bangs', 'providers'],
child: _CustomSearchEnginesTile(),
),
],
),
SettingsSectionDefinition(
title: 'Bang Shortcuts',
keywords: ['bangs'],
entries: [
SettingsEntryDefinition(
title: 'Bang Settings',
subtitle: 'Manage bang repositories and usage data',
keywords: ['shortcuts', 'bangs'],
child: _BangsTile(),
),
],
),
SettingsSectionDefinition(
title: 'History & Suggestions',
entries: [
SettingsEntryDefinition(
title: 'Search History Limit',
subtitle: 'Maximum number of recent searches to remember',
keywords: ['history', 'entries'],
child: _MaxSearchHistoryEntriesSection(),
),
SettingsEntryDefinition(
title: 'Allow clipboard access for suggestions',
subtitle: 'Browser can read clipboard to suggest URLs',
keywords: ['clipboard'],
child: _AllowClipboardAccessTile(),
),
SettingsEntryDefinition(
title: 'Autocomplete on enter',
subtitle: 'Accept the inline suggestion when pressing enter',
keywords: ['submit', 'keyboard', 'suggestions'],
child: _AcceptSuggestionOnSubmitTile(),
),
],
),
SettingsSectionDefinition(
title: 'Local Search Index',
keywords: ['on device search', 'index'],
entries: [
SettingsEntryDefinition(
title: 'Enable local search index',
subtitle: 'Index visited pages locally for content search',
keywords: ['page text', 'history'],
child: _LocalIndexEnabledTile(),
),
SettingsEntryDefinition(
title: 'Index private tabs',
subtitle: 'Include private tabs in the local index',
keywords: ['incognito'],
child: _IndexPrivateTabsTile(),
),
SettingsEntryDefinition(
title: 'Indexed pages',
subtitle: 'View and clear the local index',
keywords: ['clear index', 'stats'],
child: _LocalIndexStatsTile(),
),
],
),
];
class SearchSettingsScreen extends StatelessWidget {
const SearchSettingsScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Search')),
body: SafeArea(
child: FadingScroll(
fadingSize: 25,
builder: (context, controller) {
return ListView(
controller: controller,
padding: const EdgeInsets.symmetric(horizontal: 12.0),
children: const [
_ProvidersSection(),
_BangShortcutsSection(),
_HistorySuggestionsSection(),
],
);
},
),
),
);
}
}
class _ProvidersSection extends StatelessWidget {
const _ProvidersSection();
@override
Widget build(BuildContext context) {
return const Column(
children: [
SettingSection(name: 'Providers'),
_DefaultSearchProviderSection(),
_AutocompleteProviderSection(),
_CustomSearchEnginesTile(),
],
);
}
}
class _BangShortcutsSection extends StatelessWidget {
const _BangShortcutsSection();
@override
Widget build(BuildContext context) {
return const Column(
children: [
SettingSection(name: 'Bang Shortcuts'),
_BangsTile(),
],
);
}
}
class _HistorySuggestionsSection extends StatelessWidget {
const _HistorySuggestionsSection();
@override
Widget build(BuildContext context) {
return const Column(
children: [
SettingSection(name: 'History & Suggestions'),
_MaxSearchHistoryEntriesSection(),
_AllowClipboardAccessTile(),
],
return const SettingsDetailScaffold(
title: 'Search',
subtitle: 'Providers, bangs, history suggestions, and on-device search.',
icon: MdiIcons.magnify,
sections: searchSettingsSections,
);
}
}
@@ -328,3 +356,153 @@ class _AllowClipboardAccessTile extends HookConsumerWidget {
);
}
}
class _AcceptSuggestionOnSubmitTile extends HookConsumerWidget {
const _AcceptSuggestionOnSubmitTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final acceptSuggestionOnSubmit = ref.watch(
generalSettingsWithDefaultsProvider.select(
(s) => s.acceptSuggestionOnSubmit,
),
);
return SwitchListTile.adaptive(
title: const Text('Autocomplete on enter'),
subtitle: const Text(
'Accept the inline suggestion when pressing enter on the keyboard',
),
secondary: const Icon(Icons.keyboard_return),
value: acceptSuggestionOnSubmit,
onChanged: (value) async {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(currentSettings) =>
currentSettings.copyWith.acceptSuggestionOnSubmit(value),
);
},
);
}
}
class _LocalIndexEnabledTile extends HookConsumerWidget {
const _LocalIndexEnabledTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final enabled = ref.watch(
generalSettingsWithDefaultsProvider.select(
(s) => s.enableLocalSearchIndex,
),
);
return SwitchListTile.adaptive(
title: const Text('Enable local search index'),
subtitle: const Text(
'Index visited pages locally so the browser can search their '
'content. Visit metadata stays in the engine; only page text is '
'stored on-device.',
),
secondary: const Icon(MdiIcons.bookSearchOutline),
value: enabled,
onChanged: (value) async {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(currentSettings) =>
currentSettings.copyWith.enableLocalSearchIndex(value),
);
},
);
}
}
class _IndexPrivateTabsTile extends HookConsumerWidget {
const _IndexPrivateTabsTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(generalSettingsWithDefaultsProvider);
final enabled = settings.enableLocalSearchIndex;
final indexPrivate = settings.indexPrivateTabs;
return SwitchListTile.adaptive(
title: const Text('Index private tabs'),
subtitle: const Text(
'Include pages opened in private tabs in the local index. '
'Off by default.',
),
secondary: const Icon(MdiIcons.incognito),
value: indexPrivate,
onChanged: enabled
? (value) async {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(currentSettings) =>
currentSettings.copyWith.indexPrivateTabs(value),
);
}
: null,
);
}
}
class _LocalIndexStatsTile extends HookConsumerWidget {
const _LocalIndexStatsTile();
Future<bool?> _confirmClear(BuildContext context) {
return showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Clear local search index?'),
content: const Text(
'This removes all locally indexed page content. Engine history '
'(visit metadata) is not affected.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Clear'),
),
],
),
);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
// Bump on Clear to re-trigger the count query.
final refreshTick = useState(0);
final countSnapshot = useFuture(
useMemoized(() => ref.read(tabDatabaseProvider).historyDao.countRows(), [
refreshTick.value,
]),
);
final count = countSnapshot.data;
return ListTile(
leading: const Icon(MdiIcons.databaseOutline),
title: const Text('Indexed pages'),
subtitle: Text(count.mapNotNull((c) => '$c pages indexed') ?? 'Loading…'),
trailing: TextButton.icon(
icon: const Icon(MdiIcons.deleteOutline),
label: const Text('Clear'),
onPressed: count == null || count == 0
? null
: () async {
final confirmed = await _confirmClear(context);
if (confirmed != true) return;
await ref.read(tabDatabaseProvider).historyDao.clear();
refreshTick.value++;
},
),
);
}
}
@@ -19,34 +19,61 @@
*/
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';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/settings/presentation/screens/advanced_settings.dart';
import 'package:weblibre/features/settings/presentation/screens/browsing_settings.dart';
import 'package:weblibre/features/settings/presentation/screens/extensions_settings.dart';
import 'package:weblibre/features/settings/presentation/screens/general_settings.dart';
import 'package:weblibre/features/settings/presentation/screens/privacy_security_settings.dart';
import 'package:weblibre/features/settings/presentation/screens/search_settings.dart';
import 'package:weblibre/features/settings/presentation/screens/web_content_settings.dart';
import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart';
import 'package:weblibre/features/settings/presentation/widgets/toolbar_layout_content.dart';
class SettingsScreen extends HookConsumerWidget {
class SettingsScreen extends HookWidget {
const SettingsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
Widget build(BuildContext context) {
final search = useSettingsSearch();
final categories = _buildCategories();
final sections = search.normalizedQuery.isEmpty
? _buildCategorySections(categories)
: _buildSearchSections(categories, search.normalizedQuery);
return Scaffold(
appBar: AppBar(title: const Text('Settings')),
body: SafeArea(
child: FadingScroll(
fadingSize: 25,
builder: (context, controller) {
return ListView(
return CustomScrollView(
controller: controller,
padding: const EdgeInsets.symmetric(horizontal: 12.0),
children: const [
_GeneralTile(),
_BrowsingTile(),
_ToolbarLayoutTile(),
_WebContentTile(),
_SearchTile(),
_PrivacySecurityTile(),
_ExtensionsTile(),
_SyncTile(),
_AdvancedTile(),
slivers: [
const SliverAppBar.large(
centerTitle: false,
title: Text('Settings'),
),
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
sliver: SliverToBoxAdapter(
child: SettingsSearchField(
controller: search.controller,
hintText: 'Search all settings',
),
),
),
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 24, 16, 20),
sliver: SliverToBoxAdapter(
child: SettingsSectionList(
sections: sections,
query: search.rawQuery,
),
),
),
],
);
},
@@ -56,227 +83,255 @@ class SettingsScreen extends HookConsumerWidget {
}
}
class _GeneralTile extends StatelessWidget {
const _GeneralTile();
List<_SettingsCategoryDefinition> _buildCategories() {
return [
_SettingsCategoryDefinition(
title: 'General',
subtitle: 'Appearance, language, downloads',
icon: Icons.tune,
keywords: const ['theme', 'ui zoom', 'default browser'],
sections: generalSettingsSections,
onTap: (context) => GeneralSettingsRoute().push(context),
),
_SettingsCategoryDefinition(
title: 'Browsing',
subtitle: 'Tabs, navigation, external links',
icon: MdiIcons.compassOutline,
keywords: const ['tabs', 'small web', 'url cleaner', 'unshortener'],
sections: browsingSettingsSections,
onTap: (context) => BrowsingSettingsRoute().push(context),
),
_SettingsCategoryDefinition(
title: 'Toolbar & Layout',
subtitle: 'Tab bar, toolbar, quick switcher, tab view',
icon: MdiIcons.viewDashboardOutline,
keywords: const ['contextual toolbar', 'quick tab switcher'],
sections: toolbarLayoutSettingsSections,
onTap: (context) => ToolbarLayoutSettingsRoute().push(context),
),
_SettingsCategoryDefinition(
title: 'Web Content',
subtitle: 'Page display, PDF, reader mode, AI',
icon: MdiIcons.fileDocumentOutline,
keywords: const ['reader mode', 'pdf', 'fonts'],
sections: webContentSettingsSections,
onTap: (context) => WebContentSettingsRoute().push(context),
),
_SettingsCategoryDefinition(
title: 'Search',
subtitle: 'Providers, bangs, search history',
icon: MdiIcons.magnify,
keywords: const ['bangs', 'suggestions', 'local search index'],
sections: searchSettingsSections,
onTap: (context) => SearchSettingsRoute().push(context),
),
_SettingsCategoryDefinition(
title: 'Privacy & Security',
subtitle: 'Tracking protection, data clearing',
icon: MdiIcons.shieldLock,
keywords: const [
'fingerprinting',
'https',
'doh',
'safe browsing',
'network protection',
],
sections: privacySecuritySettingsSections,
onTap: (context) => PrivacySecuritySettingsRoute().push(context),
),
_SettingsCategoryDefinition(
title: 'Extensions',
subtitle: 'Install and manage extension sources',
icon: MdiIcons.puzzleOutline,
keywords: const ['addons', 'unsigned extensions'],
sections: extensionsSettingsSections,
onTap: (context) => ExtensionsSettingsRoute().push(context),
),
_SettingsCategoryDefinition(
title: 'WebLibre Account',
subtitle: 'Sign in, sync settings',
icon: Icons.account_circle_outlined,
keywords: const ['account', 'subscription'],
onTap: (context) => AccountSettingsRoute().push(context),
),
_SettingsCategoryDefinition(
title: 'Firefox Sync',
subtitle: 'Account, sync now, engine selection',
icon: Icons.sync,
keywords: const ['pair', 'device name', 'engines'],
onTap: (context) => SyncSettingsRoute().push(context),
),
_SettingsCategoryDefinition(
title: 'Advanced',
subtitle: 'JavaScript, user agent, debugging',
icon: Icons.developer_mode,
keywords: const ['experimental', 'error logs', 'javascript'],
sections: advancedSettingsSections,
onTap: (context) => AdvancedSettingsRoute().push(context),
),
];
}
List<SettingsSectionDefinition> _buildCategorySections(
List<_SettingsCategoryDefinition> categories,
) {
return [
SettingsSectionDefinition(
title: 'Browser',
entries: [
for (final category in categories.take(7))
_buildCategoryEntry(category),
],
),
SettingsSectionDefinition(
title: 'Services & Advanced',
entries: [
for (final category in categories.skip(7))
_buildCategoryEntry(category),
],
),
];
}
List<SettingsSectionDefinition> _buildSearchSections(
List<_SettingsCategoryDefinition> categories,
String normalizedQuery,
) {
final results = <String, List<SettingsEntryDefinition>>{};
for (final category in categories) {
final categoryEntries = <SettingsEntryDefinition>[];
if (matchesSettingsSearch(normalizedQuery, [
category.title,
category.subtitle,
...category.keywords,
])) {
categoryEntries.add(_buildCategoryEntry(category));
}
for (final section in filterSettingsSections(
sections: category.sections,
query: normalizedQuery,
)) {
for (final entry in section.entries) {
categoryEntries.add(
SettingsEntryDefinition(
title: entry.title,
subtitle: '${section.title}${category.title}',
keywords: entry.keywords,
child: _SearchResultTile(
title: entry.title,
subtitle: entry.subtitle,
category: category.title,
section: section.title,
icon: category.icon,
onTap: category.onTap,
),
),
);
}
}
if (categoryEntries.isNotEmpty) {
results[category.title] = categoryEntries;
}
}
return [
for (final result in results.entries)
SettingsSectionDefinition(title: result.key, entries: result.value),
];
}
SettingsEntryDefinition _buildCategoryEntry(
_SettingsCategoryDefinition category,
) {
return SettingsEntryDefinition(
title: category.title,
subtitle: category.subtitle,
keywords: category.keywords,
child: _CategoryTile(category: category),
);
}
class _SettingsCategoryDefinition {
final String title;
final String subtitle;
final IconData icon;
final List<String> keywords;
final List<SettingsSectionDefinition> sections;
final Future<void> Function(BuildContext context) onTap;
const _SettingsCategoryDefinition({
required this.title,
required this.subtitle,
required this.icon,
required this.onTap,
this.keywords = const [],
this.sections = const [],
});
}
class _CategoryTile extends StatelessWidget {
final _SettingsCategoryDefinition category;
const _CategoryTile({required this.category});
@override
Widget build(BuildContext context) {
return Card(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
clipBehavior: Clip.antiAlias,
child: ListTile(
title: const Text('General'),
subtitle: const Text('Appearance, language, downloads'),
contentPadding: const EdgeInsets.symmetric(
vertical: 8.0,
horizontal: 16.0,
),
leading: const Icon(Icons.tune),
trailing: const Icon(Icons.chevron_right),
onTap: () async {
await GeneralSettingsRoute().push(context);
},
return ListTile(
title: Text(category.title),
subtitle: Text(category.subtitle),
contentPadding: const EdgeInsets.symmetric(
vertical: 8.0,
horizontal: 16.0,
),
leading: Icon(category.icon),
trailing: const Icon(Icons.chevron_right),
onTap: () async {
await category.onTap(context);
},
);
}
}
class _BrowsingTile extends StatelessWidget {
const _BrowsingTile();
class _SearchResultTile extends StatelessWidget {
final String title;
final String? subtitle;
final String category;
final String section;
final IconData icon;
final Future<void> Function(BuildContext context) onTap;
const _SearchResultTile({
required this.title,
required this.category,
required this.section,
required this.icon,
required this.onTap,
this.subtitle,
});
@override
Widget build(BuildContext context) {
return Card(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
clipBehavior: Clip.antiAlias,
child: ListTile(
title: const Text('Browsing'),
subtitle: const Text('Tabs, navigation, external links'),
contentPadding: const EdgeInsets.symmetric(
vertical: 8.0,
horizontal: 16.0,
),
leading: const Icon(MdiIcons.compassOutline),
trailing: const Icon(Icons.chevron_right),
onTap: () async {
await BrowsingSettingsRoute().push(context);
},
),
);
}
}
class _ToolbarLayoutTile extends StatelessWidget {
const _ToolbarLayoutTile();
@override
Widget build(BuildContext context) {
return Card(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
clipBehavior: Clip.antiAlias,
child: ListTile(
title: const Text('Toolbar & Layout'),
subtitle: const Text('Tab bar, toolbar, quick switcher, tab view'),
contentPadding: const EdgeInsets.symmetric(
vertical: 8.0,
horizontal: 16.0,
),
leading: const Icon(MdiIcons.viewDashboardOutline),
trailing: const Icon(Icons.chevron_right),
onTap: () async {
await ToolbarLayoutSettingsRoute().push(context);
},
),
);
}
}
class _PrivacySecurityTile extends StatelessWidget {
const _PrivacySecurityTile();
@override
Widget build(BuildContext context) {
return Card(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
clipBehavior: Clip.antiAlias,
child: ListTile(
title: const Text('Privacy & Security'),
subtitle: const Text('Tracking protection, data clearing'),
contentPadding: const EdgeInsets.symmetric(
vertical: 8.0,
horizontal: 16.0,
),
leading: const Icon(MdiIcons.shieldLock),
trailing: const Icon(Icons.chevron_right),
onTap: () async {
await PrivacySecuritySettingsRoute().push(context);
},
),
);
}
}
class _WebContentTile extends StatelessWidget {
const _WebContentTile();
@override
Widget build(BuildContext context) {
return Card(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
clipBehavior: Clip.antiAlias,
child: ListTile(
title: const Text('Web Content'),
subtitle: const Text('Page display, PDF, reader mode, AI'),
contentPadding: const EdgeInsets.symmetric(
vertical: 8.0,
horizontal: 16.0,
),
leading: const Icon(MdiIcons.fileDocumentOutline),
trailing: const Icon(Icons.chevron_right),
onTap: () async {
await WebContentSettingsRoute().push(context);
},
),
);
}
}
class _SearchTile extends StatelessWidget {
const _SearchTile();
@override
Widget build(BuildContext context) {
return Card(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
clipBehavior: Clip.antiAlias,
child: ListTile(
title: const Text('Search'),
subtitle: const Text('Providers, bangs, search history'),
contentPadding: const EdgeInsets.symmetric(
vertical: 8.0,
horizontal: 16.0,
),
leading: const Icon(MdiIcons.magnify),
trailing: const Icon(Icons.chevron_right),
onTap: () async {
await SearchSettingsRoute().push(context);
},
),
);
}
}
class _ExtensionsTile extends StatelessWidget {
const _ExtensionsTile();
@override
Widget build(BuildContext context) {
return Card(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
clipBehavior: Clip.antiAlias,
child: ListTile(
title: const Text('Extensions'),
subtitle: const Text('Install and manage extension sources'),
contentPadding: const EdgeInsets.symmetric(
vertical: 8.0,
horizontal: 16.0,
),
leading: const Icon(MdiIcons.puzzleOutline),
trailing: const Icon(Icons.chevron_right),
onTap: () async {
await ExtensionsSettingsRoute().push(context);
},
),
);
}
}
class _SyncTile extends StatelessWidget {
const _SyncTile();
@override
Widget build(BuildContext context) {
return Card(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
clipBehavior: Clip.antiAlias,
child: ListTile(
title: const Text('Firefox Sync'),
subtitle: const Text('Account, sync now, engine selection'),
contentPadding: const EdgeInsets.symmetric(
vertical: 8.0,
horizontal: 16.0,
),
leading: const Icon(Icons.sync),
trailing: const Icon(Icons.chevron_right),
onTap: () async {
await SyncSettingsRoute().push(context);
},
),
);
}
}
class _AdvancedTile extends StatelessWidget {
const _AdvancedTile();
@override
Widget build(BuildContext context) {
return Card(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
clipBehavior: Clip.antiAlias,
child: ListTile(
title: const Text('Advanced'),
subtitle: const Text('JavaScript, user agent, debugging'),
contentPadding: const EdgeInsets.symmetric(
vertical: 8.0,
horizontal: 16.0,
),
leading: const Icon(Icons.developer_mode),
trailing: const Icon(Icons.chevron_right),
onTap: () async {
await AdvancedSettingsRoute().push(context);
},
return ListTile(
leading: Icon(icon),
title: Text(title),
subtitle: Text(
subtitle == null || subtitle!.isEmpty
? '$category$section'
: '$category$section\n$subtitle',
),
isThreeLine: subtitle != null && subtitle!.isNotEmpty,
contentPadding: const EdgeInsets.symmetric(
vertical: 8.0,
horizontal: 16.0,
),
trailing: const Icon(Icons.chevron_right),
onTap: () async {
await onTap(context);
},
);
}
}
@@ -17,9 +17,9 @@
* 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:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart';
import 'package:weblibre/features/settings/presentation/widgets/toolbar_layout_content.dart';
import 'package:weblibre/features/settings/presentation/widgets/toolbar_preview.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
@@ -30,32 +30,28 @@ class ToolbarLayoutSettingsScreen extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(generalSettingsWithDefaultsProvider);
final search = useSettingsSearch();
return Scaffold(
appBar: AppBar(title: const Text('Toolbar & Layout')),
body: SafeArea(
child: FadingScroll(
fadingSize: 25,
builder: (context, controller) {
return CustomScrollView(
controller: controller,
slivers: [
SliverPersistentHeader(
pinned: true,
delegate: TabBarPreviewHeaderDelegate(
settings: settings,
compact: true,
),
),
const SliverPadding(
padding: EdgeInsets.symmetric(horizontal: 12.0),
sliver: SliverToBoxAdapter(child: ToolbarLayoutContent()),
),
],
);
},
return SettingsCustomScrollScaffold(
title: 'Toolbar & Layout',
searchController: search.controller,
searchHintText: 'Search toolbar and layout settings',
slivers: [
const SliverToBoxAdapter(child: SizedBox(height: 16)),
SliverPersistentHeader(
pinned: true,
delegate: TabBarPreviewHeaderDelegate(
settings: settings,
compact: true,
),
),
),
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 20, 16, 20),
sliver: SliverToBoxAdapter(
child: ToolbarLayoutContent(query: search.rawQuery),
),
),
],
);
}
}
@@ -26,6 +26,7 @@ import 'package:weblibre/core/logger.dart';
import 'package:weblibre/extensions/uri.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/repositories/tracking_protection.dart';
import 'package:weblibre/features/settings/presentation/dialogs/delete_all_exceptions_dialog.dart';
import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart';
import 'package:weblibre/presentation/widgets/url_icon.dart';
import 'package:weblibre/utils/ui_helper.dart';
@@ -39,59 +40,89 @@ class TrackingProtectionExceptionsScreen extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final exceptionsAsync = ref.watch(trackingProtectionRepositoryProvider);
final search = useSettingsSearch();
return Scaffold(
appBar: AppBar(
title: const Text('Tracking Protection Exceptions'),
actions: [
exceptionsAsync.maybeWhen(
data: (exceptions) => exceptions.isNotEmpty
? MenuAnchor(
builder: (context, controller, child) => IconButton(
icon: const Icon(Icons.more_vert),
onPressed: () {
if (controller.isOpen) {
controller.close();
} else {
controller.open();
}
},
return SettingsCustomScrollScaffold(
title: 'Tracking Protection Exceptions',
searchController: search.controller,
searchHintText: 'Search exception URLs',
actions: [
exceptionsAsync.maybeWhen(
data: (exceptions) => exceptions.isNotEmpty
? MenuAnchor(
builder: (context, controller, child) => IconButton(
icon: const Icon(Icons.more_vert),
onPressed: () {
if (controller.isOpen) {
controller.close();
} else {
controller.open();
}
},
),
menuChildren: [
MenuItemButton(
leadingIcon: const Icon(Icons.delete_sweep),
onPressed: () => _showDeleteAllDialog(context, ref),
child: const Text('Delete All'),
),
menuChildren: [
MenuItemButton(
leadingIcon: const Icon(Icons.delete_sweep),
onPressed: () => _showDeleteAllDialog(context, ref),
child: const Text('Delete All'),
),
],
)
: const SizedBox.shrink(),
orElse: () => const SizedBox.shrink(),
),
],
),
body: SafeArea(
child: exceptionsAsync.when(
data: (exceptions) {
if (exceptions.isEmpty) {
return const _EmptyState();
}
],
)
: const SizedBox.shrink(),
orElse: () => const SizedBox.shrink(),
),
],
slivers: [
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 24, 16, 20),
sliver: SliverToBoxAdapter(
child: exceptionsAsync.when(
data: (exceptions) {
final filteredExceptions = exceptions.where((exception) {
if (search.normalizedQuery.isEmpty) return true;
return exception.url.toLowerCase().contains(
search.normalizedQuery,
);
}).toList();
return ListView.builder(
itemCount: exceptions.length,
itemBuilder: (context, index) {
final exception = exceptions[index];
return _ExceptionTile(
exception: exception,
onDelete: () => _deleteException(context, ref, exception),
if (exceptions.isEmpty) {
return const _EmptyState();
}
if (filteredExceptions.isEmpty) {
return SettingsSectionList(
sections: const [],
query: search.rawQuery,
);
}
return SettingsSectionList(
sections: [
SettingsSectionDefinition(
title: 'Exception List',
entries: [
for (final exception in filteredExceptions)
SettingsEntryDefinition(
title: exception.url,
subtitle: 'Site with tracking protection disabled',
child: _ExceptionTile(
exception: exception,
onDelete: () =>
_deleteException(context, ref, exception),
),
),
],
),
],
query: search.rawQuery,
);
},
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) => _ErrorState(error: error.toString()),
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) => _ErrorState(error: error.toString()),
),
),
),
),
],
);
}
@@ -19,11 +19,11 @@
*/
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:url_launcher/url_launcher.dart';
import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart';
import 'package:weblibre/features/user/data/models/engine_settings.dart';
import 'package:weblibre/features/user/data/models/ublock_asset.dart';
import 'package:weblibre/features/user/data/models/ublock_filter_list_settings.dart';
@@ -167,18 +167,17 @@ class UBlockFilterListsScreen extends HookConsumerWidget {
);
}
final colorScheme = Theme.of(context).colorScheme;
final search = useSettingsSearch();
return Scaffold(
backgroundColor: colorScheme.surface,
appBar: AppBar(title: const Text('uBlock Filter Lists')),
body: SafeArea(
child: FadingScroll(
fadingSize: 25,
builder: (context, controller) {
return ListView(
controller: controller,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
return SettingsCustomScrollScaffold(
title: 'uBlock Filter Lists',
searchController: search.controller,
searchHintText: 'Search lists, groups, and external URLs',
slivers: [
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 24, 16, 20),
sliver: SliverToBoxAdapter(
child: Column(
children: [
const _SectionHeader(label: 'Management'),
_ManagementCard(
@@ -245,6 +244,7 @@ class UBlockFilterListsScreen extends HookConsumerWidget {
registry: registry,
settings: settings,
onUpdate: updateSettings,
query: search.normalizedQuery,
),
loading: () => const Padding(
padding: EdgeInsets.all(24),
@@ -260,13 +260,14 @@ class UBlockFilterListsScreen extends HookConsumerWidget {
_ExternalListsCard(
settings: settings,
onUpdate: updateSettings,
query: search.normalizedQuery,
),
const SizedBox(height: 32),
],
);
},
),
),
),
),
],
);
}
}
@@ -481,11 +482,13 @@ class _FilterListGroups extends StatelessWidget {
final UBlockAssetsRegistry registry;
final UBlockFilterListSettings settings;
final _SettingsMutator onUpdate;
final String query;
const _FilterListGroups({
required this.registry,
required this.settings,
required this.onUpdate,
required this.query,
});
@override
@@ -524,6 +527,7 @@ class _FilterListGroups extends StatelessWidget {
autoTokens: autoTokens,
managementEnabled: settings.enabled,
onToggle: toggle,
query: query,
),
],
);
@@ -573,6 +577,7 @@ class _GroupCard extends StatelessWidget {
final Set<String> enabledTokens;
final Set<String> autoTokens;
final bool managementEnabled;
final String query;
final Future<void> Function(String token, bool value) onToggle;
const _GroupCard({
@@ -582,13 +587,44 @@ class _GroupCard extends StatelessWidget {
required this.enabledTokens,
required this.autoTokens,
required this.managementEnabled,
required this.query,
required this.onToggle,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final allTokens = parentTree.values.expand((l) => l).toList();
// Callers pass `search.normalizedQuery` so trimming/lowercasing already happened.
final groupMatches =
query.isEmpty || group.label.toLowerCase().contains(query);
final filteredParentTree = <String?, List<String>>{};
for (final entry in parentTree.entries) {
final parentMatches =
groupMatches ||
(entry.key?.toLowerCase().contains(query) ?? false);
final filteredTokens = entry.value.where((tokenKey) {
if (query.isEmpty || parentMatches) return true;
final asset = registry[tokenKey];
if (asset == null) return false;
return matchesSettingsSearch(query, [
tokenKey,
if (asset.title != null) asset.title!,
if (asset.tags != null) asset.tags!,
if (asset.supportURL != null) asset.supportURL!,
]);
}).toList();
if (filteredTokens.isNotEmpty) {
filteredParentTree[entry.key] = filteredTokens;
}
}
if (filteredParentTree.isEmpty) {
return const SizedBox.shrink();
}
final allTokens = filteredParentTree.values.expand((l) => l).toList();
final totalCount = allTokens.length;
final enabledCount = allTokens.where(enabledTokens.contains).length;
@@ -616,7 +652,7 @@ class _GroupCard extends StatelessWidget {
color: colorScheme.surfaceContainerLow,
child: Column(
children: [
for (final entry in parentTree.entries)
for (final entry in filteredParentTree.entries)
if (entry.key != null)
_SubGroupTile(
parentTitle: entry.key!,
@@ -828,13 +864,26 @@ class _FilterListTile extends StatelessWidget {
class _ExternalListsCard extends StatelessWidget {
final UBlockFilterListSettings settings;
final _SettingsMutator onUpdate;
final String query;
const _ExternalListsCard({required this.settings, required this.onUpdate});
const _ExternalListsCard({
required this.settings,
required this.onUpdate,
required this.query,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final lists = settings.externalFilterLists;
// Callers pass `search.normalizedQuery`.
final filteredLists = lists.where((entry) {
if (query.isEmpty) return true;
return matchesSettingsSearch(query, [
entry.url,
if (entry.description != null) entry.description!,
]);
}).toList();
final canAdd = settings.enabled && lists.length < kUBlockMaxExternalUrls;
return Card(
@@ -863,8 +912,16 @@ class _ExternalListsCard extends StatelessWidget {
child: Text('No external lists configured.'),
),
)
else if (filteredLists.isEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
child: Align(
alignment: Alignment.centerLeft,
child: Text('No external lists match "$query".'),
),
)
else
for (var i = 0; i < lists.length; i++) ...[
for (var i = 0; i < filteredLists.length; i++) ...[
if (i > 0)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
@@ -874,8 +931,8 @@ class _ExternalListsCard extends StatelessWidget {
),
),
_ExternalListRow(
index: i,
entry: lists[i],
index: lists.indexOf(filteredLists[i]),
entry: filteredLists[i],
allEntries: lists,
enabled: settings.enabled,
onUpdate: onUpdate,
@@ -17,72 +17,94 @@
* 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';
import 'package:hooks_riverpod/hooks_riverpod.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/engine_settings.dart';
import 'package:weblibre/features/user/data/models/general_settings.dart';
import 'package:weblibre/features/user/domain/repositories/engine_settings.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
const List<SettingsSectionDefinition> webContentSettingsSections = [
SettingsSectionDefinition(
title: 'Display',
entries: [
SettingsEntryDefinition(
title: 'Web Fonts',
subtitle: 'Allow websites to use custom fonts',
keywords: ['fonts'],
child: _WebFontsEnabledTile(),
),
SettingsEntryDefinition(
title: 'Automatic Font Size',
subtitle: 'Adjust font size based on system settings',
keywords: ['text size'],
child: _AutomaticFontSizeAdjustmentTile(),
),
SettingsEntryDefinition(
title: 'Font Size Factor',
subtitle: 'Scale web page text size',
keywords: ['zoom', 'text'],
child: _FontSizeFactorSlider(),
),
SettingsEntryDefinition(
title: 'Font Inflation',
subtitle: 'Enlarge text on pages without a mobile viewport',
keywords: ['readability'],
child: _FontInflationTile(),
),
SettingsEntryDefinition(
title: 'Input Auto Zoom',
subtitle: 'Automatically zoom when focusing text inputs',
keywords: ['forms'],
child: _InputAutoZoomEnabledTile(),
),
],
),
SettingsSectionDefinition(
title: 'Content Features',
entries: [
SettingsEntryDefinition(
title: 'Built-in PDF Viewer',
subtitle: 'Open PDF files directly in the browser',
keywords: ['pdf'],
child: _PdfViewerTile(),
),
SettingsEntryDefinition(
title: 'Enable Reader Mode',
subtitle: 'Extract and simplify pages for readability',
keywords: ['reader', 'readability'],
child: _EnableReaderModeTile(),
),
SettingsEntryDefinition(
title: 'Enforce Reader Mode',
subtitle: 'Always show Reader Mode capabilities',
keywords: ['reader'],
child: _EnforceReaderModeTile(),
),
SettingsEntryDefinition(
title: 'On Device AI',
subtitle: 'Local AI features including topic and tab suggestions',
keywords: ['local ai', 'suggestions'],
child: _OnDeviceAiTile(),
),
],
),
];
class WebContentSettingsScreen extends StatelessWidget {
const WebContentSettingsScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Web Content')),
body: SafeArea(
child: FadingScroll(
fadingSize: 25,
builder: (context, controller) {
return ListView(
controller: controller,
padding: const EdgeInsets.symmetric(horizontal: 12.0),
children: const [_DisplaySection(), _ContentFeaturesSection()],
);
},
),
),
);
}
}
class _DisplaySection extends StatelessWidget {
const _DisplaySection();
@override
Widget build(BuildContext context) {
return const Column(
children: [
SettingSection(name: 'Display'),
_WebFontsEnabledTile(),
_AutomaticFontSizeAdjustmentTile(),
_FontSizeFactorSlider(),
_FontInflationTile(),
_InputAutoZoomEnabledTile(),
],
);
}
}
class _ContentFeaturesSection extends StatelessWidget {
const _ContentFeaturesSection();
@override
Widget build(BuildContext context) {
return const Column(
children: [
SettingSection(name: 'Content Features'),
_PdfViewerTile(),
_EnableReaderModeTile(),
_EnforceReaderModeTile(),
_OnDeviceAiTile(),
],
return const SettingsDetailScaffold(
title: 'Web Content',
subtitle: 'Text rendering, reader mode, PDFs, and local AI features.',
icon: MdiIcons.fileDocumentOutline,
sections: webContentSettingsSections,
);
}
}
@@ -26,6 +26,7 @@ import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/geckoview/features/preferences/data/repositories/preference_settings.dart';
import 'package:weblibre/features/geckoview/features/tabs/utils/setting_groups_serializer.dart';
import 'package:weblibre/features/settings/presentation/widgets/hardening_group_icon.dart';
import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart';
import 'package:weblibre/presentation/widgets/failure_widget.dart';
class WebEngineHardeningScreen extends HookConsumerWidget {
@@ -47,104 +48,143 @@ class WebEngineHardeningScreen extends HookConsumerWidget {
);
final theme = Theme.of(context);
final search = useSettingsSearch();
final query = search.normalizedQuery;
return Scaffold(
appBar: AppBar(
title: const Text('Web Engine Hardening'),
actions: [
MenuAnchor(
menuChildren: [
MenuItemButton(
leadingIcon: const Icon(Icons.restore),
child: const Text('Reset all preferences'),
onPressed: () async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Reset all preferences?'),
content: const Text(
'This will reset all user-defined web engine preferences to their defaults.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Reset'),
),
],
return SettingsCustomScrollScaffold(
title: 'Web Engine Hardening',
searchController: search.controller,
searchHintText: 'Search hardening groups',
actions: [
MenuAnchor(
menuChildren: [
MenuItemButton(
leadingIcon: const Icon(Icons.restore),
child: const Text('Reset all preferences'),
onPressed: () async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Reset all preferences?'),
content: const Text(
'This will reset all user-defined web engine preferences to their defaults.',
),
);
if (confirmed == true) {
await ref
.read(
unifiedPreferenceSettingsRepositoryProvider(
PreferencePartition.user,
).notifier,
)
.reset();
}
},
),
],
builder: (context, controller, child) => IconButton(
icon: const Icon(Icons.more_vert),
onPressed: () {
if (controller.isOpen) {
controller.close();
} else {
controller.open();
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Reset'),
),
],
),
);
if (confirmed == true) {
await ref
.read(
unifiedPreferenceSettingsRepositoryProvider(
PreferencePartition.user,
).notifier,
)
.reset();
}
},
),
],
builder: (context, controller, child) => IconButton(
icon: const Icon(Icons.more_vert),
onPressed: () {
if (controller.isOpen) {
controller.close();
} else {
controller.open();
}
},
),
],
),
body: SafeArea(
child: preferenceGroups.when(
skipLoadingOnReload: true,
data: (data) {
return Column(
children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: Card(
color: theme.colorScheme.primaryContainer,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: SwitchListTile(
value: allGroupsActive,
title: Text(
'Complete Hardening',
style: TextStyle(
color: theme.colorScheme.onPrimaryContainer,
),
],
slivers: [
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 24, 16, 20),
sliver: SliverToBoxAdapter(
child: preferenceGroups.when(
skipLoadingOnReload: true,
data: (data) {
final overviewMatches =
query.isEmpty ||
matchesSettingsSearch(query, const [
'overview',
'complete hardening',
'apply reset all grouped hardening preferences',
]);
final filteredGroups = data.entries.where((group) {
if (query.isEmpty) return true;
return matchesSettingsSearch(query, [
group.key,
if (group.value.description != null)
group.value.description!,
]);
}).toList();
final sections = <SettingsSectionDefinition>[
if (overviewMatches)
SettingsSectionDefinition(
title: 'Overview',
entries: [
SettingsEntryDefinition(
title: 'Complete Hardening',
subtitle:
'Apply or reset all grouped hardening preferences',
child: Padding(
padding: const EdgeInsets.all(16.0),
child: DecoratedBox(
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(16),
),
child: SwitchListTile.adaptive(
value: allGroupsActive,
title: Text(
'Complete Hardening',
style: TextStyle(
color: theme.colorScheme.onPrimaryContainer,
),
),
subtitle: Text(
'Toggle all grouped hardening preferences at once.',
style: TextStyle(
color: theme.colorScheme.onPrimaryContainer,
),
),
onChanged: (value) async {
final notifier = ref.read(
unifiedPreferenceSettingsRepositoryProvider(
PreferencePartition.user,
).notifier,
);
if (value) {
await notifier.apply();
} else {
await notifier.reset();
}
},
),
),
),
),
onChanged: (value) async {
final notifier = ref.read(
unifiedPreferenceSettingsRepositoryProvider(
PreferencePartition.user,
).notifier,
);
if (value) {
await notifier.apply();
} else {
await notifier.reset();
}
},
),
],
),
),
),
Expanded(
child: ListView(
children: data.entries.map((group) {
return Row(
children: [
Expanded(
if (filteredGroups.isNotEmpty)
SettingsSectionDefinition(
title: 'Hardening Groups',
entries: [
for (final group in filteredGroups)
SettingsEntryDefinition(
title: group.key,
subtitle: group.value.description,
child: ListTile(
title: Text(group.key),
subtitle: group.value.description.mapNotNull(
@@ -165,26 +205,29 @@ class WebEngineHardeningScreen extends HookConsumerWidget {
},
),
),
],
);
}).toList(),
],
),
];
return SettingsSectionList(
sections: sections,
query: search.rawQuery,
);
},
error: (error, stackTrace) => FailureWidget(
title: 'Could not load preference settings',
exception: error,
onRetry: () => ref.refresh(
unifiedPreferenceSettingsRepositoryProvider(
PreferencePartition.user,
),
),
],
);
},
error: (error, stackTrace) => FailureWidget(
title: 'Could not load preference settings',
exception: error,
onRetry: () => ref.refresh(
unifiedPreferenceSettingsRepositoryProvider(
PreferencePartition.user,
),
loading: () => const Center(child: CircularProgressIndicator()),
),
),
loading: () => const Center(child: CircularProgressIndicator()),
),
),
],
);
}
}
@@ -20,9 +20,11 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nullability/nullability.dart';
import 'package:weblibre/features/geckoview/features/preferences/data/models/preference_setting.dart';
import 'package:weblibre/features/geckoview/features/preferences/data/repositories/preference_settings.dart';
import 'package:weblibre/features/geckoview/features/tabs/utils/setting_groups_serializer.dart';
import 'package:weblibre/features/settings/presentation/widgets/hardening_group_icon.dart';
import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart';
import 'package:weblibre/presentation/widgets/failure_widget.dart';
class WebEngineHardeningGroupScreen extends HookConsumerWidget {
@@ -40,178 +42,215 @@ class WebEngineHardeningGroupScreen extends HookConsumerWidget {
);
final theme = Theme.of(context);
final search = useSettingsSearch();
final query = search.normalizedQuery;
return Scaffold(
appBar: AppBar(title: Text(groupName)),
body: SafeArea(
child: settings.when(
skipLoadingOnReload: true,
data: (group) {
return Column(
children: [
if (group.showMasterSwitch)
Padding(
padding: const EdgeInsets.all(8.0),
child: Card(
color: theme.colorScheme.primaryContainer,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: SwitchListTile(
value: group.isActiveOrOptional,
title: Text(
groupName,
style: TextStyle(
color: theme.colorScheme.onPrimaryContainer,
),
),
subtitle: group.description.mapNotNull(
(description) => Text(
description,
style: TextStyle(
color: theme.colorScheme.onPrimaryContainer,
return SettingsCustomScrollScaffold(
title: groupName,
searchController: search.controller,
searchHintText: 'Search hardening settings',
slivers: [
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 24, 16, 20),
sliver: SliverToBoxAdapter(
child: settings.when(
skipLoadingOnReload: true,
data: (group) {
final groupControlMatches =
query.isEmpty ||
matchesSettingsSearch(query, [
groupName,
if (group.description != null) group.description!,
'group controls complete hardening',
]);
final filteredSettings = group.settings.entries.where((
setting,
) {
if (query.isEmpty) return true;
return matchesSettingsSearch(query, [
setting.key,
if (setting.value.title != null) setting.value.title!,
if (setting.value.description != null)
setting.value.description!,
]);
}).toList();
final sections = <SettingsSectionDefinition>[
if (group.showMasterSwitch && groupControlMatches)
SettingsSectionDefinition(
title: 'Group Controls',
entries: [
SettingsEntryDefinition(
title: groupName,
subtitle: group.description,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: DecoratedBox(
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(16),
),
),
),
onChanged: (value) async {
final notifier = ref.read(
preferenceSettingsGroupRepositoryProvider(
PreferencePartition.user,
groupName,
).notifier,
);
if (value) {
await notifier.apply();
} else {
await notifier.reset();
}
},
),
),
),
),
Expanded(
child: ListView(
children: group.settings.entries.map((setting) {
var value = setting.value.value.toString();
if (value.length > 160) {
value = '${value.substring(0, 160)}';
}
return Tooltip(
message: '${setting.key}: $value',
child: Row(
children: [
if (!setting.value.shouldBeDefault ||
!setting.value.isActive)
Expanded(
child: SwitchListTile(
value: setting.value.isActive,
title: Text(
setting.value.title ?? setting.key,
child: SwitchListTile.adaptive(
value: group.isActiveOrOptional,
title: Text(
groupName,
style: TextStyle(
color: theme.colorScheme.onPrimaryContainer,
),
subtitle: Text.rich(
TextSpan(
children: [
if (setting.value.requireUserOptIn)
WidgetSpan(
child: Container(
padding:
const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
margin: const EdgeInsets.only(
right: 8,
),
decoration: BoxDecoration(
color: theme.colorScheme.error,
borderRadius:
BorderRadius.circular(4),
),
child: Text(
'Optional',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w500,
color:
theme.colorScheme.onError,
),
),
),
),
if (setting.value.description != null)
TextSpan(
text: setting.value.description,
// style: theme.textTheme.bodyMedium,
),
],
),
subtitle: group.description.mapNotNull(
(description) => Text(
description,
style: TextStyle(
color:
theme.colorScheme.onPrimaryContainer,
),
),
secondary: HardeningGroupIcon(
isActive: setting.value.isActive,
),
onChanged: (value) async {
final notifier = ref.read(
preferenceSettingsGroupRepositoryProvider(
PreferencePartition.user,
groupName,
).notifier,
);
),
onChanged: (value) async {
final notifier = ref.read(
preferenceSettingsGroupRepositoryProvider(
PreferencePartition.user,
groupName,
).notifier,
);
if (value) {
await notifier.apply(
filter: [setting.key],
);
} else {
await notifier.reset(
filter: [setting.key],
);
}
},
),
)
else
Expanded(
child: ListTile(
title: Text(
setting.value.title ?? setting.key,
),
subtitle: setting.value.description
.mapNotNull(
(description) => Text(description),
),
leading: HardeningGroupIcon(
isActive: setting.value.isActive,
),
trailing: const Padding(
padding: EdgeInsets.only(right: 18.0),
child: Icon(Icons.check),
),
),
if (value) {
await notifier.apply();
} else {
await notifier.reset();
}
},
),
],
),
),
),
);
}).toList(),
],
),
if (filteredSettings.isNotEmpty)
SettingsSectionDefinition(
title: 'Preference Settings',
entries: [
for (final setting in filteredSettings)
SettingsEntryDefinition(
title: setting.value.title ?? setting.key,
subtitle: setting.value.description,
child: _HardeningSettingTile(
groupName: groupName,
settingKey: setting.key,
settingValue: setting.value,
),
),
],
),
];
return SettingsSectionList(
sections: sections,
query: search.rawQuery,
);
},
error: (error, stackTrace) => FailureWidget(
title: 'Could not load preference settings',
exception: error,
onRetry: () => ref.refresh(
preferenceSettingsGroupRepositoryProvider(
PreferencePartition.user,
groupName,
),
),
],
);
},
error: (error, stackTrace) => FailureWidget(
title: 'Could not load preference settings',
exception: error,
onRetry: () => ref.refresh(
preferenceSettingsGroupRepositoryProvider(
PreferencePartition.user,
groupName,
),
loading: () => const Center(child: CircularProgressIndicator()),
),
),
loading: () => const Center(child: CircularProgressIndicator()),
),
),
],
);
}
}
class _HardeningSettingTile extends ConsumerWidget {
const _HardeningSettingTile({
required this.groupName,
required this.settingKey,
required this.settingValue,
});
final String groupName;
final String settingKey;
final PreferenceSetting settingValue;
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
var value = settingValue.value.toString();
if (value.length > 160) {
value = '${value.substring(0, 160)}';
}
return Tooltip(
message: '$settingKey: $value',
child: (!settingValue.shouldBeDefault || !settingValue.isActive)
? SwitchListTile.adaptive(
value: settingValue.isActive,
title: Text(settingValue.title ?? settingKey),
subtitle: Text.rich(
TextSpan(
children: [
if (settingValue.requireUserOptIn)
WidgetSpan(
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
margin: const EdgeInsets.only(right: 8),
decoration: BoxDecoration(
color: theme.colorScheme.error,
borderRadius: BorderRadius.circular(4),
),
child: Text(
'Optional',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w500,
color: theme.colorScheme.onError,
),
),
),
),
if (settingValue.description != null)
TextSpan(text: settingValue.description),
],
),
),
secondary: HardeningGroupIcon(isActive: settingValue.isActive),
onChanged: (value) async {
final notifier = ref.read(
preferenceSettingsGroupRepositoryProvider(
PreferencePartition.user,
groupName,
).notifier,
);
if (value) {
await notifier.apply(filter: [settingKey]);
} else {
await notifier.reset(filter: [settingKey]);
}
},
)
: ListTile(
title: Text(settingValue.title ?? settingKey),
subtitle: settingValue.description.mapNotNull(
(description) => Text(description),
),
leading: HardeningGroupIcon(isActive: settingValue.isActive),
trailing: const Padding(
padding: EdgeInsets.only(right: 18.0),
child: Icon(Icons.check),
),
),
);
}
}
@@ -24,7 +24,6 @@ import 'package:weblibre/features/bangs/data/models/bang_key.dart';
import 'package:weblibre/features/bangs/domain/providers/bangs.dart';
import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart';
import 'package:weblibre/features/user/data/models/general_settings.dart';
import 'package:weblibre/presentation/widgets/selectable_chips.dart';
import 'package:weblibre/presentation/widgets/url_icon.dart';
class DefaultSearchSelector extends HookConsumerWidget {
@@ -35,7 +34,6 @@ class DefaultSearchSelector extends HookConsumerWidget {
final activeBang = ref.watch(
defaultSearchBangDataProvider.select((value) => value.value),
);
final availableBangs = ref.watch(frequentBangListProvider);
Future<void> updateSearchProvider(BangKey key) async {
await ref
@@ -46,47 +44,37 @@ class DefaultSearchSelector extends HookConsumerWidget {
);
}
return availableBangs.when(
skipLoadingOnReload: true,
data: (availableBangs) {
return SizedBox(
height: 48,
child: Row(
children: [
Expanded(
child: SelectableChips(
itemId: (bang) => bang.trigger,
itemAvatar: (bang) =>
UrlIcon([bang.getDefaultUrl()], iconSize: 20),
itemLabel: (bang) => Text(bang.websiteName),
itemTooltip: (bang) => bang.trigger,
availableItems: availableBangs,
selectedItem: activeBang,
onSelected: (bang) async {
await updateSearchProvider(bang.toKey());
},
),
),
IconButton(
onPressed: () async {
final trigger = await const BangSearchRoute().push<BangKey?>(
context,
);
Future<void> pickProvider() async {
final trigger = await const BangSearchRoute().push<BangKey?>(context);
if (trigger != null) {
await updateSearchProvider(trigger);
}
}
if (trigger != null) {
await updateSearchProvider(trigger);
}
},
icon: const Icon(Icons.chevron_right),
),
],
return SizedBox(
height: 48,
child: Row(
children: [
Expanded(
child: activeBang == null
? OutlinedButton.icon(
onPressed: pickProvider,
icon: const Icon(Icons.search),
label: const Text('Choose a search provider'),
)
: ActionChip(
avatar: UrlIcon([activeBang.getDefaultUrl()], iconSize: 20),
label: Text(activeBang.websiteName),
tooltip: activeBang.trigger,
onPressed: pickProvider,
),
),
);
},
error: (error, stackTrace) => Center(
child: Icon(Icons.error, color: Theme.of(context).colorScheme.error),
IconButton(
onPressed: pickProvider,
icon: const Icon(Icons.chevron_right),
),
],
),
loading: () => const SizedBox(height: 48, width: double.infinity),
);
}
}
@@ -0,0 +1,51 @@
/*
* 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';
/// Wraps [child] in the standard surface-container Card used by settings
/// content widgets — unless [embedded] is true, in which case the child is
/// returned as-is so the host container (typically a
/// [SettingsEntryDefinition] already inside a `Card.filled`) doesn't end
/// up with nested cards.
///
/// Use this anywhere a widget is rendered both stand-alone (e.g. inside a
/// dedicated screen) and embedded as the `child` of a
/// [SettingsEntryDefinition].
class SettingsContentCard extends StatelessWidget {
const SettingsContentCard({
super.key,
required this.child,
this.embedded = false,
});
final Widget child;
final bool embedded;
@override
Widget build(BuildContext context) {
if (embedded) return child;
return Card(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
clipBehavior: Clip.antiAlias,
child: child,
);
}
}
@@ -0,0 +1,362 @@
/*
* 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';
import 'package:flutter_hooks/flutter_hooks.dart';
class SettingsEntryDefinition {
final String title;
final String? subtitle;
final List<String> keywords;
final Widget child;
const SettingsEntryDefinition({
required this.title,
required this.child,
this.subtitle,
this.keywords = const [],
});
}
class SettingsSectionDefinition {
final String title;
final List<String> keywords;
final List<SettingsEntryDefinition> entries;
const SettingsSectionDefinition({
required this.title,
required this.entries,
this.keywords = const [],
});
}
/// Result of [useSettingsSearch]: the bound [TextEditingController] used by
/// [SettingsSearchField], plus the trimmed/lowercased query suitable for
/// substring matching with [matchesSettingsSearch].
class SettingsSearchState {
final TextEditingController controller;
final String normalizedQuery;
const SettingsSearchState({
required this.controller,
required this.normalizedQuery,
});
String get rawQuery => controller.text;
}
/// Standard search-field plumbing for a settings screen: returns a controller
/// that should be passed to [SettingsCustomScrollScaffold.searchController] /
/// [SettingsSearchField], plus the normalized query string callers use to
/// filter their own data. Rebuilds the surrounding widget on every keystroke.
SettingsSearchState useSettingsSearch() {
final controller = useTextEditingController();
useListenable(controller);
return SettingsSearchState(
controller: controller,
normalizedQuery: controller.text.trim().toLowerCase(),
);
}
class SettingsDetailScaffold extends HookWidget {
final String title;
final String subtitle;
final IconData icon;
final List<SettingsSectionDefinition> sections;
final List<Widget> actions;
final String searchHintText;
const SettingsDetailScaffold({
super.key,
required this.title,
required this.subtitle,
required this.icon,
required this.sections,
this.actions = const [],
this.searchHintText = 'Search settings',
});
@override
Widget build(BuildContext context) {
final search = useSettingsSearch();
final filteredSections = filterSettingsSections(
sections: sections,
query: search.rawQuery,
);
return Scaffold(
body: SafeArea(
child: FadingScroll(
fadingSize: 25,
builder: (context, controller) {
return CustomScrollView(
controller: controller,
slivers: [
SliverAppBar.large(
centerTitle: false,
title: Text(title),
actions: actions,
),
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
sliver: SliverToBoxAdapter(
child: SettingsSearchField(
controller: search.controller,
hintText: searchHintText,
),
),
),
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 24, 16, 20),
sliver: SliverToBoxAdapter(
child: SettingsSectionList(
sections: filteredSections,
query: search.rawQuery,
),
),
),
],
);
},
),
),
);
}
}
class SettingsCustomScrollScaffold extends StatelessWidget {
final String title;
final List<Widget> actions;
final TextEditingController? searchController;
final String searchHintText;
final List<Widget> slivers;
const SettingsCustomScrollScaffold({
super.key,
required this.title,
required this.slivers,
this.actions = const [],
this.searchController,
this.searchHintText = 'Search settings',
});
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: FadingScroll(
fadingSize: 25,
builder: (context, controller) {
return CustomScrollView(
controller: controller,
slivers: [
SliverAppBar.large(
centerTitle: false,
title: Text(title),
actions: actions,
),
if (searchController != null)
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
sliver: SliverToBoxAdapter(
child: SettingsSearchField(
controller: searchController!,
hintText: searchHintText,
),
),
),
...slivers,
],
);
},
),
),
);
}
}
class SettingsSearchField extends StatelessWidget {
final TextEditingController controller;
final String hintText;
const SettingsSearchField({
super.key,
required this.controller,
this.hintText = 'Search settings',
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return TextField(
controller: controller,
decoration: InputDecoration(
hintText: hintText,
prefixIcon: const Icon(Icons.search),
suffixIcon: controller.text.isEmpty
? null
: IconButton(
onPressed: controller.clear,
icon: const Icon(Icons.close),
),
filled: true,
fillColor: colorScheme.surfaceContainerHighest.withValues(alpha: 0.6),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
),
),
);
}
}
class SettingsSectionList extends StatelessWidget {
final List<SettingsSectionDefinition> sections;
final String query;
const SettingsSectionList({
super.key,
required this.sections,
required this.query,
});
@override
Widget build(BuildContext context) {
if (sections.isEmpty) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 32.0, horizontal: 8.0),
child: Center(
child: Text(
query.trim().isEmpty
? 'No settings available.'
: 'No settings match "$query".',
style: Theme.of(context).textTheme.bodyLarge,
textAlign: TextAlign.center,
),
),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: buildSettingsSectionWidgets(context, sections),
);
}
}
List<Widget> buildSettingsSectionWidgets(
BuildContext context,
List<SettingsSectionDefinition> sections,
) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return [
for (
var sectionIndex = 0;
sectionIndex < sections.length;
sectionIndex++
) ...[
if (sectionIndex > 0) const SizedBox(height: 24),
Text(
sections[sectionIndex].title,
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: [
for (
var entryIndex = 0;
entryIndex < sections[sectionIndex].entries.length;
entryIndex++
) ...[
if (entryIndex > 0) const Divider(height: 1),
sections[sectionIndex].entries[entryIndex].child,
],
],
),
),
],
];
}
List<SettingsSectionDefinition> filterSettingsSections({
required List<SettingsSectionDefinition> sections,
required String query,
}) {
final normalizedQuery = query.trim().toLowerCase();
if (normalizedQuery.isEmpty) return sections;
final filteredSections = <SettingsSectionDefinition>[];
for (final section in sections) {
if (matchesSettingsSearch(normalizedQuery, [
section.title,
...section.keywords,
])) {
filteredSections.add(section);
continue;
}
final filteredEntries = [
for (final entry in section.entries)
if (matchesSettingsSearch(normalizedQuery, [
section.title,
entry.title,
if (entry.subtitle != null) entry.subtitle!,
...section.keywords,
...entry.keywords,
]))
entry,
];
if (filteredEntries.isNotEmpty) {
filteredSections.add(
SettingsSectionDefinition(
title: section.title,
keywords: section.keywords,
entries: filteredEntries,
),
);
}
}
return filteredSections;
}
/// Token-AND substring match used by every settings search. The query is
/// expected to already be trimmed and lowercased (e.g. via
/// [useSettingsSearch] or [filterSettingsSections]).
bool matchesSettingsSearch(String normalizedQuery, List<String> values) {
final tokens = normalizedQuery
.split(RegExp(r'\s+'))
.where((token) => token.isNotEmpty);
final haystack = values.join(' ').toLowerCase();
return tokens.every(haystack.contains);
}
@@ -22,35 +22,118 @@ import 'package:flutter_material_design_icons/flutter_material_design_icons.dart
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/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> toolbarLayoutSettingsSections = [
SettingsSectionDefinition(
title: 'Tab Bar',
entries: [
SettingsEntryDefinition(
title: 'Tab Bar Position',
subtitle: 'Choose whether the tab bar stays at the top or bottom',
keywords: ['top', 'bottom'],
child: _TabBarPositionSection(),
),
SettingsEntryDefinition(
title: 'Tab Bar Style',
subtitle: 'Choose between title and compact layouts',
keywords: ['layout', 'compact'],
child: _TabBarLayoutModeSection(),
),
SettingsEntryDefinition(
title: 'Auto Hide Tab Bar',
subtitle: 'Hide the tab bar when scrolling',
keywords: ['scroll'],
child: _AutoHideTabBarTile(),
),
SettingsEntryDefinition(
title: 'Long Press URL to Copy',
subtitle: 'Copy the current URL from the tab bar',
keywords: ['copy url'],
child: _TabBarLongPressUrlCopyTile(),
),
],
),
SettingsSectionDefinition(
title: 'Contextual Toolbar',
entries: [
SettingsEntryDefinition(
title: 'Show Contextual Toolbar',
subtitle: 'Show an additional toolbar for navigation and actions',
keywords: ['bottom toolbar'],
child: _ShowContextualTabBarTile(),
),
SettingsEntryDefinition(
title: 'Customize Toolbar Buttons',
subtitle: 'Choose which actions appear in the contextual toolbar',
keywords: ['buttons'],
child: _CustomizeToolbarButtonsTile(),
),
],
),
SettingsSectionDefinition(
title: 'Quick Tab Switcher',
entries: [
SettingsEntryDefinition(
title: 'Show Quick Tab Switcher Bar',
subtitle: 'Show a bar for switching to recent tabs',
keywords: ['recent tabs'],
child: _ShowQuickTabSwitcherBarTile(),
),
SettingsEntryDefinition(
title: 'Quick Tab Switcher Mode',
subtitle: 'Choose how the switcher orders and groups tabs',
keywords: ['recently used', 'container tabs'],
child: _QuickTabSwitcherModeSection(),
),
SettingsEntryDefinition(
title: 'History Fallback in Quick Tab Switcher',
subtitle: 'Use history suggestions when there are no matching tabs',
keywords: ['suggestions'],
child: _QuickTabSwitcherHistorySuggestionsTile(),
),
SettingsEntryDefinition(
title: 'Show Titles in Quick Tab Switcher',
subtitle: 'Display page titles in the switcher list',
keywords: ['page titles'],
child: _QuickTabSwitcherShowTitlesTile(),
),
],
),
SettingsSectionDefinition(
title: 'Tab View',
entries: [
SettingsEntryDefinition(
title: 'Bottom Sheet Tab View',
subtitle: 'Open the tab switcher as a bottom sheet',
keywords: ['sheet'],
child: _BottomSheetTabViewTile(),
),
SettingsEntryDefinition(
title: 'Show Favicons in List View',
subtitle: 'Display site icons in the tab list',
keywords: ['icons'],
child: _TabListShowFaviconsTile(),
),
],
),
];
class ToolbarLayoutContent extends StatelessWidget {
const ToolbarLayoutContent({super.key});
final String query;
const ToolbarLayoutContent({super.key, this.query = ''});
@override
Widget build(BuildContext context) {
return const Column(
children: [
SettingSection(name: 'Tab Bar'),
_TabBarPositionSection(),
_TabBarLayoutModeSection(),
_AutoHideTabBarTile(),
_TabBarLongPressUrlCopyTile(),
SettingSection(name: 'Contextual Toolbar'),
_ShowContextualTabBarTile(),
_CustomizeToolbarButtonsTile(),
SettingSection(name: 'Quick Tab Switcher'),
_ShowQuickTabSwitcherBarTile(),
_QuickTabSwitcherModeSection(),
_QuickTabSwitcherHistorySuggestionsTile(),
_QuickTabSwitcherShowTitlesTile(),
SettingSection(name: 'Tab View'),
_BottomSheetTabViewTile(),
_TabListShowFaviconsTile(),
],
final filteredSections = filterSettingsSections(
sections: toolbarLayoutSettingsSections,
query: query,
);
return SettingsSectionList(sections: filteredSections, query: query);
}
}
@@ -30,6 +30,7 @@ import 'package:weblibre/features/geckoview/features/browser/presentation/widget
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/navigation_buttons.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tabs_action_button.dart';
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/data/models/general_settings.dart';
class TabBarPreviewHeaderDelegate extends SliverPersistentHeaderDelegate {
@@ -136,9 +137,7 @@ class TabBarPreviewCard extends HookWidget {
settings.effectiveUiQuickTabSwitcherMode() ==
QuickTabSwitcherMode.containerTabs,
url: Uri.parse('https://example.com/news'),
color: settings.showContainerUi
? colorScheme.primary.withValues(alpha: 0.18)
: null,
color: settings.showContainerUi ? colorScheme.primary : null,
avatar: const Icon(MdiIcons.web, size: 20),
),
QuickTabSwitcherItem(
@@ -177,6 +176,9 @@ class TabBarPreviewCard extends HookWidget {
avatar: const Icon(MdiIcons.web, size: 20),
),
];
final previewContainerPalette = settings.showContainerUi
? ContainerColors.palette(context, colorScheme.primary)
: null;
final tabCountButton = TabsCountButtonView(
isActive: false,
@@ -236,9 +238,8 @@ class TabBarPreviewCard extends HookWidget {
showQuickTabSwitcherBar: settings.tabBarShowQuickTabSwitcherBar,
displayAppBar: true,
displayQuickTabSwitcher: true,
backgroundColor: settings.showContainerUi
? colorScheme.primaryContainer.withValues(alpha: 0.55)
: colorScheme.surfaceContainer,
backgroundColor:
previewContainerPalette?.surfaceColor ?? colorScheme.surfaceContainer,
title: settings.tabBarLayout == TabBarLayout.compact
? _CompactPreviewTitle(tabState: previewTabState)
: _RegularPreviewTitle(tabState: previewTabState),
@@ -253,9 +254,8 @@ class TabBarPreviewCard extends HookWidget {
showQuickTabSwitcherBar: false,
displayAppBar: true,
displayQuickTabSwitcher: false,
backgroundColor: settings.showContainerUi
? colorScheme.primaryContainer.withValues(alpha: 0.55)
: colorScheme.surfaceContainer,
backgroundColor:
previewContainerPalette?.surfaceColor ?? colorScheme.surfaceContainer,
title: settings.tabBarLayout == TabBarLayout.compact
? _CompactPreviewTitle(tabState: previewTabState)
: _RegularPreviewTitle(tabState: previewTabState),