diff --git a/.claude/skills/themed-table-2/SKILL.md b/.claude/skills/themed-table-2/SKILL.md index d1a854c..da0b6c0 100644 --- a/.claude/skills/themed-table-2/SKILL.md +++ b/.claude/skills/themed-table-2/SKILL.md @@ -49,6 +49,7 @@ Always wrap in `Expanded` or give a fixed height — `ThemedTable2` requires bou - `actionsCount` must equal the number of buttons `actionsBuilder` actually returns — assert enforced. - `hasMultiselect: true` requires at least one entry in `multiselectActions` — assert enforced. - `onTapDefaultBehavior` defaults to `.copyToClipboard`; set to `.none` to disable. +- `onFilteredCountChanged` fires after every `_filterAndSort` cycle (initial load, search, sort, `items` update) with the visible row count. Fires with `0` for an empty dataset. Optional — `null` by default, no overhead when omitted. --- @@ -130,6 +131,18 @@ ThemedTable2( columns: [ /* ... */ ], ) +// With filtered count callback +ThemedTable2( + items: _items, + canSearch: true, + actionsCount: 0, + hasMultiselect: false, + onFilteredCountChanged: (count) { + setState(() => _visibleCount = count); + }, + columns: [ /* ... */ ], +) + // With programmatic controller final _controller = ThemedTable2Controller(); diff --git a/.claude/skills/themed-table-2/references/api.md b/.claude/skills/themed-table-2/references/api.md index 72f95c1..549bcbb 100644 --- a/.claude/skills/themed-table-2/references/api.md +++ b/.claude/skills/themed-table-2/references/api.md @@ -96,6 +96,7 @@ const ThemedTable2({ this.onTapDefaultBehavior = .copyToClipboard, this.copyToClipboardText, this.controller, + this.onFilteredCountChanged, }) // Asserts: // - columns.length > 0 @@ -121,6 +122,7 @@ const ThemedTable2({ | `canSearch` | `bool` | `true` | Shows search input above the table | | `onTapDefaultBehavior` | `ThemedTable2OnTapBehavior` | `.copyToClipboard` | Cell tap behavior when no `onTap` on column | | `controller` | `ThemedTable2Controller?` | `null` | Programmatic sort/refresh | +| `onFilteredCountChanged` | `void Function(int count)?` | `null` | Called after each filter+sort cycle with the visible row count | | `populateDelay` | `Duration` | `150ms` | Delay before rendering data | | `minColumnWidth` | `double` | `250` | Minimum width for flex columns | | `headerHeight` | `double` | `40` | Header row height | diff --git a/CHANGELOG.md b/CHANGELOG.md index 8029e9c..ba5b28f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 7.5.29 + +- Added `onFilteredCountChanged: void Function(int count)?` to `ThemedTable2`. The callback fires after every filter-and-sort cycle (initial load, search, sort, `items` update) with the count of currently visible rows. Optional and `null` by default — fully backward-compatible. +- Restored `prefixIcon` parameter in `ThemedColorPicker` as `@Deprecated`. The parameter was removed in 7.5.27 but is still referenced in downstream packages. It is now accepted (and ignored) to restore build compatibility while consumers migrate. + ## 7.5.28 - Deprecated `ThemedTable`, `ThemedColumn`, `ThemedTableAction`, `ThemedTableAvatar`, and related typedefs (`ValueBuilder`, `WidgetBuilder`, `CellTap`, `CellColor`, `ValueBuilder2`, `kThemedTableCanTrue`). Use `ThemedTable2` instead. All symbols will be removed in version 8.0.0. diff --git a/lib/src/inputs/src/pickers/general/color.dart b/lib/src/inputs/src/pickers/general/color.dart index c5d2a2d..1350336 100644 --- a/lib/src/inputs/src/pickers/general/color.dart +++ b/lib/src/inputs/src/pickers/general/color.dart @@ -30,6 +30,10 @@ class ThemedColorPicker extends StatefulWidget { /// [dense] is the state of the input being dense. final bool dense; + /// [prefixIcon] is the prefix icon of the input. + @Deprecated('prefixIcon is no longer used in ThemedColorPicker') + final IconData? prefixIcon; + /// [onPrefixTap] is the callback function when the prefix is tapped. final VoidCallback? onPrefixTap; @@ -84,6 +88,8 @@ class ThemedColorPicker extends StatefulWidget { this.hideDetails = false, this.padding, this.dense = false, + // ignore: deprecated_member_use_from_same_package + this.prefixIcon, this.onPrefixTap, this.placeholder, this.saveText = "OK", diff --git a/lib/src/table2/src/table.dart b/lib/src/table2/src/table.dart index 036f60f..389eec5 100644 --- a/lib/src/table2/src/table.dart +++ b/lib/src/table2/src/table.dart @@ -79,6 +79,11 @@ class ThemedTable2 extends StatefulWidget { /// [controller] is an optional controller to programmatically control the table. final ThemedTable2Controller? controller; + /// [onFilteredCountChanged] is called whenever the number of rows currently + /// displayed in the table changes (after filtering and sorting completes). + /// The integer argument is the new filtered row count. + final void Function(int count)? onFilteredCountChanged; + const ThemedTable2({ required this.items, required this.columns, @@ -102,6 +107,7 @@ class ThemedTable2 extends StatefulWidget { this.onTapDefaultBehavior = .copyToClipboard, this.copyToClipboardText, this.controller, + this.onFilteredCountChanged, }) : assert(columns.length > 0, 'Columns cant be empty'), assert(actionsCount >= 0, 'Actions count cant be negative'), assert(minColumnWidth > 0, 'Min column width must be greater than 0'), @@ -209,6 +215,7 @@ class _ThemedTable2State extends State> { _selectedItems.addListener(_syncSelectedSet); widget.controller?.addListener(_onControllerEvent); + _filteredData.addListener(_onFilteredDataChanged); WidgetsBinding.instance.addPostFrameCallback((_) { _filterAndSort('INIT_STATE'); @@ -295,6 +302,7 @@ class _ThemedTable2State extends State> { @override void dispose() { _isLoading.dispose(); + _filteredData.removeListener(_onFilteredDataChanged); _filteredData.dispose(); _searchController.dispose(); @@ -320,6 +328,11 @@ class _ThemedTable2State extends State> { ..addAll(_selectedItems.value); } + /// Called when [_filteredData] changes; forwards the new count to [widget.onFilteredCountChanged]. + void _onFilteredDataChanged() { + widget.onFilteredCountChanged?.call(_filteredData.value.length); + } + void _onControllerEvent(ThemedTable2Event event) { if (event is ThemedTable2SortEvent) { final columnIndex = event.columnIndex; diff --git a/pubspec.yaml b/pubspec.yaml index be190b5..366144d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: layrz_theme description: Layrz standard styling library for Flutter. Widget library following the Material Design 3 guidelines, with a focus on reliavility and functionality. -version: "7.5.28" +version: "7.5.29" homepage: https://theme.layrz.com repository: https://github.com/goldenm-software/layrz_theme diff --git a/test/widgets/table2_test.dart b/test/widgets/table2_test.dart index 685779a..ec70470 100644 --- a/test/widgets/table2_test.dart +++ b/test/widgets/table2_test.dart @@ -71,6 +71,7 @@ Widget _buildTable( List>? columns, bool canSearch = false, ThemedTable2Controller<_Item>? controller, + void Function(int count)? onFilteredCountChanged, }) { return MaterialApp( home: Scaffold( @@ -84,6 +85,7 @@ Widget _buildTable( canSearch: canSearch, populateDelay: Duration.zero, controller: controller, + onFilteredCountChanged: onFilteredCountChanged, columns: columns ?? [ ThemedColumn2<_Item>( @@ -548,5 +550,157 @@ void main() { }, ); }); + + // ───────────────────────────────────────────── + // onFilteredCountChanged + // ───────────────────────────────────────────── + group('onFilteredCountChanged', () { + testWidgets('fires with full count on initial load', (tester) async { + final counts = []; + final items = [ + const _Item(id: '1', name: 'Alpha', secondary: ''), + const _Item(id: '2', name: 'Beta', secondary: ''), + const _Item(id: '3', name: 'Gamma', secondary: ''), + ]; + + await tester.pumpWidget(_buildTable(items, onFilteredCountChanged: counts.add)); + await tester.pump(); + await _waitForCompute(tester); + + expect(counts.last, equals(3)); + }); + + testWidgets('null callback does not throw', (tester) async { + final items = [const _Item(id: '1', name: 'Alpha', secondary: '')]; + + await tester.pumpWidget(_buildTable(items)); + await tester.pump(); + await _waitForCompute(tester); + + expect(tester.takeException(), isNull); + }); + + testWidgets('fires with 0 on empty items list', (tester) async { + final counts = []; + + await tester.pumpWidget(_buildTable([], onFilteredCountChanged: counts.add)); + await tester.pump(); + await _waitForCompute(tester); + + expect(counts.last, equals(0)); + }); + + testWidgets('fires filtered count after search narrows results', (tester) async { + final counts = []; + final items = [ + const _Item(id: '1', name: 'Apple', secondary: ''), + const _Item(id: '2', name: 'Banana', secondary: ''), + const _Item(id: '3', name: 'Apricot', secondary: ''), + ]; + + await tester.pumpWidget( + _buildTable(items, canSearch: true, onFilteredCountChanged: counts.add), + ); + await tester.pump(); + await _waitForCompute(tester); + expect(counts.last, equals(3)); + + // 'Ap' matches Apple and Apricot (both start with 'Ap'), but not Banana. + await tester.enterText(find.byType(TextField), 'Ap'); + await tester.pump(const Duration(milliseconds: 700)); + await _waitForCompute(tester); + + expect(counts.last, equals(2)); + }); + + testWidgets('fires updated count when items list grows via didUpdateWidget', (tester) async { + final counts = []; + List<_Item> items = [const _Item(id: '1', name: 'First', secondary: '')]; + late StateSetter rebuildState; + + await tester.pumpWidget( + StatefulBuilder( + builder: (context, setState) { + rebuildState = setState; + return MaterialApp( + home: Scaffold( + body: SizedBox( + height: 600, + width: 800, + child: ThemedTable2<_Item>( + items: items, + actionsCount: 0, + hasMultiselect: false, + canSearch: false, + populateDelay: Duration.zero, + onFilteredCountChanged: counts.add, + columns: [ + ThemedColumn2<_Item>( + headerText: 'Name', + valueBuilder: (item) => item.name, + ), + ], + ), + ), + ), + ); + }, + ), + ); + await tester.pump(); + await _waitForCompute(tester); + expect(counts.last, equals(1)); + + rebuildState(() { + items = [ + const _Item(id: '1', name: 'First', secondary: ''), + const _Item(id: '2', name: 'Second', secondary: ''), + ]; + }); + await tester.pump(); + await _waitForCompute(tester); + + expect(counts.last, equals(2)); + }); + + testWidgets('fires with same count after controller.sort', (tester) async { + final counts = []; + final controller = ThemedTable2Controller<_Item>(); + addTearDown(controller.dispose); + + final items = [ + const _Item(id: '1', name: 'Zebra', secondary: ''), + const _Item(id: '2', name: 'Apple', secondary: ''), + ]; + + await tester.pumpWidget( + _buildTable(items, controller: controller, onFilteredCountChanged: counts.add), + ); + await tester.pump(); + await _waitForCompute(tester); + final countAfterInit = counts.last; + + controller.sort(columnIndex: 0, ascending: true); + await _waitForCompute(tester); + + expect(counts.last, equals(countAfterInit)); + expect(counts.last, equals(2)); + }); + + testWidgets('does not fire after widget is disposed', (tester) async { + final counts = []; + final items = [const _Item(id: '1', name: 'Alpha', secondary: '')]; + + await tester.pumpWidget(_buildTable(items, onFilteredCountChanged: counts.add)); + await tester.pump(); + await _waitForCompute(tester); + final countAfterMount = counts.length; + + await tester.pumpWidget(const MaterialApp(home: SizedBox.shrink())); + await tester.pump(); + + expect(counts.length, equals(countAfterMount)); + }); + }); }); }