prepare for multiple apps
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
+45
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
+48
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
+457
@@ -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);
|
||||
}
|
||||
}
|
||||
+398
@@ -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();
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+954
@@ -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()),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+222
@@ -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()),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+217
@@ -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() {}
|
||||
Reference in New Issue
Block a user