/* * Copyright (c) 2024-2025 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_material_design_icons/flutter_material_design_icons.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nullability/nullability.dart'; import 'package:weblibre/core/design/app_colors.dart'; import 'package:weblibre/core/routing/routes.dart'; import 'package:weblibre/features/search/domain/entities/abstract/i_search_suggestion_provider.dart'; import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart'; import 'package:weblibre/features/settings/presentation/widgets/bang_icon.dart'; import 'package:weblibre/features/settings/presentation/widgets/custom_list_tile.dart'; import 'package:weblibre/features/settings/presentation/widgets/default_search_selector.dart'; import 'package:weblibre/features/user/data/models/general_settings.dart'; import 'package:weblibre/features/user/domain/providers.dart'; import 'package:weblibre/features/user/domain/repositories/cache.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; class GeneralSettingsScreen extends StatelessWidget { const GeneralSettingsScreen({super.key}); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('General Settings')), body: FadingScroll( fadingSize: 25, builder: (context, controller) { return ListView( controller: controller, children: const [ _ThemeSection(), _NewTabDefaultSection(), _ExternalLinkHandlingSection(), _DefaultSearchProviderSection(), _AutocompleteProviderSection(), _OnDeviceAiTile(), _EnableReaderModeTile(), _EnforceReaderModeTile(), _ReaderModeInTabBarTile(), _TabBarPositionSection(), _ShowContextualTabBarTile(), _ShowQuickTabSwitcherBarTile(), _QuickTabSwitcherModeSection(), _CreateChildTabsTile(), _ShowExtensionShortcutTile(), _AutoHideTabBarTile(), _BottomSheetTabViewTile(), _TabBarSwipeBehaviorSection(), _PullToRefreshTile(), _IconCacheTile(), ], ); }, ), ); } } class _ThemeSection extends HookConsumerWidget { const _ThemeSection(); @override Widget build(BuildContext context, WidgetRef ref) { final themeMode = ref.watch( generalSettingsWithDefaultsProvider.select((s) => s.themeMode), ); return Padding( padding: const EdgeInsets.symmetric( horizontal: 16.0, vertical: 8, ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ const ListTile( title: Text('Theme'), leading: Icon(Icons.palette), contentPadding: EdgeInsets.zero, ), Center( child: SegmentedButton( segments: const [ ButtonSegment( value: ThemeMode.system, icon: Icon(Icons.brightness_auto), label: Text('System'), ), ButtonSegment( value: ThemeMode.light, icon: Icon(Icons.light_mode), label: Text('Light'), ), ButtonSegment( value: ThemeMode.dark, icon: Icon(Icons.dark_mode), label: Text('Dark'), ), ], selected: {themeMode}, onSelectionChanged: (value) async { await ref .read( saveGeneralSettingsControllerProvider.notifier, ) .save( (currentSettings) => currentSettings.copyWith.themeMode(value.first), ); }, ), ), ], ), ); } } class _NewTabDefaultSection extends HookConsumerWidget { const _NewTabDefaultSection(); @override Widget build(BuildContext context, WidgetRef ref) { final defaultCreateTabType = ref.watch( generalSettingsWithDefaultsProvider.select((s) => s.defaultCreateTabType), ); return Padding( padding: const EdgeInsets.symmetric( horizontal: 16.0, vertical: 8, ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ const ListTile( title: Text('New Tab Default'), subtitle: Text( 'Choose the default type for manually created tabs', ), leading: Icon(MdiIcons.tab), contentPadding: EdgeInsets.zero, ), Center( child: SegmentedButton( showSelectedIcon: false, segments: const [ ButtonSegment( value: TabType.regular, label: Text('Regular'), icon: Icon(MdiIcons.tab), ), ButtonSegment( value: TabType.private, label: Text('Private'), icon: Icon(MdiIcons.tabUnselected), ), ], selected: {defaultCreateTabType}, onSelectionChanged: (value) async { await ref .read( saveGeneralSettingsControllerProvider.notifier, ) .save( (currentSettings) => currentSettings.copyWith .defaultCreateTabType(value.first), ); }, style: switch (defaultCreateTabType) { TabType.regular => null, TabType.private => SegmentedButton.styleFrom( selectedBackgroundColor: AppColors.privateSelectionOverlay, ), TabType.child => null, }, ), ), ], ), ); } } class _ExternalLinkHandlingSection extends HookConsumerWidget { const _ExternalLinkHandlingSection(); @override Widget build(BuildContext context, WidgetRef ref) { final tabIntentOpenSetting = ref.watch( generalSettingsWithDefaultsProvider.select((s) => s.tabIntentOpenSetting), ); return Padding( padding: const EdgeInsets.symmetric( horizontal: 16.0, vertical: 8, ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ const ListTile( title: Text('External Link Handling'), subtitle: Text( 'Choose how external links open in WebLibre', ), leading: Icon(MdiIcons.tabPlus), contentPadding: EdgeInsets.zero, ), Center( child: SegmentedButton( showSelectedIcon: false, segments: const [ ButtonSegment( value: TabIntentOpenSetting.ask, label: Text('Prompt'), icon: Icon(MdiIcons.messageQuestion), ), ButtonSegment( value: TabIntentOpenSetting.regular, label: Text('Regular'), icon: Icon(MdiIcons.tab), ), ButtonSegment( value: TabIntentOpenSetting.private, label: Text('Private'), icon: Icon(MdiIcons.tabUnselected), ), ], selected: {tabIntentOpenSetting}, onSelectionChanged: (value) async { await ref .read( saveGeneralSettingsControllerProvider.notifier, ) .save( (currentSettings) => currentSettings.copyWith .tabIntentOpenSetting(value.first), ); }, style: switch (tabIntentOpenSetting) { TabIntentOpenSetting.regular => null, TabIntentOpenSetting.private => SegmentedButton.styleFrom( selectedBackgroundColor: AppColors.privateSelectionOverlay, ), TabIntentOpenSetting.ask => null, }, ), ), ], ), ); } } class _DefaultSearchProviderSection extends StatelessWidget { const _DefaultSearchProviderSection(); @override Widget build(BuildContext context) { return const Padding( padding: EdgeInsets.symmetric(horizontal: 16.0, vertical: 8), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ ListTile( title: Text('Default Search Provider'), leading: Icon(MdiIcons.cloudSearch), contentPadding: EdgeInsets.zero, ), Padding( padding: EdgeInsets.only(left: 40), child: DefaultSearchSelector(), ), ], ), ); } } class _AutocompleteProviderSection extends HookConsumerWidget { const _AutocompleteProviderSection(); @override Widget build(BuildContext context, WidgetRef ref) { final defaultSearchSuggestionsProvider = ref.watch( generalSettingsWithDefaultsProvider .select((s) => s.defaultSearchSuggestionsProvider), ); final relatedBang = defaultSearchSuggestionsProvider.relatedBang; return Padding( padding: const EdgeInsets.symmetric( horizontal: 16.0, vertical: 8, ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ const ListTile( title: Text('Default Autocomplete Provider'), leading: Icon(MdiIcons.weatherCloudyArrowRight), contentPadding: EdgeInsets.zero, ), Padding( padding: const EdgeInsets.only(left: 40), child: DropdownMenu( initialSelection: defaultSearchSuggestionsProvider, inputDecorationTheme: InputDecorationTheme( prefixIconConstraints: BoxConstraints.tight( const Size.square(24), ), ), width: double.infinity, leadingIcon: relatedBang.mapNotNull((trigger) => BangIcon(trigger: trigger)), dropdownMenuEntries: SearchSuggestionProviders.values .map((provider) { return DropdownMenuEntry( value: provider, label: provider.label, leadingIcon: provider.relatedBang.mapNotNull( (trigger) => BangIcon(trigger: trigger), ), ); }) .toList(), onSelected: (value) async { if (value != null) { await ref .read( saveGeneralSettingsControllerProvider.notifier, ) .save( (currentSettings) => currentSettings.copyWith .defaultSearchSuggestionsProvider(value), ); } }, ), ), ], ), ); } } class _OnDeviceAiTile extends HookConsumerWidget { const _OnDeviceAiTile(); @override Widget build(BuildContext context, WidgetRef ref) { final enableLocalAiFeatures = ref.watch( generalSettingsWithDefaultsProvider.select((s) => s.enableLocalAiFeatures), ); return SwitchListTile.adaptive( title: const Text('On Device AI'), subtitle: const Text( 'Local on-device features including container topic and tab suggestions', ), secondary: const Icon(MdiIcons.creation), value: enableLocalAiFeatures, onChanged: (value) async { await ref .read(saveGeneralSettingsControllerProvider.notifier) .save( (currentSettings) => currentSettings.copyWith.enableLocalAiFeatures(value), ); }, ); } } class _EnableReaderModeTile extends HookConsumerWidget { const _EnableReaderModeTile(); @override Widget build(BuildContext context, WidgetRef ref) { final enableReadability = ref.watch( generalSettingsWithDefaultsProvider.select((s) => s.enableReadability), ); return SwitchListTile.adaptive( title: const Text('Enable Reader Mode'), subtitle: const Text( 'Optional browser app bar tool that extracts and simplifies web pages for improved readability by removing ads, sidebars, and other non-essential elements.', ), secondary: const Icon(MdiIcons.bookOpen), value: enableReadability, onChanged: (value) async { await ref .read(saveGeneralSettingsControllerProvider.notifier) .save( (currentSettings) => currentSettings.copyWith.enableReadability(value), ); }, ); } } class _EnforceReaderModeTile extends HookConsumerWidget { const _EnforceReaderModeTile(); @override Widget build(BuildContext context, WidgetRef ref) { final enableReadability = ref.watch( generalSettingsWithDefaultsProvider.select((s) => s.enableReadability), ); final enforceReadability = ref.watch( generalSettingsWithDefaultsProvider.select((s) => s.enforceReadability), ); return SwitchListTile.adaptive( title: const Text('Enforce Reader Mode'), subtitle: const Text( 'Override readability probability of websites and always show Reader Mode capabilities even the site might not be compatible.', ), secondary: const Icon(MdiIcons.bookCheck), value: enableReadability && enforceReadability, onChanged: enableReadability ? (value) async { await ref .read( saveGeneralSettingsControllerProvider.notifier, ) .save( (currentSettings) => currentSettings.copyWith.enforceReadability(value), ); } : null, ); } } class _ReaderModeInTabBarTile extends HookConsumerWidget { const _ReaderModeInTabBarTile(); @override Widget build(BuildContext context, WidgetRef ref) { final tabBarReaderView = ref.watch( generalSettingsWithDefaultsProvider.select((s) => s.tabBarReaderView), ); return SwitchListTile.adaptive( title: const Text('Reader Mode in Tab Bar'), subtitle: const Text( 'Show reader mode button in the tab bar instead of only in the tab menu', ), secondary: const Icon(MdiIcons.bookHeart), value: tabBarReaderView, onChanged: (value) async { await ref .read(saveGeneralSettingsControllerProvider.notifier) .save( (currentSettings) => currentSettings.copyWith.tabBarReaderView(value), ); }, ); } } class _TabBarPositionSection extends HookConsumerWidget { const _TabBarPositionSection(); @override Widget build(BuildContext context, WidgetRef ref) { final tabBarPosition = ref.watch( generalSettingsWithDefaultsProvider.select((s) => s.tabBarPosition), ); return Padding( padding: const EdgeInsets.symmetric( horizontal: 16.0, vertical: 8, ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ const ListTile( title: Text('Tab Bar Position'), leading: Icon(MdiIcons.dockWindow), contentPadding: EdgeInsets.zero, ), RadioGroup( groupValue: tabBarPosition, onChanged: (value) async { if (value != null) { await ref .read( saveGeneralSettingsControllerProvider.notifier, ) .save( (currentSettings) => currentSettings.copyWith.tabBarPosition(value), ); } }, child: const Column( children: [ RadioListTile.adaptive( value: TabBarPosition.top, title: Text('Top'), subtitle: Text( 'Persistent tab bar without auto-hide', ), ), RadioListTile.adaptive( value: TabBarPosition.bottom, title: Text('Bottom'), subtitle: Text('Tab bar with auto-hide support'), ), ], ), ), ], ), ); } } class _ShowContextualTabBarTile extends HookConsumerWidget { const _ShowContextualTabBarTile(); @override Widget build(BuildContext context, WidgetRef ref) { final tabBarShowContextualBar = ref.watch( generalSettingsWithDefaultsProvider .select((s) => s.tabBarShowContextualBar), ); return SwitchListTile.adaptive( title: const Text('Show Contextual Tab Bar'), subtitle: const Text( 'Show additional bottom toolbar for navigation and actions', ), secondary: const Icon(MdiIcons.dockBottom), value: tabBarShowContextualBar, onChanged: (value) async { await ref .read(saveGeneralSettingsControllerProvider.notifier) .save( (currentSettings) => currentSettings.copyWith.tabBarShowContextualBar(value), ); }, ); } } class _ShowQuickTabSwitcherBarTile extends HookConsumerWidget { const _ShowQuickTabSwitcherBarTile(); @override Widget build(BuildContext context, WidgetRef ref) { final tabBarShowQuickTabSwitcherBar = ref.watch( generalSettingsWithDefaultsProvider .select((s) => s.tabBarShowQuickTabSwitcherBar), ); return SwitchListTile.adaptive( title: const Text('Show Quick Tab Switcher Bar'), subtitle: const Text( 'Show additional toolbar to quickly switch to recently used tabs', ), secondary: const Icon(MdiIcons.dockBottom), value: tabBarShowQuickTabSwitcherBar, onChanged: (value) async { await ref .read(saveGeneralSettingsControllerProvider.notifier) .save( (currentSettings) => currentSettings.copyWith .tabBarShowQuickTabSwitcherBar(value), ); }, ); } } class _QuickTabSwitcherModeSection extends HookConsumerWidget { const _QuickTabSwitcherModeSection(); @override Widget build(BuildContext context, WidgetRef ref) { final quickTabSwitcherMode = ref.watch( generalSettingsWithDefaultsProvider.select((s) => s.quickTabSwitcherMode), ); return Padding( padding: const EdgeInsets.symmetric( horizontal: 16.0, vertical: 8, ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ const ListTile( title: Text('Quick Tab Switcher Mode'), leading: Icon(MdiIcons.folderSettings), contentPadding: EdgeInsets.zero, ), RadioGroup( groupValue: quickTabSwitcherMode, onChanged: (value) async { if (value != null) { await ref .read( saveGeneralSettingsControllerProvider.notifier, ) .save( (currentSettings) => currentSettings.copyWith .quickTabSwitcherMode(value), ); } }, child: const Column( children: [ RadioListTile.adaptive( value: QuickTabSwitcherMode.lastUsedTabs, title: Text('Recently Used Tabs'), subtitle: Text( 'Recently used tabs across all containers', ), ), RadioListTile.adaptive( value: QuickTabSwitcherMode.containerTabs, title: Text('Container Tabs'), subtitle: Text( 'Ordered tabs of the selected container', ), ), ], ), ), ], ), ); } } class _CreateChildTabsTile extends HookConsumerWidget { const _CreateChildTabsTile(); @override Widget build(BuildContext context, WidgetRef ref) { final createChildTabsOption = ref.watch( generalSettingsWithDefaultsProvider.select((s) => s.createChildTabsOption), ); return SwitchListTile.adaptive( title: const Text('Create Child Tabs'), subtitle: const Text( 'Display a button to create a child tab under the current tab (tree view only)', ), secondary: const Icon(MdiIcons.fileTree), value: createChildTabsOption, onChanged: (value) async { await ref .read(saveGeneralSettingsControllerProvider.notifier) .save( (currentSettings) => currentSettings.copyWith.createChildTabsOption(value), ); }, ); } } class _ShowExtensionShortcutTile extends HookConsumerWidget { const _ShowExtensionShortcutTile(); @override Widget build(BuildContext context, WidgetRef ref) { final showExtensionShortcut = ref.watch( generalSettingsWithDefaultsProvider.select((s) => s.showExtensionShortcut), ); return SwitchListTile.adaptive( title: const Text('Show Extension Shortcut'), subtitle: const Text( 'Display an extension menu directly on the tab bar', ), secondary: const Icon(MdiIcons.puzzleHeart), value: showExtensionShortcut, onChanged: (value) async { await ref .read(saveGeneralSettingsControllerProvider.notifier) .save( (currentSettings) => currentSettings.copyWith.showExtensionShortcut(value), ); }, ); } } class _AutoHideTabBarTile extends HookConsumerWidget { const _AutoHideTabBarTile(); @override Widget build(BuildContext context, WidgetRef ref) { final autoHideTabBar = ref.watch( generalSettingsWithDefaultsProvider.select((s) => s.autoHideTabBar), ); return SwitchListTile.adaptive( title: const Text('Auto Hide Tab Bar'), subtitle: const Text('Hide tab bar when scrolling'), secondary: const Icon(MdiIcons.folderHidden), value: autoHideTabBar, onChanged: (value) async { await ref .read(saveGeneralSettingsControllerProvider.notifier) .save( (currentSettings) => currentSettings.copyWith.autoHideTabBar(value), ); }, ); } } class _BottomSheetTabViewTile extends HookConsumerWidget { const _BottomSheetTabViewTile(); @override Widget build(BuildContext context, WidgetRef ref) { final tabViewBottomSheet = ref.watch( generalSettingsWithDefaultsProvider.select((s) => s.tabViewBottomSheet), ); return SwitchListTile.adaptive( title: const Text('Bottom Sheet Tab View'), subtitle: const Text( 'Display tabs in a bottom sheet instead of fullscreen', ), secondary: const Icon(MdiIcons.dockBottom), value: tabViewBottomSheet, onChanged: (value) async { await ref .read(saveGeneralSettingsControllerProvider.notifier) .save( (currentSettings) => currentSettings.copyWith.tabViewBottomSheet(value), ); }, ); } } class _TabBarSwipeBehaviorSection extends HookConsumerWidget { const _TabBarSwipeBehaviorSection(); @override Widget build(BuildContext context, WidgetRef ref) { final tabBarSwipeAction = ref.watch( generalSettingsWithDefaultsProvider.select((s) => s.tabBarSwipeAction), ); return Padding( padding: const EdgeInsets.symmetric( horizontal: 16.0, vertical: 8, ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ const ListTile( title: Text('Tab Bar Swipe Behavior'), leading: Icon(MdiIcons.gestureSwipeHorizontal), contentPadding: EdgeInsets.zero, ), RadioGroup( groupValue: tabBarSwipeAction, onChanged: (value) async { if (value != null) { await ref .read( saveGeneralSettingsControllerProvider.notifier, ) .save( (currentSettings) => currentSettings.copyWith.tabBarSwipeAction(value), ); } }, child: const Column( children: [ RadioListTile.adaptive( value: TabBarSwipeAction.switchLastOpened, title: Text('Switch to Last Used Tab'), subtitle: Text( 'Swipe to toggle between current and previously opened tab', ), ), RadioListTile.adaptive( value: TabBarSwipeAction.navigateOrderedTabs, title: Text('Navigate Sequential Tabs'), subtitle: Text( 'Swipe left/right to move through tabs in order', ), ), ], ), ), ], ), ); } } class _PullToRefreshTile extends HookConsumerWidget { const _PullToRefreshTile(); @override Widget build(BuildContext context, WidgetRef ref) { final pullToRefreshEnabled = ref.watch( generalSettingsWithDefaultsProvider.select((s) => s.pullToRefreshEnabled), ); return SwitchListTile.adaptive( title: const Text('Pull to Refresh'), subtitle: const Text('Swipe down on pages to reload them'), secondary: const Icon(MdiIcons.gestureSwipeDown), value: pullToRefreshEnabled, onChanged: (value) async { await ref .read(saveGeneralSettingsControllerProvider.notifier) .save( (currentSettings) => currentSettings.copyWith.pullToRefreshEnabled(value), ); }, ); } } class _IconCacheTile extends HookConsumerWidget { const _IconCacheTile(); @override Widget build(BuildContext context, WidgetRef ref) { final size = ref.watch( iconCacheSizeMegabytesProvider.select((value) => value.value), ); return CustomListTile( title: 'Icon Cache', subtitle: 'Stored favicons', prefix: Padding( padding: const EdgeInsets.only(right: 16.0), child: Icon( Icons.image, size: 24, color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), content: Padding( padding: const EdgeInsets.only(top: 8.0), child: DefaultTextStyle( style: GoogleFonts.robotoMono( textStyle: DefaultTextStyle.of(context).style, ), child: Table( columnWidths: const {0: FixedColumnWidth(100)}, children: [ TableRow( children: [ const Text('Size'), Text('${size?.toStringAsFixed(2) ?? 0} MB'), ], ), ], ), ), ), suffix: FilledButton.icon( onPressed: () async { await ref.read(cacheRepositoryProvider.notifier).clearCache(); }, icon: const Icon(Icons.delete), label: const Text('Clear'), ), ); } }