diff --git a/lib/components/appbar.dart b/lib/components/appbar.dart index a3c9d3a..5fb43a8 100644 --- a/lib/components/appbar.dart +++ b/lib/components/appbar.dart @@ -548,6 +548,10 @@ class SearchBarController { String get text => _state?.getText() ?? ''; + set text(String text) { + setText(text); + } + SearchBarController({this.onSearch, this.initialText = ''}); } @@ -558,10 +562,12 @@ abstract mixin class _SearchBarMixin { } class SliverSearchBar extends StatefulWidget { - const SliverSearchBar({super.key, required this.controller}); + const SliverSearchBar({super.key, required this.controller, this.onChanged}); final SearchBarController controller; + final void Function(String)? onChanged; + @override State createState() => _SliverSearchBarState(); } @@ -593,10 +599,12 @@ class _SliverSearchBarState extends State @override Widget build(BuildContext context) { return SliverPersistentHeader( + pinned: true, delegate: _SliverSearchBarDelegate( editingController: _editingController, controller: _controller, topPadding: MediaQuery.of(context).padding.top, + onChanged: widget.onChanged, ), ); } @@ -609,10 +617,13 @@ class _SliverSearchBarDelegate extends SliverPersistentHeaderDelegate { final double topPadding; + final void Function(String)? onChanged; + const _SliverSearchBarDelegate({ required this.editingController, required this.controller, required this.topPadding, + this.onChanged, }); static const _kAppBarHeight = 52.0; @@ -625,6 +636,7 @@ class _SliverSearchBarDelegate extends SliverPersistentHeaderDelegate { width: double.infinity, padding: EdgeInsets.only(top: topPadding), decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, border: Border( bottom: BorderSide( color: Theme.of(context).colorScheme.outlineVariant, @@ -647,6 +659,7 @@ class _SliverSearchBarDelegate extends SliverPersistentHeaderDelegate { onSubmitted: (text) { controller.onSearch?.call(text); }, + onChanged: onChanged, ), ), ), diff --git a/lib/components/flyout.dart b/lib/components/flyout.dart index b915697..dd740af 100644 --- a/lib/components/flyout.dart +++ b/lib/components/flyout.dart @@ -50,10 +50,10 @@ class Flyout extends StatefulWidget { final FlyoutController? controller; @override - State createState() => _FlyoutState(); + State createState() => FlyoutState(); } -class _FlyoutState extends State { +class FlyoutState extends State { @override void initState() { if (widget.controller != null) { diff --git a/lib/foundation/comic_source/comic_source.dart b/lib/foundation/comic_source/comic_source.dart index fbd519d..099574e 100644 --- a/lib/foundation/comic_source/comic_source.dart +++ b/lib/foundation/comic_source/comic_source.dart @@ -377,13 +377,10 @@ class SearchPageData { final bool enableLanguageFilter; - final bool enableTagsSuggestions; - const SearchPageData(this.searchOptions, this.loadPage) : enableLanguageFilter = false, customOptionsBuilder = null, - overrideSearchResultBuilder = null, - enableTagsSuggestions = false; + overrideSearchResultBuilder = null; } class SearchOptions { diff --git a/lib/pages/categories_page.dart b/lib/pages/categories_page.dart index 6bf6883..5be4ce3 100644 --- a/lib/pages/categories_page.dart +++ b/lib/pages/categories_page.dart @@ -5,6 +5,7 @@ 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/ranking_page.dart'; +import 'package:venera/pages/search_result_page.dart'; import 'package:venera/utils/translations.dart'; import 'category_comics_page.dart'; @@ -88,29 +89,24 @@ class _CategoryPage extends StatelessWidget { String categoryKey, ) { if (type == 'search') { - // TODO: Implement search - /* App.mainNavigatorKey?.currentContext?.to( - () => SearchResultPage( - keyword: tag, + () => SearchResultPage( + text: tag, options: const [], sourceKey: findComicSourceKey(), ), ); - */ } else if (type == "search_with_namespace") { - /* if (tag.contains(" ")) { tag = '"$tag"'; } App.mainNavigatorKey?.currentContext?.to( - () => SearchResultPage( - keyword: "$namespace:$tag", + () => SearchResultPage( + text: "$namespace:$tag", options: const [], sourceKey: findComicSourceKey(), ), ); - */ } else if (type == "category") { App.mainNavigatorKey!.currentContext!.to( () => CategoryComicsPage( diff --git a/lib/pages/comic_page.dart b/lib/pages/comic_page.dart index 292f33e..03f92d3 100644 --- a/lib/pages/comic_page.dart +++ b/lib/pages/comic_page.dart @@ -306,7 +306,7 @@ class _ComicPageState extends LoadingState ]; color = context.useBackgroundColor(colors[(i++) % (colors.length)]); } else { - color = context.colorScheme.surfaceContainer; + color = context.colorScheme.surfaceContainerLow; } final borderRadius = BorderRadius.circular(12); diff --git a/lib/pages/search_page.dart b/lib/pages/search_page.dart index fccd79f..8c98b87 100644 --- a/lib/pages/search_page.dart +++ b/lib/pages/search_page.dart @@ -3,9 +3,15 @@ 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_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}); @@ -25,13 +31,95 @@ class _SearchPageState extends State { } void search([String? text]) { - context.to( - () => SearchResultPage( - text: text ?? controller.text, - sourceKey: searchTarget, - options: options, - ), - ); + 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 @@ -53,15 +141,27 @@ class _SearchPageState extends State { Widget build(BuildContext context) { return Scaffold( body: SmoothCustomScrollView( - slivers: [ - SliverSearchBar(controller: controller), - buildSearchTarget(), - buildSearchOptions(), - ], + slivers: buildSlivers().toList(), ), ); } + Iterable buildSlivers() sync* { + yield SliverSearchBar( + controller: controller, + onChanged: (s) { + findSuggestions(); + }, + ); + if (suggestions.isNotEmpty) { + yield buildSuggestions(context); + } else { + yield buildSearchTarget(); + yield buildSearchOptions(); + yield buildSearchHistory(); + } + } + Widget buildSearchTarget() { var sources = ComicSource.all().where((e) => e.searchPageData != null).toList(); @@ -148,21 +248,199 @@ class _SearchPageState extends State { delegate: SliverChildBuilderDelegate( (context, index) { if (index == 0) { + return const Divider( + thickness: 0.6, + ).paddingTop(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 ListTile( - contentPadding: EdgeInsets.zero, - title: Text(appdata.searchHistory[index - 1]), + contentPadding: const EdgeInsets.symmetric(horizontal: 12), + title: Text(appdata.searchHistory[index - 2]), onTap: () { - search(appdata.searchHistory[index - 1]); + search(appdata.searchHistory[index - 2]); }, ); }, - childCount: 1 + appdata.searchHistory.length, + 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 "; + } + update(); + } + + 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: () { + 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, + ), + ), + ], + ); + } } diff --git a/lib/pages/search_result_page.dart b/lib/pages/search_result_page.dart index 84f9abb..a6e2422 100644 --- a/lib/pages/search_result_page.dart +++ b/lib/pages/search_result_page.dart @@ -1,6 +1,12 @@ 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/utils/ext.dart'; +import 'package:venera/utils/tags_translation.dart'; +import 'package:venera/utils/translations.dart'; class SearchResultPage extends StatefulWidget { const SearchResultPage({ @@ -29,11 +35,47 @@ class _SearchResultPageState extends State { late String text; + OverlayEntry? get suggestionOverlay => suggestionsController.entry; + + late _SuggestionsController suggestionsController; + void search([String? text]) { if (text != null) { setState(() { this.text = text; }); + appdata.addSearchHistory(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!); } } @@ -46,6 +88,8 @@ class _SearchResultPageState extends State { sourceKey = widget.sourceKey; options = widget.options; text = widget.text; + appdata.addSearchHistory(text); + suggestionsController = _SuggestionsController(controller); super.initState(); } @@ -58,6 +102,7 @@ class _SearchResultPageState extends State { ), leadingSliver: SliverSearchBar( controller: controller, + onChanged: onChanged, ), loadPage: (i) { var source = ComicSource.find(sourceKey); @@ -70,3 +115,208 @@ class _SearchResultPageState extends State { ); } } + +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(); + } +}