mirror of
https://github.com/wgh136/pixes.git
synced 2025-09-27 04:57:23 +00:00
Add auto complete. Close #24
This commit is contained in:
@@ -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": "未找到結果"
|
||||
}
|
||||
}
|
315
lib/components/search_field.dart
Normal file
315
lib/components/search_field.dart
Normal file
@@ -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<AutoCompleteItem> items;
|
||||
final bool isLoading;
|
||||
|
||||
const AutoCompleteData({
|
||||
this.items = const <AutoCompleteItem>[],
|
||||
this.isLoading = false,
|
||||
});
|
||||
}
|
||||
|
||||
class SearchField extends StatefulWidget {
|
||||
const SearchField({
|
||||
super.key,
|
||||
this.autoCompleteItems = const <AutoCompleteItem>[],
|
||||
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<AutoCompleteItem> autoCompleteItems;
|
||||
|
||||
final bool isLoadingAutoCompleteItems;
|
||||
|
||||
final bool enableAutoComplete;
|
||||
|
||||
final TextEditingController? textEditingController;
|
||||
|
||||
final String? placeholder;
|
||||
|
||||
final Widget? leading;
|
||||
|
||||
final Widget? trailing;
|
||||
|
||||
final WidgetStatePropertyAll<BoxDecoration>? foregroundDecoration;
|
||||
|
||||
final void Function(String)? onChanged;
|
||||
|
||||
final void Function(String)? onSubmitted;
|
||||
|
||||
final EdgeInsets? padding;
|
||||
|
||||
final FocusNode? focusNode;
|
||||
|
||||
final String? autoCompleteNoResultsText;
|
||||
|
||||
@override
|
||||
State<SearchField> createState() => _SearchFieldState();
|
||||
}
|
||||
|
||||
class _SearchFieldState extends State<SearchField> with TickerProviderStateMixin {
|
||||
late final ValueNotifier<AutoCompleteData> autoCompleteItems;
|
||||
|
||||
late final FocusNode focusNode;
|
||||
|
||||
final boxKey = GlobalKey();
|
||||
|
||||
OverlayEntry? _overlayEntry;
|
||||
|
||||
AnimationController? _animationController;
|
||||
Animation<double>? _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<double>(
|
||||
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<AutoCompleteData> data;
|
||||
|
||||
final String? noResultsText;
|
||||
|
||||
@override
|
||||
State<_AutoCompleteOverlay> createState() => _AutoCompleteOverlayState();
|
||||
}
|
||||
|
||||
class _AutoCompleteOverlayState extends State<_AutoCompleteOverlay> {
|
||||
late final notifier = widget.data;
|
||||
|
||||
var items = <AutoCompleteItem>[];
|
||||
|
||||
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<AutoCompleteItem>.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<double> 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,
|
||||
);
|
||||
}
|
||||
}
|
@@ -152,6 +152,10 @@ class Tag {
|
||||
|
||||
@override
|
||||
int get hashCode => name.hashCode;
|
||||
|
||||
static Tag fromJson(Map<String, dynamic> json) {
|
||||
return Tag(json['name'] ?? "", json['translated_name']);
|
||||
}
|
||||
}
|
||||
|
||||
class IllustImage {
|
||||
|
@@ -583,4 +583,13 @@ class Network {
|
||||
return Res.fromErrorRes(res);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Res<List<Tag>>> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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<SearchPage> {
|
||||
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<SearchPage> {
|
||||
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<SearchPage> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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 = <AutoCompleteItem>[];
|
||||
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
28
lib/utils/debounce.dart
Normal file
28
lib/utils/debounce.dart
Normal file
@@ -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;
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user