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,76 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/features/user/domain/repositories/engine_settings.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
import 'package:weblibre/features/user/domain/repositories/tor_settings.dart';
part 'save_settings.g.dart';
@Riverpod(keepAlive: true)
class SaveGeneralSettingsController extends _$SaveGeneralSettingsController {
@override
Future<void> build() {
return Future.value();
}
Future<void> save(UpdateGeneralSettingsFunc updateSettings) async {
state = const AsyncLoading();
state = await AsyncValue.guard(
() => ref
.read(generalSettingsRepositoryProvider.notifier)
.updateSettings(updateSettings),
);
}
}
@Riverpod(keepAlive: true)
class SaveEngineSettingsController extends _$SaveEngineSettingsController {
@override
Future<void> build() {
return Future.value();
}
Future<void> save(UpdateEngineSettingsFunc updateSettings) async {
state = const AsyncLoading();
state = await AsyncValue.guard(
() => ref
.read(engineSettingsRepositoryProvider.notifier)
.updateSettings(updateSettings),
);
}
}
@Riverpod(keepAlive: true)
class SaveTorSettingsController extends _$SaveTorSettingsController {
@override
Future<void> build() {
return Future.value();
}
Future<void> save(UpdateTorSettingsFunc updateSettings) async {
state = const AsyncLoading();
state = await AsyncValue.guard(
() => ref
.read(torSettingsRepositoryProvider.notifier)
.updateSettings(updateSettings),
);
}
}
@@ -0,0 +1,147 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'save_settings.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(SaveGeneralSettingsController)
final saveGeneralSettingsControllerProvider =
SaveGeneralSettingsControllerProvider._();
final class SaveGeneralSettingsControllerProvider
extends $AsyncNotifierProvider<SaveGeneralSettingsController, void> {
SaveGeneralSettingsControllerProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'saveGeneralSettingsControllerProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$saveGeneralSettingsControllerHash();
@$internal
@override
SaveGeneralSettingsController create() => SaveGeneralSettingsController();
}
String _$saveGeneralSettingsControllerHash() =>
r'b75c9c52452a9cb687f69dad1523f1ed5205847e';
abstract class _$SaveGeneralSettingsController extends $AsyncNotifier<void> {
FutureOr<void> build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<AsyncValue<void>, void>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<void>, void>,
AsyncValue<void>,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@ProviderFor(SaveEngineSettingsController)
final saveEngineSettingsControllerProvider =
SaveEngineSettingsControllerProvider._();
final class SaveEngineSettingsControllerProvider
extends $AsyncNotifierProvider<SaveEngineSettingsController, void> {
SaveEngineSettingsControllerProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'saveEngineSettingsControllerProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$saveEngineSettingsControllerHash();
@$internal
@override
SaveEngineSettingsController create() => SaveEngineSettingsController();
}
String _$saveEngineSettingsControllerHash() =>
r'd3d74a6aadc89532b1d81c424d92efbfa43002d5';
abstract class _$SaveEngineSettingsController extends $AsyncNotifier<void> {
FutureOr<void> build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<AsyncValue<void>, void>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<void>, void>,
AsyncValue<void>,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@ProviderFor(SaveTorSettingsController)
final saveTorSettingsControllerProvider = SaveTorSettingsControllerProvider._();
final class SaveTorSettingsControllerProvider
extends $AsyncNotifierProvider<SaveTorSettingsController, void> {
SaveTorSettingsControllerProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'saveTorSettingsControllerProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$saveTorSettingsControllerHash();
@$internal
@override
SaveTorSettingsController create() => SaveTorSettingsController();
}
String _$saveTorSettingsControllerHash() =>
r'd8aa04d7ae60c97bdad6c072f894189509dd35d3';
abstract class _$SaveTorSettingsController extends $AsyncNotifier<void> {
FutureOr<void> build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<AsyncValue<void>, void>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<void>, void>,
AsyncValue<void>,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@@ -0,0 +1,45 @@
/*
* 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';
/// Shows a confirmation dialog for deleting all tracking protection exceptions.
///
/// Returns true if the user confirms deletion, false otherwise.
Future<bool?> showDeleteAllExceptionsDialog(BuildContext context) {
return showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete All Exceptions?'),
content: const Text(
'This will re-enable tracking protection for all exception sites.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Delete'),
),
],
),
);
}
@@ -0,0 +1,211 @@
/*
* 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:google_fonts/google_fonts.dart';
import 'package:intl/intl.dart';
import 'package:logger/logger.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,
};
}
class LogDetailsDialog extends StatelessWidget {
const LogDetailsDialog({
required this.level,
required this.message,
this.error,
this.stackTrace,
required this.time,
super.key,
});
final Level level;
final String message;
final String? error;
final String? stackTrace;
final DateTime time;
String _formatTime(DateTime time) {
return DateFormat('HH:mm:ss.SSS').format(time);
}
Future<void> _copyEntryToClipboard(BuildContext context) async {
final text = StringBuffer();
text.writeln(
'[${_formatTime(time)}] ${level.name.toUpperCase()}: $message',
);
if (error != null) {
text.writeln('Error: $error');
}
if (stackTrace != null) {
text.writeln('Stack Trace:');
text.writeln(stackTrace);
}
await Clipboard.setData(ClipboardData(text: text.toString()));
if (context.mounted) {
showInfoMessage(context, 'Entry copied');
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Row(
children: [
Icon(_levelIcon(level), color: _levelColor(level)),
const SizedBox(width: 8),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(level.name.toUpperCase()),
Text(
_formatTime(time),
style: Theme.of(context).textTheme.bodySmall,
),
],
),
],
),
content: SizedBox(
width: double.maxFinite,
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (message.isNotEmpty) ...[
Text(
'Message:',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(height: 4),
SelectableText(
message,
style: GoogleFonts.robotoMono(fontSize: 12),
),
const SizedBox(height: 16),
],
if (error != null) ...[
Text(
'Error:',
style: TextStyle(
fontWeight: FontWeight.bold,
color: _levelColor(level),
),
),
const SizedBox(height: 4),
SelectableText(
error!,
style: GoogleFonts.robotoMono(fontSize: 11),
),
const SizedBox(height: 16),
],
if (stackTrace != null) ...[
Text(
'Stack Trace:',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(height: 4),
SelectableText(
stackTrace!,
style: GoogleFonts.robotoMono(fontSize: 9),
),
],
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Close'),
),
TextButton.icon(
onPressed: () => _copyEntryToClipboard(context),
icon: const Icon(Icons.copy),
label: const Text('Copy'),
),
],
);
}
}
Future<void> showLogDetailsDialog(
BuildContext context, {
required Level level,
required String message,
String? error,
String? stackTrace,
required DateTime time,
}) {
return showDialog(
context: context,
builder: (context) => LogDetailsDialog(
level: level,
message: message,
error: error,
stackTrace: stackTrace,
time: time,
),
);
}
@@ -0,0 +1,48 @@
/*
* 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:go_router/go_router.dart';
Future<bool?> showUserAgentRestartDialog(BuildContext context) {
return showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
icon: const Icon(Icons.warning),
title: const Text('User Agent Changed'),
content: const Text(
'The Browser needs to get restarted for the new user agent to take effect',
),
actions: [
TextButton(
onPressed: () {
context.pop(false);
},
child: const Text('Later'),
),
TextButton(
onPressed: () {
context.pop(true);
},
child: const Text('Restart Now'),
),
],
),
);
}
@@ -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()),
),
),
);
}
}
@@ -0,0 +1,69 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/features/bangs/data/models/bang_group.dart';
import 'package:weblibre/features/bangs/domain/providers/bangs.dart';
import 'package:weblibre/features/bangs/domain/repositories/sync.dart';
import 'package:weblibre/features/settings/presentation/widgets/custom_list_tile.dart';
import 'package:weblibre/features/settings/presentation/widgets/sync_details_table.dart';
class BangGroupListTile extends HookConsumerWidget {
final BangGroup group;
final String title;
final String subtitle;
const BangGroupListTile({
required this.group,
required this.title,
required this.subtitle,
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final lastSync = ref.watch(
lastSyncOfGroupProvider(group).select((value) => value.value),
);
final count = ref.watch(
bangCountOfGroupProvider(group).select((value) => value.value),
);
return CustomListTile(
title: title,
subtitle: subtitle,
content: Padding(
padding: const EdgeInsets.only(top: 8.0),
child: SyncDetailsTable(count, lastSync),
),
suffix: FilledButton.icon(
onPressed: () async {
await ref
.read(bangSyncRepositoryProvider.notifier)
.syncRemoteBangGroup(group, null);
},
icon: const Icon(Icons.sync),
label: const Text('Sync'),
),
);
}
}
@@ -0,0 +1,42 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/features/bangs/data/models/bang_key.dart';
import 'package:weblibre/features/bangs/domain/providers/bangs.dart';
import 'package:weblibre/presentation/widgets/url_icon.dart';
class BangIcon extends ConsumerWidget {
final BangKey trigger;
final double iconSize;
const BangIcon({super.key, required this.trigger, this.iconSize = 20});
@override
Widget build(BuildContext context, WidgetRef ref) {
final bang = ref.watch(
bangDataProvider(trigger).select((value) => value.value),
);
return UrlIcon([
if (bang != null) bang.getDefaultUrl(),
], iconSize: iconSize);
}
}
@@ -0,0 +1,84 @@
/*
* 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';
class CustomListTile extends StatelessWidget {
final bool enabled;
final String title;
final String subtitle;
final Widget? content;
final Widget? prefix;
final Widget? suffix;
const CustomListTile({
super.key,
required this.title,
required this.subtitle,
this.content,
this.prefix,
this.suffix,
this.enabled = true,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final subtitleTextTheme = theme.textTheme.bodyMedium!.copyWith(
color: theme.colorScheme.onSurfaceVariant,
);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
child: Row(
children: [
if (prefix != null) prefix!,
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: enabled
? theme.textTheme.bodyLarge
: theme.textTheme.bodyLarge?.copyWith(
color: theme.disabledColor,
),
),
Text(
subtitle,
style: enabled
? subtitleTextTheme
: theme.textTheme.bodyMedium?.copyWith(
color: theme.disabledColor,
),
),
if (content != null) content!,
],
),
),
if (suffix != null) suffix!,
],
),
);
}
}
@@ -0,0 +1,92 @@
/*
* 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/core/routing/routes.dart';
import 'package:weblibre/features/bangs/data/models/bang_key.dart';
import 'package:weblibre/features/bangs/domain/providers/bangs.dart';
import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart';
import 'package:weblibre/features/user/data/models/general_settings.dart';
import 'package:weblibre/presentation/widgets/selectable_chips.dart';
import 'package:weblibre/presentation/widgets/url_icon.dart';
class DefaultSearchSelector extends HookConsumerWidget {
const DefaultSearchSelector({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final activeBang = ref.watch(
defaultSearchBangDataProvider.select((value) => value.value),
);
final availableBangs = ref.watch(frequentBangListProvider);
Future<void> updateSearchProvider(BangKey key) async {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(currentSettings) =>
currentSettings.copyWith.defaultSearchProvider(key),
);
}
return availableBangs.when(
skipLoadingOnReload: true,
data: (availableBangs) {
return SizedBox(
height: 48,
child: Row(
children: [
Expanded(
child: SelectableChips(
itemId: (bang) => bang.trigger,
itemAvatar: (bang) =>
UrlIcon([bang.getDefaultUrl()], iconSize: 20),
itemLabel: (bang) => Text(bang.websiteName),
itemTooltip: (bang) => bang.trigger,
availableItems: availableBangs,
selectedItem: activeBang,
onSelected: (bang) async {
await updateSearchProvider(bang.toKey());
},
),
),
IconButton(
onPressed: () async {
final trigger = await const BangSearchRoute().push<BangKey?>(
context,
);
if (trigger != null) {
await updateSearchProvider(trigger);
}
},
icon: const Icon(Icons.chevron_right),
),
],
),
);
},
error: (error, stackTrace) => Center(
child: Icon(Icons.error, color: Theme.of(context).colorScheme.error),
),
loading: () => const SizedBox(height: 48, width: double.infinity),
);
}
}
@@ -0,0 +1,168 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package: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/user/data/models/engine_settings.dart';
import 'package:weblibre/features/user/domain/repositories/engine_settings.dart';
import 'package:weblibre/utils/form_validators.dart';
class DohSettingsContent extends HookConsumerWidget {
const DohSettingsContent({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final formKey = useMemoized(() => GlobalKey<FormState>());
final dohSettings = ref.watch(
engineSettingsWithDefaultsProvider.select((value) => value.dohSettings),
);
final customProviderController = useTextEditingController(
text: BuiltInDohProviders.isBuiltin(dohSettings.dohProviderUrl)
? null
: dohSettings.dohProviderUrl,
);
return Column(
children: [
const ListTile(
leading: Icon(MdiIcons.dns),
title: Text('Protection Level'),
subtitle: Text(
'Domain Name System (DNS) over HTTPS sends your request for a domain name through an encrypted connection, providing a secure DNS and making it harder for others to see which web site you\u2019re about to access.',
),
),
RadioGroup(
groupValue: dohSettings.dohSettingsMode,
onChanged: (value) async {
if (value != null) {
await ref
.read(saveEngineSettingsControllerProvider.notifier)
.save(
(currentSettings) =>
currentSettings.copyWith.dohSettingsMode(value),
);
}
},
child: const Column(
children: [
RadioListTile.adaptive(
value: DohSettingsMode.geckoDefault,
title: Text('Default Protection'),
subtitle: Text('DoH used only when default DNS fails'),
),
RadioListTile.adaptive(
value: DohSettingsMode.increased,
title: Text('Increased Protection'),
subtitle: Text('DoH preferred, default DNS as fallback'),
),
RadioListTile.adaptive(
value: DohSettingsMode.max,
title: Text('Max Protection'),
subtitle: Text('DoH only, no fallback'),
),
RadioListTile.adaptive(
value: DohSettingsMode.off,
title: Text('Off'),
subtitle: Text('Use your default DNS resolver'),
),
],
),
),
const ListTile(
leading: Icon(MdiIcons.routerNetwork),
title: Text('DoH Provider'),
),
RadioGroup(
groupValue: dohSettings.dohProviderUrl,
onChanged: (value) async {
if (value != null) {
await ref
.read(saveEngineSettingsControllerProvider.notifier)
.save(
(currentSettings) =>
currentSettings.copyWith.dohProviderUrl(value),
);
}
},
child: Column(
children: BuiltInDohProviders.values
.map(
(provider) => RadioListTile.adaptive(
value: provider.url,
title: Text(provider.name),
subtitle: Text(provider.url),
),
)
.toList(),
),
),
RadioGroup(
groupValue: !BuiltInDohProviders.isBuiltin(
dohSettings.dohProviderUrl,
),
onChanged: (value) {},
child: RadioListTile(
value: true,
enabled: false,
title: Form(
key: formKey,
child: TextFormField(
controller: customProviderController,
keyboardType: TextInputType.url,
decoration: const InputDecoration(
label: Text('Custom Resolver URL'),
hintText: 'https://example.com/dns-query',
floatingLabelBehavior: FloatingLabelBehavior.always,
),
validator: (value) {
return validateUrl(
value,
onlyHttpProtocol: true,
eagerParsing: false,
);
},
onSaved: (newProvider) async {
if (newProvider != null) {
await ref
.read(saveEngineSettingsControllerProvider.notifier)
.save(
(currentSettings) => currentSettings.copyWith
.dohProviderUrl(newProvider),
);
}
},
onFieldSubmitted: (_) {
if (formKey.currentState?.validate() == true) {
formKey.currentState?.save();
}
},
),
),
),
),
],
);
}
}
@@ -0,0 +1,52 @@
/*
* 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';
class HardeningGroupIcon extends StatelessWidget {
final bool isActive;
final bool isPartlyActive;
const HardeningGroupIcon({
super.key,
required this.isActive,
this.isPartlyActive = false,
});
@override
Widget build(BuildContext context) {
if (isActive) {
return Icon(
MdiIcons.shieldLockOutline,
color: Theme.of(context).colorScheme.secondary,
);
} else if (isPartlyActive) {
return Icon(
MdiIcons.shieldLockOpenOutline,
color: Theme.of(context).colorScheme.secondary,
);
}
return Icon(
MdiIcons.shieldOffOutline,
color: Theme.of(context).colorScheme.error,
);
}
}
@@ -0,0 +1,58 @@
/*
* 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';
class SettingSection extends StatelessWidget {
final String name;
const SettingSection({super.key, required this.name});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(top: 16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(name, style: Theme.of(context).textTheme.titleLarge),
const Divider(),
],
),
);
}
}
class SettingSubSection extends StatelessWidget {
final String name;
const SettingSubSection({super.key, required this.name});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 16.0),
child: Text(
name,
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontSize: 18),
),
);
}
}
@@ -0,0 +1,52 @@
/*
* 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:google_fonts/google_fonts.dart';
import 'package:timeago/timeago.dart' as timeago;
class SyncDetailsTable extends StatelessWidget {
final int? count;
final DateTime? lastSync;
const SyncDetailsTable(this.count, this.lastSync, {super.key});
@override
Widget build(BuildContext context) {
return DefaultTextStyle(
style: GoogleFonts.robotoMono(
textStyle: DefaultTextStyle.of(context).style,
),
child: Table(
columnWidths: const {0: FixedColumnWidth(100)},
children: [
TableRow(
children: [const Text('Entries'), Text(count?.toString() ?? 'N/A')],
),
TableRow(
children: [
const Text('Last Sync'),
Text((lastSync != null) ? timeago.format(lastSync!) : 'N/A'),
],
),
],
),
);
}
}
@@ -0,0 +1,483 @@
/*
* 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:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart';
import 'package:weblibre/features/settings/presentation/widgets/sections.dart';
import 'package:weblibre/features/user/data/models/general_settings.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
class ToolbarLayoutContent extends StatelessWidget {
const ToolbarLayoutContent({super.key});
@override
Widget build(BuildContext context) {
return const Column(
children: [
SettingSection(name: 'Tab Bar'),
_TabBarPositionSection(),
_TabBarLayoutModeSection(),
_AutoHideTabBarTile(),
_TabBarLongPressUrlCopyTile(),
SettingSection(name: 'Contextual Toolbar'),
_ShowContextualTabBarTile(),
_CustomizeToolbarButtonsTile(),
SettingSection(name: 'Quick Tab Switcher'),
_ShowQuickTabSwitcherBarTile(),
_QuickTabSwitcherModeSection(),
_QuickTabSwitcherHistorySuggestionsTile(),
_QuickTabSwitcherShowTitlesTile(),
SettingSection(name: 'Tab View'),
_BottomSheetTabViewTile(),
_TabListShowFaviconsTile(),
],
);
}
}
class _TabBarPositionSection extends HookConsumerWidget {
const _TabBarPositionSection();
@override
Widget build(BuildContext context, WidgetRef ref) {
final tabBarPosition = ref.watch(
generalSettingsWithDefaultsProvider.select((s) => s.tabBarPosition),
);
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 Position'),
leading: Icon(MdiIcons.dockWindow),
contentPadding: EdgeInsets.zero,
),
RadioGroup(
groupValue: tabBarPosition,
onChanged: (value) async {
if (value != null) {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(currentSettings) =>
currentSettings.copyWith.tabBarPosition(value),
);
}
},
child: const Column(
children: [
RadioListTile.adaptive(
value: TabBarPosition.top,
title: Text('Top'),
subtitle: Text('Persistent tab bar without auto-hide'),
),
RadioListTile.adaptive(
value: TabBarPosition.bottom,
title: Text('Bottom'),
subtitle: Text('Tab bar with auto-hide support'),
),
],
),
),
],
),
);
}
}
class _TabBarLayoutModeSection extends HookConsumerWidget {
const _TabBarLayoutModeSection();
@override
Widget build(BuildContext context, WidgetRef ref) {
final tabBarLayout = ref.watch(
generalSettingsWithDefaultsProvider.select((s) => s.tabBarLayout),
);
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 Style'),
leading: Icon(MdiIcons.tabUnselected),
contentPadding: EdgeInsets.zero,
),
RadioGroup(
groupValue: tabBarLayout,
onChanged: (value) async {
if (value != null) {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(currentSettings) =>
currentSettings.copyWith.tabBarLayout(value),
);
}
},
child: const Column(
children: [
RadioListTile.adaptive(
value: TabBarLayout.withTitle,
title: Text('With Title'),
subtitle: Text('Shows page title and URL breadcrumb'),
),
RadioListTile.adaptive(
value: TabBarLayout.compact,
title: Text('Compact'),
subtitle: Text('Centered URL pill without page title'),
),
],
),
),
],
),
);
}
}
class _ShowContextualTabBarTile extends HookConsumerWidget {
const _ShowContextualTabBarTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final tabBarShowContextualBar = ref.watch(
generalSettingsWithDefaultsProvider.select(
(s) => s.tabBarShowContextualBar,
),
);
return SwitchListTile.adaptive(
title: const Text('Show Contextual Toolbar'),
subtitle: const Text(
'Show additional bottom toolbar for navigation and actions',
),
secondary: const Icon(MdiIcons.dockBottom),
value: tabBarShowContextualBar,
onChanged: (value) async {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(currentSettings) =>
currentSettings.copyWith.tabBarShowContextualBar(value),
);
},
);
}
}
class _CustomizeToolbarButtonsTile extends HookConsumerWidget {
const _CustomizeToolbarButtonsTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final tabBarShowContextualBar = ref.watch(
generalSettingsWithDefaultsProvider.select(
(s) => s.tabBarShowContextualBar,
),
);
return ListTile(
leading: const Icon(Icons.tune),
title: const Text('Customize Toolbar Buttons'),
trailing: const Icon(Icons.chevron_right),
enabled: tabBarShowContextualBar,
onTap: () async {
await const ContextualToolbarSettingsRoute().push(context);
},
);
}
}
class _ShowQuickTabSwitcherBarTile extends HookConsumerWidget {
const _ShowQuickTabSwitcherBarTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final tabBarShowQuickTabSwitcherBar = ref.watch(
generalSettingsWithDefaultsProvider.select(
(s) => s.tabBarShowQuickTabSwitcherBar,
),
);
return SwitchListTile.adaptive(
title: const Text('Show Quick Tab Switcher Bar'),
subtitle: const Text(
'Show additional toolbar to quickly switch to recently used tabs',
),
secondary: const Icon(MdiIcons.dockBottom),
value: tabBarShowQuickTabSwitcherBar,
onChanged: (value) async {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(currentSettings) =>
currentSettings.copyWith.tabBarShowQuickTabSwitcherBar(value),
);
},
);
}
}
class _QuickTabSwitcherModeSection extends HookConsumerWidget {
const _QuickTabSwitcherModeSection();
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(generalSettingsWithDefaultsProvider);
final quickTabSwitcherMode = settings.effectiveUiQuickTabSwitcherMode();
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ListTile(
title: Text('Quick Tab Switcher Mode'),
leading: Icon(MdiIcons.folderSettings),
contentPadding: EdgeInsets.zero,
),
RadioGroup(
groupValue: quickTabSwitcherMode,
onChanged: (value) async {
if (value != null) {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(currentSettings) =>
currentSettings.copyWith.quickTabSwitcherMode(value),
);
}
},
child: Column(
children: [
const RadioListTile.adaptive(
value: QuickTabSwitcherMode.lastUsedTabs,
title: Text('Recently Used Tabs'),
subtitle: Text('Recently used tabs across all containers'),
),
if (settings.showContainerUi)
const RadioListTile.adaptive(
value: QuickTabSwitcherMode.containerTabs,
title: Text('Container Tabs'),
subtitle: Text('Ordered tabs of the selected container'),
),
],
),
),
],
),
);
}
}
class _QuickTabSwitcherHistorySuggestionsTile extends HookConsumerWidget {
const _QuickTabSwitcherHistorySuggestionsTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final showHistorySuggestions = ref.watch(
generalSettingsWithDefaultsProvider.select(
(s) => s.quickTabSwitcherShowHistorySuggestions,
),
);
final tabBarShowQuickTabSwitcherBar = ref.watch(
generalSettingsWithDefaultsProvider.select(
(s) => s.tabBarShowQuickTabSwitcherBar,
),
);
return SwitchListTile.adaptive(
title: const Text('History Fallback in Quick Tab Switcher'),
subtitle: const Text(
'Use browsing history suggestions when no tab chips are available',
),
secondary: const Icon(MdiIcons.history),
value: showHistorySuggestions,
onChanged: tabBarShowQuickTabSwitcherBar
? (value) async {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(currentSettings) => currentSettings.copyWith
.quickTabSwitcherShowHistorySuggestions(value),
);
}
: null,
);
}
}
class _QuickTabSwitcherShowTitlesTile extends HookConsumerWidget {
const _QuickTabSwitcherShowTitlesTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final quickTabSwitcherShowTitles = ref.watch(
generalSettingsWithDefaultsProvider.select(
(s) => s.quickTabSwitcherShowTitles,
),
);
final tabBarShowQuickTabSwitcherBar = ref.watch(
generalSettingsWithDefaultsProvider.select(
(s) => s.tabBarShowQuickTabSwitcherBar,
),
);
return SwitchListTile.adaptive(
title: const Text('Show Titles in Quick Tab Switcher'),
subtitle: const Text(
'Display tab titles alongside icons in the quick tab switcher bar',
),
secondary: const Icon(MdiIcons.textRecognition),
value: quickTabSwitcherShowTitles,
onChanged: tabBarShowQuickTabSwitcherBar
? (value) async {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(currentSettings) => currentSettings.copyWith
.quickTabSwitcherShowTitles(value),
);
}
: null,
);
}
}
class _AutoHideTabBarTile extends HookConsumerWidget {
const _AutoHideTabBarTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final autoHideTabBar = ref.watch(
generalSettingsWithDefaultsProvider.select((s) => s.autoHideTabBar),
);
return SwitchListTile.adaptive(
title: const Text('Auto Hide Tab Bar'),
subtitle: const Text('Hide tab bar when scrolling'),
secondary: const Icon(MdiIcons.folderHidden),
value: autoHideTabBar,
onChanged: (value) async {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(currentSettings) =>
currentSettings.copyWith.autoHideTabBar(value),
);
},
);
}
}
class _BottomSheetTabViewTile extends HookConsumerWidget {
const _BottomSheetTabViewTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final tabViewBottomSheet = ref.watch(
generalSettingsWithDefaultsProvider.select((s) => s.tabViewBottomSheet),
);
return SwitchListTile.adaptive(
title: const Text('Bottom Sheet Tab View'),
subtitle: const Text(
'Display tabs in a bottom sheet instead of fullscreen',
),
secondary: const Icon(MdiIcons.dockBottom),
value: tabViewBottomSheet,
onChanged: (value) async {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(currentSettings) =>
currentSettings.copyWith.tabViewBottomSheet(value),
);
},
);
}
}
class _TabBarLongPressUrlCopyTile extends HookConsumerWidget {
const _TabBarLongPressUrlCopyTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final tabBarLongPressUrlCopy = ref.watch(
generalSettingsWithDefaultsProvider.select(
(s) => s.tabBarLongPressUrlCopy,
),
);
return SwitchListTile.adaptive(
title: const Text('Long Press URL to Copy'),
subtitle: const Text(
'Copy the page URL to clipboard when long pressing the address bar',
),
secondary: const Icon(MdiIcons.contentCopy),
value: tabBarLongPressUrlCopy,
onChanged: (value) async {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(currentSettings) =>
currentSettings.copyWith.tabBarLongPressUrlCopy(value),
);
},
);
}
}
class _TabListShowFaviconsTile extends HookConsumerWidget {
const _TabListShowFaviconsTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final tabListShowFavicons = ref.watch(
generalSettingsWithDefaultsProvider.select((s) => s.tabListShowFavicons),
);
return SwitchListTile.adaptive(
title: const Text('Show Favicons in List View'),
subtitle: const Text(
'Display website icons instead of page thumbnails in tab list view',
),
secondary: const Icon(MdiIcons.web),
value: tabListShowFavicons,
onChanged: (value) async {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(currentSettings) =>
currentSettings.copyWith.tabListShowFavicons(value),
);
},
);
}
}
@@ -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 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/security.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart';
import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/presentation/widgets/contextual_bar_buttons.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/app_bar_title.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/navigation_buttons.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tabs_action_button.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
import 'package:weblibre/features/user/data/models/general_settings.dart';
class TabBarPreviewHeaderDelegate extends SliverPersistentHeaderDelegate {
const TabBarPreviewHeaderDelegate({
required this.settings,
this.backgroundColor,
this.compact = false,
this.padding = const EdgeInsets.symmetric(horizontal: 12.0),
});
static const _kPreviewBaseHeight = 90.0;
static const _kCompactPreviewBaseHeight = 42.0;
static const _kHeaderHeight = 72.0;
final GeneralSettings settings;
final Color? backgroundColor;
final bool compact;
final EdgeInsets padding;
double get _toolbarHeight {
var height = kToolbarHeight;
if (settings.tabBarShowContextualBar) {
height += BrowserTabBar.contextualToolabarHeight;
}
if (settings.tabBarShowQuickTabSwitcherBar) {
height += BrowserTabBar.quickTabSwitcherHeight;
}
return height;
}
double get _baseHeight =>
compact ? _kCompactPreviewBaseHeight : _kPreviewBaseHeight;
double get _headerHeight => compact ? 0.0 : _kHeaderHeight;
@override
double get minExtent =>
_baseHeight + _toolbarHeight + _headerHeight + padding.vertical;
@override
double get maxExtent =>
_baseHeight + _toolbarHeight + _headerHeight + padding.vertical;
@override
Widget build(
BuildContext context,
double shrinkOffset,
bool overlapsContent,
) {
return ColoredBox(
color: backgroundColor ?? Theme.of(context).scaffoldBackgroundColor,
child: Padding(
padding: padding,
child: TabBarPreviewCard(settings: settings, compact: compact),
),
);
}
@override
bool shouldRebuild(covariant TabBarPreviewHeaderDelegate oldDelegate) {
return oldDelegate.settings != settings ||
oldDelegate.backgroundColor != backgroundColor ||
oldDelegate.compact != compact;
}
}
class TabBarPreviewCard extends HookWidget {
const TabBarPreviewCard({
super.key,
required this.settings,
this.compact = false,
});
final GeneralSettings settings;
final bool compact;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final quickTabsController = useScrollController();
final showMainToolbarActionButtons = !settings.tabBarShowContextualBar;
final previewTabState = TabState.$default('preview-tab').copyWith(
url: Uri.parse('https://weblibre.eu/docs'),
title: 'WebLibre Preview',
securityInfoState: SecurityState(
secure: true,
host: 'weblibre.eu',
issuer: 'WebLibre',
),
);
final previewQuickItems = <QuickTabSwitcherItem>[
QuickTabSwitcherItem(
id: 'regular-preview-tab',
isActive: true,
title: 'News',
tabMode: TabMode.regular,
isHistory: false,
isPinned:
settings.effectiveUiQuickTabSwitcherMode() ==
QuickTabSwitcherMode.containerTabs,
url: Uri.parse('https://example.com/news'),
color: settings.showContainerUi
? colorScheme.primary.withValues(alpha: 0.18)
: null,
avatar: const Icon(MdiIcons.web, size: 20),
),
QuickTabSwitcherItem(
id: 'private-preview-tab',
isActive: false,
title: 'Private',
tabMode: TabMode.private,
isHistory: false,
isPinned: false,
url: Uri.parse('https://example.com/private'),
color: null,
avatar: const Icon(MdiIcons.web, size: 20),
),
if (settings.showIsolatedTabUi)
QuickTabSwitcherItem(
id: 'isolated-preview-tab',
isActive: false,
title: 'Bank',
tabMode: TabMode.isolated('preview-isolated-context'),
isHistory: false,
isPinned: false,
url: Uri.parse('https://example.com/bank'),
color: null,
avatar: const Icon(MdiIcons.web, size: 20),
),
if (settings.quickTabSwitcherShowHistorySuggestions)
QuickTabSwitcherItem(
id: 'history-preview-tab',
isActive: false,
title: 'Search',
tabMode: TabMode.regular,
isHistory: true,
isPinned: false,
url: Uri.parse('https://search.example.com'),
color: null,
avatar: const Icon(MdiIcons.web, size: 20),
),
];
final tabCountButton = TabsCountButtonView(
isActive: false,
onTap: () {},
onLongPress: () {},
buttonBuilder: (isActive, onTap, onLongPress) {
return TabsActionButtonView(
isActive: isActive,
tabCountText: '5',
onTap: onTap,
onLongPress: onLongPress,
);
},
);
Widget buildQuickTabSwitcher() {
return QuickTabSwitcherView(
availableItems: previewQuickItems,
activeItem: previewQuickItems.firstWhere((item) => item.isActive),
scrollController: quickTabsController,
showTitles: settings.quickTabSwitcherShowTitles,
showIsolatedTabUi: settings.showIsolatedTabUi,
onSelected: (_) async {},
itemWrapBuilder: (child, _) => child,
);
}
Widget buildContextualToolbar() {
return ContextualToolbarView(
buttons: [
NavigateBackButtonView(
canGoBack: true,
isLoading: false,
onPressed: () {},
onLongPress: () {},
),
NavigateForwardButtonView(
canGoForward: true,
onPressed: () {},
onLongPress: () {},
),
AddTabButtonView(onPressed: () {}, onLongPress: () {}),
tabCountButton,
NavigationMenuButtonView(onTap: () {}),
],
);
}
final mainToolbarActions = <Widget>[
if (showMainToolbarActionButtons) tabCountButton,
if (showMainToolbarActionButtons) NavigationMenuButtonView(onTap: () {}),
];
final bottomCombinedToolbar = BrowserTabBarView(
showMainToolbar: true,
showContextualToolbar: settings.tabBarShowContextualBar,
showQuickTabSwitcherBar: settings.tabBarShowQuickTabSwitcherBar,
displayAppBar: true,
displayQuickTabSwitcher: true,
backgroundColor: settings.showContainerUi
? colorScheme.primaryContainer.withValues(alpha: 0.55)
: colorScheme.surfaceContainer,
title: settings.tabBarLayout == TabBarLayout.compact
? _CompactPreviewTitle(tabState: previewTabState)
: _RegularPreviewTitle(tabState: previewTabState),
actions: mainToolbarActions,
quickTabSwitcher: buildQuickTabSwitcher(),
contextualToolbar: buildContextualToolbar(),
);
final topMainToolbar = BrowserTabBarView(
showMainToolbar: true,
showContextualToolbar: false,
showQuickTabSwitcherBar: false,
displayAppBar: true,
displayQuickTabSwitcher: false,
backgroundColor: settings.showContainerUi
? colorScheme.primaryContainer.withValues(alpha: 0.55)
: colorScheme.surfaceContainer,
title: settings.tabBarLayout == TabBarLayout.compact
? _CompactPreviewTitle(tabState: previewTabState)
: _RegularPreviewTitle(tabState: previewTabState),
actions: mainToolbarActions,
quickTabSwitcher: const SizedBox.shrink(),
contextualToolbar: const SizedBox.shrink(),
);
final topBottomToolbar = BrowserTabBarView(
showMainToolbar: false,
showContextualToolbar: settings.tabBarShowContextualBar,
showQuickTabSwitcherBar: settings.tabBarShowQuickTabSwitcherBar,
displayAppBar: false,
displayQuickTabSwitcher: true,
backgroundColor: colorScheme.surfaceContainer,
title: null,
actions: const [],
quickTabSwitcher: buildQuickTabSwitcher(),
contextualToolbar: buildContextualToolbar(),
);
final previewContent = Container(
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: compact
? colorScheme.surface.withValues(alpha: 0.7)
: colorScheme.surface,
border: Border.all(color: colorScheme.outlineVariant),
borderRadius: BorderRadius.circular(4),
),
child: Column(
children: [
if (settings.tabBarPosition == TabBarPosition.top) topMainToolbar,
Container(
height: compact ? 40 : 72,
width: double.infinity,
alignment: Alignment.center,
decoration: BoxDecoration(
color: compact
? colorScheme.surfaceContainerLowest.withValues(alpha: 0.7)
: colorScheme.surfaceContainerLowest,
border: Border.symmetric(
horizontal: BorderSide(color: colorScheme.outlineVariant),
),
),
child: Text(
'Page Content',
style: Theme.of(context).textTheme.labelMedium,
),
),
if (settings.tabBarPosition == TabBarPosition.top)
topBottomToolbar
else
bottomCombinedToolbar,
],
),
);
if (compact) {
return previewContent;
}
return Card(
margin: EdgeInsets.zero,
clipBehavior: Clip.antiAlias,
child: Padding(
padding: const EdgeInsets.fromLTRB(8.0, 0.0, 8.0, 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ListTile(
title: Text('Live Preview'),
subtitle: Text(
'Reflects your current toolbar and layout settings',
),
leading: Icon(MdiIcons.televisionGuide),
contentPadding: EdgeInsets.symmetric(horizontal: 8.0),
),
previewContent,
],
),
),
);
}
}
class _RegularPreviewTitle extends StatelessWidget {
const _RegularPreviewTitle({required this.tabState});
final TabState tabState;
@override
Widget build(BuildContext context) {
return AppBarTitleView(
tabState: tabState,
isTabTunneled: false,
showSiteSettingsBadge: false,
onSiteSettingsTap: _noop,
onTitleTap: _noop,
tabIcon: const Icon(MdiIcons.web, size: 24),
longPressUrlCopy: false,
);
}
}
class _CompactPreviewTitle extends StatelessWidget {
const _CompactPreviewTitle({required this.tabState});
final TabState tabState;
@override
Widget build(BuildContext context) {
return CompactAppBarTitleView(
tabState: tabState,
isTabTunneled: false,
showSiteSettingsBadge: false,
onSiteSettingsTap: _noop,
onTitleTap: _noop,
tabIcon: const Icon(MdiIcons.web, size: 24),
);
}
}
void _noop() {}