/* * 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 . */ 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/domain/providers/pending_settings_highlight.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/home_settings.dart'; import 'package:weblibre/features/settings/presentation/screens/privacy_security_settings.dart'; import 'package:weblibre/features/settings/presentation/screens/proxy_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'; import 'package:weblibre/features/web_push/presentation/screens/web_push_settings.dart'; class SettingsScreen extends HookWidget { const SettingsScreen({super.key}); @override Widget build(BuildContext context) { final search = useSettingsSearch(); final categories = _buildCategories(); final sections = search.normalizedQuery.isEmpty ? _buildCategorySections(categories) : _buildSearchSections([ ...categories.browser, ...categories.services, ], search.normalizedQuery); return Scaffold( body: SafeArea( child: FadingScroll( fadingSize: 25, builder: (context, controller) { return CustomScrollView( controller: controller, 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, ), ), ), ], ); }, ), ), ); } } typedef _CategoryGroups = ({ List<_SettingsCategoryDefinition> browser, List<_SettingsCategoryDefinition> services, }); _CategoryGroups _buildCategories() { final browser = [ _SettingsCategoryDefinition( title: 'General', subtitle: 'Appearance, 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: 'Home & New Tab', subtitle: 'What the home and new tab pages show', icon: MdiIcons.homeOutline, keywords: const [ 'home', 'new tab', 'start page', 'sections', 'shortcuts', 'top sites', 'quote', ], sections: homeSettingsSections, onTap: (context) => const HomeSettingsRoute().push(context), ), _SettingsCategoryDefinition( title: 'Gestures', subtitle: 'Stroke gestures for browser actions', icon: MdiIcons.gestureSwipe, keywords: const ['gesture', 'swipe', 'stroke'], onTap: (context) => GestureSettingsRoute().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: 'Notifications', subtitle: 'Web push delivery, distributor, site subscriptions', icon: MdiIcons.bellBadgeOutline, keywords: const ['push', 'unifiedpush', 'ntfy', 'distributor'], sections: webPushSettingsSections, onTap: (context) => WebPushSettingsRoute().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: 'Proxy', subtitle: 'Connections and routing', icon: MdiIcons.lanConnect, keywords: const [ 'proxy', 'sing-box', 'socks', 'vpn', 'wireguard', 'routing', 'tor', 'container', ], sections: proxySettingsSections, onTap: (context) => const ProxySettingsRoute().push(context), ), ]; final services = [ _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), ), ]; return (browser: browser, services: services); } List _buildCategorySections( _CategoryGroups categories, ) { return [ SettingsSectionDefinition( title: 'Browser', entries: [ for (final category in categories.browser) _buildCategoryEntry(category), ], ), SettingsSectionDefinition( title: 'Services & Advanced', entries: [ for (final category in categories.services) _buildCategoryEntry(category), ], ), ]; } List _buildSearchSections( List<_SettingsCategoryDefinition> categories, String normalizedQuery, ) { final results = >{}; for (final category in categories) { final categoryEntries = []; 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 keywords; final List sections; final Future 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 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 _SearchResultTile extends HookConsumerWidget { final String title; final String? subtitle; final String category; final String section; final IconData icon; final Future 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, WidgetRef ref) { 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 { final pendingHighlight = ref.read( pendingSettingsHighlightProvider.notifier, ); pendingHighlight.set(title); try { await onTap(context); } catch (_) { pendingHighlight.clear(); rethrow; } }, ); } }