import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:sliver_tools/sliver_tools.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/aggregated_search_page.dart'; import 'package:venera/pages/search_result_page.dart'; import 'package:venera/utils/app_links.dart'; import 'package:venera/utils/ext.dart'; import 'package:venera/utils/tags_translation.dart'; import 'package:venera/utils/translations.dart'; import 'comic_page.dart'; class SearchPage extends StatefulWidget { const SearchPage({super.key}); @override State createState() => _SearchPageState(); } class _SearchPageState extends State { late final SearchBarController controller; String searchTarget = ""; bool aggregatedSearch = false; var focusNode = FocusNode(); var options = []; void update() { setState(() {}); } void search([String? text]) { if (aggregatedSearch) { context .to(() => AggregatedSearchPage(keyword: text ?? controller.text)) .then((_) => update()); } else { context .to( () => SearchResultPage( text: text ?? controller.text, sourceKey: searchTarget, options: options, ), ) .then((_) => update()); } } var suggestions = >[]; bool canHandleUrl(String text) { if (!text.isURL) return false; for (var source in ComicSource.all()) { if (source.linkHandler != null) { var uri = Uri.parse(text); if (source.linkHandler!.domains.contains(uri.host)) { return true; } } } return false; } void findSuggestions() { var text = controller.text.split(" ").last; var suggestions = this.suggestions; suggestions.clear(); if (canHandleUrl(controller.text)) { suggestions.add(Pair("**URL**", TranslationType.other)); } else { var text = controller.text; for (var comicSource in ComicSource.all()) { if (comicSource.idMatcher?.hasMatch(text) ?? false) { suggestions.add(Pair( "**${comicSource.key}**", TranslationType.other, )); } } } if (!ComicSource.find(searchTarget)!.enableTagsSuggestions) { update(); return; } 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 > 100) { 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); update(); } @override void initState() { var defaultSearchTarget = appdata.settings['defaultSearchTarget']; if (defaultSearchTarget == "_aggregated_") { aggregatedSearch = true; } else if (defaultSearchTarget != null && ComicSource.find(defaultSearchTarget) != null) { searchTarget = defaultSearchTarget; } else { searchTarget = ComicSource.all().first.key; } controller = SearchBarController( onSearch: search, ); super.initState(); } @override void dispose() { focusNode.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Scaffold( body: SmoothCustomScrollView( slivers: buildSlivers().toList(), ), ); } Iterable buildSlivers() sync* { yield SliverSearchBar( controller: controller, onChanged: (s) { findSuggestions(); }, focusNode: focusNode, ); if (suggestions.isNotEmpty) { yield buildSuggestions(context); } else { yield buildSearchTarget(); yield SliverAnimatedPaintExtent( duration: const Duration(milliseconds: 200), child: buildSearchOptions(), ); yield buildSearchHistory(); } } Widget buildSearchTarget() { var sources = ComicSource.all().where((e) => e.searchPageData != null).toList(); return SliverToBoxAdapter( child: Container( width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ ListTile( contentPadding: EdgeInsets.zero, leading: const Icon(Icons.search), title: Text("Search in".tl), ), Wrap( spacing: 8, runSpacing: 8, children: sources.map((e) { return OptionChip( text: e.name, isSelected: searchTarget == e.key || aggregatedSearch, onTap: () { if (aggregatedSearch) return; setState(() { searchTarget = e.key; useDefaultOptions(); }); }, ); }).toList(), ), ListTile( contentPadding: EdgeInsets.zero, title: Text("Aggregated Search".tl), leading: Checkbox( value: aggregatedSearch, onChanged: (value) { setState(() { aggregatedSearch = value ?? false; }); }, ), ), ], ), ), ); } void useDefaultOptions() { final searchOptions = ComicSource.find(searchTarget)!.searchPageData!.searchOptions ?? []; options = searchOptions.map((e) => e.defaultValue).toList(); } Widget buildSearchOptions() { if (aggregatedSearch) { return const SliverToBoxAdapter(child: SizedBox()); } var children = []; final searchOptions = ComicSource.find(searchTarget)!.searchPageData!.searchOptions ?? []; if (searchOptions.length != options.length) { useDefaultOptions(); } if (searchOptions.isEmpty) { return const SliverToBoxAdapter(child: SizedBox()); } for (int i = 0; i < searchOptions.length; i++) { final option = searchOptions[i]; children.add(SearchOptionWidget( option: option, value: options[i], onChanged: (value) { options[i] = value; update(); }, sourceKey: searchTarget, )); } return SliverToBoxAdapter( child: Container( width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: children, ), ), ); } Widget buildSearchHistory() { return SliverList( delegate: SliverChildBuilderDelegate( (context, index) { if (index == 0) { return const SizedBox( height: 16, ); } if (index == 1) { return ListTile( leading: const Icon(Icons.history), contentPadding: EdgeInsets.zero, title: Text("Search History".tl), trailing: Flyout( flyoutBuilder: (context) { return FlyoutContent( title: "Clear Search History".tl, actions: [ FilledButton( child: Text("Clear".tl), onPressed: () { appdata.clearSearchHistory(); context.pop(); update(); }, ) ], ); }, child: Builder( builder: (context) { return Tooltip( message: "Clear".tl, child: IconButton( icon: const Icon(Icons.clear_all), onPressed: () { context .findAncestorStateOfType()! .show(); }, ), ); }, ), ), ); } return InkWell( onTap: () { search(appdata.searchHistory[index - 2]); }, child: Container( decoration: BoxDecoration( // color: context.colorScheme.surfaceContainer, border: Border( left: BorderSide( color: context.colorScheme.outlineVariant, width: 2, ), ), ), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: Text(appdata.searchHistory[index - 2], style: ts.s14), ), ).paddingBottom(8).paddingHorizontal(4); }, childCount: 2 + appdata.searchHistory.length, ), ).sliverPaddingHorizontal(16); } Widget buildSuggestions(BuildContext context) { 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 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 (type != null) { controller.text += "${type.name}:$text "; } else { controller.text += "$text "; } suggestions.clear(); update(); focusNode.requestFocus(); } bool showMethod = MediaQuery.of(context).size.width < 600; bool showTranslation = App.locale.languageCode == "zh"; Widget buildItem(Pair value) { if (value.left == "**URL**") { return ListTile( leading: const Icon(Icons.link), title: Text("Open link".tl), subtitle: Text( controller.text, maxLines: 1, overflow: TextOverflow.fade, ), trailing: const Icon(Icons.arrow_right), onTap: () { setState(() { suggestions.clear(); }); handleAppLink(Uri.parse(controller.text)); }, ); } if (RegExp(r"^\*\*.*\*\*$").hasMatch(value.left)) { var key = value.left.substring(2, value.left.length - 2); var comicSource = ComicSource.find(key); if (comicSource == null) { return const SizedBox(); } return ListTile( leading: const Icon(Icons.link), title: Text("${"Open comic".tl}: ${comicSource.name}"), subtitle: Text( controller.text, maxLines: 1, overflow: TextOverflow.fade, ), trailing: const Icon(Icons.arrow_right), onTap: () { context.to( () => ComicPage( sourceKey: key, id: controller.text, ), ); }, ); } var subTitle = TagsTranslation.translationTagWithNamespace( value.left, value.right.name); return ListTile( title: Row( mainAxisSize: MainAxisSize.min, children: [ Expanded( child: Text(value.left), ), 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 SliverMainAxisGroup( slivers: [ SliverToBoxAdapter( child: 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: () { suggestions.clear(); update(); }, ), ), ), ), SliverList( delegate: SliverChildBuilderDelegate( (context, index) { return buildItem(suggestions[index]); }, childCount: suggestions.length, ), ), ], ); } } class SearchOptionWidget extends StatelessWidget { const SearchOptionWidget({ super.key, required this.option, required this.value, required this.onChanged, required this.sourceKey, }); final SearchOptions option; final String value; final void Function(String) onChanged; final String sourceKey; @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ ListTile( contentPadding: EdgeInsets.zero, title: Text(option.label.ts(sourceKey)), ), if (option.type == 'select') Wrap( runSpacing: 8, spacing: 8, children: option.options.entries.map((e) { return OptionChip( text: e.value.ts(sourceKey), isSelected: value == e.key, onTap: () { onChanged(e.key); }, ); }).toList(), ), if (option.type == 'multi-select') Wrap( runSpacing: 8, spacing: 8, children: option.options.entries.map((e) { return OptionChip( text: e.value.ts(sourceKey), isSelected: (jsonDecode(value) as List).contains(e.key), onTap: () { var list = jsonDecode(value) as List; if (list.contains(e.key)) { list.remove(e.key); } else { list.add(e.key); } onChanged(jsonEncode(list)); }, ); }).toList(), ), if (option.type == 'dropdown') Select( current: option.options[value], values: option.options.values.toList(), onTap: (index) { onChanged(option.options.keys.elementAt(index)); }, minWidth: 96, ) ], ); } }