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
170 changes: 170 additions & 0 deletions lib/features/main_screen/data_grid_calc_bar.dart
Original file line number Diff line number Diff line change
@@ -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',
),
),
],
),
),
),
);
}
}
181 changes: 181 additions & 0 deletions lib/features/main_screen/grid_selection_calc_engine.dart
Original file line number Diff line number Diff line change
@@ -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 = <String>[
'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<String> values) {
if (values.isEmpty) return GridCalcStats.empty;

final total = values.length;
var nulls = 0;
final distinctSet = <String>{};
final numericList = <double>[];
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'\.$'), '');
}
}
11 changes: 11 additions & 0 deletions test/features/main_screen/data_grid_engines_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
Loading