diff --git a/assets/tr.json b/assets/tr.json index 90aeb09..fb6f8d2 100644 --- a/assets/tr.json +++ b/assets/tr.json @@ -184,7 +184,8 @@ "Emphasize artworks from following artists": "强调关注画师的作品", "The border of the artworks will be darker": "作品的边框将被加深", "Initial Page": "初始页面", - "Close the pane to apply the settings": "关闭面板以应用设置" + "Close the pane to apply the settings": "关闭面板以应用设置", + "No results found": "未找到结果" }, "zh_TW": { "Search": "搜索", @@ -371,6 +372,7 @@ "Emphasize artworks from following artists": "強調關注畫師的作品", "The border of the artworks will be darker": "作品的邊框將被加深", "Initial Page": "初始頁面", - "Close the pane to apply the settings": "關閉面板以應用設置" + "Close the pane to apply the settings": "關閉面板以應用設置", + "No results found": "未找到結果" } } \ No newline at end of file diff --git a/lib/components/search_field.dart b/lib/components/search_field.dart new file mode 100644 index 0000000..70e0c29 --- /dev/null +++ b/lib/components/search_field.dart @@ -0,0 +1,315 @@ +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:pixes/foundation/app.dart'; + +class AutoCompleteItem { + final String title; + final String? subtitle; + final VoidCallback onTap; + + const AutoCompleteItem({ + required this.title, + this.subtitle, + required this.onTap, + }); +} + +class AutoCompleteData { + final List items; + final bool isLoading; + + const AutoCompleteData({ + this.items = const [], + this.isLoading = false, + }); +} + +class SearchField extends StatefulWidget { + const SearchField({ + super.key, + this.autoCompleteItems = const [], + this.isLoadingAutoCompleteItems = false, + this.enableAutoComplete = true, + this.textEditingController, + this.placeholder, + this.leading, + this.trailing, + this.foregroundDecoration, + this.onChanged, + this.onSubmitted, + this.padding, + this.focusNode, + this.autoCompleteNoResultsText, + }); + + final List autoCompleteItems; + + final bool isLoadingAutoCompleteItems; + + final bool enableAutoComplete; + + final TextEditingController? textEditingController; + + final String? placeholder; + + final Widget? leading; + + final Widget? trailing; + + final WidgetStatePropertyAll? foregroundDecoration; + + final void Function(String)? onChanged; + + final void Function(String)? onSubmitted; + + final EdgeInsets? padding; + + final FocusNode? focusNode; + + final String? autoCompleteNoResultsText; + + @override + State createState() => _SearchFieldState(); +} + +class _SearchFieldState extends State with TickerProviderStateMixin { + late final ValueNotifier autoCompleteItems; + + late final FocusNode focusNode; + + final boxKey = GlobalKey(); + + OverlayEntry? _overlayEntry; + + AnimationController? _animationController; + Animation? _fadeAnimation; + + @override + void initState() { + autoCompleteItems = ValueNotifier(AutoCompleteData( + items: widget.autoCompleteItems, + isLoading: widget.isLoadingAutoCompleteItems, + )); + focusNode = widget.focusNode ?? FocusNode(); + focusNode.addListener(onfocusChange); + super.initState(); + } + + @override + void dispose() { + _animationController?.dispose(); + focusNode.removeListener(onfocusChange); + if (widget.focusNode == null) { + focusNode.dispose(); + } + super.dispose(); + } + + @override + void didUpdateWidget(covariant SearchField oldWidget) { + if (widget.autoCompleteItems != oldWidget.autoCompleteItems || + widget.isLoadingAutoCompleteItems != + oldWidget.isLoadingAutoCompleteItems) { + Future.microtask(() { + autoCompleteItems.value = AutoCompleteData( + items: widget.autoCompleteItems, + isLoading: widget.isLoadingAutoCompleteItems, + ); + }); + } + super.didUpdateWidget(oldWidget); + } + + void onfocusChange() { + if (focusNode.hasFocus && widget.enableAutoComplete) { + final box = context.findRenderObject() as RenderBox?; + if (box == null) return; + final overlay = Overlay.of(context); + final position = box.localToGlobal( + Offset.zero, + ancestor: overlay.context.findRenderObject(), + ); + + if (_overlayEntry != null) { + _removeOverlayWithAnimation(); + } + + _animationController = AnimationController( + duration: const Duration(milliseconds: 200), + vsync: this, + ); + + _fadeAnimation = Tween( + begin: 0.0, + end: 1.0, + ).animate(CurvedAnimation( + parent: _animationController!, + curve: Curves.easeOut, + )); + + _overlayEntry = OverlayEntry( + builder: (context) { + return Positioned( + left: position.dx, + width: box.size.width, + top: position.dy + box.size.height, + child: _AnimatedOverlayWrapper( + animation: _fadeAnimation!, + child: _AutoCompleteOverlay( + data: autoCompleteItems, + noResultsText: widget.autoCompleteNoResultsText, + ), + ), + ); + }, + ); + + overlay.insert(_overlayEntry!); + _animationController!.forward(); + } else { + _removeOverlayWithAnimation(); + } + } + + void _removeOverlayWithAnimation() { + if (_overlayEntry != null && _animationController != null) { + _animationController!.reverse().then((_) { + _overlayEntry?.remove(); + _overlayEntry = null; + _animationController?.dispose(); + _animationController = null; + _fadeAnimation = null; + }); + } + } + + @override + Widget build(BuildContext context) { + return TextBox( + controller: widget.textEditingController, + key: boxKey, + focusNode: focusNode, + padding: const EdgeInsets.symmetric(horizontal: 12), + placeholder: widget.placeholder, + onChanged: widget.onChanged, + onSubmitted: widget.onSubmitted, + foregroundDecoration: widget.foregroundDecoration, + prefix: widget.leading, + suffix: widget.trailing, + ); + } +} + +class _AutoCompleteOverlay extends StatefulWidget { + const _AutoCompleteOverlay({required this.data, this.noResultsText}); + + final ValueNotifier data; + + final String? noResultsText; + + @override + State<_AutoCompleteOverlay> createState() => _AutoCompleteOverlayState(); +} + +class _AutoCompleteOverlayState extends State<_AutoCompleteOverlay> { + late final notifier = widget.data; + + var items = []; + + var isLoading = false; + + @override + void initState() { + items = notifier.value.items; + isLoading = notifier.value.isLoading; + notifier.addListener(onItemsChanged); + super.initState(); + } + + @override + void dispose() { + notifier.removeListener(onItemsChanged); + super.dispose(); + } + + void onItemsChanged() { + setState(() { + items = notifier.value.items; + isLoading = notifier.value.isLoading; + }); + } + + @override + Widget build(BuildContext context) { + var items = List.from(this.items); + + Widget? content; + + if (isLoading) { + content = SizedBox( + height: 44, + child: Center( + child: ProgressRing( + activeColor: FluentTheme.of(context).accentColor, + strokeWidth: 2, + ).fixWidth(24).fixHeight(24), + ), + ); + } else if (items.isEmpty) { + content = ListTile( + title: Text(widget.noResultsText ?? 'No results found'), + onPressed: () {}, + ); + } else { + if (items.length > 8) { + items = items.sublist(0, 8); + } + content = Column( + mainAxisSize: MainAxisSize.min, + children: items.map((item) { + return ListTile( + title: Text(item.title), + subtitle: item.subtitle != null ? Text(item.subtitle!) : null, + onPressed: item.onTap, + ); + }).toList(), + ); + } + + return Card( + backgroundColor: FluentTheme.of(context).micaBackgroundColor, + child: AnimatedSize( + alignment: Alignment.topCenter, + duration: const Duration(milliseconds: 160), + child: content, + ), + ); + } +} + +class _AnimatedOverlayWrapper extends StatelessWidget { + const _AnimatedOverlayWrapper({ + required this.animation, + required this.child, + }); + + final Animation animation; + final Widget child; + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: animation, + builder: (context, child) { + return FadeTransition( + opacity: animation, + child: Transform.scale( + scale: 0.9 + (0.1 * animation.value), + alignment: Alignment.topCenter, + child: child, + ), + ); + }, + child: child, + ); + } +} diff --git a/lib/network/models.dart b/lib/network/models.dart index 8b93179..8162deb 100644 --- a/lib/network/models.dart +++ b/lib/network/models.dart @@ -152,6 +152,10 @@ class Tag { @override int get hashCode => name.hashCode; + + static Tag fromJson(Map json) { + return Tag(json['name'] ?? "", json['translated_name']); + } } class IllustImage { diff --git a/lib/network/network.dart b/lib/network/network.dart index 38cff76..e270220 100644 --- a/lib/network/network.dart +++ b/lib/network/network.dart @@ -583,4 +583,13 @@ class Network { return Res.fromErrorRes(res); } } + + Future>> getAutoCompleteTags(String keyword) async { + var res = await apiGet("/v2/search/autocomplete?merge_plain_keyword_results=true&word=${Uri.encodeComponent(keyword)}"); + if (res.success) { + return Res((res.data["tags"] as List).map((e) => Tag.fromJson(e)).toList()); + } else { + return Res.error(res.errorMessage); + } + } } diff --git a/lib/pages/search_page.dart b/lib/pages/search_page.dart index 145f8f8..0adefd0 100644 --- a/lib/pages/search_page.dart +++ b/lib/pages/search_page.dart @@ -4,6 +4,7 @@ import 'package:pixes/appdata.dart'; import 'package:pixes/components/loading.dart'; import 'package:pixes/components/novel.dart'; import 'package:pixes/components/page_route.dart'; +import 'package:pixes/components/search_field.dart'; import 'package:pixes/components/user_preview.dart'; import 'package:pixes/foundation/app.dart'; import 'package:pixes/network/network.dart'; @@ -12,6 +13,7 @@ import 'package:pixes/pages/novel_page.dart'; import 'package:pixes/pages/user_info_page.dart'; import 'package:pixes/utils/app_links.dart'; import 'package:pixes/utils/block.dart'; +import 'package:pixes/utils/debounce.dart'; import 'package:pixes/utils/ext.dart'; import 'package:pixes/utils/translation.dart'; @@ -21,6 +23,15 @@ import '../components/illust_widget.dart'; import '../components/md.dart'; import '../foundation/image_provider.dart'; +const searchTypes = [ + "Search artwork", + "Search novel", + "Search user", + "Artwork ID", + "Artist ID", + "Novel ID" +]; + class SearchPage extends StatefulWidget { const SearchPage({super.key}); @@ -29,20 +40,9 @@ class SearchPage extends StatefulWidget { } class _SearchPageState extends State { - String text = ""; - int searchType = 0; - static const searchTypes = [ - "Search artwork", - "Search novel", - "Search user", - "Artwork ID", - "Artist ID", - "Novel ID" - ]; - - void search() { + void search(String text) { if (text.isURL && handleLink(Uri.parse(text))) { return; } else if ("https://$text".isURL && @@ -71,9 +71,19 @@ class _SearchPageState extends State { padding: const EdgeInsets.only(top: 8), content: Column( children: [ - buildSearchBar(), - const SizedBox( - height: 8, + _SearchBar( + searchType: searchType, + onTypeChanged: (type) { + setState(() { + searchType = type; + }); + }, + onSearch: (text) { + if (text.isEmpty) { + return; + } + search(text); + }, ), const Expanded( child: _TrendingTagsView(), @@ -82,102 +92,6 @@ class _SearchPageState extends State { ), ); } - - final optionController = FlyoutController(); - - Widget buildSearchBar() { - return ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 560), - child: SizedBox( - height: 42, - width: double.infinity, - child: LayoutBuilder( - builder: (context, constrains) { - return SizedBox( - height: 42, - width: constrains.maxWidth, - child: Row( - children: [ - Expanded( - child: TextBox( - padding: const EdgeInsets.symmetric(horizontal: 12), - placeholder: - '${searchTypes[searchType].tl} / ${"Open link".tl}', - onChanged: (s) => text = s, - onSubmitted: (s) => search(), - foregroundDecoration: WidgetStatePropertyAll( - BoxDecoration( - border: Border.all( - color: ColorScheme.of(context) - .outlineVariant - .toOpacity(0.6)), - borderRadius: BorderRadius.circular(4))), - suffix: MouseRegion( - cursor: SystemMouseCursors.click, - child: GestureDetector( - onTap: search, - child: const Icon( - FluentIcons.search, - size: 16, - ).paddingHorizontal(12), - ), - ), - ), - ), - const SizedBox( - width: 4, - ), - FlyoutTarget( - controller: optionController, - child: Button( - child: const SizedBox( - height: 42, - child: Center( - child: Icon(FluentIcons.chevron_down), - ), - ), - onPressed: () { - optionController.showFlyout( - placementMode: FlyoutPlacementMode.bottomCenter, - builder: buildSearchOption, - barrierColor: Colors.transparent); - }, - ), - ), - const SizedBox( - width: 4, - ), - Button( - child: const SizedBox( - height: 42, - child: Center( - child: Icon(FluentIcons.settings), - ), - ), - onPressed: () { - Navigator.of(context).push(SideBarRoute(SearchSettings( - isNovel: searchType == 1, - ))); - }, - ) - ], - ), - ); - }, - ), - ).paddingHorizontal(16), - ); - } - - Widget buildSearchOption(BuildContext context) { - return MenuFlyout( - items: List.generate( - searchTypes.length, - (index) => MenuFlyoutItem( - text: Text(searchTypes[index].tl), - onPressed: () => setState(() => searchType = index))), - ); - } } class _TrendingTagsView extends StatefulWidget { @@ -803,3 +717,184 @@ class _SearchNovelResultPageState return res; } } + +class _SearchBar extends StatefulWidget { + const _SearchBar({ + required this.searchType, + required this.onTypeChanged, + required this.onSearch, + }); + + final int searchType; + + final void Function(int) onTypeChanged; + + final void Function(String) onSearch; + + @override + State<_SearchBar> createState() => _SearchBarState(); +} + +class _SearchBarState extends State<_SearchBar> { + final optionController = FlyoutController(); + + final textController = TextEditingController(); + + var autoCompleteItems = []; + + var debouncer = Debounce(delay: const Duration(milliseconds: 300)); + + var autoCompleteKey = 0; + + var isLoadingAutoCompleteItems = false; + + Widget buildSearchOption(BuildContext context) { + return MenuFlyout( + items: List.generate( + searchTypes.length, + (index) => MenuFlyoutItem( + text: Text(searchTypes[index].tl), + onPressed: () => widget.onTypeChanged(index), + ), + ), + ); + } + + void onTextChanged(String text) { + if (widget.searchType == 3 || + widget.searchType == 4 || + widget.searchType == 5) { + return; + } + + if (text.isEmpty) { + setState(() { + autoCompleteItems = []; + isLoadingAutoCompleteItems = false; + }); + return; + } + setState(() { + isLoadingAutoCompleteItems = true; + }); + debouncer.call(() async { + var key = ++autoCompleteKey; + + var res = await Network().getAutoCompleteTags(text); + if (res.error) { + return; + } + var items = res.data.map((e) { + return AutoCompleteItem( + title: e.name, + subtitle: e.translatedName, + onTap: () { + textController.text = e.name; + widget.onSearch(e.name); + }, + ); + }).toList(); + + if (key != autoCompleteKey) { + return; // ignore old request + } + + setState(() { + autoCompleteItems = items; + isLoadingAutoCompleteItems = false; + }); + }); + } + + @override + Widget build(BuildContext context) { + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 560), + child: SizedBox( + height: 42, + width: double.infinity, + child: LayoutBuilder( + builder: (context, constrains) { + return SizedBox( + height: 42, + width: constrains.maxWidth, + child: Row( + children: [ + Expanded( + child: SearchField( + enableAutoComplete: widget.searchType != 3 && + widget.searchType != 4 && + widget.searchType != 5, + textEditingController: textController, + autoCompleteNoResultsText: "No results found".tl, + isLoadingAutoCompleteItems: isLoadingAutoCompleteItems, + autoCompleteItems: autoCompleteItems, + padding: const EdgeInsets.symmetric(horizontal: 12), + placeholder: + '${searchTypes[widget.searchType].tl} / ${"Open link".tl}', + onChanged: onTextChanged, + onSubmitted: widget.onSearch, + foregroundDecoration: WidgetStatePropertyAll( + BoxDecoration( + border: Border.all( + color: ColorScheme.of(context) + .outlineVariant + .toOpacity(0.6), + ), + borderRadius: BorderRadius.circular(4), + ), + ), + trailing: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () => widget.onSearch(textController.text), + child: const Icon( + FluentIcons.search, + size: 16, + ).paddingHorizontal(12), + ), + ), + ), + ), + const SizedBox(width: 4), + FlyoutTarget( + controller: optionController, + child: Button( + child: const SizedBox( + height: 42, + child: Center( + child: Icon(FluentIcons.chevron_down), + ), + ), + onPressed: () { + optionController.showFlyout( + placementMode: FlyoutPlacementMode.bottomCenter, + builder: buildSearchOption, + barrierColor: Colors.transparent, + ); + }, + ), + ), + const SizedBox(width: 4), + Button( + child: const SizedBox( + height: 42, + child: Center( + child: Icon(FluentIcons.settings), + ), + ), + onPressed: () { + Navigator.of(context).push(SideBarRoute(SearchSettings( + isNovel: widget.searchType == 1, + ))); + }, + ) + ], + ), + ); + }, + ), + ).paddingHorizontal(16), + ); + } +} diff --git a/lib/utils/debounce.dart b/lib/utils/debounce.dart new file mode 100644 index 0000000..83e22f7 --- /dev/null +++ b/lib/utils/debounce.dart @@ -0,0 +1,28 @@ +import 'dart:async'; +import 'dart:ui'; + +class Debounce { + final Duration delay; + VoidCallback? _action; + Timer? _timer; + + Debounce({required this.delay}); + + void call(VoidCallback action) { + _action = action; + _timer?.cancel(); + _timer = Timer(delay, _execute); + } + + void _execute() { + if (_action != null) { + _action!(); + _action = null; + } + } + + void cancel() { + _timer?.cancel(); + _action = null; + } +} \ No newline at end of file