Skip to content

Commit 295663d

Browse files
feat(grid): add in-cell data type validator, visual error cues, and bidirectional navigation shortcuts (#567)
Closes #567
1 parent b0d9bad commit 295663d

5 files changed

Lines changed: 326 additions & 6 deletions

File tree

‎lib/features/main_screen/grid_cell_editor.dart‎

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import 'package:flutter/material.dart' as material;
22
import 'package:flutter/services.dart';
3+
import 'package:querya_desktop/features/main_screen/grid_data_type_validator.dart';
34
import 'package:shadcn_flutter/shadcn_flutter.dart';
45

56
/// Active inline editor widget for a data grid cell.
@@ -11,17 +12,20 @@ class GridCellEditor extends material.StatefulWidget {
1112
required this.height,
1213
required this.onCommit,
1314
required this.onCancel,
15+
this.dataTypeName,
1416
this.onOpenInspector,
1517
});
1618

1719
final String initialValue;
1820
final double width;
1921
final double height;
22+
final String? dataTypeName;
2023
final void Function(
2124
String value, {
2225
bool moveNextCol,
2326
bool movePrevCol,
2427
bool moveNextRow,
28+
bool movePrevRow,
2529
}) onCommit;
2630
final material.VoidCallback onCancel;
2731
final material.VoidCallback? onOpenInspector;
@@ -33,6 +37,7 @@ class GridCellEditor extends material.StatefulWidget {
3337
class _GridCellEditorState extends material.State<GridCellEditor> {
3438
late final material.TextEditingController _controller;
3539
final _focusNode = material.FocusNode();
40+
String? _validationError;
3641

3742
@override
3843
void initState() {
@@ -45,10 +50,26 @@ class _GridCellEditorState extends material.State<GridCellEditor> {
4550
baseOffset: 0,
4651
extentOffset: _controller.text.length,
4752
);
53+
54+
_validate();
55+
_controller.addListener(_validate);
56+
}
57+
58+
void _validate() {
59+
final error = GridDataTypeValidator.validate(
60+
_controller.text,
61+
dataTypeName: widget.dataTypeName,
62+
);
63+
if (error != _validationError) {
64+
setState(() {
65+
_validationError = error;
66+
});
67+
}
4868
}
4969

5070
@override
5171
void dispose() {
72+
_controller.removeListener(_validate);
5273
_controller.dispose();
5374
_focusNode.dispose();
5475
super.dispose();
@@ -59,18 +80,35 @@ class _GridCellEditorState extends material.State<GridCellEditor> {
5980

6081
final isShift = HardwareKeyboard.instance.isShiftPressed;
6182
final isAlt = HardwareKeyboard.instance.isAltPressed;
83+
final isControl = HardwareKeyboard.instance.isControlPressed ||
84+
HardwareKeyboard.instance.isMetaPressed;
6285

63-
if (event.logicalKey == LogicalKeyboardKey.keyN && isAlt) {
86+
// Alt+N / Ctrl+Alt+N -> Set NULL
87+
if (event.logicalKey == LogicalKeyboardKey.keyN && (isAlt || (isControl && isAlt))) {
6488
widget.onCommit('NULL');
6589
return;
6690
}
6791

92+
// Alt+Enter or Ctrl+Enter -> Open Inspector
93+
if ((event.logicalKey == LogicalKeyboardKey.enter ||
94+
event.logicalKey == LogicalKeyboardKey.numpadEnter) &&
95+
(isAlt || isControl)) {
96+
widget.onOpenInspector?.call();
97+
return;
98+
}
99+
100+
// Enter / Shift+Enter -> Commit and navigate row
68101
if (event.logicalKey == LogicalKeyboardKey.enter ||
69102
event.logicalKey == LogicalKeyboardKey.numpadEnter) {
70-
widget.onCommit(_controller.text, moveNextRow: true);
103+
if (isShift) {
104+
widget.onCommit(_controller.text, movePrevRow: true);
105+
} else {
106+
widget.onCommit(_controller.text, moveNextRow: true);
107+
}
71108
return;
72109
}
73110

111+
// Tab / Shift+Tab -> Commit and navigate col
74112
if (event.logicalKey == LogicalKeyboardKey.tab) {
75113
if (isShift) {
76114
widget.onCommit(_controller.text, movePrevCol: true);
@@ -80,6 +118,7 @@ class _GridCellEditorState extends material.State<GridCellEditor> {
80118
return;
81119
}
82120

121+
// Escape -> Cancel
83122
if (event.logicalKey == LogicalKeyboardKey.escape) {
84123
widget.onCancel();
85124
return;
@@ -89,14 +128,15 @@ class _GridCellEditorState extends material.State<GridCellEditor> {
89128
@override
90129
material.Widget build(material.BuildContext context) {
91130
final cs = Theme.of(context).colorScheme;
131+
final hasError = _validationError != null;
92132

93133
return material.Container(
94134
width: widget.width,
95135
height: widget.height,
96136
decoration: material.BoxDecoration(
97137
color: cs.card,
98138
border: material.Border.all(
99-
color: cs.primary,
139+
color: hasError ? material.Colors.red.shade500 : cs.primary,
100140
width: 1.5,
101141
),
102142
),
@@ -128,6 +168,18 @@ class _GridCellEditorState extends material.State<GridCellEditor> {
128168
),
129169
),
130170
),
171+
if (hasError)
172+
material.Tooltip(
173+
message: _validationError!,
174+
child: material.Padding(
175+
padding: const material.EdgeInsets.only(left: 4),
176+
child: material.Icon(
177+
material.Icons.error_outline_rounded,
178+
size: 14,
179+
color: material.Colors.red.shade500,
180+
),
181+
),
182+
),
131183
if (widget.onOpenInspector != null)
132184
material.MouseRegion(
133185
cursor: material.SystemMouseCursors.click,
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import 'dart:convert';
2+
3+
/// Helper utility for validating cell values against SQL data types.
4+
abstract final class GridDataTypeValidator {
5+
static final _uuidRegex = RegExp(
6+
r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$',
7+
);
8+
static final _intRegex = RegExp(r'^-?\d+$');
9+
static final _numRegex = RegExp(r'^-?\d+(\.\d+)?$');
10+
static final _dateRegex = RegExp(r'^\d{4}-\d{2}-\d{2}$');
11+
static final _timestampRegex = RegExp(
12+
r'^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}(:\d{2})?)?$',
13+
);
14+
15+
/// Validates [value] against the column's [dataTypeName].
16+
/// Returns `null` if valid (or type is unknown), or an error description string if invalid.
17+
static String? validate(String value, {String? dataTypeName}) {
18+
if (value.isEmpty || value == 'NULL' || value == 'null') {
19+
return null;
20+
}
21+
if (dataTypeName == null || dataTypeName.isEmpty) {
22+
return null;
23+
}
24+
25+
final type = dataTypeName.toLowerCase().trim();
26+
27+
// Integer types
28+
if (type.contains('int') || type == 'serial' || type == 'bigserial') {
29+
if (!_intRegex.hasMatch(value.trim())) {
30+
return 'Expected valid integer';
31+
}
32+
return null;
33+
}
34+
35+
// Floating / Decimal / Numeric types
36+
if (type.contains('num') ||
37+
type.contains('decimal') ||
38+
type.contains('float') ||
39+
type.contains('double') ||
40+
type == 'real') {
41+
if (!_numRegex.hasMatch(value.trim())) {
42+
return 'Expected valid number';
43+
}
44+
return null;
45+
}
46+
47+
// Boolean types
48+
if (type == 'bool' || type == 'boolean') {
49+
final lower = value.toLowerCase().trim();
50+
if (lower != 'true' &&
51+
lower != 'false' &&
52+
lower != '1' &&
53+
lower != '0' &&
54+
lower != 't' &&
55+
lower != 'f') {
56+
return 'Expected boolean (true/false/1/0)';
57+
}
58+
return null;
59+
}
60+
61+
// UUID
62+
if (type == 'uuid') {
63+
if (!_uuidRegex.hasMatch(value.trim())) {
64+
return 'Expected valid UUID (e.g. 123e4567-e89b-12d3-a456-426614174000)';
65+
}
66+
return null;
67+
}
68+
69+
// JSON / JSONB
70+
if (type.contains('json')) {
71+
final trimmed = value.trim();
72+
if (!((trimmed.startsWith('{') && trimmed.endsWith('}')) ||
73+
(trimmed.startsWith('[') && trimmed.endsWith(']')))) {
74+
return 'Expected valid JSON object or array';
75+
}
76+
try {
77+
jsonDecode(trimmed);
78+
} catch (e) {
79+
return 'Malformed JSON: $e';
80+
}
81+
return null;
82+
}
83+
84+
// Date
85+
if (type == 'date') {
86+
if (!_dateRegex.hasMatch(value.trim())) {
87+
return 'Expected date in YYYY-MM-DD format';
88+
}
89+
return null;
90+
}
91+
92+
// Timestamp / DateTime
93+
if (type.contains('timestamp') ||
94+
type.contains('datetime') ||
95+
type == 'timestamptz') {
96+
if (!_timestampRegex.hasMatch(value.trim())) {
97+
return 'Expected timestamp (YYYY-MM-DD HH:MM:SS)';
98+
}
99+
return null;
100+
}
101+
102+
return null;
103+
}
104+
}

‎lib/features/main_screen/result_grid_view.dart‎

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -338,12 +338,16 @@ class VirtualResultGrid extends material.StatefulWidget {
338338
required this.rows,
339339
this.stagingBuffer,
340340
this.onRowSelected,
341+
this.onSelectionValuesChanged,
342+
this.onCellFocused,
341343
});
342344

343345
final List<String> columns;
344346
final List<List<String>> rows;
345347
final DataGridStagingBuffer? stagingBuffer;
346348
final material.ValueChanged<int?>? onRowSelected;
349+
final material.ValueChanged<List<String>>? onSelectionValuesChanged;
350+
final void Function(String columnName, String cellValue, int rowIndex)? onCellFocused;
347351

348352
@override
349353
material.State<VirtualResultGrid> createState() => _VirtualResultGridState();
@@ -446,6 +450,7 @@ class _VirtualResultGridState extends material.State<VirtualResultGrid> {
446450
bool moveNextCol = false,
447451
bool movePrevCol = false,
448452
bool moveNextRow = false,
453+
bool movePrevRow = false,
449454
}) {
450455
if (widget.stagingBuffer != null) {
451456
widget.stagingBuffer!.setCell(row, column, value);
@@ -503,6 +508,18 @@ class _VirtualResultGridState extends material.State<VirtualResultGrid> {
503508
} else {
504509
_editingCell = null;
505510
}
511+
} else if (movePrevRow) {
512+
if (row > 0) {
513+
_editingCell = ResultGridCellCoordinate(row - 1, column);
514+
_selection = ResultGridSelection(
515+
startRow: row - 1,
516+
startColumn: column,
517+
endRow: row - 1,
518+
endColumn: column,
519+
);
520+
} else {
521+
_editingCell = null;
522+
}
506523
} else {
507524
_editingCell = null;
508525
}
@@ -585,6 +602,38 @@ class _VirtualResultGridState extends material.State<VirtualResultGrid> {
585602
}
586603
}
587604

605+
void _notifySelectionAndFocus() {
606+
if (widget.onSelectionValuesChanged != null) {
607+
if (_selection == null) {
608+
widget.onSelectionValuesChanged!(const []);
609+
} else {
610+
final rows = _sortedRows;
611+
final values = <String>[];
612+
for (var r = _selection!.startRow; r <= _selection!.endRow; r++) {
613+
if (r >= 0 && r < rows.length) {
614+
for (var c = _selection!.startColumn; c <= _selection!.endColumn; c++) {
615+
if (c >= 0 && c < rows[r].length) {
616+
values.add(rows[r][c]);
617+
}
618+
}
619+
}
620+
}
621+
widget.onSelectionValuesChanged!(values);
622+
}
623+
}
624+
625+
if (widget.onCellFocused != null && _selectionAnchor != null) {
626+
final r = _selectionAnchor!.row;
627+
final c = _selectionAnchor!.column;
628+
final rows = _sortedRows;
629+
if (r >= 0 && r < rows.length && c >= 0 && c < widget.columns.length) {
630+
final colName = widget.columns[c];
631+
final val = c < rows[r].length ? rows[r][c] : '';
632+
widget.onCellFocused!(colName, val, r);
633+
}
634+
}
635+
}
636+
588637
void _onCellTap(int row, int column, {bool isShift = false}) {
589638
_focusNode.requestFocus();
590639
widget.onRowSelected?.call(row);
@@ -605,6 +654,7 @@ class _VirtualResultGridState extends material.State<VirtualResultGrid> {
605654
);
606655
}
607656
});
657+
_notifySelectionAndFocus();
608658
}
609659

610660
void _onCellSecondaryTap(int row, int column) {
@@ -622,6 +672,7 @@ class _VirtualResultGridState extends material.State<VirtualResultGrid> {
622672
endColumn: column,
623673
);
624674
});
675+
_notifySelectionAndFocus();
625676
_copySelection();
626677
}
627678
}
@@ -1078,6 +1129,7 @@ class _DataRow extends material.StatelessWidget {
10781129
bool moveNextCol,
10791130
bool movePrevCol,
10801131
bool moveNextRow,
1132+
bool movePrevRow,
10811133
})? onCommitEdit;
10821134
final material.VoidCallback? onCancelEdit;
10831135
final void Function(int row, int col)? onOpenInspector;
@@ -1181,6 +1233,7 @@ class _GridCell extends material.StatelessWidget {
11811233
bool moveNextCol,
11821234
bool movePrevCol,
11831235
bool moveNextRow,
1236+
bool movePrevRow,
11841237
})? onCommitEdit;
11851238
final material.VoidCallback? onCancelEdit;
11861239
final void Function(int row, int col)? onOpenInspector;
@@ -1192,14 +1245,15 @@ class _GridCell extends material.StatelessWidget {
11921245
initialValue: text,
11931246
width: width,
11941247
height: double.infinity,
1195-
onCommit: (val, {moveNextCol = false, movePrevCol = false, moveNextRow = false}) {
1248+
onCommit: (val, {moveNextCol = false, movePrevCol = false, moveNextRow = false, movePrevRow = false}) {
11961249
onCommitEdit?.call(
11971250
row,
11981251
column,
11991252
val,
12001253
moveNextCol: moveNextCol,
12011254
movePrevCol: movePrevCol,
12021255
moveNextRow: moveNextRow,
1256+
movePrevRow: movePrevRow,
12031257
);
12041258
},
12051259
onCancel: () => onCancelEdit?.call(),

0 commit comments

Comments
 (0)