import 'package:flutter/material.dart'; import 'package:venera/components/components.dart'; import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/appdata.dart'; import 'package:venera/foundation/comic_source/comic_source.dart'; import 'package:venera/foundation/state_controller.dart'; import 'package:venera/pages/search_page.dart'; import 'package:venera/utils/ext.dart'; import 'package:venera/utils/tags_translation.dart'; import 'package:venera/utils/translations.dart'; class SearchResultPage extends StatefulWidget { const SearchResultPage({ super.key, required this.text, required this.sourceKey, this.options, }); final String text; final String sourceKey; final List? options; @override State createState() => _SearchResultPageState(); } class _SearchResultPageState extends State { late SearchBarController controller; late String sourceKey; late List options; late String text; OverlayEntry? get suggestionOverlay => suggestionsController.entry; late _SuggestionsController suggestionsController; void search([String? text]) { if (text != null) { if (suggestionsController.entry != null) { suggestionsController.remove(); } text = checkAutoLanguage(text); setState(() { this.text = text!; }); appdata.addSearchHistory(text); controller.currentText = text; } } void onChanged(String s) { if (!ComicSource.find(sourceKey)!.enableTagsSuggestions) { return; } suggestionsController.findSuggestions(); if (suggestionOverlay != null) { if (suggestionsController.suggestions.isEmpty) { suggestionsController.remove(); } else { suggestionsController.updateWidget(); } } else if (suggestionsController.suggestions.isNotEmpty) { suggestionsController.entry = OverlayEntry( builder: (context) { return Positioned( top: context.padding.top + 56, left: 0, right: 0, bottom: 0, child: Material( child: _Suggestions( controller: suggestionsController, ), ), ); }, ); Overlay.of(context).insert(suggestionOverlay!); } } @override void dispose() { Future.microtask(() { suggestionsController.remove(); }); super.dispose(); } String checkAutoLanguage(String text) { var setting = appdata.settings["autoAddLanguageFilter"] ?? 'none'; if (setting == 'none') { return text; } var searchSource = sourceKey; // TODO: Move it to a better place const enabledSources = [ 'nhentai', 'ehentai', ]; if (!enabledSources.contains(searchSource)) { return text; } if (!text.contains('language:')) { return '$text language:$setting'; } return text; } @override void initState() { sourceKey = widget.sourceKey; controller = SearchBarController( currentText: checkAutoLanguage(widget.text), onSearch: search, ); options = widget.options ?? const []; validateOptions(); text = widget.text; appdata.addSearchHistory(text); suggestionsController = _SuggestionsController(controller); super.initState(); } void validateOptions() { var source = ComicSource.find(sourceKey); if (source == null) { return; } var searchOptions = source.searchPageData!.searchOptions; if (searchOptions == null) { return; } if (options.length != searchOptions.length) { options = searchOptions.map((e) => e.defaultValue).toList(); } } @override Widget build(BuildContext context) { var source = ComicSource.find(sourceKey); return ComicList( key: Key(text + options.toString() + sourceKey), errorLeading: AppSearchBar( controller: controller, action: buildAction(), ), leadingSliver: SliverSearchBar( controller: controller, onChanged: onChanged, action: buildAction(), ), loadPage: source!.searchPageData!.loadPage == null ? null : (i) { return source.searchPageData!.loadPage!( text, i, options, ); }, loadNext: source.searchPageData!.loadNext == null ? null : (i) { return source.searchPageData!.loadNext!( text, i, options, ); }, ); } Widget buildAction() { return Tooltip( message: "Settings".tl, child: IconButton( icon: const Icon(Icons.tune), onPressed: () async { if (suggestionOverlay != null) { suggestionsController.remove(); } var previousOptions = options; var previousSourceKey = sourceKey; await showDialog( context: context, useRootNavigator: true, builder: (context) { return _SearchSettingsDialog(state: this); }, ); if (previousOptions != options || previousSourceKey != sourceKey) { text = checkAutoLanguage(controller.text); controller.currentText = text; setState(() {}); } }, ), ); } } class _SuggestionsController { _SuggestionsState? _state; final SearchBarController controller; OverlayEntry? entry; void updateWidget() { _state?.update(); } void remove() { entry?.remove(); entry = null; } var suggestions = >[]; void findSuggestions() { var text = controller.text.split(" ").last; var suggestions = this.suggestions; suggestions.clear(); bool check(String text, String key, String value) { if (text.removeAllBlank == "") { return false; } if (key.length >= text.length && key.substring(0, text.length) == text || (key.contains(" ") && key.split(" ").last.length >= text.length && key.split(" ").last.substring(0, text.length) == text)) { return true; } else if (value.length >= text.length && value.contains(text)) { return true; } return false; } void find(Map map, TranslationType type) { for (var element in map.entries) { if (suggestions.length > 200) { break; } if (check(text, element.key, element.value)) { suggestions.add(Pair(element.key, type)); } } } find(TagsTranslation.femaleTags, TranslationType.female); find(TagsTranslation.maleTags, TranslationType.male); find(TagsTranslation.parodyTags, TranslationType.parody); find(TagsTranslation.characterTranslations, TranslationType.character); find(TagsTranslation.otherTags, TranslationType.other); find(TagsTranslation.mixedTags, TranslationType.mixed); find(TagsTranslation.languageTranslations, TranslationType.language); find(TagsTranslation.artistTags, TranslationType.artist); find(TagsTranslation.groupTags, TranslationType.group); find(TagsTranslation.cosplayerTags, TranslationType.cosplayer); } _SuggestionsController(this.controller); } class _Suggestions extends StatefulWidget { const _Suggestions({required this.controller}); final _SuggestionsController controller; @override State<_Suggestions> createState() => _SuggestionsState(); } class _SuggestionsState extends State<_Suggestions> { void update() { setState(() {}); } @override void initState() { widget.controller._state = this; super.initState(); } @override void didUpdateWidget(covariant _Suggestions oldWidget) { if (oldWidget.controller != widget.controller) { oldWidget.controller._state = null; widget.controller._state = this; } super.didUpdateWidget(oldWidget); } @override Widget build(BuildContext context) { return buildSuggestions(context); } Widget buildSuggestions(BuildContext context) { bool showMethod = MediaQuery.of(context).size.width < 600; bool showTranslation = App.locale.languageCode == "zh"; Widget buildItem(Pair value) { var subTitle = TagsTranslation.translationTagWithNamespace( value.left, value.right.name); return ListTile( title: Row( mainAxisSize: MainAxisSize.min, children: [ Expanded( child: Text( value.left, maxLines: 2, ), ), if (!showMethod) const SizedBox( width: 12, ), if (!showMethod && showTranslation) Text( subTitle, style: TextStyle( fontSize: 14, color: Theme.of(context).colorScheme.outline), ) ], ), subtitle: (showMethod && showTranslation) ? Text(subTitle) : null, trailing: Text( value.right.name, style: const TextStyle(fontSize: 13), ), onTap: () => onSelected(value.left, value.right), ); } return Column( children: [ ListTile( leading: const Icon(Icons.hub_outlined), title: Text("Suggestions".tl), trailing: Tooltip( message: "Clear".tl, child: IconButton( icon: const Icon(Icons.clear_all), onPressed: () { widget.controller.suggestions.clear(); widget.controller.remove(); }, ), ), ), Expanded( child: ListView.builder( padding: const EdgeInsets.symmetric(horizontal: 16), itemCount: widget.controller.suggestions.length, itemBuilder: (context, index) => buildItem(widget.controller.suggestions[index]), ), ) ], ); } bool check(String text, String key, String value) { if (text.removeAllBlank == "") { return false; } if (key.length >= text.length && key.substring(0, text.length) == text || (key.contains(" ") && key.split(" ").last.length >= text.length && key.split(" ").last.substring(0, text.length) == text)) { return true; } else if (value.length >= text.length && value.contains(text)) { return true; } return false; } void onSelected(String text, TranslationType? type) { var controller = widget.controller.controller; var words = controller.text.split(" "); if (words.length >= 2 && check("${words[words.length - 2]} ${words[words.length - 1]}", text, text.translateTagsToCN)) { controller.text = controller.text.replaceLast( "${words[words.length - 2]} ${words[words.length - 1]}", ""); } else { controller.text = controller.text.replaceLast(words[words.length - 1], ""); } if (text.contains(' ')) { text = "'$text'"; } if (type != null) { controller.text += "${type.name}:$text "; } else { controller.text += "$text "; } widget.controller.suggestions.clear(); widget.controller.remove(); } } class _SearchSettingsDialog extends StatefulWidget { const _SearchSettingsDialog({required this.state}); final _SearchResultPageState state; @override State<_SearchSettingsDialog> createState() => _SearchSettingsDialogState(); } class _SearchSettingsDialogState extends State<_SearchSettingsDialog> { late String searchTarget; late List options; @override void initState() { searchTarget = widget.state.sourceKey; options = widget.state.options; super.initState(); } void onChanged() { widget.state.sourceKey = searchTarget; widget.state.options = options; } @override Widget build(BuildContext context) { return ContentDialog( title: "Settings".tl, content: Column( children: [ ListTile( contentPadding: const EdgeInsets.symmetric(horizontal: 16), title: Text("Search in".tl), ), Wrap( spacing: 8, runSpacing: 8, children: ComicSource.all().map((e) { return OptionChip( text: e.name.tl, isSelected: searchTarget == e.key, onTap: () { setState(() { searchTarget = e.key; options.clear(); final searchOptions = ComicSource.find(searchTarget)! .searchPageData! .searchOptions ?? []; options = searchOptions.map((e) => e.defaultValue).toList(); onChanged(); }); }, ); }).toList(), ).fixWidth(double.infinity).paddingHorizontal(16), buildSearchOptions(), const SizedBox(height: 24), FilledButton( child: Text("Confirm".tl), onPressed: () { context.pop(); }, ), ], ).fixWidth(double.infinity), ); } Widget buildSearchOptions() { var children = []; final searchOptions = ComicSource.find(searchTarget)!.searchPageData!.searchOptions ?? []; if (searchOptions.length != options.length) { options = searchOptions.map((e) => e.defaultValue).toList(); } if (searchOptions.isEmpty) { return const SizedBox(); } for (int i = 0; i < searchOptions.length; i++) { final option = searchOptions[i]; children.add(SearchOptionWidget( option: option, value: options[i], onChanged: (value) { setState(() { options[i] = value; }); }, sourceKey: searchTarget, )); } return Container( width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: children, ), ); } }