fix(search): fix history overlay flicker and clear/delete behavior

This commit is contained in:
ssbg04 committed 2026-09-14 20:07:55 +08:00
1 parent 20d5d4d694
commit 28f1dfb1b0
4 files changed
+529 -169

No files matched your search

@@ -22,7 +22,21 @@ class SearchHistoryNotifier extends Notifier<List<String>> {
final data = prefs.getString(_searchHistoryKey);
if (data != null) {
final List<dynamic> decoded = jsonDecode(data);
state = decoded.map((e) => e.toString()).toList();
final loaded = decoded
.map((e) => e.toString().trim())
.where((e) => e.isNotEmpty)
.toList();
final merged = [...state];
for (final item in loaded) {
if (!merged.contains(item)) {
merged.add(item);
}
}
if (merged.length > 10) {
merged.removeRange(10, merged.length);
}
state = merged;
}
} catch (e) {
// Ignore errors on load
@@ -49,15 +63,20 @@ class SearchHistoryNotifier extends Notifier<List<String>> {
}
Future<void> removeSearch(String query) async {
final newList = List<String>.from(state)..remove(query);
final trimQuery = query.trim();
final newList = List<String>.from(state)..remove(trimQuery);
state = newList;
await _saveHistory(newList);
}
Future<void> clearHistory() async {
state = [];
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_searchHistoryKey);
try {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_searchHistoryKey);
} catch (e) {
// Ignore errors on clear
}
}
Future<void> _saveHistory(List<String> list) async {
+337 -163
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/constants/app_colors.dart';
import '../../providers/search_history_provider.dart';
@@ -34,17 +35,39 @@ class AppSearchBar extends ConsumerStatefulWidget {
}
class _AppSearchBarState extends ConsumerState<AppSearchBar> {
late final TextEditingController _controller;
late final FocusNode _focusNode;
late TextEditingController _controller;
late FocusNode _focusNode;
bool _hasText = false;
bool _isExpanded = false;
OverlayEntry? _overlayEntry;
final LayerLink _layerLink = LayerLink();
final Object _tapRegionGroupId = Object();
bool _isOverlayOpen = false;
@override
void didUpdateWidget(AppSearchBar oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.controller != widget.controller) {
if (oldWidget.controller == null) {
_controller.removeListener(_onTextChanged);
_controller.dispose();
} else {
oldWidget.controller!.removeListener(_onTextChanged);
}
_controller = widget.controller ?? TextEditingController();
_controller.addListener(_onTextChanged);
}
if (oldWidget.focusNode != widget.focusNode) {
if (oldWidget.focusNode == null) {
_focusNode.removeListener(_onFocusChanged);
_focusNode.dispose();
} else {
oldWidget.focusNode!.removeListener(_onFocusChanged);
}
_focusNode = widget.focusNode ?? FocusNode();
_focusNode.addListener(_onFocusChanged);
}
final currentHasText = _controller.text.isNotEmpty;
if (currentHasText != _hasText) {
setState(() {
@@ -73,6 +96,9 @@ class _AppSearchBarState extends ConsumerState<AppSearchBar> {
if (hasText) _isExpanded = true;
});
}
if (_isOverlayOpen) {
_overlayEntry?.markNeedsBuild();
}
}
void _onFocusChanged() {
@@ -89,12 +115,10 @@ class _AppSearchBarState extends ConsumerState<AppSearchBar> {
if (_focusNode.hasFocus && widget.enableHistory) {
_showOverlay();
} else {
// Delay removal to allow overlay tap events (InkWell) to fire before the widget is destroyed
Future.delayed(const Duration(milliseconds: 200), () {
Future.delayed(const Duration(milliseconds: 150), () {
if (mounted && !_focusNode.hasFocus) {
_removeOverlay();
// Auto collapse if empty and collapsible
if (widget.collapsible && _controller.text.trim().isEmpty) {
setState(() => _isExpanded = false);
}
@@ -104,38 +128,66 @@ class _AppSearchBarState extends ConsumerState<AppSearchBar> {
}
void _showOverlay() {
if (_overlayEntry != null) return;
if (!widget.enableHistory || !mounted) return;
final history = ref.read(searchHistoryProvider);
if (history.isEmpty) return;
if (_overlayEntry != null) {
_isOverlayOpen = true;
_overlayEntry?.markNeedsBuild();
return;
}
_isOverlayOpen = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _overlayEntry != null || !_focusNode.hasFocus) return;
if (!mounted ||
!_isOverlayOpen ||
!_focusNode.hasFocus ||
_overlayEntry != null) {
return;
}
final overlay = Overlay.maybeOf(context);
if (overlay == null) return;
_overlayEntry = OverlayEntry(
builder: (ctx) {
final renderBox = context.findRenderObject() as RenderBox?;
final actualWidth = renderBox?.size.width;
final safeWidth =
actualWidth ??
(widget.maxWidth == double.infinity ? 400.0 : widget.maxWidth);
final actualWidth = (renderBox != null && renderBox.hasSize)
? renderBox.size.width
: (widget.maxWidth == double.infinity
? 400.0
: widget.maxWidth);
return Positioned(
width: safeWidth,
width: actualWidth,
child: CompositedTransformFollower(
link: _layerLink,
showWhenUnlinked: false,
offset: const Offset(0, 48),
child: _buildHistoryOverlay(),
child: TapRegion(
groupId: _tapRegionGroupId,
child: _buildHistoryOverlay(),
),
),
);
},
);
Overlay.of(context).insert(_overlayEntry!);
overlay.insert(_overlayEntry!);
});
}
void _removeOverlay() {
_overlayEntry?.remove();
_overlayEntry = null;
_isOverlayOpen = false;
if (_overlayEntry != null) {
if (_overlayEntry!.mounted) {
_overlayEntry!.remove();
}
_overlayEntry = null;
}
}
Widget _buildHistoryOverlay() {
@@ -147,6 +199,15 @@ class _AppSearchBarState extends ConsumerState<AppSearchBar> {
final history = ref.watch(searchHistoryProvider);
if (history.isEmpty) return const SizedBox.shrink();
final query = _controller.text.trim().toLowerCase();
final displayList = query.isEmpty
? history
: history
.where((item) => item.toLowerCase().contains(query))
.toList();
if (displayList.isEmpty) return const SizedBox.shrink();
return Material(
elevation: 4,
color: isDark ? AppColors.darkSurfaceCard : Colors.white,
@@ -159,88 +220,154 @@ class _AppSearchBarState extends ConsumerState<AppSearchBar> {
color: isDark ? AppColors.darkBorder : Colors.grey.shade200,
),
),
child: ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 8),
shrinkWrap: true,
itemCount: history.length,
itemBuilder: (context, index) {
final term = history[index];
return Row(
children: [
Expanded(
child: InkWell(
borderRadius: BorderRadius.circular(8),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 10, 12, 6),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Recent Searches',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: isDark
? AppColors.darkTextMuted
: Colors.grey.shade500,
letterSpacing: 0.3,
),
),
InkWell(
borderRadius: BorderRadius.circular(4),
canRequestFocus: false,
onTap: () {
_controller.text = term;
if (widget.onSubmitted != null) {
widget.onSubmitted!(term);
}
ref
.read(searchHistoryProvider.notifier)
.addSearch(term);
Future.delayed(
const Duration(milliseconds: 50),
() {
if (mounted) _focusNode.unfocus();
},
);
.clearHistory();
_removeOverlay();
if (!_focusNode.hasFocus) {
_focusNode.requestFocus();
}
},
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
horizontal: 6,
vertical: 2,
),
child: Row(
children: [
Icon(
Icons.history,
size: 18,
color: isDark
? AppColors.darkTextSecondary
: Colors.grey.shade400,
),
const SizedBox(width: 12),
Expanded(
child: Text(
term,
style: TextStyle(
fontSize: 14,
color: isDark
? AppColors.darkTextPrimary
: Colors.black87,
),
child: Text(
'Clear all',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: isDark
? AppColors.darkTextSecondary
: Colors.grey.shade600,
),
),
),
),
],
),
),
Divider(
height: 1,
thickness: 1,
color: isDark ? AppColors.darkBorder : Colors.grey.shade200,
),
Flexible(
child: ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 4),
shrinkWrap: true,
itemCount: displayList.length,
itemBuilder: (context, index) {
final term = displayList[index];
return Row(
children: [
Expanded(
child: InkWell(
borderRadius: BorderRadius.circular(8),
canRequestFocus: false,
onTap: () {
final selected = term;
_controller.text = selected;
_controller.selection = TextSelection.collapsed(
offset: selected.length,
);
setState(() {
_hasText = selected.isNotEmpty;
});
ref
.read(searchHistoryProvider.notifier)
.addSearch(selected);
widget.onChanged?.call(selected);
widget.onSubmitted?.call(selected);
_removeOverlay();
_focusNode.unfocus();
},
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 9,
),
child: Row(
children: [
Icon(
Icons.history,
size: 18,
color: isDark
? AppColors.darkTextSecondary
: Colors.grey.shade400,
),
const SizedBox(width: 12),
Expanded(
child: Text(
term,
style: TextStyle(
fontSize: 14,
color: isDark
? AppColors.darkTextPrimary
: Colors.black87,
),
),
),
],
),
),
],
),
),
),
),
),
InkResponse(
radius: 16,
onTap: () {
ref
.read(searchHistoryProvider.notifier)
.removeSearch(term);
_focusNode.requestFocus();
},
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
child: Icon(
Icons.close_rounded,
size: 16,
color: isDark
? AppColors.darkTextMuted
: Colors.grey.shade400,
),
),
),
],
);
},
InkResponse(
radius: 16,
canRequestFocus: false,
onTap: () {
ref
.read(searchHistoryProvider.notifier)
.removeSearch(term);
if (!_focusNode.hasFocus) {
_focusNode.requestFocus();
}
},
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 9,
),
child: Icon(
Icons.close_rounded,
size: 16,
color: isDark
? AppColors.darkTextMuted
: Colors.grey.shade400,
),
),
),
],
);
},
),
),
],
),
),
);
@@ -259,17 +386,31 @@ class _AppSearchBarState extends ConsumerState<AppSearchBar> {
}
void _handleSubmit(String val) {
if (val.trim().isNotEmpty) {
final query = val.trim();
if (query.isNotEmpty) {
if (widget.enableHistory) {
ref.read(searchHistoryProvider.notifier).addSearch(val.trim());
ref.read(searchHistoryProvider.notifier).addSearch(query);
}
widget.onSubmitted?.call(val.trim());
_focusNode.unfocus();
widget.onSubmitted?.call(query);
}
_removeOverlay();
_focusNode.unfocus();
}
@override
Widget build(BuildContext context) {
ref.listen<List<String>>(searchHistoryProvider, (previous, next) {
if (!mounted) return;
if (next.isEmpty && _isOverlayOpen) {
_removeOverlay();
} else if (next.isNotEmpty &&
_focusNode.hasFocus &&
!_isOverlayOpen &&
widget.enableHistory) {
_showOverlay();
}
});
if (!_isExpanded && widget.collapsible && !_hasText) {
return IconButton(
onPressed: () {
@@ -277,6 +418,9 @@ class _AppSearchBarState extends ConsumerState<AppSearchBar> {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_focusNode.requestFocus();
if (widget.enableHistory) {
_showOverlay();
}
}
});
},
@@ -287,82 +431,112 @@ class _AppSearchBarState extends ConsumerState<AppSearchBar> {
final isDark = Theme.of(context).brightness == Brightness.dark;
return TapRegion(
onTapOutside: (event) {
if (_focusNode.hasFocus) {
_focusNode.unfocus();
}
return CallbackShortcuts(
bindings: {
const SingleActivator(LogicalKeyboardKey.escape): () {
if (_isOverlayOpen) {
_removeOverlay();
} else if (_focusNode.hasFocus) {
_focusNode.unfocus();
}
},
},
child: CompositedTransformTarget(
link: _layerLink,
child: Container(
width: widget.maxWidth,
height: 42.0,
decoration: BoxDecoration(
color: _focusNode.hasFocus
? AppColors.primaryGreen.withValues(alpha: 0.05)
: Colors.transparent,
borderRadius: BorderRadius.circular(12),
),
child: TextField(
controller: _controller,
focusNode: _focusNode,
textInputAction: TextInputAction.search,
onSubmitted: _handleSubmit,
onChanged: widget.onChanged,
style: TextStyle(
fontSize: 14,
color: isDark ? AppColors.darkTextPrimary : Colors.black87,
child: TapRegion(
groupId: _tapRegionGroupId,
onTapOutside: (event) {
if (_focusNode.hasFocus) {
_focusNode.unfocus();
}
_removeOverlay();
},
child: CompositedTransformTarget(
link: _layerLink,
child: Container(
width: widget.maxWidth,
height: 42.0,
decoration: BoxDecoration(
color: _focusNode.hasFocus
? AppColors.primaryGreen.withValues(alpha: 0.05)
: Colors.transparent,
borderRadius: BorderRadius.circular(12),
),
decoration: InputDecoration(
hintText: widget.hint,
hintStyle: TextStyle(
fontSize: 13,
color: isDark ? AppColors.darkTextMuted : Colors.grey.shade500,
child: TextField(
controller: _controller,
focusNode: _focusNode,
textInputAction: TextInputAction.search,
onSubmitted: _handleSubmit,
onChanged: widget.onChanged,
onTap: () {
if (widget.enableHistory) {
_showOverlay();
}
},
style: TextStyle(
fontSize: 14,
color: isDark ? AppColors.darkTextPrimary : Colors.black87,
),
prefixIcon: widget.hideIconWhenExpanded
? null
: const Icon(
Icons.search_rounded,
size: 20,
color: AppColors.primaryGreen,
),
prefixIconConstraints: widget.hideIconWhenExpanded
? const BoxConstraints(minWidth: 16, minHeight: 0)
: const BoxConstraints(minWidth: 42, minHeight: 42),
suffixIcon:
widget.showClear &&
(_hasText ||
_controller.text.isNotEmpty ||
(widget.collapsible && _isExpanded))
? IconButton(
icon: Icon(
Icons.close_rounded,
size: 18,
color: isDark ? AppColors.darkTextMuted : Colors.grey.shade500,
decoration: InputDecoration(
hintText: widget.hint,
hintStyle: TextStyle(
fontSize: 13,
color:
isDark ? AppColors.darkTextMuted : Colors.grey.shade500,
),
prefixIcon: widget.hideIconWhenExpanded
? null
: const Icon(
Icons.search_rounded,
size: 20,
color: AppColors.primaryGreen,
),
splashRadius: 16,
onPressed: () {
_controller.clear();
widget.onChanged?.call('');
widget.onSubmitted?.call('');
setState(() {
_hasText = false;
});
_focusNode.requestFocus();
},
)
: null,
filled: false,
isDense: true,
contentPadding: const EdgeInsets.only(
top: 11,
bottom: 11,
right: 12,
prefixIconConstraints: widget.hideIconWhenExpanded
? const BoxConstraints(minWidth: 16, minHeight: 0)
: const BoxConstraints(minWidth: 42, minHeight: 42),
suffixIcon: widget.showClear &&
(_hasText ||
_controller.text.isNotEmpty ||
(widget.collapsible && _isExpanded))
? IconButton(
focusNode: FocusNode(
skipTraversal: true,
canRequestFocus: false,
),
icon: Icon(
Icons.close_rounded,
size: 18,
color: isDark
? AppColors.darkTextMuted
: Colors.grey.shade500,
),
splashRadius: 16,
onPressed: () {
_controller.clear();
widget.onChanged?.call('');
widget.onSubmitted?.call('');
setState(() {
_hasText = false;
});
if (!_focusNode.hasFocus) {
_focusNode.requestFocus();
}
if (widget.enableHistory) {
_showOverlay();
_overlayEntry?.markNeedsBuild();
}
},
)
: null,
filled: false,
isDense: true,
contentPadding: const EdgeInsets.only(
top: 11,
bottom: 11,
right: 12,
),
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
),
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
),
),
),