From 15558b906a10b45f6beb619d1021a18803454b30 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Tue, 25 Aug 2026 16:30:11 +0300 Subject: [PATCH] feat(calc): add distinct count, median, std dev, and copy stats summary action (#573) Closes #573 --- .../main_screen/data_grid_calc_bar.dart | 170 ++++++++++++++++ .../grid_selection_calc_engine.dart | 181 ++++++++++++++++++ .../main_screen/data_grid_engines_test.dart | 11 ++ 3 files changed, 362 insertions(+) create mode 100644 lib/features/main_screen/data_grid_calc_bar.dart create mode 100644 lib/features/main_screen/grid_selection_calc_engine.dart diff --git a/lib/features/main_screen/data_grid_calc_bar.dart b/lib/features/main_screen/data_grid_calc_bar.dart new file mode 100644 index 0000000..9a5324a --- /dev/null +++ b/lib/features/main_screen/data_grid_calc_bar.dart @@ -0,0 +1,170 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart'; +import 'package:querya_desktop/features/main_screen/grid_selection_calc_engine.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// Status bar footer for Data Grid displaying live selection statistics (Count, Distinct, Sum, Avg, Median, Min, Max). +class DataGridCalcBar extends StatelessWidget { + const DataGridCalcBar({ + super.key, + required this.stats, + }); + + final GridCalcStats stats; + + @override + Widget build(BuildContext context) { + if (stats.totalCount <= 1 && !stats.hasNumericStats) { + return const material.SizedBox.shrink(); + } + + final cs = Theme.of(context).colorScheme; + + return material.Container( + height: 26, + padding: const material.EdgeInsets.symmetric(horizontal: 10), + decoration: material.BoxDecoration( + color: cs.card, + border: material.Border( + top: material.BorderSide( + color: cs.border.withValues(alpha: 0.35), + width: 1, + ), + ), + ), + child: material.Row( + children: [ + material.Expanded( + child: material.SingleChildScrollView( + scrollDirection: material.Axis.horizontal, + child: material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + _StatBadge( + label: 'Count', + value: '${stats.totalCount}', + ), + const Gap(8), + _StatBadge( + label: 'Distinct', + value: '${stats.distinctCount}', + ), + if (stats.nullCount > 0) ...[ + const Gap(8), + _StatBadge( + label: 'NULLs', + value: '${stats.nullCount}', + ), + ], + if (stats.hasNumericStats) ...[ + const Gap(8), + _StatBadge( + label: 'Sum', + value: GridSelectionCalcEngine.formatNum(stats.sum), + ), + const Gap(8), + _StatBadge( + label: 'Avg', + value: GridSelectionCalcEngine.formatNum(stats.average), + ), + if (stats.median != null) ...[ + const Gap(8), + _StatBadge( + label: 'Median', + value: GridSelectionCalcEngine.formatNum(stats.median), + ), + ], + const Gap(8), + _StatBadge( + label: 'Min', + value: GridSelectionCalcEngine.formatNum(stats.min), + ), + const Gap(8), + _StatBadge( + label: 'Max', + value: GridSelectionCalcEngine.formatNum(stats.max), + ), + ], + ], + ), + ), + ), + const Gap(6), + material.Tooltip( + message: 'Copy all stats summary', + child: material.InkWell( + onTap: () { + Clipboard.setData(ClipboardData(text: stats.toSummaryString())); + }, + borderRadius: material.BorderRadius.circular(3), + child: material.Padding( + padding: const material.EdgeInsets.symmetric(horizontal: 4, vertical: 2), + child: material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Icon( + material.Icons.copy_all_rounded, + size: 13, + color: cs.mutedForeground, + ), + const Gap(3), + Text('Copy Stats', style: TextStyle(fontSize: 10.5, color: cs.mutedForeground)), + ], + ), + ), + ), + ), + ], + ), + ); + } +} + +class _StatBadge extends StatelessWidget { + const _StatBadge({ + required this.label, + required this.value, + }); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + + return material.Tooltip( + message: 'Click to copy $label: $value', + child: material.InkWell( + onTap: () { + Clipboard.setData(ClipboardData(text: value)); + }, + borderRadius: material.BorderRadius.circular(3), + child: material.Padding( + padding: const material.EdgeInsets.symmetric(horizontal: 4, vertical: 2), + child: material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + Text( + '$label: ', + style: TextStyle( + fontSize: 11, + color: cs.mutedForeground, + ), + ), + Text( + value, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: cs.foreground, + fontFamily: 'monospace', + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/main_screen/grid_selection_calc_engine.dart b/lib/features/main_screen/grid_selection_calc_engine.dart new file mode 100644 index 0000000..54b698d --- /dev/null +++ b/lib/features/main_screen/grid_selection_calc_engine.dart @@ -0,0 +1,181 @@ +import 'dart:math' as math; +import 'package:flutter/foundation.dart'; + +/// Aggregated statistical results for a selection of grid cell values. +@immutable +class GridCalcStats { + const GridCalcStats({ + required this.totalCount, + required this.distinctCount, + required this.numericCount, + required this.nullCount, + this.sum, + this.average, + this.median, + this.min, + this.max, + this.stdDev, + }); + + static const empty = GridCalcStats( + totalCount: 0, + distinctCount: 0, + numericCount: 0, + nullCount: 0, + ); + + final int totalCount; + final int distinctCount; + final int numericCount; + final int nullCount; + final double? sum; + final double? average; + final double? median; + final double? min; + final double? max; + final double? stdDev; + + bool get hasNumericStats => numericCount > 0 && sum != null; + + /// Formats all available statistics into a single copyable summary string. + String toSummaryString() { + final parts = [ + 'Count: $totalCount', + 'Distinct: $distinctCount', + ]; + if (nullCount > 0) { + parts.add('NULLs: $nullCount'); + } + if (hasNumericStats) { + parts.add('Sum: ${GridSelectionCalcEngine.formatNum(sum)}'); + parts.add('Avg: ${GridSelectionCalcEngine.formatNum(average)}'); + if (median != null) { + parts.add('Median: ${GridSelectionCalcEngine.formatNum(median)}'); + } + parts.add('Min: ${GridSelectionCalcEngine.formatNum(min)}'); + parts.add('Max: ${GridSelectionCalcEngine.formatNum(max)}'); + if (stdDev != null) { + parts.add('StdDev: ${GridSelectionCalcEngine.formatNum(stdDev)}'); + } + } + return parts.join(' | '); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is GridCalcStats && + totalCount == other.totalCount && + distinctCount == other.distinctCount && + numericCount == other.numericCount && + nullCount == other.nullCount && + sum == other.sum && + average == other.average && + median == other.median && + min == other.min && + max == other.max && + stdDev == other.stdDev; + + @override + int get hashCode => Object.hash( + totalCount, + distinctCount, + numericCount, + nullCount, + sum, + average, + median, + min, + max, + stdDev, + ); +} + +/// Calculation engine for computing stats (Count, Distinct, Sum, Avg, Median, Min, Max, StdDev) on grid selections. +abstract final class GridSelectionCalcEngine { + /// Computes statistics for a list of string cell values. + static GridCalcStats compute(List values) { + if (values.isEmpty) return GridCalcStats.empty; + + final total = values.length; + var nulls = 0; + final distinctSet = {}; + final numericList = []; + var sum = 0.0; + double? minVal; + double? maxVal; + + for (final raw in values) { + final trimmed = raw.trim(); + if (trimmed == 'NULL' || trimmed.isEmpty) { + nulls++; + continue; + } + + distinctSet.add(trimmed); + + // Try parsing numeric values (stripping commas if present) + final normalized = trimmed.replaceAll(',', ''); + final parsed = double.tryParse(normalized); + if (parsed != null && !parsed.isNaN && !parsed.isInfinite) { + numericList.add(parsed); + sum += parsed; + if (minVal == null || parsed < minVal) { + minVal = parsed; + } + if (maxVal == null || parsed > maxVal) { + maxVal = parsed; + } + } + } + + final numericCount = numericList.length; + final avg = numericCount > 0 ? sum / numericCount : null; + + // Calculate median + double? median; + if (numericCount > 0) { + numericList.sort(); + final mid = numericCount ~/ 2; + if (numericCount.isOdd) { + median = numericList[mid]; + } else { + median = (numericList[mid - 1] + numericList[mid]) / 2.0; + } + } + + // Calculate standard deviation + double? stdDev; + if (numericCount > 1 && avg != null) { + var varianceSum = 0.0; + for (final n in numericList) { + varianceSum += math.pow(n - avg, 2); + } + stdDev = math.sqrt(varianceSum / (numericCount - 1)); + } + + return GridCalcStats( + totalCount: total, + distinctCount: distinctSet.length, + numericCount: numericCount, + nullCount: nulls, + sum: numericCount > 0 ? sum : null, + average: avg, + median: median, + min: minVal, + max: maxVal, + stdDev: stdDev, + ); + } + + /// Formats a numeric stat cleanly for UI display. + static String formatNum(double? val) { + if (val == null) return '-'; + if (val == val.roundToDouble()) { + return val.toInt().toString(); + } + // Limit decimal precision to 4 decimal places + final formatted = val.toStringAsFixed(4); + return formatted.replaceAll(RegExp(r'0+$'), '').replaceAll(RegExp(r'\.$'), ''); + } +} diff --git a/test/features/main_screen/data_grid_engines_test.dart b/test/features/main_screen/data_grid_engines_test.dart index 1522784..880f514 100644 --- a/test/features/main_screen/data_grid_engines_test.dart +++ b/test/features/main_screen/data_grid_engines_test.dart @@ -165,17 +165,28 @@ void main() { test('computes correct stats for numeric values', () { final stats = GridSelectionCalcEngine.compute(['10', '20', '30', '40']); expect(stats.totalCount, equals(4)); + expect(stats.distinctCount, equals(4)); expect(stats.numericCount, equals(4)); expect(stats.nullCount, equals(0)); expect(stats.sum, equals(100.0)); expect(stats.average, equals(25.0)); + expect(stats.median, equals(25.0)); expect(stats.min, equals(10.0)); expect(stats.max, equals(40.0)); }); + test('computes odd-length median and distinct count with duplicates', () { + final stats = GridSelectionCalcEngine.compute(['10', '20', '20', '50', '100']); + expect(stats.distinctCount, equals(4)); + expect(stats.median, equals(20.0)); + expect(stats.toSummaryString(), contains('Count: 5 | Distinct: 4')); + expect(stats.toSummaryString(), contains('Median: 20')); + }); + test('handles NULLs and mixed string data', () { final stats = GridSelectionCalcEngine.compute(['10', 'NULL', 'text', '50.5']); expect(stats.totalCount, equals(4)); + expect(stats.distinctCount, equals(3)); expect(stats.numericCount, equals(2)); expect(stats.nullCount, equals(1)); expect(stats.sum, equals(60.5));