prepare for multiple apps

This commit is contained in:
Fabian Freund
2026-04-06 12:23:11 +02:00
parent bd1600e8dc
commit 5afc323f04
904 changed files with 29 additions and 29 deletions
@@ -0,0 +1,151 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:hooks_riverpod/hooks_riverpod.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';
import 'package:weblibre/utils/form_validators.dart';
const _defaultServerUrl = 'https://services.addons.mozilla.org';
class AddonCollectionScreen extends HookConsumerWidget {
const AddonCollectionScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final formKey = useMemoized(() => GlobalKey<FormState>());
final addonCollectionSetting = ref.watch(
engineSettingsWithDefaultsProvider.select(
(value) => value.addonCollection,
),
);
final serverURLController = useTextEditingController(
text: addonCollectionSetting?.serverURL ?? _defaultServerUrl,
keys: [addonCollectionSetting],
);
final collectionUserController = useTextEditingController(
text: addonCollectionSetting?.collectionUser,
keys: [addonCollectionSetting],
);
final collectionNameController = useTextEditingController(
text: addonCollectionSetting?.collectionName,
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),
);
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);
}
},
child: const Text('Save & Restart Browser'),
),
],
),
),
),
),
);
}
}
@@ -0,0 +1,381 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:developer';
import 'package:fading_scroll/fading_scroll.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/providers/app_state.dart';
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/user/data/models/engine_settings.dart';
import 'package:weblibre/features/user/domain/providers.dart';
import 'package:weblibre/features/user/domain/repositories/cache.dart';
import 'package:weblibre/features/user/domain/repositories/engine_settings.dart';
import 'package:weblibre/utils/exit_app.dart';
import 'package:weblibre/utils/ui_helper.dart';
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(),
],
);
}
}
class _JavaScriptTile extends HookConsumerWidget {
const _JavaScriptTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final javascriptEnabled = ref.watch(
engineSettingsWithDefaultsProvider.select((s) => s.javascriptEnabled),
);
return SwitchListTile.adaptive(
title: const Text('Enable JavaScript'),
subtitle: const Text(
'While turning off JavaScript can boost security, privacy, and speed, it may cause some sites to not work as intended.',
),
// ignore: deprecated_member_use use this icon for now
secondary: const Icon(MdiIcons.languageJavascript),
value: javascriptEnabled,
onChanged: (value) async {
await ref
.read(saveEngineSettingsControllerProvider.notifier)
.save(
(currentSettings) =>
currentSettings.copyWith.javascriptEnabled(value),
);
},
);
}
}
class _UserAgentTile extends HookConsumerWidget {
const _UserAgentTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final userAgent = ref.watch(
engineSettingsWithDefaultsProvider.select((s) => s.userAgent),
);
final userAgentTextController = useTextEditingController(
text: userAgent,
keys: [userAgent],
);
return ListTile(
leading: const Icon(MdiIcons.cardAccountDetails),
title: TextField(
controller: userAgentTextController,
decoration: const InputDecoration(
labelText: 'Custom User Agent',
floatingLabelBehavior: FloatingLabelBehavior.always,
hintText: 'Mozilla/5.0 …',
),
onSubmitted: (value) async {
await ref
.read(saveEngineSettingsControllerProvider.notifier)
.save(
(currentSettings) => currentSettings.copyWith.userAgent(value),
);
if (context.mounted) {
final restart = await showUserAgentRestartDialog(context);
if (restart == true) {
await exitApp(ref.container);
}
}
},
),
);
}
}
class _EnterpriseRootsTile extends HookConsumerWidget {
const _EnterpriseRootsTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final enterpriseRootsEnabled = ref.watch(
engineSettingsWithDefaultsProvider.select(
(s) => s.enterpriseRootsEnabled,
),
);
return SwitchListTile.adaptive(
title: const Text('Use third party CA certificates'),
subtitle: const Text(
'Allows the use of third party certificates from the Android CA store',
),
secondary: const Icon(MdiIcons.certificate),
value: enterpriseRootsEnabled,
onChanged: (value) async {
await ref
.read(saveEngineSettingsControllerProvider.notifier)
.save(
(currentSettings) =>
currentSettings.copyWith.enterpriseRootsEnabled(value),
);
},
);
}
}
class _ExperimentalSettingsTile extends StatelessWidget {
const _ExperimentalSettingsTile();
@override
Widget build(BuildContext context) {
return ListTile(
title: const Text('Experimental Features'),
subtitle: const Text('Low-level runtime features and startup behavior'),
contentPadding: const EdgeInsets.symmetric(
vertical: 8.0,
horizontal: 16.0,
),
leading: const Icon(MdiIcons.flaskOutline),
trailing: const Icon(Icons.chevron_right),
onTap: () async {
await ExperimentalSettingsRoute().push(context);
},
);
}
}
class _IconCacheTile extends HookConsumerWidget {
const _IconCacheTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final size = ref.watch(
iconCacheSizeMegabytesProvider.select((value) => value.value),
);
return CustomListTile(
title: 'Icon Cache',
subtitle: 'Stored favicons',
prefix: Padding(
padding: const EdgeInsets.only(right: 16.0),
child: Icon(
Icons.image,
size: 24,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
content: Padding(
padding: const EdgeInsets.only(top: 8.0),
child: DefaultTextStyle(
style: GoogleFonts.robotoMono(
textStyle: DefaultTextStyle.of(context).style,
),
child: Table(
columnWidths: const {0: FixedColumnWidth(100)},
children: [
TableRow(
children: [
const Text('Size'),
Text('${size?.toStringAsFixed(2) ?? 0} MB'),
],
),
],
),
),
),
suffix: FilledButton.icon(
onPressed: () async {
await ref.read(cacheRepositoryProvider.notifier).clearCache();
},
icon: const Icon(Icons.delete),
label: const Text('Clear'),
),
);
}
}
class _ErrorLogsTile extends StatelessWidget {
const _ErrorLogsTile();
@override
Widget build(BuildContext context) {
return CustomListTile(
title: 'Error Logs',
subtitle: 'View and copy logs for issue reporting',
prefix: Padding(
padding: const EdgeInsets.only(right: 16.0),
child: Icon(
Icons.bug_report,
size: 24,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
suffix: FilledButton.icon(
onPressed: () async {
await ErrorLogsRoute().push(context);
},
icon: const Icon(Icons.open_in_new),
label: const Text('View'),
),
);
}
}
class _DartVmTile extends StatelessWidget {
const _DartVmTile();
@override
Widget build(BuildContext context) {
if (!kDebugMode) return const SizedBox.shrink();
return CustomListTile(
title: 'Dart VM',
subtitle: 'Copy Dart VM service URL',
prefix: Padding(
padding: const EdgeInsets.only(right: 16.0),
child: Icon(
Icons.bug_report,
size: 24,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
suffix: FilledButton.icon(
onPressed: () async {
final serviceProtocolInfo = await Service.getInfo();
await Clipboard.setData(
ClipboardData(
text: serviceProtocolInfo.serverUri?.toString() ?? 'Error',
),
);
if (context.mounted) {
showInfoMessage(context, 'Service URL copied');
}
},
icon: const Icon(Icons.copy),
label: const Text('Copy'),
),
);
}
}
class _ResetUITile extends ConsumerWidget {
const _ResetUITile();
@override
Widget build(BuildContext context, WidgetRef ref) {
return CustomListTile(
title: 'Reset UI',
subtitle: 'Rebuild the entire browser UI',
prefix: Padding(
padding: const EdgeInsets.only(right: 16.0),
child: Icon(
Icons.bug_report,
size: 24,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
suffix: FilledButton.icon(
onPressed: () {
ref.read(appStateKeyProvider.notifier).reset();
},
icon: const Icon(Icons.restore),
label: const Text('Reset'),
),
);
}
}
@@ -0,0 +1,74 @@
/*
* 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: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';
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',
),
],
);
},
),
),
);
}
}
@@ -0,0 +1,744 @@
/*
* 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_material_design_icons/flutter_material_design_icons.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/design/app_colors.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/geckoview/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/user/data/models/general_settings.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
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(),
_NewTabPositionSection(),
_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(),
],
);
}
}
class _NewTabDefaultSection extends HookConsumerWidget {
const _NewTabDefaultSection();
@override
Widget build(BuildContext context, WidgetRef ref) {
final appColors = AppColors.of(context);
final settings = ref.watch(generalSettingsWithDefaultsProvider);
final defaultCreateTabType = settings.effectiveDefaultCreateTabType;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ListTile(
title: Text('New Tab Default'),
subtitle: Text('Choose the default type for manually created tabs'),
leading: Icon(MdiIcons.tab),
contentPadding: EdgeInsets.zero,
),
Center(
child: SegmentedButton(
showSelectedIcon: false,
segments: [
const ButtonSegment(
value: TabType.regular,
label: Text('Regular'),
icon: Icon(MdiIcons.tab),
),
ButtonSegment(
value: TabType.private,
label: const Text('Private'),
icon: Icon(
MdiIcons.dominoMask,
color: defaultCreateTabType == TabType.private
? null
: appColors.privateTabPurple,
),
),
if (settings.showIsolatedTabUi)
ButtonSegment(
value: TabType.isolated,
label: const Text('Isolated'),
icon: Icon(
MdiIcons.snowflake,
color: defaultCreateTabType == TabType.isolated
? null
: appColors.isolatedTabTeal,
),
),
],
selected: {defaultCreateTabType},
onSelectionChanged: (value) async {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(currentSettings) => currentSettings.copyWith
.storedDefaultCreateTabType(value.first),
);
},
style: switch (defaultCreateTabType) {
TabType.regular => null,
TabType.private => SegmentedButton.styleFrom(
selectedBackgroundColor: appColors.privateSelectionOverlay,
),
TabType.child => null,
TabType.isolated => SegmentedButton.styleFrom(
selectedBackgroundColor: appColors.isolatedSelectionOverlay,
),
},
),
),
],
),
);
}
}
class _SmallWebTabDefaultSection extends HookConsumerWidget {
const _SmallWebTabDefaultSection();
@override
Widget build(BuildContext context, WidgetRef ref) {
final appColors = AppColors.of(context);
final settings = ref.watch(generalSettingsWithDefaultsProvider);
final smallWebTabType = settings.smallWebTabType;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ListTile(
title: Text('Small Web Tab Default'),
subtitle: Text('Choose the tab type used when entering Small Web'),
leading: Icon(Icons.explore),
contentPadding: EdgeInsets.zero,
),
Center(
child: SegmentedButton(
showSelectedIcon: false,
segments: [
const ButtonSegment(
value: TabType.regular,
label: Text('Regular'),
icon: Icon(MdiIcons.tab),
),
ButtonSegment(
value: TabType.private,
label: const Text('Private'),
icon: Icon(
MdiIcons.dominoMask,
color: smallWebTabType == TabType.private
? null
: appColors.privateTabPurple,
),
),
if (settings.showIsolatedTabUi)
ButtonSegment(
value: TabType.isolated,
label: const Text('Isolated'),
icon: Icon(
MdiIcons.snowflake,
color: smallWebTabType == TabType.isolated
? null
: appColors.isolatedTabTeal,
),
),
],
selected: {smallWebTabType},
onSelectionChanged: (value) async {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(currentSettings) =>
currentSettings.copyWith.smallWebTabType(value.first),
);
},
style: switch (smallWebTabType) {
TabType.regular => null,
TabType.private => SegmentedButton.styleFrom(
selectedBackgroundColor: appColors.privateSelectionOverlay,
),
TabType.child => null,
TabType.isolated => SegmentedButton.styleFrom(
selectedBackgroundColor: appColors.isolatedSelectionOverlay,
),
},
),
),
],
),
);
}
}
class _ExternalLinkHandlingSection extends HookConsumerWidget {
const _ExternalLinkHandlingSection();
@override
Widget build(BuildContext context, WidgetRef ref) {
final appColors = AppColors.of(context);
final tabIntentOpenSetting = ref.watch(
generalSettingsWithDefaultsProvider.select((s) => s.tabIntentOpenSetting),
);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ListTile(
title: Text('External Link Handling'),
subtitle: Text('Choose how external links open in WebLibre'),
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,
),
),
],
selected: {tabIntentOpenSetting},
onSelectionChanged: (value) async {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(currentSettings) => currentSettings.copyWith
.tabIntentOpenSetting(value.first),
);
},
style: switch (tabIntentOpenSetting) {
TabIntentOpenSetting.regular => null,
TabIntentOpenSetting.private => SegmentedButton.styleFrom(
selectedBackgroundColor: appColors.privateSelectionOverlay,
),
TabIntentOpenSetting.ask => null,
},
),
),
],
),
);
}
}
class _NewTabPositionSection extends HookConsumerWidget {
const _NewTabPositionSection();
@override
Widget build(BuildContext context, WidgetRef ref) {
final newTabPosition = ref.watch(
generalSettingsWithDefaultsProvider.select((s) => s.newTabPosition),
);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ListTile(
title: Text('New Tab Position'),
subtitle: Text('Choose where newly created tabs appear by default'),
leading: Icon(MdiIcons.reorderHorizontal),
contentPadding: EdgeInsets.zero,
),
Center(
child: SegmentedButton(
showSelectedIcon: false,
segments: const [
ButtonSegment(
value: NewTabPosition.first,
label: Text('First'),
icon: Icon(MdiIcons.arrowCollapseLeft),
),
ButtonSegment(
value: NewTabPosition.end,
label: Text('End'),
icon: Icon(MdiIcons.arrowCollapseRight),
),
],
selected: {newTabPosition},
onSelectionChanged: (value) async {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(currentSettings) =>
currentSettings.copyWith.newTabPosition(value.first),
);
},
),
),
],
),
);
}
}
class _CreateChildTabsTile extends HookConsumerWidget {
const _CreateChildTabsTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final createChildTabsOption = ref.watch(
generalSettingsWithDefaultsProvider.select(
(s) => s.createChildTabsOption,
),
);
return SwitchListTile.adaptive(
title: const Text('Create Child Tabs'),
subtitle: const Text(
'Display a button to create a child tab under the current tab (tree view only)',
),
secondary: const Icon(MdiIcons.fileTree),
value: createChildTabsOption,
onChanged: (value) async {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(currentSettings) =>
currentSettings.copyWith.createChildTabsOption(value),
);
},
);
}
}
class _ShowContainerUiTile extends HookConsumerWidget {
const _ShowContainerUiTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final showContainerUi = ref.watch(
generalSettingsWithDefaultsProvider.select((s) => s.showContainerUi),
);
return SwitchListTile.adaptive(
title: const Text('Show Container UI'),
subtitle: const Text('Show container selectors, menus, and management'),
secondary: const Icon(MdiIcons.folder),
value: showContainerUi,
onChanged: (value) async {
await ref.read(saveGeneralSettingsControllerProvider.notifier).save((
currentSettings,
) {
var updated = currentSettings.copyWith.showContainerUi(value);
if (!value &&
updated.quickTabSwitcherMode ==
QuickTabSwitcherMode.containerTabs) {
updated = updated.copyWith.quickTabSwitcherMode(
QuickTabSwitcherMode.lastUsedTabs,
);
}
return updated;
});
if (!value) {
ref.read(selectedContainerProvider.notifier).clearContainer();
}
},
);
}
}
class _ShowIsolatedTabUiTile extends HookConsumerWidget {
const _ShowIsolatedTabUiTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final showIsolatedTabUi = ref.watch(
generalSettingsWithDefaultsProvider.select((s) => s.showIsolatedTabUi),
);
return SwitchListTile.adaptive(
title: const Text('Show Isolated Tab UI'),
subtitle: const Text('Show isolated-tab creation options in the UI'),
secondary: Icon(
MdiIcons.snowflake,
color: AppColors.of(context).isolatedTabTeal,
),
value: showIsolatedTabUi,
onChanged: (value) async {
await ref.read(saveGeneralSettingsControllerProvider.notifier).save((
currentSettings,
) {
var updated = currentSettings.copyWith.showIsolatedTabUi(value);
if (!value &&
updated.storedDefaultCreateTabType == TabType.isolated) {
updated = updated.copyWith.storedDefaultCreateTabType(
TabType.regular,
);
}
return updated;
});
},
);
}
}
class _TabBarSwipeBehaviorSection extends HookConsumerWidget {
const _TabBarSwipeBehaviorSection();
@override
Widget build(BuildContext context, WidgetRef ref) {
final tabBarSwipeAction = ref.watch(
generalSettingsWithDefaultsProvider.select((s) => s.tabBarSwipeAction),
);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ListTile(
title: Text('Tab Bar Swipe Behavior'),
leading: Icon(MdiIcons.gestureSwipeHorizontal),
contentPadding: EdgeInsets.zero,
),
RadioGroup(
groupValue: tabBarSwipeAction,
onChanged: (value) async {
if (value != null) {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(currentSettings) =>
currentSettings.copyWith.tabBarSwipeAction(value),
);
}
},
child: const Column(
children: [
RadioListTile.adaptive(
value: TabBarSwipeAction.switchLastOpened,
title: Text('Switch to Last Used Tab'),
subtitle: Text(
'Swipe to toggle between current and previously opened tab',
),
),
RadioListTile.adaptive(
value: TabBarSwipeAction.navigateOrderedTabs,
title: Text('Navigate Sequential Tabs'),
subtitle: Text(
'Swipe left/right to move through tabs in order',
),
),
],
),
),
],
),
);
}
}
class _AppLinksModeSection extends HookConsumerWidget {
const _AppLinksModeSection();
@override
Widget build(BuildContext context, WidgetRef ref) {
final appLinksMode = ref.watch(
appLinksModeProvider.select((value) => value.value),
);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ListTile(
title: Text('Open Links in Apps'),
subtitle: Text(
'Choose how links that can be opened in other apps are handled',
),
leading: Icon(MdiIcons.openInApp),
contentPadding: EdgeInsets.zero,
),
RadioGroup(
groupValue: appLinksMode,
onChanged: (value) async {
if (value != null) {
await ref.read(appLinksModeProvider.notifier).setMode(value);
}
},
child: const Column(
children: [
RadioListTile.adaptive(
value: AppLinksMode.always,
title: Text('Always'),
subtitle: Text(
'Always open links in their native apps without asking',
),
),
RadioListTile.adaptive(
value: AppLinksMode.ask,
title: Text('Ask before opening'),
subtitle: Text('Show a prompt before opening links in apps'),
),
RadioListTile.adaptive(
value: AppLinksMode.never,
title: Text('Never'),
subtitle: Text(
'Always open links in the browser instead of apps',
),
),
],
),
),
],
),
);
}
}
class _PullToRefreshTile extends HookConsumerWidget {
const _PullToRefreshTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final pullToRefreshEnabled = ref.watch(
generalSettingsWithDefaultsProvider.select((s) => s.pullToRefreshEnabled),
);
return SwitchListTile.adaptive(
title: const Text('Pull to Refresh'),
subtitle: const Text('Swipe down on pages to reload them'),
secondary: const Icon(MdiIcons.gestureSwipeDown),
value: pullToRefreshEnabled,
onChanged: (value) async {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(currentSettings) =>
currentSettings.copyWith.pullToRefreshEnabled(value),
);
},
);
}
}
class _DoubleBackCloseTabTile extends HookConsumerWidget {
const _DoubleBackCloseTabTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final doubleBackCloseTab = ref.watch(
generalSettingsWithDefaultsProvider.select((s) => s.doubleBackCloseTab),
);
return SwitchListTile.adaptive(
title: const Text('Double Back to Close Tab'),
subtitle: const Text(
'When enabled, press back twice to close the tab. When disabled, back button only navigates page history.',
),
secondary: const Icon(MdiIcons.gestureDoubleTap),
value: doubleBackCloseTab,
onChanged: (value) async {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(currentSettings) =>
currentSettings.copyWith.doubleBackCloseTab(value),
);
},
);
}
}
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();
@override
Widget build(BuildContext context, WidgetRef ref) {
final allowNonManifestPwaInstall = ref.watch(
generalSettingsWithDefaultsProvider
.select((s) => s.allowNonManifestPwaInstall),
);
return SwitchListTile.adaptive(
title: const Text('Install Sites as Apps'),
subtitle: const Text(
'Allow installing websites without a PWA manifest as standalone apps',
),
secondary: const Icon(Icons.add_to_home_screen),
value: allowNonManifestPwaInstall,
onChanged: (value) async {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(currentSettings) =>
currentSettings.copyWith.allowNonManifestPwaInstall(value),
);
},
);
}
}
class _UrlCleanerSettingsTile extends StatelessWidget {
const _UrlCleanerSettingsTile();
@override
Widget build(BuildContext context) {
return ListTile(
leading: const Icon(MdiIcons.broom),
title: const Text('URL Cleaner'),
subtitle: const Text('Tracking removal rules and catalog updates'),
trailing: const Icon(Icons.chevron_right),
onTap: () async {
await UrlCleanerSettingsRoute().push(context);
},
);
}
}
class _UnshortenerSettingsTile extends StatelessWidget {
const _UnshortenerSettingsTile();
@override
Widget build(BuildContext context) {
return ListTile(
leading: const Icon(MdiIcons.linkVariant),
title: const Text('Unshortener'),
subtitle: const Text('Short link resolver and API token'),
trailing: const Icon(Icons.chevron_right),
onTap: () async {
await UnshortenerSettingsRoute().push(context);
},
);
}
}
@@ -0,0 +1,457 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/data/providers/toolbar_button_configs.dart';
import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/data/repositories/contextual_toolbar_config_repository.dart';
import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/domain/entities/toolbar_fallback_choice.dart';
import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/presentation/models/contextual_toolbar_scope.dart';
import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/presentation/toolbar_button_registry.dart';
import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/presentation/widgets/contextual_toolbar.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart';
import 'package:weblibre/features/user/data/database/definitions.drift.dart';
class ContextualToolbarSettingsScreen extends HookConsumerWidget {
const ContextualToolbarSettingsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final configs = ref.watch(effectiveToolbarButtonConfigsProvider);
final repository = ref.watch(contextualToolbarConfigRepositoryProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Customize Toolbar'),
actions: [
MenuAnchor(
builder: (context, controller, child) => IconButton(
onPressed: () {
if (controller.isOpen) {
controller.close();
} else {
controller.open();
}
},
icon: const Icon(Icons.more_vert),
),
menuChildren: [
MenuItemButton(
leadingIcon: const Icon(Icons.restore),
onPressed: () => _resetToDefaults(ref),
child: const Text('Reset to Defaults'),
),
],
),
],
),
body: SafeArea(
child: CustomScrollView(
slivers: [
SliverPersistentHeader(
pinned: true,
delegate: _ToolbarPreviewDelegate(configs: configs.value),
),
SliverToBoxAdapter(
child: ListTile(
title: Text(
'Button Order',
style: Theme.of(context).textTheme.labelLarge,
),
),
),
SliverReorderableList(
itemCount: configs.value.length,
onReorder: (oldIndex, newIndex) =>
_onReorder(configs.value, oldIndex, newIndex, repository),
itemBuilder: (context, index) {
final config = configs.value[index];
return _ToolbarButtonConfigTile(
key: ValueKey(config.buttonId),
index: index,
config: config,
repository: repository,
);
},
),
],
),
),
);
}
({String movedId, int targetIndex}) _resolveToolbarReorder(
List<ToolbarButtonConfig> configs,
int oldIndex,
int newIndex,
) {
var targetIndex = newIndex;
if (targetIndex > oldIndex) {
targetIndex -= 1;
}
targetIndex = targetIndex.clamp(0, configs.length - 1);
return (movedId: configs[oldIndex].buttonId, targetIndex: targetIndex);
}
void _onReorder(
List<ToolbarButtonConfig> configs,
int oldIndex,
int newIndex,
ContextualToolbarConfigRepository repository,
) {
if (oldIndex == newIndex) return;
final reorder = _resolveToolbarReorder(configs, oldIndex, newIndex);
if (reorder.targetIndex == oldIndex) return;
unawaited(
_reorderViaDb(
repository,
configs,
oldIndex,
reorder.targetIndex,
reorder.movedId,
),
);
}
Future<void> _reorderViaDb(
ContextualToolbarConfigRepository repository,
List<ToolbarButtonConfig> configs,
int oldIndex,
int targetIndex,
String movedId,
) async {
final String orderKey;
if (targetIndex <= 0) {
orderKey = await repository.generateLeadingOrderKey();
} else if (targetIndex >= configs.length - 1) {
orderKey = await repository.generateTrailingOrderKey();
} else if (targetIndex < oldIndex) {
orderKey =
await repository.generateOrderKeyAfterButtonId(
configs[targetIndex - 1].buttonId,
) ??
await repository.generateLeadingOrderKey();
} else {
orderKey = await repository.generateOrderKeyBeforeButtonId(
configs[targetIndex + 1].buttonId,
);
}
await repository.assignOrderKey(movedId, orderKey: orderKey);
}
Future<void> _resetToDefaults(WidgetRef ref) async {
final repository = ref.read(contextualToolbarConfigRepositoryProvider);
await repository.replaceAll(defaultToolbarButtonConfigs.value);
}
}
class _ToolbarButtonConfigTile extends HookConsumerWidget {
const _ToolbarButtonConfigTile({
super.key,
required this.index,
required this.config,
required this.repository,
});
final int index;
final ToolbarButtonConfig config;
final ContextualToolbarConfigRepository repository;
@override
Widget build(BuildContext context, WidgetRef ref) {
final def = toolbarButtonRegistryById[config.buttonId];
if (def == null) return const SizedBox.shrink();
final isVisible = config.isVisible;
final hasStatefulFallback =
def.isPrimaryAvailable != null || def.spec.defaultFallback != null;
final fallbackOptions = toolbarButtonRegistry
.where(
(d) =>
d.spec.id.name != config.buttonId && d.spec.canBeFallbackTarget,
)
.toList();
final longPressActions = def.longPressActions;
return Material(
color: Colors.transparent,
child: ListTile(
leading: Icon(def.icon),
title: Text(def.label),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (hasStatefulFallback)
_FallbackPicker(
current: ToolbarFallbackChoice.fromStored(config.fallbackId),
options: fallbackOptions,
onChanged: (newFallback) => repository.assignFallback(
config.buttonId,
(newFallback ?? ToolbarFallbackNone()).toStoredFallbackId(),
),
),
if (longPressActions.isNotEmpty)
_LongPressHint(
buttonLabel: def.label,
icon: def.icon,
actions: longPressActions,
),
],
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Switch.adaptive(
value: isVisible,
onChanged: (v) =>
repository.assignVisibility(config.buttonId, visible: v),
),
ReorderableDragStartListener(
index: index,
child: const Padding(
padding: EdgeInsets.all(8),
child: Icon(Icons.drag_handle),
),
),
],
),
),
);
}
}
class _LongPressHint extends StatelessWidget {
const _LongPressHint({
required this.buttonLabel,
required this.icon,
required this.actions,
});
final String buttonLabel;
final IconData icon;
final List<String> actions;
@override
Widget build(BuildContext context) {
return InkWell(
borderRadius: BorderRadius.circular(4),
onTap: () => _showLongPressDetails(context),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.touch_app,
size: 14,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
Flexible(
child: Text(
'Long press available',
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontSize: 12,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 4),
Icon(
Icons.info_outline,
size: 14,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
],
),
),
);
}
Future<void> _showLongPressDetails(BuildContext context) async {
await showModalBottomSheet(
context: context,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (context) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, color: Theme.of(context).colorScheme.primary),
const SizedBox(width: 12),
Text(
'$buttonLabel Long Press',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 8),
Text(
'Press and hold this button to access:',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 12),
...actions.map(
(action) => Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
Icon(
Icons.touch_app,
size: 18,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 12),
Expanded(
child: Text(
action,
style: Theme.of(context).textTheme.bodyLarge,
),
),
],
),
),
),
const SizedBox(height: 16),
],
),
),
);
},
);
}
}
class _FallbackPicker extends StatelessWidget {
const _FallbackPicker({
required this.current,
required this.options,
required this.onChanged,
});
final ToolbarFallbackChoice current;
final List<ToolbarButtonDefinition> options;
final ValueChanged<ToolbarFallbackChoice?> onChanged;
@override
Widget build(BuildContext context) {
return DropdownButton<ToolbarFallbackChoice>(
value: current,
hint: const Text('No fallback'),
isExpanded: true,
isDense: true,
padding: const EdgeInsets.symmetric(vertical: 2.0),
underline: const SizedBox.shrink(),
items: [
DropdownMenuItem(
value: ToolbarFallbackNone(),
child: const Text('No fallback'),
),
for (final opt in options)
DropdownMenuItem(
value: ToolbarFallbackButton(buttonId: opt.spec.id.name),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(opt.icon, size: 16),
const SizedBox(width: 8),
Text(opt.label),
],
),
),
],
onChanged: onChanged,
);
}
}
class _ToolbarPreviewDelegate extends SliverPersistentHeaderDelegate {
const _ToolbarPreviewDelegate({required this.configs});
final List<ToolbarButtonConfig> configs;
static const _previewHeight = BrowserTabBar.contextualToolabarHeight;
@override
double get minExtent => _previewHeight;
@override
double get maxExtent => _previewHeight;
@override
Widget build(
BuildContext context,
double shrinkOffset,
bool overlapsContent,
) {
return SizedBox(
height: maxExtent,
child: ColoredBox(
color: Theme.of(context).colorScheme.surfaceContainer,
child: _ToolbarPreview(configs: configs),
),
);
}
@override
bool shouldRebuild(_ToolbarPreviewDelegate old) => old.configs != configs;
}
class _ToolbarPreview extends ConsumerWidget {
const _ToolbarPreview({required this.configs});
final List<ToolbarButtonConfig> configs;
@override
Widget build(BuildContext context, WidgetRef ref) {
final visibleConfigs = configs.where((c) => c.isVisible).toList();
final scope = ContextualToolbarScope(
selectedTabId: null,
displayedSheet: null,
tabState: null,
isPreview: true,
);
final buttons = visibleConfigs.map((config) {
final def = toolbarButtonRegistryById[config.buttonId];
if (def == null) return const SizedBox.shrink();
return def.builder(scope, context, ref);
}).toList();
return ContextualToolbarView(buttons: buttons);
}
}
@@ -0,0 +1,398 @@
/*
* 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_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/user/data/models/engine_settings.dart';
import 'package:weblibre/features/user/domain/repositories/engine_settings.dart';
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(),
],
);
},
),
),
);
}
}
class _AllowlistSection extends HookConsumerWidget {
const _AllowlistSection();
@override
Widget build(BuildContext context, WidgetRef ref) {
final allowListBaseline = ref.watch(
engineSettingsWithDefaultsProvider.select((s) => s.allowListBaseline),
);
final allowListConvenience = ref.watch(
engineSettingsWithDefaultsProvider.select((s) => s.allowListConvenience),
);
return Column(
children: [
const SettingSection(name: 'Allowlist Exceptions'),
SwitchListTile.adaptive(
title: const Text('Fix website major issues'),
subtitle: const Text(
'Apply exceptions required to avoid major website breakage (recommended)',
),
secondary: const Icon(MdiIcons.shieldCheck),
value: allowListBaseline,
onChanged: (value) async {
await ref
.read(saveEngineSettingsControllerProvider.notifier)
.save((s) => s.copyWith.allowListBaseline(value));
},
),
SwitchListTile.adaptive(
title: const Text('Fix website minor issues'),
subtitle: const Text(
'Apply exceptions to fix minor issues and enable convenience features',
),
secondary: const Icon(MdiIcons.shieldHalfFull),
value: allowListConvenience,
onChanged: (value) async {
await ref
.read(saveEngineSettingsControllerProvider.notifier)
.save((s) => s.copyWith.allowListConvenience(value));
},
),
],
);
}
}
class _CookiesSection extends HookConsumerWidget {
const _CookiesSection();
@override
Widget build(BuildContext context, WidgetRef ref) {
final blockCookies = ref.watch(
engineSettingsWithDefaultsProvider.select((s) => s.blockCookies),
);
final customCookiePolicy = ref.watch(
engineSettingsWithDefaultsProvider.select((s) => s.customCookiePolicy),
);
return Column(
children: [
const SettingSection(name: 'Cookies'),
SwitchListTile.adaptive(
title: const Text('Block Cookies'),
subtitle: const Text('Block cookies based on the policy below'),
secondary: const Icon(MdiIcons.cookie),
value: blockCookies,
onChanged: (value) async {
await ref
.read(saveEngineSettingsControllerProvider.notifier)
.save((s) => s.copyWith.blockCookies(value));
},
),
if (blockCookies)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ListTile(
title: Text('Cookie Policy'),
contentPadding: EdgeInsets.zero,
),
DropdownMenu<CustomCookiePolicy>(
initialSelection: customCookiePolicy,
width: double.infinity,
dropdownMenuEntries: const [
DropdownMenuEntry(
value: CustomCookiePolicy.totalProtection,
label: 'Total Cookie Protection (Recommended)',
leadingIcon: Icon(MdiIcons.shieldLock),
),
DropdownMenuEntry(
value: CustomCookiePolicy.crossSiteTrackers,
label: 'Cross-site and social media trackers',
leadingIcon: Icon(MdiIcons.accountGroup),
),
DropdownMenuEntry(
value: CustomCookiePolicy.unvisited,
label: 'Unvisited sites',
leadingIcon: Icon(MdiIcons.webOff),
),
DropdownMenuEntry(
value: CustomCookiePolicy.thirdParty,
label: 'All third-party cookies',
leadingIcon: Icon(MdiIcons.cookieOff),
),
DropdownMenuEntry(
value: CustomCookiePolicy.allCookies,
label: 'All cookies (may break sites)',
leadingIcon: Icon(MdiIcons.cookieRemove),
),
],
onSelected: (value) async {
if (value != null) {
await ref
.read(saveEngineSettingsControllerProvider.notifier)
.save((s) => s.copyWith.customCookiePolicy(value));
}
},
),
],
),
),
],
);
}
}
class _TrackingContentSection extends HookConsumerWidget {
const _TrackingContentSection();
@override
Widget build(BuildContext context, WidgetRef ref) {
final blockTrackingContent = ref.watch(
engineSettingsWithDefaultsProvider.select((s) => s.blockTrackingContent),
);
final trackingContentScope = ref.watch(
engineSettingsWithDefaultsProvider.select((s) => s.trackingContentScope),
);
return Column(
children: [
const SettingSection(name: 'Tracking Content'),
SwitchListTile.adaptive(
title: const Text('Block Tracking Content'),
subtitle: const Text(
'Block tracking scripts and resources embedded in websites',
),
secondary: const Icon(MdiIcons.scriptTextOutline),
value: blockTrackingContent,
onChanged: (value) async {
await ref
.read(saveEngineSettingsControllerProvider.notifier)
.save((s) => s.copyWith.blockTrackingContent(value));
},
),
if (blockTrackingContent)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ListTile(
title: Text('Apply to'),
contentPadding: EdgeInsets.zero,
),
SizedBox(
width: double.infinity,
child: SegmentedButton<TrackingScope>(
segments: const [
ButtonSegment(
value: TrackingScope.all,
label: Text('All tabs'),
),
ButtonSegment(
value: TrackingScope.privateOnly,
label: Text('Private tabs only'),
),
],
selected: {trackingContentScope},
onSelectionChanged: (value) async {
await ref
.read(saveEngineSettingsControllerProvider.notifier)
.save(
(s) => s.copyWith.trackingContentScope(value.first),
);
},
),
),
],
),
),
],
);
}
}
class _TrackersSection extends HookConsumerWidget {
const _TrackersSection();
@override
Widget build(BuildContext context, WidgetRef ref) {
final blockCryptominers = ref.watch(
engineSettingsWithDefaultsProvider.select((s) => s.blockCryptominers),
);
final blockFingerprinters = ref.watch(
engineSettingsWithDefaultsProvider.select((s) => s.blockFingerprinters),
);
final blockRedirectTrackers = ref.watch(
engineSettingsWithDefaultsProvider.select((s) => s.blockRedirectTrackers),
);
return Column(
children: [
const SettingSection(name: 'Trackers'),
const ListTile(
title: Text('Always Blocked'),
subtitle: Text(
'Ads, analytics, social trackers, and Mozilla social trackers are always blocked in Custom mode.',
),
leading: Icon(MdiIcons.shieldLock),
),
SwitchListTile.adaptive(
title: const Text('Cryptominers'),
subtitle: const Text(
'Block scripts that use your device to mine cryptocurrency',
),
secondary: const Icon(MdiIcons.currencyBtc),
value: blockCryptominers,
onChanged: (value) async {
await ref
.read(saveEngineSettingsControllerProvider.notifier)
.save((s) => s.copyWith.blockCryptominers(value));
},
),
SwitchListTile.adaptive(
title: const Text('Known Fingerprinters'),
subtitle: const Text(
'Block scripts that collect information to uniquely identify your device',
),
secondary: const Icon(MdiIcons.fingerprint),
value: blockFingerprinters,
onChanged: (value) async {
await ref
.read(saveEngineSettingsControllerProvider.notifier)
.save((s) => s.copyWith.blockFingerprinters(value));
},
),
SwitchListTile.adaptive(
title: const Text('Redirect Trackers'),
subtitle: const Text(
'Block trackers that collect data through intermediate URL redirects',
),
secondary: const Icon(MdiIcons.routerNetwork),
value: blockRedirectTrackers,
onChanged: (value) async {
await ref
.read(saveEngineSettingsControllerProvider.notifier)
.save((s) => s.copyWith.blockRedirectTrackers(value));
},
),
],
);
}
}
class _AdvancedFingerprintingSection extends HookConsumerWidget {
const _AdvancedFingerprintingSection();
@override
Widget build(BuildContext context, WidgetRef ref) {
final blockSuspectedFingerprinters = ref.watch(
engineSettingsWithDefaultsProvider.select(
(s) => s.blockSuspectedFingerprinters,
),
);
final suspectedFingerprintersScope = ref.watch(
engineSettingsWithDefaultsProvider.select(
(s) => s.suspectedFingerprintersScope,
),
);
return Column(
children: [
const SettingSection(name: 'Advanced Fingerprinting Protection'),
SwitchListTile.adaptive(
title: const Text('Suspected Fingerprinters'),
subtitle: const Text(
'Block additional fingerprinting techniques that may be used to track you',
),
secondary: const Icon(MdiIcons.shieldSearch),
value: blockSuspectedFingerprinters,
onChanged: (value) async {
await ref
.read(saveEngineSettingsControllerProvider.notifier)
.save((s) => s.copyWith.blockSuspectedFingerprinters(value));
},
),
if (blockSuspectedFingerprinters)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ListTile(
title: Text('Apply to'),
contentPadding: EdgeInsets.zero,
),
SizedBox(
width: double.infinity,
child: SegmentedButton<TrackingScope>(
segments: const [
ButtonSegment(
value: TrackingScope.all,
label: Text('All tabs'),
),
ButtonSegment(
value: TrackingScope.privateOnly,
label: Text('Private tabs only'),
),
],
selected: {suspectedFingerprintersScope},
onSelectionChanged: (value) async {
await ref
.read(saveEngineSettingsControllerProvider.notifier)
.save(
(s) => s.copyWith.suspectedFingerprintersScope(
value.first,
),
);
},
),
),
],
),
),
],
);
}
}
@@ -0,0 +1,36 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/features/settings/presentation/widgets/doh_settings_content.dart';
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()),
),
);
}
}
@@ -0,0 +1,241 @@
/*
* 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/>.
*/
// ignore_for_file: deprecated_member_use
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:logger/logger.dart';
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/utils/ui_helper.dart';
IconData _levelIcon(Level level) {
return switch (level) {
Level.trace => Icons.blur_circular,
Level.debug => Icons.bug_report,
Level.info => Icons.info,
Level.warning => Icons.warning,
Level.error => Icons.error,
Level.fatal => Icons.dangerous,
Level.all => Icons.notes,
Level.verbose => Icons.chat_bubble_outline,
Level.wtf => Icons.question_mark,
Level.nothing => Icons.close,
Level.off => Icons.offline_bolt,
};
}
Color _levelColor(Level level) {
return switch (level) {
Level.trace => Colors.grey,
Level.debug => Colors.blue,
Level.info => Colors.cyan,
Level.warning => Colors.orange,
Level.error => Colors.red,
Level.fatal => Colors.purple,
Level.all => Colors.grey,
Level.verbose => Colors.teal,
Level.wtf => Colors.brown,
Level.nothing => Colors.grey,
Level.off => Colors.grey,
};
}
Color _levelBackgroundColor(Level level, BuildContext context) {
return switch (level) {
Level.trace => Colors.grey.withValues(alpha: 0.1),
Level.debug => Colors.blue.withValues(alpha: 0.05),
Level.info => Colors.cyan.withValues(alpha: 0.05),
Level.warning => Colors.orange.withValues(alpha: 0.1),
Level.error => Colors.red.withValues(alpha: 0.1),
Level.fatal => Colors.purple.withValues(alpha: 0.1),
Level.all => Colors.grey.withValues(alpha: 0.1),
Level.verbose => Colors.teal.withValues(alpha: 0.05),
Level.wtf => Colors.brown.withValues(alpha: 0.1),
Level.nothing => Colors.grey.withValues(alpha: 0.05),
Level.off => Colors.grey.withValues(alpha: 0.05),
};
}
class ErrorLogsScreen extends HookConsumerWidget {
const ErrorLogsScreen({super.key});
String _logsText() {
final logs = loggerMemory.buffer;
return logs.map((e) => e.lines.join('\n')).join('\n\n');
}
Future<void> _copyToClipboard(BuildContext context) async {
await Clipboard.setData(ClipboardData(text: _logsText()));
if (context.mounted) {
showInfoMessage(context, 'Logs copied');
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final minLogLevel = ref.watch(logFilterProvider);
final allLogs = useMemoized(() => loggerMemory.buffer.toList());
final sortedLogs = useMemoized(
() => allLogs.reversed
.where((e) => e.level.value >= minLogLevel.value)
.toList(),
[allLogs, minLogLevel.value],
);
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),
],
),
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);
},
),
),
);
}
Widget _buildLevelFilterMenuItem(
BuildContext context,
WidgetRef ref,
Level minLogLevel,
Level level,
) {
final isSelected = level.value == minLogLevel.value;
final levelColor = _levelColor(level);
return CheckboxMenuButton(
trailingIcon: Icon(_levelIcon(level), color: levelColor),
value: isSelected,
onChanged: (value) {
if (value == true) {
ref.read(logFilterProvider.notifier).setFilter(level);
}
},
child: Text(level.name.toUpperCase()),
);
}
}
class _LogEntryTile extends StatelessWidget {
const _LogEntryTile({required this.event});
final OutputEvent event;
@override
Widget build(BuildContext context) {
final logEvent = event.origin;
final level = logEvent.level;
final message = logEvent.message?.toString() ?? '';
final error = logEvent.error?.toString();
final stackTrace = logEvent.stackTrace?.toString();
final time = logEvent.time;
final iconData = _levelIcon(level);
final iconColor = _levelColor(level);
final backgroundColor = _levelBackgroundColor(level, context);
return Card(
color: backgroundColor,
margin: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 8.0),
child: InkWell(
onTap: () async {
await showLogDetailsDialog(
context,
level: level,
message: message,
error: error,
stackTrace: stackTrace,
time: time,
);
},
child: ListTile(
leading: Icon(iconData, color: iconColor, size: 24),
title: Text(
message,
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.robotoMono(fontSize: 12),
),
subtitle: _buildTime(time, context),
),
),
);
}
Widget? _buildTime(DateTime? time, BuildContext context) {
if (time == null) return null;
return Text(
timeago.format(time),
style: TextStyle(
fontSize: 10,
fontStyle: FontStyle.italic,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
);
}
}
@@ -0,0 +1,129 @@
/*
* 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_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/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';
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: 'Runtime & Startup'),
_IsolatedProcessEnabledTile(),
_AppZygoteProcessEnabledTile(),
],
);
},
),
),
);
}
}
class _IsolatedProcessEnabledTile extends HookConsumerWidget {
const _IsolatedProcessEnabledTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final isolatedProcessEnabled = ref.watch(
engineSettingsWithDefaultsProvider.select(
(s) => s.isolatedProcessEnabled,
),
);
return SwitchListTile.adaptive(
title: const Text('Isolated Content Process'),
subtitle: const Text(
'Run web content in an isolated process. Requires app restart.',
),
secondary: const Icon(MdiIcons.shieldCheck),
value: isolatedProcessEnabled,
onChanged: (value) async {
await ref
.read(saveEngineSettingsControllerProvider.notifier)
.save(
(currentSettings) =>
currentSettings.copyWith.isolatedProcessEnabled(value),
);
if (context.mounted) {
await _showRestartDialog(context);
}
},
);
}
}
class _AppZygoteProcessEnabledTile extends HookConsumerWidget {
const _AppZygoteProcessEnabledTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final appZygoteProcessEnabled = ref.watch(
engineSettingsWithDefaultsProvider.select(
(s) => s.appZygoteProcessEnabled,
),
);
return SwitchListTile.adaptive(
title: const Text('App Zygote Process'),
subtitle: const Text(
'Preload the content service for faster isolated process startup. Requires Android 10+ and app restart.',
),
secondary: const Icon(MdiIcons.rocketLaunch),
value: appZygoteProcessEnabled,
onChanged: (value) async {
await ref
.read(saveEngineSettingsControllerProvider.notifier)
.save(
(currentSettings) =>
currentSettings.copyWith.appZygoteProcessEnabled(value),
);
if (context.mounted) {
await _showRestartDialog(context);
}
},
);
}
}
Future<void> _showRestartDialog(BuildContext context) async {
final result = await showQuitBrowserDialog(context);
if (result == true && context.mounted) {
await exitApp(ProviderScope.containerOf(context));
}
}
@@ -0,0 +1,265 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:async';
import '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/geckoview/features/browser/domain/services/browser_addon.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/dialogs/install_local_addon_dialog.dart';
import 'package:weblibre/features/settings/presentation/widgets/custom_list_tile.dart';
import 'package:weblibre/features/settings/presentation/widgets/sections.dart';
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'),
_InstallLocalAddonTile(),
_AddonCollectionTile(),
SettingSection(name: 'Security'),
_AllowUnsignedExtensionsTile(),
],
);
},
),
),
);
}
}
class _InstallLocalAddonTile extends StatelessWidget {
const _InstallLocalAddonTile();
@override
Widget build(BuildContext context) {
return CustomListTile(
title: 'Install from File',
subtitle: 'Install an extension from a local .xpi file',
prefix: Padding(
padding: const EdgeInsets.only(right: 16.0),
child: Icon(
MdiIcons.puzzle,
size: 24,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
suffix: FilledButton.icon(
onPressed: () async {
await showInstallLocalAddonDialog(context);
},
icon: const Icon(Icons.file_open),
label: const Text('Install'),
),
);
}
}
class _AddonCollectionTile extends StatelessWidget {
const _AddonCollectionTile();
@override
Widget build(BuildContext context) {
return CustomListTile(
title: 'Custom Collection',
subtitle: 'Use a custom Mozilla addon collection',
prefix: Padding(
padding: const EdgeInsets.only(right: 16.0),
child: Icon(
MdiIcons.folderMultiple,
size: 24,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
suffix: FilledButton.icon(
onPressed: () async {
await AddonCollectionRoute().push(context);
},
icon: const Icon(Icons.settings),
label: const Text('Configure'),
),
);
}
}
class _AllowUnsignedExtensionsTile extends ConsumerWidget {
const _AllowUnsignedExtensionsTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final allowUnsigned = ref.watch(allowUnsignedExtensionsProvider);
return Column(
children: [
SwitchListTile.adaptive(
title: const Text('Allow unsigned extensions'),
subtitle: const Text(
'Unsigned extensions have not been verified by Mozilla',
),
secondary: const Icon(Icons.extension_off),
value: allowUnsigned.value ?? false,
onChanged: allowUnsigned.isLoading
? null
: (value) async {
if (value) {
final confirmed =
await _showAllowUnsignedConfirmationDialog(context);
if (confirmed != true) return;
}
await ref
.read(allowUnsignedExtensionsProvider.notifier)
.setAllowUnsigned(allow: value);
},
),
if (allowUnsigned.value == true)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.errorContainer.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Theme.of(context).colorScheme.error),
),
child: Row(
children: [
Icon(
Icons.warning_amber,
color: Theme.of(context).colorScheme.error,
size: 20,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'Only install unsigned extensions from sources you trust. '
'They may contain malicious code.',
style: TextStyle(
color: Theme.of(context).colorScheme.onErrorContainer,
fontSize: 12,
),
),
),
],
),
),
),
],
);
}
}
Future<bool?> _showAllowUnsignedConfirmationDialog(BuildContext context) {
return showDialog<bool>(
context: context,
builder: (context) => const _AllowUnsignedConfirmationDialog(),
);
}
class _AllowUnsignedConfirmationDialog extends HookWidget {
const _AllowUnsignedConfirmationDialog();
static const _countdownSeconds = 15;
@override
Widget build(BuildContext context) {
final remaining = useState(_countdownSeconds);
useEffect(() {
final timer = Timer.periodic(const Duration(seconds: 1), (_) {
if (remaining.value > 0) {
remaining.value--;
}
});
return timer.cancel;
}, []);
final theme = Theme.of(context);
final canConfirm = remaining.value == 0;
return AlertDialog(
icon: Icon(
Icons.warning_amber_rounded,
color: theme.colorScheme.error,
size: 40,
),
title: const Text('Allow unsigned extensions?'),
content: Text.rich(
TextSpan(
children: [
TextSpan(
text:
"Warning: This significantly weakens your browser's security."
'\n\n',
style: TextStyle(
fontWeight: FontWeight.bold,
color: theme.colorScheme.error,
),
),
const TextSpan(
text:
"Unsigned extensions bypass Mozilla's safety review process. "
'Malicious extensions can:\n\n'
'\u2022 Read and modify everything you see on any website\n'
'\u2022 Steal passwords, banking details, and personal data\n'
'\u2022 Monitor your browsing activity silently\n'
'\u2022 Install additional malware on your device\n\n',
),
const TextSpan(
text:
'Only enable this if you are a developer installing your own '
'extension or absolutely trust the source.',
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: canConfirm ? () => Navigator.of(context).pop(true) : null,
style: FilledButton.styleFrom(
backgroundColor: theme.colorScheme.error,
foregroundColor: theme.colorScheme.onError,
),
child: Text(canConfirm ? 'Allow' : 'Allow (${remaining.value})'),
),
],
);
}
}
@@ -0,0 +1,180 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nullability/nullability.dart';
import 'package:weblibre/features/settings/presentation/controllers/save_settings.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';
import 'package:weblibre/features/user/domain/services/fingerprinting.dart';
import 'package:weblibre/presentation/widgets/failure_widget.dart';
class FingerprintSettingsScreen extends HookConsumerWidget {
const FingerprintSettingsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final targetsAsync = ref.watch(fingerprintTargetsProvider);
final settingsAsync = ref.watch(fingerprintOverrideSettingsProvider);
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),
),
);
},
);
},
error: (error, stackTrace) {
return Center(
child: FailureWidget(
exception: error,
onRetry: () {
ref.invalidate(fingerprintTargetsProvider);
},
),
);
},
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()),
),
),
);
}
}
@@ -0,0 +1,364 @@
/*
* 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';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'
show GeckoBrowserService;
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/user/data/models/general_settings.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
import 'package:weblibre/presentation/hooks/cached_future.dart';
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(),
],
);
}
}
class _DefaultBrowserTile extends HookConsumerWidget {
const _DefaultBrowserTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final defaultBrowserRefreshKey = useState(0);
useOnAppLifecycleStateChange((previous, current) {
if (current == AppLifecycleState.resumed) {
defaultBrowserRefreshKey.value++;
}
});
final isDefault = useCachedFuture(
() => GeckoBrowserService().isDefaultBrowser(),
[defaultBrowserRefreshKey.value],
);
final isCurrentDefaultBrowser = isDefault.data == true;
return CustomListTile(
title: 'Default Browser',
subtitle: isCurrentDefaultBrowser
? 'WebLibre is your default browser'
: 'Set WebLibre as your default browser',
prefix: Padding(
padding: const EdgeInsets.only(right: 16.0),
child: Icon(
Icons.public,
size: 24,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
suffix: FilledButton.icon(
onPressed: isCurrentDefaultBrowser
? null
: () async {
await GeckoBrowserService().requestDefaultBrowser();
defaultBrowserRefreshKey.value++;
},
icon: Icon(isCurrentDefaultBrowser ? Icons.check : Icons.open_in_new),
label: Text(isCurrentDefaultBrowser ? 'Default' : 'Set'),
),
);
}
}
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();
static final _sliderDivisions =
((maxUiScaleFactor - minUiScaleFactor) / uiScaleFactorStep).round();
@override
Widget build(BuildContext context, WidgetRef ref) {
final uiScaleFactor = ref.watch(
generalSettingsWithDefaultsProvider.select((s) => s.uiScaleFactor),
);
final sliderValue = useState(uiScaleFactor);
useEffect(() {
sliderValue.value = uiScaleFactor;
return null;
}, [uiScaleFactor]);
final sliderLabel = '${(sliderValue.value * 100).round()}%';
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ListTile(
title: Text('User Interface Zoom'),
subtitle: Text('Make the user interface smaller or larger'),
leading: Icon(Icons.zoom_in),
contentPadding: EdgeInsets.zero,
),
Row(
children: [
Text(sliderLabel, style: Theme.of(context).textTheme.titleLarge),
Expanded(
child: Slider(
min: minUiScaleFactor,
max: maxUiScaleFactor,
divisions: _sliderDivisions,
label: sliderLabel,
value: sliderValue.value.clamp(
minUiScaleFactor,
maxUiScaleFactor,
),
onChanged: (value) {
sliderValue.value = value;
},
onChangeEnd: (value) async {
final normalized = _normalizeUiScale(value);
sliderValue.value = normalized;
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(currentSettings) => currentSettings.copyWith
.uiScaleFactor(normalized),
);
},
),
),
],
),
],
),
);
}
}
double _normalizeUiScale(double value) {
final clampedValue = value.clamp(minUiScaleFactor, maxUiScaleFactor);
final stepIndex = ((clampedValue - minUiScaleFactor) / uiScaleFactorStep)
.round();
final normalized = minUiScaleFactor + (stepIndex * uiScaleFactorStep);
return normalized.clamp(minUiScaleFactor, maxUiScaleFactor);
}
class _DisableAnimationsTile extends HookConsumerWidget {
const _DisableAnimationsTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final disableAnimations = ref.watch(
generalSettingsWithDefaultsProvider.select((s) => s.disableAnimations),
);
return SwitchListTile.adaptive(
title: const Text('Disable Animations'),
subtitle: const Text('Reduce motion and turn off app animations'),
secondary: const Icon(Icons.animation),
value: disableAnimations,
onChanged: (value) async {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(currentSettings) =>
currentSettings.copyWith.disableAnimations(value),
);
},
);
}
}
class _ShowModalBarrierTile extends HookConsumerWidget {
const _ShowModalBarrierTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final showModalBarrier = ref.watch(
generalSettingsWithDefaultsProvider.select((s) => s.showModalBarrier),
);
return SwitchListTile.adaptive(
title: const Text('Show Modal Barrier'),
subtitle: const Text(
'Dim the background behind dialogs and bottom sheets',
),
secondary: const Icon(Icons.layers),
value: showModalBarrier,
onChanged: (value) async {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(currentSettings) =>
currentSettings.copyWith.showModalBarrier(value),
);
},
);
}
}
class _ThemeSection extends HookConsumerWidget {
const _ThemeSection();
@override
Widget build(BuildContext context, WidgetRef ref) {
final themeMode = ref.watch(
generalSettingsWithDefaultsProvider.select((s) => s.themeMode),
);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ListTile(
title: Text('Theme'),
leading: Icon(Icons.palette),
contentPadding: EdgeInsets.zero,
),
Center(
child: SegmentedButton<ThemeMode>(
segments: const [
ButtonSegment(
value: ThemeMode.system,
icon: Icon(Icons.brightness_auto),
label: Text('System'),
),
ButtonSegment(
value: ThemeMode.light,
icon: Icon(Icons.light_mode),
label: Text('Light'),
),
ButtonSegment(
value: ThemeMode.dark,
icon: Icon(Icons.dark_mode),
label: Text('Dark'),
),
],
selected: {themeMode},
onSelectionChanged: (value) async {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(currentSettings) =>
currentSettings.copyWith.themeMode(value.first),
);
},
),
),
],
),
);
}
}
class _ExternalDownloadManagerTile extends HookConsumerWidget {
const _ExternalDownloadManagerTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final useExternalDownloadManager = ref.watch(
generalSettingsWithDefaultsProvider.select(
(s) => s.useExternalDownloadManager,
),
);
return SwitchListTile.adaptive(
title: const Text('Use external download manager'),
subtitle: const Text('Manage downloads with another app'),
secondary: const Icon(Icons.download),
value: useExternalDownloadManager,
onChanged: (value) async {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(currentSettings) =>
currentSettings.copyWith.useExternalDownloadManager(value),
);
},
);
}
}
@@ -0,0 +1,167 @@
/*
* 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:country_flags/country_flags.dart';
import 'package:fast_equatable/fast_equatable.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:intl/locale.dart';
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/user/data/models/engine_settings.dart';
import 'package:weblibre/features/user/domain/repositories/engine_settings.dart';
class LocaleSettingsScreen extends HookConsumerWidget {
const LocaleSettingsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final formKey = useMemoized(() => GlobalKey<FormState>());
final systemLocales = useMemoized(
() => WidgetsBinding.instance.platformDispatcher.locales
.map((locale) => locale.toIntlLocale())
.toList(),
);
final userLocales = ref.watch(
engineSettingsWithDefaultsProvider.select(
(settings) => EquatableValue(
settings.locales.map(Locale.tryParse).nonNulls.toSet(),
),
),
);
final availableLocales = {
...systemLocales,
...userLocales.value,
Locale.fromSubtags(languageCode: 'en', countryCode: 'US'),
};
final customLocaleController = useTextEditingController();
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),
);
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 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();
}
},
),
),
),
],
),
),
);
}
}
@@ -0,0 +1,954 @@
/*
* 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_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:nullability/nullability.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/dialogs/delete_data.dart';
import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart';
import 'package:weblibre/features/settings/presentation/widgets/sections.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';
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';
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(),
_DataManagementSection(),
_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(),
],
);
}
}
class _TrackingProtectionExceptionsTile extends StatelessWidget {
const _TrackingProtectionExceptionsTile();
@override
Widget build(BuildContext context) {
return ListTile(
leading: const Icon(MdiIcons.shieldOffOutline),
title: const Text('Tracking Protection Exceptions'),
subtitle: const Text('Sites where tracking protection is disabled'),
trailing: const Icon(Icons.chevron_right),
onTap: () async {
await TrackingProtectionExceptionsRoute().push(context);
},
);
}
}
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(),
_GlobalPrivacyControlTile(),
],
);
}
}
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();
@override
Widget build(BuildContext context, WidgetRef ref) {
final deleteBrowsingDataOnQuit = ref.watch(
generalSettingsWithDefaultsProvider.select(
(s) => s.deleteBrowsingDataOnQuit,
),
);
return Column(
children: [
SwitchListTile.adaptive(
title: const Text('Incognito Mode'),
subtitle: const Text(
'Deletes selected browsing data upon app restart for enhanced privacy.',
),
secondary: const Icon(MdiIcons.incognito),
value: deleteBrowsingDataOnQuit != null,
onChanged: (value) async {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(currentSettings) => value
? currentSettings.copyWith.deleteBrowsingDataOnQuit({})
: currentSettings.copyWith.deleteBrowsingDataOnQuit(null),
);
},
),
if (deleteBrowsingDataOnQuit != null)
_DeleteBrowsingDataTypes(selectedTypes: deleteBrowsingDataOnQuit),
],
);
}
}
class _DeleteBrowsingDataTypes extends HookConsumerWidget {
final Set<DeleteBrowsingDataType> selectedTypes;
const _DeleteBrowsingDataTypes({required this.selectedTypes});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
child: Column(
children: [
for (final type in DeleteBrowsingDataType.values)
CheckboxListTile.adaptive(
value: selectedTypes.contains(type),
controlAffinity: ListTileControlAffinity.leading,
title: Text(type.title),
subtitle: type.description.mapNotNull(
(description) => Text(description),
),
onChanged: (value) async {
final notifier = ref.read(
saveGeneralSettingsControllerProvider.notifier,
);
if (value == true) {
await notifier.save(
(currentSettings) =>
currentSettings.copyWith.deleteBrowsingDataOnQuit({
...currentSettings.deleteBrowsingDataOnQuit!,
type,
}),
);
} else {
await notifier.save(
(currentSettings) =>
currentSettings.copyWith.deleteBrowsingDataOnQuit(
{...currentSettings.deleteBrowsingDataOnQuit!}
..remove(type),
),
);
}
},
),
],
),
);
}
}
class _DeleteBrowsingDataTile extends StatelessWidget {
const _DeleteBrowsingDataTile();
@override
Widget build(BuildContext context) {
return ListTile(
title: const Text('Delete Browsing Data'),
contentPadding: const EdgeInsets.symmetric(
vertical: 8.0,
horizontal: 16.0,
),
leading: const Icon(MdiIcons.databaseRemove),
trailing: const Icon(Icons.chevron_right),
onTap: () async {
await showDeleteDataDialog(context);
},
);
}
}
class _AutoClearHistorySection extends HookConsumerWidget {
const _AutoClearHistorySection();
@override
Widget build(BuildContext context, WidgetRef ref) {
final historyAutoCleanInterval = ref.watch(
generalSettingsWithDefaultsProvider.select(
(s) => s.historyAutoCleanInterval,
),
);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ListTile(
title: Text('Auto-Clear History'),
subtitle: Text(
'Automatically delete browsing history older than the selected time period',
),
leading: Icon(MdiIcons.deleteClock),
contentPadding: EdgeInsets.zero,
),
Padding(
padding: const EdgeInsets.only(left: 40.0),
child: DropdownMenu<Duration>(
initialSelection: historyAutoCleanInterval,
inputDecorationTheme: InputDecorationTheme(
prefixIconConstraints: BoxConstraints.tight(
const Size.square(24),
),
),
width: double.infinity,
dropdownMenuEntries: const [
DropdownMenuEntry(value: Duration.zero, label: 'Never'),
DropdownMenuEntry(value: Duration(days: 1), label: '1 Day'),
DropdownMenuEntry(value: Duration(days: 3), label: '3 Days'),
DropdownMenuEntry(value: Duration(days: 7), label: '1 Week'),
DropdownMenuEntry(value: Duration(days: 14), label: '2 Weeks'),
DropdownMenuEntry(value: Duration(days: 30), label: '1 Month'),
DropdownMenuEntry(value: Duration(days: 90), label: '3 Months'),
],
onSelected: (value) async {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(currentSettings) => currentSettings.copyWith
.historyAutoCleanInterval(value ?? Duration.zero),
);
},
),
),
],
),
);
}
}
class _AutoClearUnassignedTabsSection extends HookConsumerWidget {
const _AutoClearUnassignedTabsSection();
@override
Widget build(BuildContext context, WidgetRef ref) {
final unassignedTabsAutoCleanInterval = ref.watch(
generalSettingsWithDefaultsProvider.select(
(s) => s.unassignedTabsAutoCleanInterval,
),
);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ListTile(
title: Text('Auto-Clear Unassigned Tabs'),
subtitle: Text(
'Automatically close unassigned tabs older than the selected time period',
),
leading: Icon(MdiIcons.tabRemove),
contentPadding: EdgeInsets.zero,
),
Padding(
padding: const EdgeInsets.only(left: 40.0),
child: DropdownMenu<Duration>(
initialSelection: unassignedTabsAutoCleanInterval,
inputDecorationTheme: InputDecorationTheme(
prefixIconConstraints: BoxConstraints.tight(
const Size.square(24),
),
),
width: double.infinity,
dropdownMenuEntries: const [
DropdownMenuEntry(value: Duration.zero, label: 'Never'),
DropdownMenuEntry(value: Duration(days: 1), label: '1 Day'),
DropdownMenuEntry(value: Duration(days: 3), label: '3 Days'),
DropdownMenuEntry(value: Duration(days: 7), label: '1 Week'),
DropdownMenuEntry(value: Duration(days: 14), label: '2 Weeks'),
DropdownMenuEntry(value: Duration(days: 30), label: '1 Month'),
DropdownMenuEntry(value: Duration(days: 90), label: '3 Months'),
],
onSelected: (value) async {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(currentSettings) => currentSettings.copyWith
.unassignedTabsAutoCleanInterval(
value ?? Duration.zero,
),
);
},
),
),
],
),
);
}
}
class _GlobalPrivacyControlTile extends HookConsumerWidget {
const _GlobalPrivacyControlTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final globalPrivacyControlEnabled = ref.watch(
engineSettingsWithDefaultsProvider.select(
(s) => s.globalPrivacyControlEnabled,
),
);
return SwitchListTile.adaptive(
title: const Text('Global Privacy Control (GPC)'),
secondary: const Icon(MdiIcons.incognitoCircleOff),
value: globalPrivacyControlEnabled,
onChanged: (value) async {
await ref
.read(saveEngineSettingsControllerProvider.notifier)
.save(
(currentSettings) =>
currentSettings.copyWith.globalPrivacyControlEnabled(value),
);
},
);
}
}
class _HttpsOnlyModeSection extends HookConsumerWidget {
const _HttpsOnlyModeSection();
@override
Widget build(BuildContext context, WidgetRef ref) {
final httpsOnlyMode = ref.watch(
engineSettingsWithDefaultsProvider.select((s) => s.httpsOnlyMode),
);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ListTile(
title: Text('Block insecure HTTP connections'),
leading: Icon(MdiIcons.lockOpen),
contentPadding: EdgeInsets.zero,
),
Center(
child: SegmentedButton<HttpsOnlyMode>(
segments: const [
ButtonSegment(
value: HttpsOnlyMode.disabled,
label: Text('Disabled'),
),
ButtonSegment(
value: HttpsOnlyMode.enabled,
label: Text('Enabled'),
),
ButtonSegment(
value: HttpsOnlyMode.privateOnly,
label: Text('Private mode only'),
),
],
selected: {httpsOnlyMode},
onSelectionChanged: (value) async {
await ref
.read(saveEngineSettingsControllerProvider.notifier)
.save(
(currentSettings) =>
currentSettings.copyWith.httpsOnlyMode(value.first),
);
},
),
),
],
),
);
}
}
class _DnsTile extends StatelessWidget {
const _DnsTile();
@override
Widget build(BuildContext context) {
return ListTile(
title: const Text('DNS over HTTPS'),
contentPadding: const EdgeInsets.symmetric(
vertical: 8.0,
horizontal: 16.0,
),
leading: const Icon(MdiIcons.dns),
trailing: const Icon(Icons.chevron_right),
onTap: () async {
await DohSettingsRoute().push(context);
},
);
}
}
class _EnhancedTrackingProtectionSection extends HookConsumerWidget {
const _EnhancedTrackingProtectionSection();
@override
Widget build(BuildContext context, WidgetRef ref) {
final trackingProtectionPolicy = ref.watch(
engineSettingsWithDefaultsProvider.select(
(s) => s.trackingProtectionPolicy,
),
);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ListTile(
title: Text('Enhanced Tracking Protection'),
leading: Icon(MdiIcons.incognitoCircleOff),
contentPadding: EdgeInsets.zero,
),
RadioGroup(
groupValue: trackingProtectionPolicy,
onChanged: (value) async {
if (value != null) {
// Save the policy change
await ref
.read(saveEngineSettingsControllerProvider.notifier)
.save(
(currentSettings) => currentSettings.copyWith
.trackingProtectionPolicy(value),
);
}
// Navigate to custom settings screen when Custom is selected
if (value == TrackingProtectionPolicy.custom ||
(value == null &&
trackingProtectionPolicy ==
TrackingProtectionPolicy.custom)) {
if (context.mounted) {
await CustomTrackingProtectionRoute().push(context);
}
}
},
child: const Column(
children: [
RadioListTile<TrackingProtectionPolicy>.adaptive(
value: TrackingProtectionPolicy.none,
title: Text('Disabled'),
),
RadioListTile<TrackingProtectionPolicy>.adaptive(
value: TrackingProtectionPolicy.recommended,
title: Text('Standard'),
subtitle: Text(
'Pages will load normally, but block fewer trackers.',
),
),
RadioListTile<TrackingProtectionPolicy>.adaptive(
value: TrackingProtectionPolicy.strict,
title: Text('Strict'),
subtitle: Text(
'Stronger tracking protection and faster performance, but some sites may not work properly.',
),
),
RadioListTile<TrackingProtectionPolicy>.adaptive(
value: TrackingProtectionPolicy.custom,
toggleable: true,
title: Text('Custom'),
subtitle: Text('Choose which trackers and scripts to block.'),
secondary: Icon(Icons.chevron_right),
),
],
),
),
],
),
);
}
}
class _BounceTrackingProtectionTile extends HookConsumerWidget {
const _BounceTrackingProtectionTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final bounceTrackingProtectionMode = ref.watch(
engineSettingsWithDefaultsProvider.select(
(s) => s.contentBlocking.bounceTrackingProtectionMode,
),
);
final isEnabled = switch (bounceTrackingProtectionMode) {
BounceTrackingProtectionMode.disabled => false,
BounceTrackingProtectionMode.enabled => true,
BounceTrackingProtectionMode.enabledStandby => false,
BounceTrackingProtectionMode.enabledDryRun => false,
};
return SwitchListTile.adaptive(
title: const Text('Bounce Tracking Protection'),
subtitle: const Text(
'Blocks redirect trackers that collect data through intermediate URL redirects between websites',
),
secondary: const Icon(MdiIcons.securityNetwork),
value: isEnabled,
onChanged: (value) async {
await ref
.read(saveEngineSettingsControllerProvider.notifier)
.save(
(currentSettings) =>
currentSettings.copyWith.bounceTrackingProtectionMode(
value
? BounceTrackingProtectionMode.enabled
: BounceTrackingProtectionMode.disabled,
),
);
if (context.mounted) {
await _showRestartDialog(context, ref);
}
},
);
}
}
class _QueryParameterStrippingSection extends HookConsumerWidget {
const _QueryParameterStrippingSection();
@override
Widget build(BuildContext context, WidgetRef ref) {
final queryParameterStripping = ref.watch(
engineSettingsWithDefaultsProvider.select(
(s) => s.queryParameterStripping,
),
);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ListTile(
title: Text('Query Parameter Stripping'),
subtitle: Text(
'Removes tracking parameters from URLs to prevent cross-site user tracking',
),
leading: Icon(MdiIcons.closeNetwork),
contentPadding: EdgeInsets.zero,
),
Center(
child: SegmentedButton<QueryParameterStripping>(
segments: const [
ButtonSegment(
value: QueryParameterStripping.disabled,
label: Text('Disabled'),
),
ButtonSegment(
value: QueryParameterStripping.enabled,
label: Text('Enabled'),
),
ButtonSegment(
value: QueryParameterStripping.privateOnly,
label: Text('Private mode only'),
),
],
selected: {queryParameterStripping},
onSelectionChanged: (value) async {
await ref
.read(saveEngineSettingsControllerProvider.notifier)
.save(
(currentSettings) => currentSettings.copyWith
.queryParameterStripping(value.first),
);
},
),
),
],
),
);
}
}
class _WebEngineHardeningTile extends StatelessWidget {
const _WebEngineHardeningTile();
@override
Widget build(BuildContext context) {
return ListTile(
title: const Text('Web Engine Hardening'),
contentPadding: const EdgeInsets.symmetric(
vertical: 8.0,
horizontal: 16.0,
),
leading: const Icon(MdiIcons.shieldLock),
trailing: const Icon(Icons.chevron_right),
onTap: () async {
await WebEngineHardeningRoute().push(context);
},
);
}
}
class _FissionEnabledTile extends HookConsumerWidget {
const _FissionEnabledTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final fissionEnabled = ref.watch(
engineSettingsWithDefaultsProvider.select((s) => s.fissionEnabled),
);
return SwitchListTile.adaptive(
title: const Text('Fission (Site Isolation)'),
subtitle: const Text(
'Isolates each site into a separate OS process for improved security. Requires app restart.',
),
secondary: const Icon(MdiIcons.shieldHalfFull),
value: fissionEnabled,
onChanged: (value) async {
await ref
.read(saveEngineSettingsControllerProvider.notifier)
.save(
(currentSettings) =>
currentSettings.copyWith.fissionEnabled(value),
);
if (context.mounted) {
await _showRestartDialog(context, ref);
}
},
);
}
}
class _ExtensionsWebAPIEnabledTile extends HookConsumerWidget {
const _ExtensionsWebAPIEnabledTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final extensionsWebAPIEnabled = ref.watch(
engineSettingsWithDefaultsProvider.select(
(s) => s.extensionsWebAPIEnabled,
),
);
return SwitchListTile.adaptive(
title: const Text('Extensions Web API'),
subtitle: const Text(
'Enable mozAddonManager API exposure for web content and extension pages. Requires app restart.',
),
secondary: const Icon(Icons.extension),
value: extensionsWebAPIEnabled,
onChanged: (value) async {
await ref
.read(saveEngineSettingsControllerProvider.notifier)
.save(
(currentSettings) =>
currentSettings.copyWith.extensionsWebAPIEnabled(value),
);
if (context.mounted) {
await _showRestartDialog(context, ref);
}
},
);
}
}
Future<void> _showRestartDialog(BuildContext context, WidgetRef ref) async {
final result = await showQuitBrowserDialog(context);
if (result == true && context.mounted) {
await exitApp(ProviderScope.containerOf(context));
}
}
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();
@override
Widget build(BuildContext context) {
return ListTile(
title: const Text('Browser Languages'),
subtitle: const Text(
'Configure language preferences exposed to websites',
),
contentPadding: const EdgeInsets.symmetric(
vertical: 8.0,
horizontal: 16.0,
),
leading: const Icon(Icons.translate),
trailing: const Icon(Icons.chevron_right),
onTap: () async {
await LocaleSettingsRoute().push(context);
},
);
}
}
class _FingerprintProtectionTile extends StatelessWidget {
const _FingerprintProtectionTile();
@override
Widget build(BuildContext context) {
return ListTile(
title: const Text('Fingerprint Protection'),
subtitle: const Text('Granular control over browser fingerprinting'),
contentPadding: const EdgeInsets.symmetric(
vertical: 8.0,
horizontal: 16.0,
),
leading: const Icon(MdiIcons.fingerprint),
trailing: const Icon(Icons.chevron_right),
onTap: () async {
await FingerprintSettingsRoute().push(context);
},
);
}
}
class _ResistFingerprintingTile extends StatelessWidget {
const _ResistFingerprintingTile();
@override
Widget build(BuildContext context) {
return ListTile(
title: const Text('Resist Fingerprinting'),
subtitle: const Text('Advanced fingerprinting protection hardening'),
contentPadding: const EdgeInsets.symmetric(
vertical: 8.0,
horizontal: 16.0,
),
leading: const Icon(MdiIcons.shieldLock),
trailing: const Icon(Icons.chevron_right),
onTap: () async {
await const WebEngineHardeningGroupRoute(
group: 'Resist Fingerprinting',
).push(context);
},
);
}
}
class _LnaEnabledTile extends HookConsumerWidget {
const _LnaEnabledTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final lnaEnabled = ref.watch(
engineSettingsWithDefaultsProvider.select((s) => s.lnaEnabled),
);
return SwitchListTile.adaptive(
title: const Text('Local Network Access'),
subtitle: const Text('Enable local network and device access blocking'),
secondary: const Icon(MdiIcons.lanDisconnect),
value: lnaEnabled ?? false,
onChanged: (value) async {
await ref
.read(saveEngineSettingsControllerProvider.notifier)
.save(
(currentSettings) => currentSettings.copyWith.lnaEnabled(value),
);
},
);
}
}
class _LnaBlockingTile extends HookConsumerWidget {
const _LnaBlockingTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final lnaEnabled = ref.watch(
engineSettingsWithDefaultsProvider.select((s) => s.lnaEnabled),
);
final lnaBlocking = ref.watch(
engineSettingsWithDefaultsProvider.select((s) => s.lnaBlocking),
);
return SwitchListTile.adaptive(
title: const Text('Block Local Network Requests'),
subtitle: const Text(
'Block web page requests to local network addresses',
),
secondary: const Icon(MdiIcons.shieldLockOpen),
value: lnaBlocking ?? false,
onChanged: lnaEnabled == true
? (value) async {
await ref
.read(saveEngineSettingsControllerProvider.notifier)
.save(
(currentSettings) =>
currentSettings.copyWith.lnaBlocking(value),
);
}
: null,
);
}
}
class _LnaBlockTrackersTile extends HookConsumerWidget {
const _LnaBlockTrackersTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final lnaEnabled = ref.watch(
engineSettingsWithDefaultsProvider.select((s) => s.lnaEnabled),
);
final lnaBlockTrackers = ref.watch(
engineSettingsWithDefaultsProvider.select((s) => s.lnaBlockTrackers),
);
return SwitchListTile.adaptive(
title: const Text('Block Local Network Trackers'),
subtitle: const Text(
'Block trackers from accessing local network resources',
),
secondary: const Icon(MdiIcons.shieldBug),
value: lnaBlockTrackers ?? false,
onChanged: lnaEnabled == true
? (value) async {
await ref
.read(saveEngineSettingsControllerProvider.notifier)
.save(
(currentSettings) =>
currentSettings.copyWith.lnaBlockTrackers(value),
);
}
: null,
);
}
}
@@ -0,0 +1,330 @@
/*
* 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';
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/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/user/data/models/general_settings.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
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(),
],
);
}
}
class _DefaultSearchProviderSection extends StatelessWidget {
const _DefaultSearchProviderSection();
@override
Widget build(BuildContext context) {
return const Padding(
padding: EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
title: Text('Default Search Provider'),
leading: Icon(MdiIcons.cloudSearch),
contentPadding: EdgeInsets.zero,
),
Padding(
padding: EdgeInsets.only(left: 40),
child: DefaultSearchSelector(),
),
],
),
);
}
}
class _AutocompleteProviderSection extends HookConsumerWidget {
const _AutocompleteProviderSection();
@override
Widget build(BuildContext context, WidgetRef ref) {
final defaultSearchSuggestionsProvider = ref.watch(
generalSettingsWithDefaultsProvider.select(
(s) => s.defaultSearchSuggestionsProvider,
),
);
final relatedBang = defaultSearchSuggestionsProvider.relatedBang;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ListTile(
title: Text('Default Autocomplete Provider'),
leading: Icon(MdiIcons.weatherCloudyArrowRight),
contentPadding: EdgeInsets.zero,
),
Padding(
padding: const EdgeInsets.only(left: 40),
child: DropdownMenu<SearchSuggestionProviders>(
initialSelection: defaultSearchSuggestionsProvider,
inputDecorationTheme: InputDecorationTheme(
prefixIconConstraints: BoxConstraints.tight(
const Size.square(24),
),
),
width: double.infinity,
leadingIcon: relatedBang.mapNotNull(
(trigger) => BangIcon(trigger: trigger),
),
dropdownMenuEntries: SearchSuggestionProviders.values.map((
provider,
) {
return DropdownMenuEntry(
value: provider,
label: provider.label,
leadingIcon: provider.relatedBang.mapNotNull(
(trigger) => BangIcon(trigger: trigger),
),
);
}).toList(),
onSelected: (value) async {
if (value != null) {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(currentSettings) => currentSettings.copyWith
.defaultSearchSuggestionsProvider(value),
);
}
},
),
),
],
),
);
}
}
class _CustomSearchEnginesTile extends StatelessWidget {
const _CustomSearchEnginesTile();
@override
Widget build(BuildContext context) {
return ListTile(
title: const Text('Custom Search Engines'),
subtitle: const Text('Add and manage your own search providers'),
contentPadding: const EdgeInsets.symmetric(
vertical: 8.0,
horizontal: 16.0,
),
leading: const Icon(MdiIcons.searchWeb),
trailing: const Icon(Icons.chevron_right),
onTap: () async {
await const UserBangsRoute().push(context);
},
);
}
}
class _BangsTile extends StatelessWidget {
const _BangsTile();
@override
Widget build(BuildContext context) {
return ListTile(
title: const Text('Bang Settings'),
subtitle: const Text('Manage bang repositories and usage data'),
contentPadding: const EdgeInsets.symmetric(
vertical: 8.0,
horizontal: 16.0,
),
leading: const Icon(MdiIcons.exclamationThick),
trailing: const Icon(Icons.chevron_right),
onTap: () async {
await BangSettingsRoute().push(context);
},
);
}
}
class _MaxSearchHistoryEntriesSection extends HookConsumerWidget {
const _MaxSearchHistoryEntriesSection();
@override
Widget build(BuildContext context, WidgetRef ref) {
final formKey = useMemoized(() => GlobalKey<FormState>());
final maxSearchHistoryEntries = ref.watch(
generalSettingsWithDefaultsProvider.select(
(s) => s.maxSearchHistoryEntries,
),
);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ListTile(
title: Text('Search History Limit'),
subtitle: Text('Maximum number of recent searches to remember'),
leading: Icon(MdiIcons.history),
contentPadding: EdgeInsets.zero,
),
Padding(
padding: const EdgeInsets.only(left: 40.0),
child: Form(
key: formKey,
child: TextFormField(
initialValue: maxSearchHistoryEntries.toString(),
keyboardType: TextInputType.number,
decoration: const InputDecoration(suffixText: 'entries'),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter a value';
}
final parsedValue = int.tryParse(value);
if (parsedValue == null) {
return 'Please enter a valid number';
}
if (parsedValue < 0 || parsedValue > 100) {
return 'Value must be between 0 and 100';
}
return null;
},
onFieldSubmitted: (value) async {
if (formKey.currentState?.validate() ?? false) {
final parsedValue = int.parse(value);
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(currentSettings) => currentSettings.copyWith
.maxSearchHistoryEntries(parsedValue),
);
}
},
),
),
),
],
),
);
}
}
class _AllowClipboardAccessTile extends HookConsumerWidget {
const _AllowClipboardAccessTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final allowClipboardAccess = ref.watch(
generalSettingsWithDefaultsProvider.select((s) => s.allowClipboardAccess),
);
return SwitchListTile.adaptive(
title: const Text('Allow clipboard access for suggestions'),
subtitle: const Text('Browser can read clipboard to suggest URLs'),
secondary: const Icon(MdiIcons.clipboardTextOutline),
value: allowClipboardAccess,
onChanged: (value) async {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(currentSettings) =>
currentSettings.copyWith.allowClipboardAccess(value),
);
},
);
}
}
@@ -0,0 +1,282 @@
/*
* 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_material_design_icons/flutter_material_design_icons.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/routing/routes.dart';
class SettingsScreen extends HookConsumerWidget {
const SettingsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
appBar: AppBar(title: const Text('Settings')),
body: SafeArea(
child: FadingScroll(
fadingSize: 25,
builder: (context, controller) {
return ListView(
controller: controller,
padding: const EdgeInsets.symmetric(horizontal: 12.0),
children: const [
_GeneralTile(),
_BrowsingTile(),
_ToolbarLayoutTile(),
_WebContentTile(),
_SearchTile(),
_PrivacySecurityTile(),
_ExtensionsTile(),
_SyncTile(),
_AdvancedTile(),
],
);
},
),
),
);
}
}
class _GeneralTile extends StatelessWidget {
const _GeneralTile();
@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);
},
),
);
}
}
class _BrowsingTile extends StatelessWidget {
const _BrowsingTile();
@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);
},
),
);
}
}
@@ -0,0 +1,61 @@
/*
* 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:hooks_riverpod/hooks_riverpod.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';
class ToolbarLayoutSettingsScreen extends HookConsumerWidget {
const ToolbarLayoutSettingsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(generalSettingsWithDefaultsProvider);
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()),
),
],
);
},
),
),
);
}
}
@@ -0,0 +1,222 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/logger.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/presentation/widgets/url_icon.dart';
import 'package:weblibre/utils/ui_helper.dart';
/// Screen to view and manage tracking protection exceptions
///
/// Shows list of all sites where ETP is disabled, with options
/// to remove individual exceptions or remove all exceptions.
class TrackingProtectionExceptionsScreen extends HookConsumerWidget {
const TrackingProtectionExceptionsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final exceptionsAsync = ref.watch(trackingProtectionRepositoryProvider);
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();
}
},
),
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();
}
return ListView.builder(
itemCount: exceptions.length,
itemBuilder: (context, index) {
final exception = exceptions[index];
return _ExceptionTile(
exception: exception,
onDelete: () => _deleteException(context, ref, exception),
);
},
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) => _ErrorState(error: error.toString()),
),
),
);
}
Future<void> _showDeleteAllDialog(BuildContext context, WidgetRef ref) async {
final confirmed = await showDeleteAllExceptionsDialog(context);
if (confirmed == true) {
try {
await ref
.read(trackingProtectionRepositoryProvider.notifier)
.removeAllExceptions();
} catch (e, s) {
logger.e(
'Failed to delete tracking protection exceptions',
error: e,
stackTrace: s,
);
if (context.mounted) {
showErrorMessage(context, 'Failed to delete exceptions: $e');
}
}
}
}
Future<void> _deleteException(
BuildContext context,
WidgetRef ref,
TrackingProtectionException exception,
) async {
try {
await ref
.read(trackingProtectionRepositoryProvider.notifier)
.removeExceptionByUrl(exception.url);
} catch (e, s) {
logger.e(
'Failed to remove tracking protection exception',
error: e,
stackTrace: s,
);
if (context.mounted) {
showErrorMessage(context, 'Failed to remove exception: $e');
}
}
}
}
class _ExceptionTile extends StatelessWidget {
final TrackingProtectionException exception;
final VoidCallback onDelete;
const _ExceptionTile({required this.exception, required this.onDelete});
@override
Widget build(BuildContext context) {
final uri = Uri.tryParse(exception.url);
return ListTile(
leading: uri != null
? UrlIcon([uri], iconSize: 24)
: const Icon(MdiIcons.shieldOutline),
title: Text(exception.url),
trailing: IconButton(
icon: const Icon(Icons.close),
onPressed: onDelete,
tooltip: 'Remove exception',
),
);
}
}
class _EmptyState extends StatelessWidget {
const _EmptyState();
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.shield_outlined,
size: 64,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(height: 16),
Text('No exceptions', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
Text(
'Sites added to exceptions will appear here',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
);
}
}
class _ErrorState extends StatelessWidget {
final String error;
const _ErrorState({required this.error});
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 64),
const SizedBox(height: 16),
Text(
'Error loading exceptions',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
error,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.error,
),
textAlign: TextAlign.center,
),
],
),
);
}
}
@@ -0,0 +1,410 @@
/*
* 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';
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/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';
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(),
],
);
}
}
class _WebFontsEnabledTile extends HookConsumerWidget {
const _WebFontsEnabledTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final webFontsEnabled = ref.watch(
engineSettingsWithDefaultsProvider.select((s) => s.webFontsEnabled),
);
return SwitchListTile.adaptive(
title: const Text('Web Fonts'),
subtitle: const Text('Allow websites to use custom fonts'),
secondary: const Icon(MdiIcons.formatFont),
value: webFontsEnabled,
onChanged: (value) async {
await ref
.read(saveEngineSettingsControllerProvider.notifier)
.save(
(currentSettings) =>
currentSettings.copyWith.webFontsEnabled(value),
);
},
);
}
}
class _AutomaticFontSizeAdjustmentTile extends HookConsumerWidget {
const _AutomaticFontSizeAdjustmentTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final automaticFontSizeAdjustment = ref.watch(
engineSettingsWithDefaultsProvider.select(
(s) => s.automaticFontSizeAdjustment,
),
);
return SwitchListTile.adaptive(
title: const Text('Automatic Font Size'),
subtitle: const Text(
'Automatically adjust font size based on system settings. Disable to manually control font size factor and inflation.',
),
secondary: const Icon(MdiIcons.formatFontSizeIncrease),
value: automaticFontSizeAdjustment,
onChanged: (value) async {
await ref
.read(saveEngineSettingsControllerProvider.notifier)
.save(
(currentSettings) =>
currentSettings.copyWith.automaticFontSizeAdjustment(value),
);
},
);
}
}
class _FontSizeFactorSlider extends HookConsumerWidget {
const _FontSizeFactorSlider();
@override
Widget build(BuildContext context, WidgetRef ref) {
final automaticFontSizeAdjustment = ref.watch(
engineSettingsWithDefaultsProvider.select(
(s) => s.automaticFontSizeAdjustment,
),
);
final fontSizeFactor = ref.watch(
engineSettingsWithDefaultsProvider.select((s) => s.fontSizeFactor),
);
final sliderValue = useState(fontSizeFactor);
useEffect(() {
sliderValue.value = fontSizeFactor;
return null;
}, [fontSizeFactor]);
final sliderLabel = '${(sliderValue.value * 100).round()}%';
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
title: const Text('Font Size Factor'),
subtitle: Text(
automaticFontSizeAdjustment
? 'Disabled while automatic font size is enabled'
: 'Scale web page text size',
),
leading: const Icon(MdiIcons.formatSize),
contentPadding: EdgeInsets.zero,
enabled: !automaticFontSizeAdjustment,
),
Row(
children: [
Text(
sliderLabel,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: automaticFontSizeAdjustment
? Theme.of(context).disabledColor
: null,
),
),
Expanded(
child: Slider(
min: 0.5,
max: 3.0,
divisions: 25,
label: sliderLabel,
value: sliderValue.value.clamp(0.5, 3.0),
onChanged: automaticFontSizeAdjustment
? null
: (value) {
sliderValue.value = value;
},
onChangeEnd: automaticFontSizeAdjustment
? null
: (value) async {
final rounded = (value * 10).round() / 10;
sliderValue.value = rounded;
await ref
.read(
saveEngineSettingsControllerProvider.notifier,
)
.save(
(currentSettings) => currentSettings.copyWith
.fontSizeFactor(rounded),
);
},
),
),
],
),
],
),
);
}
}
class _FontInflationTile extends HookConsumerWidget {
const _FontInflationTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final automaticFontSizeAdjustment = ref.watch(
engineSettingsWithDefaultsProvider.select(
(s) => s.automaticFontSizeAdjustment,
),
);
final fontInflationEnabled = ref.watch(
engineSettingsWithDefaultsProvider.select((s) => s.fontInflationEnabled),
);
return SwitchListTile.adaptive(
title: const Text('Font Inflation'),
subtitle: Text(
automaticFontSizeAdjustment
? 'Disabled while automatic font size is enabled'
: 'Enlarge text on pages that lack a mobile viewport meta tag',
),
secondary: const Icon(MdiIcons.formatTextVariantOutline),
value: fontInflationEnabled,
onChanged: automaticFontSizeAdjustment
? null
: (value) async {
await ref
.read(saveEngineSettingsControllerProvider.notifier)
.save(
(currentSettings) =>
currentSettings.copyWith.fontInflationEnabled(value),
);
},
);
}
}
class _InputAutoZoomEnabledTile extends HookConsumerWidget {
const _InputAutoZoomEnabledTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final inputAutoZoomEnabled = ref.watch(
engineSettingsWithDefaultsProvider.select((s) => s.inputAutoZoomEnabled),
);
return SwitchListTile.adaptive(
title: const Text('Input Auto Zoom'),
subtitle: const Text('Automatically zoom in when focusing text inputs'),
secondary: const Icon(MdiIcons.formTextbox),
value: inputAutoZoomEnabled,
onChanged: (value) async {
await ref
.read(saveEngineSettingsControllerProvider.notifier)
.save(
(currentSettings) =>
currentSettings.copyWith.inputAutoZoomEnabled(value),
);
},
);
}
}
class _PdfViewerTile extends HookConsumerWidget {
const _PdfViewerTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final enablePdfJs = ref.watch(
engineSettingsWithDefaultsProvider.select((s) => s.enablePdfJs),
);
return SwitchListTile.adaptive(
title: const Text('Built-in PDF Viewer'),
subtitle: const Text(
'Open PDF files directly in the browser without downloading',
),
secondary: const Icon(MdiIcons.filePdfBox),
value: enablePdfJs,
onChanged: (value) async {
await ref
.read(saveEngineSettingsControllerProvider.notifier)
.save(
(currentSettings) => currentSettings.copyWith.enablePdfJs(value),
);
},
);
}
}
class _EnableReaderModeTile extends HookConsumerWidget {
const _EnableReaderModeTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final enableReadability = ref.watch(
generalSettingsWithDefaultsProvider.select((s) => s.enableReadability),
);
return SwitchListTile.adaptive(
title: const Text('Enable Reader Mode'),
subtitle: const Text(
'Optional browser app bar tool that extracts and simplifies web pages for improved readability by removing ads, sidebars, and other non-essential elements.',
),
secondary: const Icon(MdiIcons.bookOpen),
value: enableReadability,
onChanged: (value) async {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(currentSettings) =>
currentSettings.copyWith.enableReadability(value),
);
},
);
}
}
class _EnforceReaderModeTile extends HookConsumerWidget {
const _EnforceReaderModeTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final enableReadability = ref.watch(
generalSettingsWithDefaultsProvider.select((s) => s.enableReadability),
);
final enforceReadability = ref.watch(
generalSettingsWithDefaultsProvider.select((s) => s.enforceReadability),
);
return SwitchListTile.adaptive(
title: const Text('Enforce Reader Mode'),
subtitle: const Text(
'Override readability probability of websites and always show Reader Mode capabilities even the site might not be compatible.',
),
secondary: const Icon(MdiIcons.bookCheck),
value: enableReadability && enforceReadability,
onChanged: enableReadability
? (value) async {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(currentSettings) =>
currentSettings.copyWith.enforceReadability(value),
);
}
: null,
);
}
}
class _OnDeviceAiTile extends HookConsumerWidget {
const _OnDeviceAiTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final enableLocalAiFeatures = ref.watch(
generalSettingsWithDefaultsProvider.select(
(s) => s.enableLocalAiFeatures,
),
);
return SwitchListTile.adaptive(
title: const Text('On Device AI'),
subtitle: const Text(
'Local on-device features including container topic and tab suggestions',
),
secondary: const Icon(MdiIcons.creation),
value: enableLocalAiFeatures,
onChanged: (value) async {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(currentSettings) =>
currentSettings.copyWith.enableLocalAiFeatures(value),
);
},
);
}
}
@@ -0,0 +1,190 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:fast_equatable/fast_equatable.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nullability/nullability.dart';
import 'package: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/presentation/widgets/failure_widget.dart';
class WebEngineHardeningScreen extends HookConsumerWidget {
const WebEngineHardeningScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final preferenceGroups = ref.watch(
unifiedPreferenceSettingsRepositoryProvider(PreferencePartition.user),
);
final allGroupsActive = useMemoized(
() =>
preferenceGroups.value?.values.every(
(element) => element.isActiveOrOptional,
) ??
false,
[EquatableValue(preferenceGroups.value)],
);
final theme = Theme.of(context);
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'),
),
],
),
);
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,
),
),
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(
child: ListTile(
title: Text(group.key),
subtitle: group.value.description.mapNotNull(
(description) => Text(description),
),
leading: Badge(
isLabelVisible: group.value.hasInactiveOptional,
child: HardeningGroupIcon(
isActive: group.value.isActiveOrOptional,
isPartlyActive: group.value.isPartlyActive,
),
),
trailing: const Icon(Icons.chevron_right),
onTap: () async {
await WebEngineHardeningGroupRoute(
group: group.key,
).push(context);
},
),
),
],
);
}).toList(),
),
),
],
);
},
error: (error, stackTrace) => FailureWidget(
title: 'Could not load preference settings',
exception: error,
onRetry: () => ref.refresh(
unifiedPreferenceSettingsRepositoryProvider(
PreferencePartition.user,
),
),
),
loading: () => const Center(child: CircularProgressIndicator()),
),
),
);
}
}
@@ -0,0 +1,217 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nullability/nullability.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/presentation/widgets/failure_widget.dart';
class WebEngineHardeningGroupScreen extends HookConsumerWidget {
final String groupName;
const WebEngineHardeningGroupScreen({super.key, required this.groupName});
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(
preferenceSettingsGroupRepositoryProvider(
PreferencePartition.user,
groupName,
),
);
final theme = Theme.of(context);
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,
),
),
),
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,
),
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,
),
],
),
),
secondary: HardeningGroupIcon(
isActive: setting.value.isActive,
),
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),
),
),
),
],
),
);
}).toList(),
),
),
],
);
},
error: (error, stackTrace) => FailureWidget(
title: 'Could not load preference settings',
exception: error,
onRetry: () => ref.refresh(
preferenceSettingsGroupRepositoryProvider(
PreferencePartition.user,
groupName,
),
),
),
loading: () => const Center(child: CircularProgressIndicator()),
),
),
);
}
}