diff --git a/.gitignore b/.gitignore index 3f8fcf83..2f069431 100644 --- a/.gitignore +++ b/.gitignore @@ -83,3 +83,8 @@ build/ # FVM Version Cache .fvm/ + +# Agent runtime notes (never committed) +AI_AGENT_CHANGES/ +AGENT.md +AGENTS.md diff --git a/lib/src/modules/common/search_list/integrated_search_textfield.dart b/lib/src/modules/common/search_list/integrated_search_textfield.dart index 867f0c1f..bdec5c54 100644 --- a/lib/src/modules/common/search_list/integrated_search_textfield.dart +++ b/lib/src/modules/common/search_list/integrated_search_textfield.dart @@ -18,6 +18,7 @@ class IntegratedSearchTextField extends StatefulWidget { required this.queryTextController, this.showCrossbutton = false, this.borderRadius = 5, + this.trailing, Key? key, }) : super(key: key); @@ -36,6 +37,8 @@ class IntegratedSearchTextField extends StatefulWidget { final VoidCallback? onMicTap; final double borderRadius; + final Widget? trailing; + // final SearchListBloc _searchListBloc=SearchListBloc(); @override @@ -49,95 +52,106 @@ class _IntegratedSearchTextFieldState extends State { FlutterTts _flutterTts = FlutterTts(); @override Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.all(8.0), - child: Card( - elevation: widget.elevation ?? 0, - child: TextField( - onTap: widget.onTap, - controller: widget.queryTextController, - style: TextStyle(color: Colors.black), - autofocus: widget.autoFocus ?? true, - textInputAction: widget.textInputAction, - keyboardType: widget.keyboardType, - onSubmitted: widget.onSubmitted, - decoration: InputDecoration( - filled: true, - fillColor: widget.bgColor, - prefixIcon: widget.prefixIcon, - isDense: true, - border: OutlineInputBorder( - borderSide: BorderSide.none, - borderRadius: BorderRadius.circular(widget.borderRadius), - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, - ), - hintText: widget.searchFieldLabel, - suffixIcon: Row( - children: [ - if (widget.showCrossbutton) - Container( - width: 18, - height: 18, - margin: EdgeInsets.fromLTRB(10, 10, 10, 10), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(9), - color: Colors.black.withOpacity(0.4), - ), - child: InkWell( - onTap: widget.queryTextController.clear, - child: Icon( - Icons.close, - size: 12, - color: Colors.white, - ), + final searchCard = Card( + elevation: widget.elevation ?? 0, + child: TextField( + onTap: widget.onTap, + controller: widget.queryTextController, + style: TextStyle(color: Colors.black), + autofocus: widget.autoFocus ?? true, + textInputAction: widget.textInputAction, + keyboardType: widget.keyboardType, + onSubmitted: widget.onSubmitted, + decoration: InputDecoration( + filled: true, + fillColor: widget.bgColor, + prefixIcon: widget.prefixIcon, + isDense: true, + border: OutlineInputBorder( + borderSide: BorderSide.none, + borderRadius: BorderRadius.circular(widget.borderRadius), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + hintText: widget.searchFieldLabel, + suffixIcon: Row( + children: [ + if (widget.showCrossbutton) + Container( + width: 18, + height: 18, + margin: EdgeInsets.fromLTRB(10, 10, 10, 10), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(9), + color: Colors.black.withOpacity(0.4), + ), + child: InkWell( + onTap: widget.queryTextController.clear, + child: Icon( + Icons.close, + size: 12, + color: Colors.white, ), ), - if (widget.searchThroughMic) - SizedBox( - width: 20, - child: IconButton( - onPressed: widget.onMicTap ?? - () async { - await _speakPrompt(); - await Future.delayed( - const Duration(seconds: 1), + ), + if (widget.searchThroughMic) + SizedBox( + width: 20, + child: IconButton( + onPressed: widget.onMicTap ?? + () async { + await _speakPrompt(); + await Future.delayed( + const Duration(seconds: 1), + ); + var available = await speech.initialize(); + if (available) { + await speech.listen( + onResult: (result) { + setState(() { + print(widget.queryTextController.text); + recognizedText = result.recognizedWords; + widget.queryTextController.text = + recognizedText; + }); + if (result.finalResult) { + speech.stop(); + } + }, ); - var available = await speech.initialize(); - if (available) { - await speech.listen( - onResult: (result) { - setState(() { - print(widget.queryTextController.text); - recognizedText = result.recognizedWords; - widget.queryTextController.text = - recognizedText; - }); - if (result.finalResult) { - speech.stop(); - } - }, - ); - } - }, - icon: Icon(Icons.mic), - ), + } + }, + icon: Icon(Icons.mic), ), - ], - ), - suffixIconConstraints: BoxConstraints( - maxHeight: 38, - maxWidth: 38, - ), - hintStyle: TextStyle( - color: Colors.black26, - ), + ), + ], + ), + suffixIconConstraints: BoxConstraints( + maxHeight: 38, + maxWidth: 38, + ), + hintStyle: TextStyle( + color: Colors.black26, ), ), ), ); + + return Padding( + key: const Key('integrated-search-textfield-padding'), + padding: const EdgeInsets.all(8.0), + child: widget.trailing == null + ? searchCard + : Row( + children: [ + Expanded(child: searchCard), + const SizedBox(width: 8), + widget.trailing!, + ], + ), + ); } Future _speakPrompt() async { diff --git a/lib/src/modules/common/search_list/search_list.dart b/lib/src/modules/common/search_list/search_list.dart index 8e90892f..36aa93a4 100644 --- a/lib/src/modules/common/search_list/search_list.dart +++ b/lib/src/modules/common/search_list/search_list.dart @@ -61,6 +61,7 @@ class SearchList extends StatefulWidget { this.showCrossbutton = false, this.bottomGradient, this.actionWidget, + this.searchBarTrailing, Key? key, }) : assert(!showDefaultAppBar ? textEditingController != null : true), super(key: key); @@ -112,6 +113,10 @@ class SearchList extends StatefulWidget { final LinearGradient? bottomGradient; final Widget? actionWidget; + /// Optional widget rendered beside the search field (e.g. a filter button). + /// Null by default, so every existing consumer renders byte-identically. + final Widget? searchBarTrailing; + @override _SearchListState createState() => _SearchListState(); } @@ -236,6 +241,7 @@ class _SearchListState extends State> { queryTextController: searchQueryController!, searchFieldLabel: widget.searchBarTitle ?? 'Search', showCrossbutton: widget.showCrossbutton, + trailing: widget.searchBarTrailing, ), Expanded(child: _child), ], @@ -290,6 +296,7 @@ class _SearchListState extends State> { widget.searchBarTitle ?? 'Search', showCrossbutton: widget.showCrossbutton, borderRadius: 14, + trailing: widget.searchBarTrailing, ), ], ), diff --git a/test/integrated_search_textfield_test.dart b/test/integrated_search_textfield_test.dart new file mode 100644 index 00000000..aab15709 --- /dev/null +++ b/test/integrated_search_textfield_test.dart @@ -0,0 +1,59 @@ +// [REQ-177] IntegratedSearchTextField.trailing: an optional widget rendered beside the +// search field. Null by default, so an existing consumer's widget tree is unchanged. +import 'package:fa_flutter_ui_kit/src/modules/common/search_list/integrated_search_textfield.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + Widget wrap(Widget child) => MaterialApp(home: Scaffold(body: child)); + + testWidgets( + 'renders the Card directly (no wrapping Row) when trailing is null', + (tester) async { + await tester.pumpWidget( + wrap( + IntegratedSearchTextField( + searchFieldLabel: 'Search', + queryTextController: TextEditingController(), + ), + ), + ); + + // find.byKey('search-trailing') can never fail here since that + // key is only ever attached by a caller-supplied trailing widget — assert the actual + // structural invariant instead: the outer Padding's child is the Card itself, not a Row. + final padding = tester.widget( + find.byKey(const Key('integrated-search-textfield-padding'))); + expect(padding.child, isA()); + expect(find.byType(TextField), findsOneWidget); + }); + + testWidgets( + 'renders the trailing widget beside the search field, inside a Row, when supplied', + (tester) async { + await tester.pumpWidget( + wrap( + IntegratedSearchTextField( + searchFieldLabel: 'Search', + queryTextController: TextEditingController(), + trailing: const Icon(Icons.filter_list, key: Key('search-trailing')), + ), + ), + ); + + expect(find.byKey(const Key('search-trailing')), findsOneWidget); + expect(find.byType(TextField), findsOneWidget); + + // find.byType(Row) alone also matches the suffixIcon Row inside + // the TextField's decoration, so it passes even if `trailing` were placed elsewhere. + // Assert the actual wiring: the outer Padding's child is a Row whose children are + // Expanded(searchCard) followed by the trailing widget itself. + final padding = tester.widget( + find.byKey(const Key('integrated-search-textfield-padding'))); + expect(padding.child, isA()); + final row = padding.child! as Row; + expect(row.children.first, isA()); + expect((row.children.first as Expanded).child, isA()); + expect(row.children.last.key, const Key('search-trailing')); + }); +} diff --git a/test/search_list_test.dart b/test/search_list_test.dart new file mode 100644 index 00000000..ede9c47c --- /dev/null +++ b/test/search_list_test.dart @@ -0,0 +1,39 @@ +// SearchList.searchBarTrailing is plumbed to IntegratedSearchTextField +// on both the SearchBarInBody and SearchWithAppBar code paths, but neither was pumped by a +// widget test. Covers both here. +import 'package:fa_flutter_ui_kit/src/modules/common/search_list/search_list.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + Widget buildSearchList(SearchListType type) { + return MaterialApp( + home: SearchList( + data: const ['Alpha', 'Beta'], + selectedItem: (_) {}, + itemBuilder: (item, isSelected) => Text(item), + type: type, + searchBarTrailing: + const Icon(Icons.filter_list, key: Key('search-trailing')), + ), + ); + } + + testWidgets( + 'SearchBarInBody renders searchBarTrailing beside the search field', + (tester) async { + await tester.pumpWidget(buildSearchList(SearchListType.SearchBarInBody)); + await tester.pump(); + + expect(find.byKey(const Key('search-trailing')), findsOneWidget); + }); + + testWidgets( + 'SearchWithAppBar renders searchBarTrailing beside the search field', + (tester) async { + await tester.pumpWidget(buildSearchList(SearchListType.SearchWithAppBar)); + await tester.pump(); + + expect(find.byKey(const Key('search-trailing')), findsOneWidget); + }); +}