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