Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .claude/skills/themed-table-2/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down Expand Up @@ -130,6 +131,18 @@ ThemedTable2<Asset>(
columns: [ /* ... */ ],
)

// With filtered count callback
ThemedTable2<Asset>(
items: _items,
canSearch: true,
actionsCount: 0,
hasMultiselect: false,
onFilteredCountChanged: (count) {
setState(() => _visibleCount = count);
},
columns: [ /* ... */ ],
)

// With programmatic controller
final _controller = ThemedTable2Controller<Asset>();

Expand Down
2 changes: 2 additions & 0 deletions .claude/skills/themed-table-2/references/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ const ThemedTable2({
this.onTapDefaultBehavior = .copyToClipboard,
this.copyToClipboardText,
this.controller,
this.onFilteredCountChanged,
})
// Asserts:
// - columns.length > 0
Expand All @@ -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<T>?` | `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 |
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Changelog

## 7.5.29

- Added `onFilteredCountChanged: void Function(int count)?` to `ThemedTable2<T>`. 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.
Expand Down
6 changes: 6 additions & 0 deletions lib/src/inputs/src/pickers/general/color.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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",
Expand Down
13 changes: 13 additions & 0 deletions lib/src/table2/src/table.dart
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,11 @@ class ThemedTable2<T> extends StatefulWidget {
/// [controller] is an optional controller to programmatically control the table.
final ThemedTable2Controller<T>? 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,
Expand All @@ -102,6 +107,7 @@ class ThemedTable2<T> 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'),
Expand Down Expand Up @@ -209,6 +215,7 @@ class _ThemedTable2State<T> extends State<ThemedTable2<T>> {
_selectedItems.addListener(_syncSelectedSet);

widget.controller?.addListener(_onControllerEvent);
_filteredData.addListener(_onFilteredDataChanged);

WidgetsBinding.instance.addPostFrameCallback((_) {
_filterAndSort('INIT_STATE');
Expand Down Expand Up @@ -295,6 +302,7 @@ class _ThemedTable2State<T> extends State<ThemedTable2<T>> {
@override
void dispose() {
_isLoading.dispose();
_filteredData.removeListener(_onFilteredDataChanged);
_filteredData.dispose();
_searchController.dispose();

Expand All @@ -320,6 +328,11 @@ class _ThemedTable2State<T> extends State<ThemedTable2<T>> {
..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<T>) {
final columnIndex = event.columnIndex;
Expand Down
2 changes: 1 addition & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
@@ -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

Expand Down
154 changes: 154 additions & 0 deletions test/widgets/table2_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ Widget _buildTable(
List<ThemedColumn2<_Item>>? columns,
bool canSearch = false,
ThemedTable2Controller<_Item>? controller,
void Function(int count)? onFilteredCountChanged,
}) {
return MaterialApp(
home: Scaffold(
Expand All @@ -84,6 +85,7 @@ Widget _buildTable(
canSearch: canSearch,
populateDelay: Duration.zero,
controller: controller,
onFilteredCountChanged: onFilteredCountChanged,
columns: columns ??
[
ThemedColumn2<_Item>(
Expand Down Expand Up @@ -548,5 +550,157 @@ void main() {
},
);
});

// ─────────────────────────────────────────────
// onFilteredCountChanged
// ─────────────────────────────────────────────
group('onFilteredCountChanged', () {
testWidgets('fires with full count on initial load', (tester) async {
final counts = <int>[];
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 = <int>[];

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 = <int>[];
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 = <int>[];
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 = <int>[];
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 = <int>[];
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));
});
});
});
}
Loading