UI: update search bar design
This commit is contained in:
1 parent
3633ad7c81
commit
b6bbb757f2
1 file changed
+201
-67
@@ -1,35 +1,20 @@
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/constants/app_colors.dart';
|
||||
import '../../providers/search_history_provider.dart';
|
||||
|
||||
/// A reusable, styled search bar module.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// AppSearchBar(
|
||||
/// hint: 'Search students...',
|
||||
/// onSubmitted: (value) { /* handle search */ },
|
||||
/// )
|
||||
/// ```
|
||||
class AppSearchBar extends StatefulWidget {
|
||||
/// Placeholder text inside the field.
|
||||
class AppSearchBar extends ConsumerStatefulWidget {
|
||||
final String hint;
|
||||
|
||||
/// Called when the user submits (presses Enter / search action).
|
||||
final void Function(String value)? onSubmitted;
|
||||
|
||||
/// Called on every keystroke.
|
||||
final void Function(String value)? onChanged;
|
||||
|
||||
/// External controller – if not provided, one is created internally.
|
||||
final TextEditingController? controller;
|
||||
|
||||
/// Max width the field will stretch to. Defaults to 420.
|
||||
final double maxWidth;
|
||||
|
||||
/// Whether to show a clear (×) button when the field has text.
|
||||
final bool showClear;
|
||||
|
||||
final FocusNode? focusNode;
|
||||
final bool enableHistory;
|
||||
final bool collapsible;
|
||||
final bool hideIconWhenExpanded;
|
||||
|
||||
const AppSearchBar({
|
||||
super.key,
|
||||
@@ -40,23 +25,30 @@ class AppSearchBar extends StatefulWidget {
|
||||
this.focusNode,
|
||||
this.maxWidth = 420,
|
||||
this.showClear = true,
|
||||
this.enableHistory = true,
|
||||
this.collapsible = false,
|
||||
this.hideIconWhenExpanded = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AppSearchBar> createState() => _AppSearchBarState();
|
||||
ConsumerState<AppSearchBar> createState() => _AppSearchBarState();
|
||||
}
|
||||
|
||||
class _AppSearchBarState extends State<AppSearchBar> {
|
||||
class _AppSearchBarState extends ConsumerState<AppSearchBar> {
|
||||
late final TextEditingController _controller;
|
||||
late final FocusNode _focusNode;
|
||||
bool _hasText = false;
|
||||
bool _isExpanded = false;
|
||||
|
||||
OverlayEntry? _overlayEntry;
|
||||
final LayerLink _layerLink = LayerLink();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = widget.controller ?? TextEditingController();
|
||||
_focusNode = widget.focusNode ?? FocusNode();
|
||||
_isExpanded = !widget.collapsible;
|
||||
_controller.addListener(_onTextChanged);
|
||||
_focusNode.addListener(_onFocusChanged);
|
||||
}
|
||||
@@ -64,64 +56,206 @@ class _AppSearchBarState extends State<AppSearchBar> {
|
||||
void _onTextChanged() {
|
||||
final hasText = _controller.text.isNotEmpty;
|
||||
if (hasText != _hasText) setState(() => _hasText = hasText);
|
||||
|
||||
// Update overlay if history is visible
|
||||
if (_overlayEntry != null && widget.enableHistory) {
|
||||
_overlayEntry?.markNeedsBuild();
|
||||
}
|
||||
}
|
||||
|
||||
void _onFocusChanged() {
|
||||
setState(() {});
|
||||
if (mounted) setState(() {});
|
||||
|
||||
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: 150), () {
|
||||
if (mounted && !_focusNode.hasFocus) {
|
||||
_removeOverlay();
|
||||
|
||||
// Auto collapse if empty and collapsible
|
||||
if (widget.collapsible && _controller.text.trim().isEmpty) {
|
||||
setState(() => _isExpanded = false);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _showOverlay() {
|
||||
if (_overlayEntry != null) return;
|
||||
|
||||
final renderBox = context.findRenderObject() as RenderBox?;
|
||||
final actualWidth = renderBox?.size.width ?? 420.0;
|
||||
|
||||
_overlayEntry = OverlayEntry(
|
||||
builder: (context) => Positioned(
|
||||
width: actualWidth,
|
||||
child: CompositedTransformFollower(
|
||||
link: _layerLink,
|
||||
showWhenUnlinked: false,
|
||||
offset: const Offset(0, 48),
|
||||
child: _buildHistoryOverlay(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Overlay.of(context).insert(_overlayEntry!);
|
||||
}
|
||||
|
||||
void _removeOverlay() {
|
||||
_overlayEntry?.remove();
|
||||
_overlayEntry = null;
|
||||
}
|
||||
|
||||
Widget _buildHistoryOverlay() {
|
||||
return Consumer(
|
||||
builder: (context, ref, child) {
|
||||
final history = ref.watch(searchHistoryProvider);
|
||||
if (history.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return Material(
|
||||
elevation: 4,
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxHeight: 200),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: 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 InkWell(
|
||||
onTap: () {
|
||||
_controller.text = term;
|
||||
_focusNode.unfocus();
|
||||
if (widget.onSubmitted != null) {
|
||||
widget.onSubmitted!(term);
|
||||
}
|
||||
ref.read(searchHistoryProvider.notifier).addSearch(term);
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.history, size: 18, color: Colors.grey.shade400),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
term,
|
||||
style: const TextStyle(fontSize: 14, color: Colors.black87),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(Icons.close, size: 16, color: Colors.grey.shade400),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
onPressed: () {
|
||||
ref.read(searchHistoryProvider.notifier).removeSearch(term);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_removeOverlay();
|
||||
_controller.removeListener(_onTextChanged);
|
||||
_focusNode.removeListener(_onFocusChanged);
|
||||
if (widget.controller == null) _controller.dispose();
|
||||
if (widget.focusNode == null) _focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleSubmit(String val) {
|
||||
if (val.trim().isNotEmpty) {
|
||||
if (widget.enableHistory) {
|
||||
ref.read(searchHistoryProvider.notifier).addSearch(val.trim());
|
||||
}
|
||||
widget.onSubmitted?.call(val.trim());
|
||||
_focusNode.unfocus();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: widget.maxWidth,
|
||||
height: 42.0,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: _focusNode.hasFocus ? AppColors.primaryGreen : Colors.grey.shade200,
|
||||
width: _focusNode.hasFocus ? 1.5 : 1.0,
|
||||
),
|
||||
),
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
focusNode: _focusNode,
|
||||
textInputAction: TextInputAction.search,
|
||||
onSubmitted: (val) {
|
||||
if (val.trim().isNotEmpty) {
|
||||
widget.onSubmitted?.call(val.trim());
|
||||
}
|
||||
if (!_isExpanded && widget.collapsible) {
|
||||
return IconButton(
|
||||
onPressed: () {
|
||||
setState(() => _isExpanded = true);
|
||||
_focusNode.requestFocus();
|
||||
},
|
||||
onChanged: widget.onChanged,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
decoration: InputDecoration(
|
||||
hintText: widget.hint,
|
||||
hintStyle: TextStyle(fontSize: 13, color: Colors.grey.shade500),
|
||||
prefixIcon: const Icon(Icons.search_rounded, size: 20, color: AppColors.primaryGreen),
|
||||
prefixIconConstraints: const BoxConstraints(minWidth: 42, minHeight: 42),
|
||||
suffixIcon: widget.showClear && _hasText
|
||||
? IconButton(
|
||||
icon: Icon(Icons.close_rounded, size: 18, color: Colors.grey.shade500),
|
||||
splashRadius: 16,
|
||||
onPressed: () {
|
||||
_controller.clear();
|
||||
widget.onChanged?.call('');
|
||||
},
|
||||
)
|
||||
: null,
|
||||
filled: false,
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.only(top: 11, bottom: 11, right: 12),
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
icon: const Icon(Icons.search, size: 28),
|
||||
tooltip: 'Search',
|
||||
);
|
||||
}
|
||||
|
||||
return CompositedTransformTarget(
|
||||
link: _layerLink,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
width: widget.maxWidth,
|
||||
height: 42.0,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
focusNode: _focusNode,
|
||||
textInputAction: TextInputAction.search,
|
||||
onTapOutside: (event) => _focusNode.unfocus(),
|
||||
onSubmitted: _handleSubmit,
|
||||
onChanged: widget.onChanged,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
decoration: InputDecoration(
|
||||
hintText: widget.hint,
|
||||
hintStyle: TextStyle(fontSize: 13, color: Colors.grey.shade500),
|
||||
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
|
||||
? IconButton(
|
||||
icon: Icon(Icons.close_rounded, size: 18, color: Colors.grey.shade500),
|
||||
splashRadius: 16,
|
||||
onPressed: () {
|
||||
_controller.clear();
|
||||
widget.onChanged?.call('');
|
||||
_focusNode.requestFocus();
|
||||
},
|
||||
)
|
||||
: null,
|
||||
filled: false,
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.only(top: 11, bottom: 11, right: 12),
|
||||
border: const UnderlineInputBorder(
|
||||
borderSide: BorderSide(color: Colors.black26),
|
||||
),
|
||||
enabledBorder: const UnderlineInputBorder(
|
||||
borderSide: BorderSide(color: Colors.black26),
|
||||
),
|
||||
focusedBorder: const UnderlineInputBorder(
|
||||
borderSide: BorderSide(color: AppColors.primaryGreen, width: 1.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user