/* * 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:nullability/nullability.dart'; import 'package:weblibre/core/routing/routes.dart'; import 'package:weblibre/features/search/domain/entities/abstract/i_search_suggestion_provider.dart'; import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart'; import 'package:weblibre/features/settings/presentation/widgets/bang_icon.dart'; import 'package:weblibre/features/settings/presentation/widgets/default_search_selector.dart'; import 'package:weblibre/features/settings/presentation/widgets/sections.dart'; import 'package:weblibre/features/user/data/models/general_settings.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; class SearchSettingsScreen extends StatelessWidget { const SearchSettingsScreen({super.key}); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Search')), body: SafeArea( child: FadingScroll( fadingSize: 25, builder: (context, controller) { return ListView( controller: controller, padding: const EdgeInsets.symmetric(horizontal: 12.0), children: const [ _ProvidersSection(), _BangShortcutsSection(), _HistorySuggestionsSection(), ], ); }, ), ), ); } } class _ProvidersSection extends StatelessWidget { const _ProvidersSection(); @override Widget build(BuildContext context) { return const Column( children: [ SettingSection(name: 'Providers'), _DefaultSearchProviderSection(), _AutocompleteProviderSection(), _CustomSearchEnginesTile(), ], ); } } class _BangShortcutsSection extends StatelessWidget { const _BangShortcutsSection(); @override Widget build(BuildContext context) { return const Column( children: [ SettingSection(name: 'Bang Shortcuts'), _BangsTile(), ], ); } } class _HistorySuggestionsSection extends StatelessWidget { const _HistorySuggestionsSection(); @override Widget build(BuildContext context) { return const Column( children: [ SettingSection(name: 'History & Suggestions'), _MaxSearchHistoryEntriesSection(), _AllowClipboardAccessTile(), ], ); } } class _DefaultSearchProviderSection extends StatelessWidget { const _DefaultSearchProviderSection(); @override Widget build(BuildContext context) { return const Padding( padding: EdgeInsets.symmetric(horizontal: 16.0, vertical: 8), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ ListTile( title: Text('Default Search Provider'), leading: Icon(MdiIcons.cloudSearch), contentPadding: EdgeInsets.zero, ), Padding( padding: EdgeInsets.only(left: 40), child: DefaultSearchSelector(), ), ], ), ); } } class _AutocompleteProviderSection extends HookConsumerWidget { const _AutocompleteProviderSection(); @override Widget build(BuildContext context, WidgetRef ref) { final defaultSearchSuggestionsProvider = ref.watch( generalSettingsWithDefaultsProvider.select( (s) => s.defaultSearchSuggestionsProvider, ), ); final relatedBang = defaultSearchSuggestionsProvider.relatedBang; return Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ const ListTile( title: Text('Default Autocomplete Provider'), leading: Icon(MdiIcons.weatherCloudyArrowRight), contentPadding: EdgeInsets.zero, ), Padding( padding: const EdgeInsets.only(left: 40), child: DropdownMenu( initialSelection: defaultSearchSuggestionsProvider, inputDecorationTheme: InputDecorationTheme( prefixIconConstraints: BoxConstraints.tight( const Size.square(24), ), ), width: double.infinity, leadingIcon: relatedBang.mapNotNull( (trigger) => BangIcon(trigger: trigger), ), dropdownMenuEntries: SearchSuggestionProviders.values.map(( provider, ) { return DropdownMenuEntry( value: provider, label: provider.label, leadingIcon: provider.relatedBang.mapNotNull( (trigger) => BangIcon(trigger: trigger), ), ); }).toList(), onSelected: (value) async { if (value != null) { await ref .read(saveGeneralSettingsControllerProvider.notifier) .save( (currentSettings) => currentSettings.copyWith .defaultSearchSuggestionsProvider(value), ); } }, ), ), ], ), ); } } class _CustomSearchEnginesTile extends StatelessWidget { const _CustomSearchEnginesTile(); @override Widget build(BuildContext context) { return ListTile( title: const Text('Custom Search Engines'), subtitle: const Text('Add and manage your own search providers'), contentPadding: const EdgeInsets.symmetric( vertical: 8.0, horizontal: 16.0, ), leading: const Icon(MdiIcons.searchWeb), trailing: const Icon(Icons.chevron_right), onTap: () async { await const UserBangsRoute().push(context); }, ); } } class _BangsTile extends StatelessWidget { const _BangsTile(); @override Widget build(BuildContext context) { return ListTile( title: const Text('Bang Settings'), subtitle: const Text('Manage bang repositories and usage data'), contentPadding: const EdgeInsets.symmetric( vertical: 8.0, horizontal: 16.0, ), leading: const Icon(MdiIcons.exclamationThick), trailing: const Icon(Icons.chevron_right), onTap: () async { await BangSettingsRoute().push(context); }, ); } } class _MaxSearchHistoryEntriesSection extends HookConsumerWidget { const _MaxSearchHistoryEntriesSection(); @override Widget build(BuildContext context, WidgetRef ref) { final formKey = useMemoized(() => GlobalKey()); final maxSearchHistoryEntries = ref.watch( generalSettingsWithDefaultsProvider.select( (s) => s.maxSearchHistoryEntries, ), ); return Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ const ListTile( title: Text('Search History Limit'), subtitle: Text('Maximum number of recent searches to remember'), leading: Icon(MdiIcons.history), contentPadding: EdgeInsets.zero, ), Padding( padding: const EdgeInsets.only(left: 40.0), child: Form( key: formKey, child: TextFormField( initialValue: maxSearchHistoryEntries.toString(), keyboardType: TextInputType.number, decoration: const InputDecoration(suffixText: 'entries'), validator: (value) { if (value == null || value.isEmpty) { return 'Please enter a value'; } final parsedValue = int.tryParse(value); if (parsedValue == null) { return 'Please enter a valid number'; } if (parsedValue < 0 || parsedValue > 100) { return 'Value must be between 0 and 100'; } return null; }, onFieldSubmitted: (value) async { if (formKey.currentState?.validate() ?? false) { final parsedValue = int.parse(value); await ref .read(saveGeneralSettingsControllerProvider.notifier) .save( (currentSettings) => currentSettings.copyWith .maxSearchHistoryEntries(parsedValue), ); } }, ), ), ), ], ), ); } } class _AllowClipboardAccessTile extends HookConsumerWidget { const _AllowClipboardAccessTile(); @override Widget build(BuildContext context, WidgetRef ref) { final allowClipboardAccess = ref.watch( generalSettingsWithDefaultsProvider.select((s) => s.allowClipboardAccess), ); return SwitchListTile.adaptive( title: const Text('Allow clipboard access for suggestions'), subtitle: const Text('Browser can read clipboard to suggest URLs'), secondary: const Icon(MdiIcons.clipboardTextOutline), value: allowClipboardAccess, onChanged: (value) async { await ref .read(saveGeneralSettingsControllerProvider.notifier) .save( (currentSettings) => currentSettings.copyWith.allowClipboardAccess(value), ); }, ); } }