diff --git a/lib/features/main_screen/data_grid_groupings_view.dart b/lib/features/main_screen/data_grid_groupings_view.dart new file mode 100644 index 0000000..1dbf069 --- /dev/null +++ b/lib/features/main_screen/data_grid_groupings_view.dart @@ -0,0 +1,391 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart'; +import 'package:querya_desktop/features/main_screen/grid_groupings_engine.dart'; +import 'package:querya_desktop/features/main_screen/result_grid_view.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// Groupings / Pivot view tab for tabular data with hierarchical grouping and custom aggregations. +class DataGridGroupingsView extends material.StatefulWidget { + const DataGridGroupingsView({ + super.key, + required this.columns, + required this.rows, + }); + + final List columns; + final List> rows; + + @override + material.State createState() => + _DataGridGroupingsViewState(); +} + +class _DataGridGroupingsViewState + extends material.State { + late List _selectedColIndices; + GroupingAggType _aggType = GroupingAggType.count; + int? _aggTargetColIndex; + GroupSortBy _sortBy = GroupSortBy.count; + bool _sortAscending = false; + final Set _expandedKeys = {}; + + @override + void initState() { + super.initState(); + _selectedColIndices = widget.columns.isNotEmpty ? [0] : []; + if (widget.columns.length > 1) { + // Pick first numeric-looking column as default target for sum/avg if available + _aggTargetColIndex = 1; + } + } + + @override + void didUpdateWidget(covariant DataGridGroupingsView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.columns != widget.columns) { + if (_selectedColIndices.isEmpty && widget.columns.isNotEmpty) { + _selectedColIndices = [0]; + } else { + _selectedColIndices.removeWhere((idx) => idx >= widget.columns.length); + if (_selectedColIndices.isEmpty && widget.columns.isNotEmpty) { + _selectedColIndices = [0]; + } + } + } + } + + void _exportPivot() { + final groups = GridGroupingsEngine.buildGroups( + groupColIndices: _selectedColIndices, + rows: widget.rows, + aggConfig: GroupAggregationConfig( + aggType: _aggType, + targetColIndex: _aggTargetColIndex, + ), + sortBy: _sortBy, + sortAscending: _sortAscending, + ); + + final groupName = _selectedColIndices.isNotEmpty + ? widget.columns[_selectedColIndices.first] + : 'Group'; + final csv = GridGroupingsEngine.exportPivotToCsv( + groups: groups, + groupByColumnName: groupName, + aggConfig: GroupAggregationConfig( + aggType: _aggType, + targetColIndex: _aggTargetColIndex, + ), + ); + + Clipboard.setData(ClipboardData(text: csv)); + } + + @override + material.Widget build(material.BuildContext context) { + if (widget.columns.isEmpty || widget.rows.isEmpty) { + return material.Center( + child: const Text('No data available for grouping.').muted(), + ); + } + + final cs = Theme.of(context).colorScheme; + final groups = GridGroupingsEngine.buildGroups( + groupColIndices: _selectedColIndices, + rows: widget.rows, + aggConfig: GroupAggregationConfig( + aggType: _aggType, + targetColIndex: _aggTargetColIndex, + ), + sortBy: _sortBy, + sortAscending: _sortAscending, + ); + + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + // Top Toolbar for selecting Group By, Aggregation, and Sorting + material.Container( + height: 40, + padding: const material.EdgeInsets.symmetric(horizontal: 10), + decoration: material.BoxDecoration( + color: cs.card, + border: material.Border( + bottom: material.BorderSide( + color: cs.border.withValues(alpha: 0.35), + width: 1, + ), + ), + ), + child: material.SingleChildScrollView( + scrollDirection: material.Axis.horizontal, + child: material.Row( + children: [ + material.Icon( + material.Icons.account_tree_outlined, + size: 15, + color: cs.primary, + ), + const Gap(6), + const Text('Group:').small().semiBold(), + const Gap(6), + material.DropdownButton( + value: _selectedColIndices.isNotEmpty && + _selectedColIndices.first < widget.columns.length + ? _selectedColIndices.first + : 0, + isDense: true, + underline: const material.SizedBox.shrink(), + style: TextStyle(fontSize: 12, color: cs.foreground), + items: List.generate(widget.columns.length, (i) { + return material.DropdownMenuItem( + value: i, + child: Text(widget.columns[i]), + ); + }), + onChanged: (idx) { + if (idx != null) { + setState(() { + _selectedColIndices = [idx]; + _expandedKeys.clear(); + }); + } + }, + ), + + const Gap(12), + material.VerticalDivider( + width: 1, + thickness: 1, + indent: 8, + endIndent: 8, + color: cs.border.withValues(alpha: 0.3), + ), + const Gap(12), + + // Aggregation Selector + const Text('Agg:').small().semiBold(), + const Gap(6), + material.DropdownButton( + value: _aggType, + isDense: true, + underline: const material.SizedBox.shrink(), + style: TextStyle(fontSize: 12, color: cs.foreground), + items: GroupingAggType.values.map((t) { + return material.DropdownMenuItem( + value: t, + child: Text(t.label), + ); + }).toList(), + onChanged: (val) { + if (val != null) { + setState(() => _aggType = val); + } + }, + ), + if (_aggType != GroupingAggType.count) ...[ + const Gap(4), + material.DropdownButton( + value: _aggTargetColIndex != null && + _aggTargetColIndex! < widget.columns.length + ? _aggTargetColIndex + : 0, + isDense: true, + underline: const material.SizedBox.shrink(), + style: TextStyle(fontSize: 12, color: cs.foreground), + items: List.generate(widget.columns.length, (i) { + return material.DropdownMenuItem( + value: i, + child: Text(widget.columns[i]), + ); + }), + onChanged: (idx) { + if (idx != null) { + setState(() => _aggTargetColIndex = idx); + } + }, + ), + ], + + const Gap(12), + material.VerticalDivider( + width: 1, + thickness: 1, + indent: 8, + endIndent: 8, + color: cs.border.withValues(alpha: 0.3), + ), + const Gap(12), + + // Sort Selector + const Text('Sort:').small().semiBold(), + const Gap(6), + material.DropdownButton( + value: _sortBy, + isDense: true, + underline: const material.SizedBox.shrink(), + style: TextStyle(fontSize: 12, color: cs.foreground), + items: GroupSortBy.values.map((s) { + return material.DropdownMenuItem( + value: s, + child: Text(s.label), + ); + }).toList(), + onChanged: (val) { + if (val != null) { + setState(() => _sortBy = val); + } + }, + ), + material.IconButton( + icon: material.Icon( + _sortAscending + ? material.Icons.arrow_upward_rounded + : material.Icons.arrow_downward_rounded, + size: 14, + ), + padding: material.EdgeInsets.zero, + constraints: const material.BoxConstraints(minWidth: 24, minHeight: 24), + color: cs.mutedForeground, + onPressed: () => setState(() => _sortAscending = !_sortAscending), + ), + + const Gap(8), + material.Tooltip( + message: 'Copy Pivot CSV to Clipboard', + child: material.IconButton( + icon: const material.Icon(material.Icons.copy_rounded, size: 14), + padding: material.EdgeInsets.zero, + constraints: const material.BoxConstraints(minWidth: 24, minHeight: 24), + color: cs.mutedForeground, + onPressed: _exportPivot, + ), + ), + ], + ), + ), + ), + + // Groupings List + material.Expanded( + child: material.ListView.separated( + itemCount: groups.length, + separatorBuilder: (_, __) => material.Divider( + height: 1, + color: cs.border.withValues(alpha: 0.2), + ), + itemBuilder: (context, idx) { + final group = groups[idx]; + final isExpanded = _expandedKeys.contains(group.groupKey); + + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.InkWell( + onTap: () { + setState(() { + if (isExpanded) { + _expandedKeys.remove(group.groupKey); + } else { + _expandedKeys.add(group.groupKey); + } + }); + }, + child: material.Padding( + padding: const material.EdgeInsets.symmetric( + horizontal: 14, + vertical: 8, + ), + child: material.Row( + children: [ + material.Icon( + isExpanded + ? material.Icons.keyboard_arrow_down_rounded + : material.Icons.keyboard_arrow_right_rounded, + size: 18, + color: cs.mutedForeground, + ), + const Gap(8), + material.Expanded( + child: Text( + group.groupKey, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + fontFamily: 'monospace', + ), + ), + ), + if (group.aggValue != null && _aggType != GroupingAggType.count) ...[ + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + margin: const material.EdgeInsets.only(right: 6), + decoration: material.BoxDecoration( + color: cs.secondary.withValues(alpha: 0.3), + borderRadius: material.BorderRadius.circular(6), + ), + child: Text( + '${_aggType.label}: ${group.aggValue!.toStringAsFixed(2)}', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: cs.foreground, + ), + ), + ), + ], + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: material.BoxDecoration( + color: cs.primary.withValues(alpha: 0.15), + borderRadius: material.BorderRadius.circular(10), + ), + child: Text( + '${group.count} rows (${group.percentage.toStringAsFixed(1)}%)', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: cs.primary, + ), + ), + ), + ], + ), + ), + ), + + // Expanded sub-grid + if (isExpanded) + material.Container( + height: 220, + margin: const material.EdgeInsets.only( + left: 28, + right: 12, + bottom: 8, + ), + decoration: material.BoxDecoration( + border: material.Border.all( + color: cs.border.withValues(alpha: 0.4), + ), + borderRadius: material.BorderRadius.circular(6), + ), + child: VirtualResultGrid( + columns: widget.columns, + rows: group.rows, + ), + ), + ], + ); + }, + ), + ), + ], + ); + } +} diff --git a/lib/features/main_screen/grid_groupings_engine.dart b/lib/features/main_screen/grid_groupings_engine.dart new file mode 100644 index 0000000..e117df1 --- /dev/null +++ b/lib/features/main_screen/grid_groupings_engine.dart @@ -0,0 +1,239 @@ +import 'package:flutter/foundation.dart'; + +/// Aggregation operation to perform on groups. +enum GroupingAggType { + count('COUNT'), + sum('SUM'), + avg('AVG'), + min('MIN'), + max('MAX'); + + const GroupingAggType(this.label); + final String label; +} + +/// Sort criteria for grouping categories. +enum GroupSortBy { + count('Count'), + key('Group Key'), + aggregate('Aggregate'); + + const GroupSortBy(this.label); + final String label; +} + +/// Configuration for group aggregations. +@immutable +class GroupAggregationConfig { + const GroupAggregationConfig({ + this.aggType = GroupingAggType.count, + this.targetColIndex, + }); + + final GroupingAggType aggType; + final int? targetColIndex; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is GroupAggregationConfig && + aggType == other.aggType && + targetColIndex == other.targetColIndex; + + @override + int get hashCode => Object.hash(aggType, targetColIndex); +} + +/// Represents an aggregated group in Groupings / Pivot View (supports nested sub-groups). +@immutable +class GroupedCategory { + const GroupedCategory({ + required this.groupKey, + required this.count, + required this.percentage, + required this.rows, + this.aggValue, + this.subGroups = const [], + this.level = 0, + }); + + final String groupKey; + final int count; + final double percentage; + final List> rows; + final double? aggValue; + final List subGroups; + final int level; + + bool get hasSubGroups => subGroups.isNotEmpty; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is GroupedCategory && + groupKey == other.groupKey && + count == other.count && + percentage == other.percentage && + aggValue == other.aggValue && + level == other.level; + + @override + int get hashCode => Object.hash(groupKey, count, percentage, aggValue, level); +} + +/// Engine to construct multi-column pivot / hierarchical grouping breakdown tables. +abstract final class GridGroupingsEngine { + /// Builds multi-level groups by [groupColIndices] with optional aggregation and sorting. + static List buildGroups({ + required List groupColIndices, + required List> rows, + GroupAggregationConfig aggConfig = const GroupAggregationConfig(), + GroupSortBy sortBy = GroupSortBy.count, + bool sortAscending = false, + }) { + if (rows.isEmpty || groupColIndices.isEmpty) return const []; + + return _buildSubGroups( + groupColIndices: groupColIndices, + levelIndex: 0, + rows: rows, + totalRootRows: rows.length, + aggConfig: aggConfig, + sortBy: sortBy, + sortAscending: sortAscending, + ); + } + + static List _buildSubGroups({ + required List groupColIndices, + required int levelIndex, + required List> rows, + required int totalRootRows, + required GroupAggregationConfig aggConfig, + required GroupSortBy sortBy, + required bool sortAscending, + }) { + if (levelIndex >= groupColIndices.length || rows.isEmpty) return const []; + + final colIndex = groupColIndices[levelIndex]; + final map = >>{}; + + for (final row in rows) { + final key = colIndex < row.length ? row[colIndex] : 'NULL'; + final effectiveKey = key.isEmpty ? '(Empty)' : key; + map.putIfAbsent(effectiveKey, () => []).add(row); + } + + final categories = []; + final hasNextLevel = levelIndex + 1 < groupColIndices.length; + + map.forEach((key, categoryRows) { + final count = categoryRows.length; + final pct = totalRootRows > 0 ? (count / totalRootRows) * 100 : 0.0; + final agg = _computeAggregation(categoryRows, aggConfig); + + List subGroups = const []; + if (hasNextLevel) { + subGroups = _buildSubGroups( + groupColIndices: groupColIndices, + levelIndex: levelIndex + 1, + rows: categoryRows, + totalRootRows: totalRootRows, + aggConfig: aggConfig, + sortBy: sortBy, + sortAscending: sortAscending, + ); + } + + categories.add( + GroupedCategory( + groupKey: key, + count: count, + percentage: pct, + rows: categoryRows, + aggValue: agg, + subGroups: subGroups, + level: levelIndex, + ), + ); + }); + + // Sorting + categories.sort((a, b) { + int cmp; + switch (sortBy) { + case GroupSortBy.count: + cmp = a.count.compareTo(b.count); + break; + case GroupSortBy.key: + cmp = a.groupKey.compareTo(b.groupKey); + break; + case GroupSortBy.aggregate: + final aVal = a.aggValue ?? (a.count.toDouble()); + final bVal = b.aggValue ?? (b.count.toDouble()); + cmp = aVal.compareTo(bVal); + break; + } + return sortAscending ? cmp : -cmp; + }); + + return categories; + } + + static double? _computeAggregation( + List> rows, + GroupAggregationConfig config, + ) { + if (config.aggType == GroupingAggType.count) { + return rows.length.toDouble(); + } + if (config.targetColIndex == null) return null; + + final targetCol = config.targetColIndex!; + final numbers = []; + + for (final row in rows) { + if (targetCol < row.length) { + final val = row[targetCol].replaceAll(',', '').trim(); + final parsed = double.tryParse(val); + if (parsed != null && !parsed.isNaN && !parsed.isInfinite) { + numbers.add(parsed); + } + } + } + + if (numbers.isEmpty) return null; + + switch (config.aggType) { + case GroupingAggType.count: + return numbers.length.toDouble(); + case GroupingAggType.sum: + return numbers.reduce((a, b) => a + b); + case GroupingAggType.avg: + return numbers.reduce((a, b) => a + b) / numbers.length; + case GroupingAggType.min: + return numbers.reduce((a, b) => a < b ? a : b); + case GroupingAggType.max: + return numbers.reduce((a, b) => a > b ? a : b); + } + } + + /// Exports pivot summary to CSV format. + static String exportPivotToCsv({ + required List groups, + required String groupByColumnName, + GroupAggregationConfig aggConfig = const GroupAggregationConfig(), + }) { + final buffer = StringBuffer(); + buffer.writeln('Group Key,Count,Percentage,Aggregate'); + + for (final g in groups) { + final aggStr = g.aggValue != null ? g.aggValue!.toStringAsFixed(2) : '-'; + buffer.writeln( + '"${g.groupKey.replaceAll('"', '""')}",${g.count},${g.percentage.toStringAsFixed(2)}%,$aggStr', + ); + } + + return buffer.toString(); + } +} diff --git a/test/features/main_screen/data_grid_engines_test.dart b/test/features/main_screen/data_grid_engines_test.dart index 880f514..07b8f8c 100644 --- a/test/features/main_screen/data_grid_engines_test.dart +++ b/test/features/main_screen/data_grid_engines_test.dart @@ -198,16 +198,16 @@ void main() { group('GridGroupingsEngine', () { final rows = [ - ['1', 'ACTIVE'], - ['2', 'PENDING'], - ['3', 'ACTIVE'], - ['4', 'ACTIVE'], - ['5', 'CANCELLED'], + ['1', 'ACTIVE', '100'], + ['2', 'PENDING', '50'], + ['3', 'ACTIVE', '200'], + ['4', 'ACTIVE', '300'], + ['5', 'CANCELLED', '0'], ]; test('groups rows by column index and calculates percentages', () { final groups = GridGroupingsEngine.buildGroups( - colIndex: 1, + groupColIndices: [1], rows: rows, ); @@ -215,8 +215,65 @@ void main() { expect(groups[0].groupKey, equals('ACTIVE')); expect(groups[0].count, equals(3)); expect(groups[0].percentage, closeTo(60.0, 0.1)); - expect(groups[1].count, equals(1)); }); + + test('computes custom aggregations (SUM and AVG)', () { + final sumGroups = GridGroupingsEngine.buildGroups( + groupColIndices: [1], + rows: rows, + aggConfig: const GroupAggregationConfig( + aggType: GroupingAggType.sum, + targetColIndex: 2, + ), + ); + + final activeGroup = sumGroups.firstWhere((g) => g.groupKey == 'ACTIVE'); + expect(activeGroup.aggValue, equals(600.0)); + + final avgGroups = GridGroupingsEngine.buildGroups( + groupColIndices: [1], + rows: rows, + aggConfig: const GroupAggregationConfig( + aggType: GroupingAggType.avg, + targetColIndex: 2, + ), + ); + + final activeAvg = avgGroups.firstWhere((g) => g.groupKey == 'ACTIVE'); + expect(activeAvg.aggValue, equals(200.0)); + }); + + test('sorts groups by key or aggregate', () { + final keySorted = GridGroupingsEngine.buildGroups( + groupColIndices: [1], + rows: rows, + sortBy: GroupSortBy.key, + sortAscending: true, + ); + + expect(keySorted[0].groupKey, equals('ACTIVE')); + expect(keySorted[1].groupKey, equals('CANCELLED')); + expect(keySorted[2].groupKey, equals('PENDING')); + }); + + test('exports pivot table to CSV format', () { + final groups = GridGroupingsEngine.buildGroups( + groupColIndices: [1], + rows: rows, + aggConfig: const GroupAggregationConfig( + aggType: GroupingAggType.sum, + targetColIndex: 2, + ), + ); + + final csv = GridGroupingsEngine.exportPivotToCsv( + groups: groups, + groupByColumnName: 'status', + ); + + expect(csv, contains('Group Key,Count,Percentage,Aggregate')); + expect(csv, contains('"ACTIVE",3,60.00%,600.00')); + }); }); }