Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14c6193367 |
No files matched your search
@@ -14,6 +14,7 @@ requirements
|
|||||||
TIS*
|
TIS*
|
||||||
!*.iss
|
!*.iss
|
||||||
screenshots/
|
screenshots/
|
||||||
|
issues/
|
||||||
|
|
||||||
# Environment secrets
|
# Environment secrets
|
||||||
backend/.env
|
backend/.env
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ class _AppSearchBarState extends ConsumerState<AppSearchBar> {
|
|||||||
final LayerLink _layerLink = LayerLink();
|
final LayerLink _layerLink = LayerLink();
|
||||||
final Object _tapRegionGroupId = Object();
|
final Object _tapRegionGroupId = Object();
|
||||||
bool _isOverlayOpen = false;
|
bool _isOverlayOpen = false;
|
||||||
|
bool _isPointerOverOverlay = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didUpdateWidget(AppSearchBar oldWidget) {
|
void didUpdateWidget(AppSearchBar oldWidget) {
|
||||||
@@ -115,8 +116,8 @@ class _AppSearchBarState extends ConsumerState<AppSearchBar> {
|
|||||||
if (_focusNode.hasFocus && widget.enableHistory) {
|
if (_focusNode.hasFocus && widget.enableHistory) {
|
||||||
_showOverlay();
|
_showOverlay();
|
||||||
} else {
|
} else {
|
||||||
Future.delayed(const Duration(milliseconds: 150), () {
|
Future.delayed(const Duration(milliseconds: 200), () {
|
||||||
if (mounted && !_focusNode.hasFocus) {
|
if (mounted && !_focusNode.hasFocus && !_isPointerOverOverlay) {
|
||||||
_removeOverlay();
|
_removeOverlay();
|
||||||
|
|
||||||
if (widget.collapsible && _controller.text.trim().isEmpty) {
|
if (widget.collapsible && _controller.text.trim().isEmpty) {
|
||||||
@@ -144,7 +145,7 @@ class _AppSearchBarState extends ConsumerState<AppSearchBar> {
|
|||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (!mounted ||
|
if (!mounted ||
|
||||||
!_isOverlayOpen ||
|
!_isOverlayOpen ||
|
||||||
!_focusNode.hasFocus ||
|
(!_focusNode.hasFocus && !_isPointerOverOverlay) ||
|
||||||
_overlayEntry != null) {
|
_overlayEntry != null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -169,7 +170,12 @@ class _AppSearchBarState extends ConsumerState<AppSearchBar> {
|
|||||||
offset: const Offset(0, 48),
|
offset: const Offset(0, 48),
|
||||||
child: TapRegion(
|
child: TapRegion(
|
||||||
groupId: _tapRegionGroupId,
|
groupId: _tapRegionGroupId,
|
||||||
child: _buildHistoryOverlay(),
|
behavior: HitTestBehavior.opaque,
|
||||||
|
child: MouseRegion(
|
||||||
|
onEnter: (_) => _isPointerOverOverlay = true,
|
||||||
|
onExit: (_) => _isPointerOverOverlay = false,
|
||||||
|
child: _buildHistoryOverlay(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -182,6 +188,7 @@ class _AppSearchBarState extends ConsumerState<AppSearchBar> {
|
|||||||
|
|
||||||
void _removeOverlay() {
|
void _removeOverlay() {
|
||||||
_isOverlayOpen = false;
|
_isOverlayOpen = false;
|
||||||
|
_isPointerOverOverlay = false;
|
||||||
if (_overlayEntry != null) {
|
if (_overlayEntry != null) {
|
||||||
if (_overlayEntry!.mounted) {
|
if (_overlayEntry!.mounted) {
|
||||||
_overlayEntry!.remove();
|
_overlayEntry!.remove();
|
||||||
@@ -208,166 +215,173 @@ class _AppSearchBarState extends ConsumerState<AppSearchBar> {
|
|||||||
|
|
||||||
if (displayList.isEmpty) return const SizedBox.shrink();
|
if (displayList.isEmpty) return const SizedBox.shrink();
|
||||||
|
|
||||||
return Material(
|
return Focus(
|
||||||
elevation: 4,
|
canRequestFocus: false,
|
||||||
color: isDark ? AppColors.darkSurfaceCard : Colors.white,
|
skipTraversal: true,
|
||||||
borderRadius: BorderRadius.circular(12),
|
descendantsAreFocusable: false,
|
||||||
child: Container(
|
child: Material(
|
||||||
constraints: BoxConstraints(maxHeight: halfScreenHeight),
|
elevation: 4,
|
||||||
decoration: BoxDecoration(
|
color: isDark ? AppColors.darkSurfaceCard : Colors.white,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(
|
child: Container(
|
||||||
color: isDark ? AppColors.darkBorder : Colors.grey.shade200,
|
constraints: BoxConstraints(maxHeight: halfScreenHeight),
|
||||||
),
|
decoration: BoxDecoration(
|
||||||
),
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: Column(
|
border: Border.all(
|
||||||
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: () {
|
|
||||||
ref
|
|
||||||
.read(searchHistoryProvider.notifier)
|
|
||||||
.clearHistory();
|
|
||||||
_removeOverlay();
|
|
||||||
if (!_focusNode.hasFocus) {
|
|
||||||
_focusNode.requestFocus();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 6,
|
|
||||||
vertical: 2,
|
|
||||||
),
|
|
||||||
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,
|
color: isDark ? AppColors.darkBorder : Colors.grey.shade200,
|
||||||
),
|
),
|
||||||
Flexible(
|
),
|
||||||
child: ListView.builder(
|
child: Column(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
mainAxisSize: MainAxisSize.min,
|
||||||
shrinkWrap: true,
|
children: [
|
||||||
itemCount: displayList.length,
|
Padding(
|
||||||
itemBuilder: (context, index) {
|
padding: const EdgeInsets.fromLTRB(16, 10, 12, 6),
|
||||||
final term = displayList[index];
|
child: Row(
|
||||||
return Row(
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Text(
|
||||||
child: InkWell(
|
'Recent Searches',
|
||||||
borderRadius: BorderRadius.circular(8),
|
style: TextStyle(
|
||||||
canRequestFocus: false,
|
fontSize: 11,
|
||||||
onTap: () {
|
fontWeight: FontWeight.w600,
|
||||||
final selected = term;
|
color: isDark
|
||||||
_controller.text = selected;
|
? AppColors.darkTextMuted
|
||||||
_controller.selection = TextSelection.collapsed(
|
: Colors.grey.shade500,
|
||||||
offset: selected.length,
|
letterSpacing: 0.3,
|
||||||
);
|
),
|
||||||
setState(() {
|
),
|
||||||
_hasText = selected.isNotEmpty;
|
InkWell(
|
||||||
});
|
borderRadius: BorderRadius.circular(4),
|
||||||
ref
|
canRequestFocus: false,
|
||||||
.read(searchHistoryProvider.notifier)
|
onTap: () {
|
||||||
.addSearch(selected);
|
ref
|
||||||
widget.onChanged?.call(selected);
|
.read(searchHistoryProvider.notifier)
|
||||||
widget.onSubmitted?.call(selected);
|
.clearHistory();
|
||||||
_removeOverlay();
|
_removeOverlay();
|
||||||
_focusNode.unfocus();
|
if (!_focusNode.hasFocus) {
|
||||||
},
|
_focusNode.requestFocus();
|
||||||
child: Padding(
|
}
|
||||||
padding: const EdgeInsets.symmetric(
|
},
|
||||||
horizontal: 16,
|
child: Padding(
|
||||||
vertical: 9,
|
padding: const EdgeInsets.symmetric(
|
||||||
),
|
horizontal: 6,
|
||||||
child: Row(
|
vertical: 2,
|
||||||
children: [
|
),
|
||||||
Icon(
|
child: Text(
|
||||||
Icons.history,
|
'Clear all',
|
||||||
size: 18,
|
style: TextStyle(
|
||||||
color: isDark
|
fontSize: 11,
|
||||||
? AppColors.darkTextSecondary
|
fontWeight: FontWeight.w500,
|
||||||
: Colors.grey.shade400,
|
color: isDark
|
||||||
),
|
? AppColors.darkTextSecondary
|
||||||
const SizedBox(width: 12),
|
: Colors.grey.shade600,
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
term,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
color: isDark
|
|
||||||
? AppColors.darkTextPrimary
|
|
||||||
: Colors.black87,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
InkResponse(
|
),
|
||||||
radius: 16,
|
],
|
||||||
canRequestFocus: false,
|
),
|
||||||
onTap: () {
|
),
|
||||||
ref
|
Divider(
|
||||||
.read(searchHistoryProvider.notifier)
|
height: 1,
|
||||||
.removeSearch(term);
|
thickness: 1,
|
||||||
if (!_focusNode.hasFocus) {
|
color: isDark ? AppColors.darkBorder : Colors.grey.shade200,
|
||||||
_focusNode.requestFocus();
|
),
|
||||||
}
|
Flexible(
|
||||||
},
|
child: ListView.builder(
|
||||||
child: Padding(
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
padding: const EdgeInsets.symmetric(
|
shrinkWrap: true,
|
||||||
horizontal: 12,
|
itemCount: displayList.length,
|
||||||
vertical: 9,
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: Icon(
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(
|
||||||
Icons.close_rounded,
|
Icons.close_rounded,
|
||||||
size: 16,
|
size: 16,
|
||||||
color: isDark
|
color: isDark
|
||||||
? AppColors.darkTextMuted
|
? AppColors.darkTextMuted
|
||||||
: Colors.grey.shade400,
|
: Colors.grey.shade400,
|
||||||
),
|
),
|
||||||
|
splashRadius: 16,
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 10,
|
||||||
|
vertical: 8,
|
||||||
|
),
|
||||||
|
constraints: const BoxConstraints(),
|
||||||
|
tooltip: 'Remove',
|
||||||
|
onPressed: () {
|
||||||
|
ref
|
||||||
|
.read(searchHistoryProvider.notifier)
|
||||||
|
.removeSearch(term);
|
||||||
|
if (!_focusNode.hasFocus) {
|
||||||
|
_focusNode.requestFocus();
|
||||||
|
}
|
||||||
|
if (_overlayEntry != null) {
|
||||||
|
_overlayEntry!.markNeedsBuild();
|
||||||
|
}
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
);
|
||||||
);
|
},
|
||||||
},
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -408,6 +422,8 @@ class _AppSearchBarState extends ConsumerState<AppSearchBar> {
|
|||||||
!_isOverlayOpen &&
|
!_isOverlayOpen &&
|
||||||
widget.enableHistory) {
|
widget.enableHistory) {
|
||||||
_showOverlay();
|
_showOverlay();
|
||||||
|
} else if (_isOverlayOpen) {
|
||||||
|
_overlayEntry?.markNeedsBuild();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -443,11 +459,14 @@ class _AppSearchBarState extends ConsumerState<AppSearchBar> {
|
|||||||
},
|
},
|
||||||
child: TapRegion(
|
child: TapRegion(
|
||||||
groupId: _tapRegionGroupId,
|
groupId: _tapRegionGroupId,
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
onTapOutside: (event) {
|
onTapOutside: (event) {
|
||||||
if (_focusNode.hasFocus) {
|
if (!_isPointerOverOverlay) {
|
||||||
_focusNode.unfocus();
|
if (_focusNode.hasFocus) {
|
||||||
|
_focusNode.unfocus();
|
||||||
|
}
|
||||||
|
_removeOverlay();
|
||||||
}
|
}
|
||||||
_removeOverlay();
|
|
||||||
},
|
},
|
||||||
child: CompositedTransformTarget(
|
child: CompositedTransformTarget(
|
||||||
link: _layerLink,
|
link: _layerLink,
|
||||||
|
|||||||
@@ -161,4 +161,89 @@ void main() {
|
|||||||
// Overlay is dismissed
|
// Overlay is dismissed
|
||||||
expect(find.text('Recent Searches'), findsNothing);
|
expect(find.text('Recent Searches'), findsNothing);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('AppSearchBar inside showDialog supports clear all and item deletion', (tester) async {
|
||||||
|
final container = ProviderContainer();
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
|
container.read(searchHistoryProvider);
|
||||||
|
await tester.pump(const Duration(milliseconds: 100));
|
||||||
|
|
||||||
|
final searchController = TextEditingController();
|
||||||
|
final searchFocusNode = FocusNode();
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
UncontrolledProviderScope(
|
||||||
|
container: container,
|
||||||
|
child: MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: Builder(
|
||||||
|
builder: (context) {
|
||||||
|
return ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
barrierColor: Colors.black54,
|
||||||
|
builder: (ctx) {
|
||||||
|
return Align(
|
||||||
|
alignment: Alignment.topCenter,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 80, 16, 0),
|
||||||
|
child: Material(
|
||||||
|
child: AppSearchBar(
|
||||||
|
controller: searchController,
|
||||||
|
focusNode: searchFocusNode,
|
||||||
|
collapsible: false,
|
||||||
|
hint: 'Search students by LRN or Name...',
|
||||||
|
maxWidth: 600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
searchFocusNode.requestFocus();
|
||||||
|
},
|
||||||
|
child: const Text('Open Search Dialog'),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.text('Open Search Dialog'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Recent Searches'), findsOneWidget);
|
||||||
|
expect(find.text('test item 1'), findsOneWidget);
|
||||||
|
expect(find.text('test item 2'), findsOneWidget);
|
||||||
|
|
||||||
|
// Find the close buttons in the history list
|
||||||
|
final closeIcons = find.byIcon(Icons.close_rounded);
|
||||||
|
expect(closeIcons, findsWidgets);
|
||||||
|
|
||||||
|
// Tap the first history item's delete button
|
||||||
|
await tester.tap(closeIcons.first);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 200));
|
||||||
|
|
||||||
|
// Item 1 should be removed, item 2 should still be there, and overlay should remain open!
|
||||||
|
expect(container.read(searchHistoryProvider), isNot(contains('test item 1')));
|
||||||
|
expect(container.read(searchHistoryProvider), contains('test item 2'));
|
||||||
|
expect(find.text('Recent Searches'), findsOneWidget);
|
||||||
|
expect(find.text('test item 2'), findsOneWidget);
|
||||||
|
|
||||||
|
// Tap Clear all
|
||||||
|
await tester.tap(find.text('Clear all'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// All items cleared and overlay closed
|
||||||
|
expect(container.read(searchHistoryProvider), isEmpty);
|
||||||
|
expect(find.text('Recent Searches'), findsNothing);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
Reference in new issue
Block a user