Code slides: - Theme code (broncode) background and text colours, with an optional syntax-colouring toggle. With it off the block renders monochrome, so a black background + bright green gives a classic CRT-screen look. - Colour pickers gained a custom hex entry so arbitrary colours (e.g. CRT green) can be set, not just presets. Exported HTML mirrors the code colours. Radar/spider charts: - Optional min/max now define the radar scale (centre/outer ring) instead of threshold lines. Evenly spaced, labelled tick rings are drawn in both the live preview and the SVG export so the scale is readable. A nice scale is derived from the data when no bounds are set. Line chart tooltips: - Detect the touched dot by true (x and y) distance instead of the x-only default, so the tooltip belongs to the point under the cursor. Overlapping dots all show, and the font shrinks a step when several stack. New UI strings are translated across all supported languages. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
903 lines
30 KiB
Dart
903 lines
30 KiB
Dart
import 'dart:convert';
|
|
import 'dart:io';
|
|
import 'dart:math' as math;
|
|
|
|
import 'package:file_picker/file_picker.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:path/path.dart' as p;
|
|
import '../../l10n/app_localizations.dart';
|
|
import '../../models/chart.dart';
|
|
import '../../models/slide.dart';
|
|
import '_editor_field.dart';
|
|
|
|
/// Editor for a chart slide: type, title, and an editable data grid. Data can
|
|
/// be entered directly in the interface, imported from a CSV (inline), or
|
|
/// linked to a CSV file kept in the deck's data/ directory (the living source).
|
|
class ChartEditor extends StatefulWidget {
|
|
final Slide slide;
|
|
final ValueChanged<Slide> onUpdate;
|
|
final String? projectPath;
|
|
|
|
const ChartEditor({
|
|
super.key,
|
|
required this.slide,
|
|
required this.onUpdate,
|
|
this.projectPath,
|
|
});
|
|
|
|
@override
|
|
State<ChartEditor> createState() => _ChartEditorState();
|
|
}
|
|
|
|
class _ChartEditorState extends State<ChartEditor> {
|
|
late final TextEditingController _title;
|
|
late final TextEditingController _minBound;
|
|
late final TextEditingController _maxBound;
|
|
late ChartType _type;
|
|
String? _source;
|
|
|
|
// Editable grid model (strings while editing).
|
|
List<String> _xLabels = [];
|
|
List<String?> _rowColors = [];
|
|
List<String> _seriesNames = [];
|
|
List<String?> _seriesColors = [];
|
|
List<List<String>> _values = []; // [row][col]
|
|
|
|
// Bumped on structural changes so cell fields rebuild with fresh values.
|
|
int _rev = 0;
|
|
|
|
static const _minLabelW = 238.0;
|
|
static const _minCellW = 150.0;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
final spec = ChartSpec.parse(widget.slide.customMarkdown);
|
|
_type = spec.type;
|
|
_source = spec.source;
|
|
_title = TextEditingController(text: spec.title);
|
|
_title.addListener(_emit);
|
|
_minBound = TextEditingController(text: _fmtBound(spec.minBound));
|
|
_maxBound = TextEditingController(text: _fmtBound(spec.maxBound));
|
|
_minBound.addListener(_emit);
|
|
_maxBound.addListener(_emit);
|
|
_loadFromSpec(spec);
|
|
}
|
|
|
|
bool get _supportsBounds => _type != ChartType.pie;
|
|
|
|
static String _fmtBound(double? v) => v == null ? '' : _fmt(v);
|
|
|
|
static double? _parseBound(String raw) {
|
|
final text = raw.trim().replaceAll(',', '.');
|
|
return text.isEmpty ? null : double.tryParse(text);
|
|
}
|
|
|
|
void _loadFromSpec(ChartSpec spec) {
|
|
if (spec.hasInlineData) {
|
|
_seriesNames = [for (final s in spec.series) s.name];
|
|
_seriesColors = [for (final s in spec.series) s.color];
|
|
_xLabels = List<String>.from(spec.x);
|
|
_rowColors = [
|
|
for (var i = 0; i < spec.x.length; i++)
|
|
i < spec.rowColors.length ? spec.rowColors[i] : null,
|
|
];
|
|
_values = [
|
|
for (var r = 0; r < spec.x.length; r++)
|
|
[
|
|
for (final s in spec.series)
|
|
r < s.data.length ? _fmt(s.data[r]) : '',
|
|
],
|
|
];
|
|
} else {
|
|
// Sensible empty starting grid.
|
|
_seriesNames = ['Reeks 1'];
|
|
_seriesColors = [null];
|
|
_xLabels = ['', '', ''];
|
|
_rowColors = [null, null, null];
|
|
_values = List.generate(3, (_) => ['']);
|
|
}
|
|
}
|
|
|
|
static String _fmt(double v) =>
|
|
v == v.roundToDouble() ? v.toInt().toString() : v.toString();
|
|
|
|
@override
|
|
void dispose() {
|
|
_title.dispose();
|
|
_minBound.dispose();
|
|
_maxBound.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _emit() {
|
|
final series = <ChartSeries>[
|
|
for (var c = 0; c < _seriesNames.length; c++)
|
|
ChartSeries(
|
|
name: _seriesNames[c],
|
|
color: _seriesColors[c],
|
|
data: [
|
|
for (var r = 0; r < _values.length; r++)
|
|
double.tryParse(
|
|
(c < _values[r].length ? _values[r][c] : '')
|
|
.trim()
|
|
.replaceAll(',', '.'),
|
|
) ??
|
|
0,
|
|
],
|
|
),
|
|
];
|
|
final spec = ChartSpec(
|
|
type: _type,
|
|
title: _title.text,
|
|
source: _source,
|
|
x: List<String>.from(_xLabels),
|
|
rowColors: List<String?>.from(_rowColors),
|
|
series: series,
|
|
minBound: _supportsBounds ? _parseBound(_minBound.text) : null,
|
|
maxBound: _supportsBounds ? _parseBound(_maxBound.text) : null,
|
|
);
|
|
widget.onUpdate(widget.slide.copyWith(customMarkdown: spec.toBlock()));
|
|
}
|
|
|
|
void _bump() => setState(() => _rev++);
|
|
|
|
void _addColumn() {
|
|
_seriesNames.add('Reeks ${_seriesNames.length + 1}');
|
|
_seriesColors.add(null);
|
|
for (final row in _values) {
|
|
row.add('');
|
|
}
|
|
_bump();
|
|
_emit();
|
|
}
|
|
|
|
void _removeColumn(int c) {
|
|
if (_seriesNames.length <= 1) return;
|
|
_seriesNames.removeAt(c);
|
|
_seriesColors.removeAt(c);
|
|
for (final row in _values) {
|
|
if (c < row.length) row.removeAt(c);
|
|
}
|
|
_bump();
|
|
_emit();
|
|
}
|
|
|
|
void _addRow() {
|
|
_xLabels.add('');
|
|
_rowColors.add(null);
|
|
_values.add(List<String>.filled(_seriesNames.length, '', growable: true));
|
|
_bump();
|
|
_emit();
|
|
}
|
|
|
|
void _removeRow(int r) {
|
|
if (_xLabels.length <= 1) return;
|
|
_xLabels.removeAt(r);
|
|
_rowColors.removeAt(r);
|
|
_values.removeAt(r);
|
|
_bump();
|
|
_emit();
|
|
}
|
|
|
|
Future<void> _importCsv() async {
|
|
final result = await FilePicker.pickFiles(
|
|
type: FileType.custom,
|
|
allowedExtensions: ['csv'],
|
|
withData: true,
|
|
);
|
|
if (result == null || result.files.isEmpty) return;
|
|
final file = result.files.first;
|
|
final text = file.bytes != null
|
|
? utf8.decode(file.bytes!)
|
|
: (file.path != null ? await File(file.path!).readAsString() : null);
|
|
if (text == null) return;
|
|
|
|
var asFile = false;
|
|
if (widget.projectPath != null && mounted) {
|
|
asFile =
|
|
await showDialog<bool>(
|
|
context: context,
|
|
builder: (ctx) => AlertDialog(
|
|
title: Text(ctx.l10n.d('CSV importeren')),
|
|
content: Text(
|
|
ctx.l10n.d(
|
|
'Data in de slide opslaan, of als los CSV-bestand naast de presentatie bewaren?',
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(ctx, false),
|
|
child: Text(ctx.l10n.d('In de slide')),
|
|
),
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(ctx, true),
|
|
child: Text(ctx.l10n.d('Als CSV-bestand')),
|
|
),
|
|
],
|
|
),
|
|
) ??
|
|
false;
|
|
}
|
|
|
|
String? source;
|
|
if (asFile && widget.projectPath != null) {
|
|
final name = p.basename(file.name);
|
|
final dir = Directory(p.join(widget.projectPath!, chartDataDirName));
|
|
await dir.create(recursive: true);
|
|
await File(p.join(dir.path, name)).writeAsString(text, flush: true);
|
|
source = '$chartDataDirName/$name';
|
|
}
|
|
|
|
final parsed = parseCsv(text);
|
|
setState(() {
|
|
_source = source;
|
|
_xLabels = parsed.$1.isEmpty ? [''] : parsed.$1;
|
|
_rowColors = [
|
|
for (var i = 0; i < _xLabels.length; i++)
|
|
i < _rowColors.length ? _rowColors[i] : null,
|
|
];
|
|
_seriesNames = parsed.$2.isEmpty
|
|
? ['Reeks 1']
|
|
: [for (final s in parsed.$2) s.name];
|
|
_seriesColors = [
|
|
for (var i = 0; i < _seriesNames.length; i++)
|
|
i < _seriesColors.length ? _seriesColors[i] : null,
|
|
];
|
|
_values = [
|
|
for (var r = 0; r < _xLabels.length; r++)
|
|
if (parsed.$2.isEmpty)
|
|
['']
|
|
else
|
|
[
|
|
for (final s in parsed.$2)
|
|
r < s.data.length ? _fmt(s.data[r]) : '',
|
|
],
|
|
];
|
|
_rev++;
|
|
});
|
|
_emit();
|
|
}
|
|
|
|
void _unlink() {
|
|
setState(() => _source = null);
|
|
_emit();
|
|
}
|
|
|
|
void _moveRow(int from, int to) {
|
|
if (to < 0 || to >= _xLabels.length || from == to) return;
|
|
setState(() {
|
|
final label = _xLabels.removeAt(from);
|
|
final color = _rowColors.removeAt(from);
|
|
final values = _values.removeAt(from);
|
|
_xLabels.insert(to, label);
|
|
_rowColors.insert(to, color);
|
|
_values.insert(to, values);
|
|
_rev++;
|
|
});
|
|
_emit();
|
|
}
|
|
|
|
void _sortRows({int? column, required bool ascending}) {
|
|
final indices = List<int>.generate(_xLabels.length, (i) => i);
|
|
indices.sort((a, b) {
|
|
int result;
|
|
if (column == null) {
|
|
result = _xLabels[a].toLowerCase().compareTo(_xLabels[b].toLowerCase());
|
|
} else {
|
|
final av =
|
|
double.tryParse(_values[a][column].replaceAll(',', '.')) ?? 0;
|
|
final bv =
|
|
double.tryParse(_values[b][column].replaceAll(',', '.')) ?? 0;
|
|
result = av.compareTo(bv);
|
|
}
|
|
return ascending ? result : -result;
|
|
});
|
|
setState(() {
|
|
final labels = [for (final i in indices) _xLabels[i]];
|
|
final colors = [for (final i in indices) _rowColors[i]];
|
|
final values = [for (final i in indices) _values[i]];
|
|
_xLabels = labels;
|
|
_rowColors = colors;
|
|
_values = values;
|
|
_rev++;
|
|
});
|
|
_emit();
|
|
}
|
|
|
|
Future<void> _pickSeriesColor(int index) async {
|
|
final selected = await _pickColor(
|
|
initial:
|
|
_seriesColors[index] ??
|
|
chartSeriesColor(ChartSeries(name: '', data: const []), index),
|
|
title: context.l10n.d('Kleur van reeks'),
|
|
);
|
|
if (selected == null || !mounted) return;
|
|
setState(() => _seriesColors[index] = selected);
|
|
_emit();
|
|
}
|
|
|
|
Future<void> _pickRowColor(int index) async {
|
|
final selected = await _pickColor(
|
|
initial:
|
|
_rowColors[index] ??
|
|
chartColorPalette[index % chartColorPalette.length],
|
|
title: context.l10n.d('Kleur van rij'),
|
|
);
|
|
if (selected == null || !mounted) return;
|
|
setState(() => _rowColors[index] = selected);
|
|
_emit();
|
|
}
|
|
|
|
Future<String?> _pickColor({
|
|
required String initial,
|
|
required String title,
|
|
}) async {
|
|
final controller = TextEditingController(text: initial);
|
|
final selected = await showDialog<String>(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: Text(title),
|
|
content: StatefulBuilder(
|
|
builder: (context, setDialogState) => SizedBox(
|
|
width: 320,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Wrap(
|
|
spacing: 10,
|
|
runSpacing: 10,
|
|
children: [
|
|
for (final hex in chartColorPalette)
|
|
_colorChoice(
|
|
hex,
|
|
selected: normalizeChartColor(controller.text) == hex,
|
|
onTap: () {
|
|
controller.text = hex;
|
|
setDialogState(() {});
|
|
},
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 18),
|
|
TextField(
|
|
controller: controller,
|
|
decoration: InputDecoration(
|
|
labelText: context.l10n.d('Hexkleur'),
|
|
hintText: '#2563EB',
|
|
border: const OutlineInputBorder(),
|
|
isDense: true,
|
|
),
|
|
inputFormatters: [
|
|
FilteringTextInputFormatter.allow(RegExp(r'[#0-9a-fA-F]')),
|
|
LengthLimitingTextInputFormatter(7),
|
|
],
|
|
onChanged: (_) => setDialogState(() {}),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: Text(context.l10n.d('Annuleren')),
|
|
),
|
|
FilledButton(
|
|
onPressed: normalizeChartColor(controller.text) == null
|
|
? null
|
|
: () => Navigator.pop(
|
|
context,
|
|
normalizeChartColor(controller.text),
|
|
),
|
|
child: Text(context.l10n.d('Toepassen')),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
controller.dispose();
|
|
return selected;
|
|
}
|
|
|
|
Widget _colorChoice(
|
|
String hex, {
|
|
required bool selected,
|
|
required VoidCallback onTap,
|
|
}) {
|
|
final color = Color(int.parse(hex.substring(1), radix: 16) | 0xFF000000);
|
|
return InkWell(
|
|
onTap: onTap,
|
|
borderRadius: BorderRadius.circular(18),
|
|
child: Container(
|
|
width: 34,
|
|
height: 34,
|
|
decoration: BoxDecoration(
|
|
color: color,
|
|
shape: BoxShape.circle,
|
|
border: Border.all(
|
|
color: selected ? const Color(0xFF0F172A) : Colors.white,
|
|
width: selected ? 3 : 2,
|
|
),
|
|
boxShadow: const [BoxShadow(color: Color(0x330F172A), blurRadius: 3)],
|
|
),
|
|
child: selected
|
|
? const Icon(Icons.check, size: 18, color: Colors.white)
|
|
: null,
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = context.l10n;
|
|
final linked = _source != null;
|
|
return Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
EditorField(label: 'Titel (optioneel)', controller: _title),
|
|
const SizedBox(height: 16),
|
|
Row(
|
|
children: [
|
|
Text(
|
|
l10n.d('Type grafiek'),
|
|
style: const TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w600,
|
|
color: Color(0xFF64748B),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
DropdownButton<ChartType>(
|
|
value: _type,
|
|
isDense: true,
|
|
borderRadius: BorderRadius.circular(6),
|
|
style: const TextStyle(fontSize: 12, color: Color(0xFF0F172A)),
|
|
items: [
|
|
DropdownMenuItem(
|
|
value: ChartType.bar,
|
|
child: Text(l10n.d('Staaf')),
|
|
),
|
|
DropdownMenuItem(
|
|
value: ChartType.line,
|
|
child: Text(l10n.d('Lijn')),
|
|
),
|
|
DropdownMenuItem(
|
|
value: ChartType.pie,
|
|
child: Text(l10n.d('Cirkel')),
|
|
),
|
|
DropdownMenuItem(
|
|
value: ChartType.radar,
|
|
child: Text(l10n.d('Spider')),
|
|
),
|
|
],
|
|
onChanged: (v) {
|
|
if (v == null) return;
|
|
setState(() => _type = v);
|
|
_emit();
|
|
},
|
|
),
|
|
const Spacer(),
|
|
TextButton.icon(
|
|
onPressed: _importCsv,
|
|
icon: const Icon(Icons.upload_file, size: 16),
|
|
label: Text(l10n.d('CSV importeren')),
|
|
),
|
|
],
|
|
),
|
|
if (_type == ChartType.pie)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 8),
|
|
child: Text(
|
|
l10n.d(
|
|
'Bij een cirkel worden maximaal de eerste twee reeksen getoond; de labels vormen de segmenten.',
|
|
),
|
|
style: const TextStyle(fontSize: 11, color: Color(0xFF64748B)),
|
|
),
|
|
),
|
|
if (_type == ChartType.radar)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 8),
|
|
child: Text(
|
|
l10n.d(
|
|
'Een spider-diagram heeft minstens drie labels (assen) nodig; elke reeks vormt een vlak.',
|
|
),
|
|
style: const TextStyle(fontSize: 11, color: Color(0xFF64748B)),
|
|
),
|
|
),
|
|
if (_supportsBounds)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 12),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Expanded(
|
|
child: _boundField(
|
|
key: const ValueKey('chart-min-bound'),
|
|
controller: _minBound,
|
|
label: _type == ChartType.radar
|
|
? l10n.d('Schaalminimum (optioneel)')
|
|
: l10n.d('Minimumlijn (optioneel)'),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: _boundField(
|
|
key: const ValueKey('chart-max-bound'),
|
|
controller: _maxBound,
|
|
label: _type == ChartType.radar
|
|
? l10n.d('Schaalmaximum (optioneel)')
|
|
: l10n.d('Maximumlijn (optioneel)'),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (linked)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 8),
|
|
child: Row(
|
|
children: [
|
|
const Icon(Icons.link, size: 14, color: Color(0xFF0369A1)),
|
|
const SizedBox(width: 6),
|
|
Expanded(
|
|
child: Text(
|
|
'${l10n.d('Gekoppeld aan')} $_source',
|
|
style: const TextStyle(
|
|
fontSize: 11,
|
|
color: Color(0xFF0369A1),
|
|
),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
TextButton(
|
|
onPressed: _unlink,
|
|
child: Text(l10n.d('Ontkoppelen')),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
Expanded(
|
|
child: LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final availableWidth = constraints.maxWidth;
|
|
return SingleChildScrollView(
|
|
child: SingleChildScrollView(
|
|
scrollDirection: Axis.horizontal,
|
|
child: _grid(
|
|
enabled: !linked,
|
|
availableWidth: availableWidth,
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
if (!linked) ...[
|
|
const SizedBox(height: 8),
|
|
Row(
|
|
children: [
|
|
OutlinedButton.icon(
|
|
onPressed: _addRow,
|
|
icon: const Icon(Icons.add, size: 16),
|
|
label: Text(l10n.d('Rij')),
|
|
),
|
|
const SizedBox(width: 8),
|
|
OutlinedButton.icon(
|
|
onPressed: _addColumn,
|
|
icon: const Icon(Icons.add, size: 16),
|
|
label: Text(l10n.d('Reeks')),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _grid({required bool enabled, required double availableWidth}) {
|
|
final cols = _seriesNames.length;
|
|
const trailingWidth = 40.0;
|
|
final labelWidth = math.max(_minLabelW, availableWidth * 0.28);
|
|
final remaining = availableWidth - labelWidth - trailingWidth;
|
|
final cellWidth = math.max(_minCellW, remaining / cols);
|
|
final gridWidth = math.max(
|
|
availableWidth,
|
|
labelWidth + cellWidth * cols + trailingWidth,
|
|
);
|
|
return SizedBox(
|
|
key: const ValueKey('chart-grid'),
|
|
width: gridWidth,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Header row: empty label cell + series name fields.
|
|
Row(
|
|
children: [
|
|
SizedBox(
|
|
width: labelWidth,
|
|
child: Row(
|
|
children: [
|
|
Expanded(child: _headerHint(context.l10n.d('Label'))),
|
|
_sortButton(column: null, enabled: enabled),
|
|
],
|
|
),
|
|
),
|
|
for (var c = 0; c < cols; c++)
|
|
Container(
|
|
key: ValueKey('chart-series-column-$c'),
|
|
width: cellWidth,
|
|
color: _type == ChartType.pie && c >= 2
|
|
? const Color(0xFFE2E8F0)
|
|
: null,
|
|
child: Row(
|
|
children: [
|
|
IconButton(
|
|
onPressed: enabled ? () => _pickSeriesColor(c) : null,
|
|
tooltip: context.l10n.d('Kleur van reeks'),
|
|
icon: Container(
|
|
width: 16,
|
|
height: 16,
|
|
decoration: BoxDecoration(
|
|
color: Color(
|
|
_type == ChartType.pie && c >= 2
|
|
? 0xFF94A3B8
|
|
: int.parse(
|
|
chartSeriesColor(
|
|
ChartSeries(
|
|
name: '',
|
|
data: const [],
|
|
color: _seriesColors[c],
|
|
),
|
|
c,
|
|
).substring(1),
|
|
radix: 16,
|
|
) |
|
|
0xFF000000,
|
|
),
|
|
shape: BoxShape.circle,
|
|
border: Border.all(color: Colors.white, width: 1.5),
|
|
boxShadow: const [
|
|
BoxShadow(
|
|
color: Color(0x330F172A),
|
|
blurRadius: 2,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
visualDensity: VisualDensity.compact,
|
|
padding: EdgeInsets.zero,
|
|
constraints: const BoxConstraints(
|
|
minWidth: 24,
|
|
minHeight: 32,
|
|
),
|
|
),
|
|
Expanded(
|
|
child: _cell(
|
|
key: ValueKey('s-$_rev-$c'),
|
|
value: _seriesNames[c],
|
|
enabled: enabled,
|
|
onChanged: (v) => _seriesNames[c] = v,
|
|
bold: true,
|
|
muted: _type == ChartType.pie && c >= 2,
|
|
),
|
|
),
|
|
_sortButton(column: c, enabled: enabled),
|
|
if (enabled && cols > 1)
|
|
_iconBtn(Icons.close, () => _removeColumn(c)),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 4),
|
|
// Data rows.
|
|
for (var r = 0; r < _xLabels.length; r++)
|
|
Padding(
|
|
padding: const EdgeInsets.only(bottom: 4),
|
|
child: Row(
|
|
children: [
|
|
SizedBox(
|
|
width: labelWidth,
|
|
child: Row(
|
|
children: [
|
|
IconButton(
|
|
key: ValueKey('chart-row-color-$r'),
|
|
onPressed: enabled ? () => _pickRowColor(r) : null,
|
|
tooltip: context.l10n.d('Kleur van rij'),
|
|
icon: _colorDot(
|
|
_rowColors[r] ??
|
|
chartColorPalette[r % chartColorPalette.length],
|
|
),
|
|
visualDensity: VisualDensity.compact,
|
|
padding: EdgeInsets.zero,
|
|
constraints: const BoxConstraints(
|
|
minWidth: 26,
|
|
minHeight: 32,
|
|
),
|
|
),
|
|
Expanded(
|
|
child: _cell(
|
|
key: ValueKey('x-$_rev-$r'),
|
|
value: _xLabels[r],
|
|
enabled: enabled,
|
|
onChanged: (v) => _xLabels[r] = v,
|
|
),
|
|
),
|
|
if (enabled) ...[
|
|
_iconBtn(
|
|
Icons.keyboard_arrow_up,
|
|
r == 0 ? null : () => _moveRow(r, r - 1),
|
|
key: ValueKey('chart-row-up-$r'),
|
|
),
|
|
_iconBtn(
|
|
Icons.keyboard_arrow_down,
|
|
r == _xLabels.length - 1
|
|
? null
|
|
: () => _moveRow(r, r + 1),
|
|
key: ValueKey('chart-row-down-$r'),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
for (var c = 0; c < cols; c++)
|
|
Container(
|
|
width: cellWidth,
|
|
color: _type == ChartType.pie && c >= 2
|
|
? const Color(0xFFE2E8F0)
|
|
: null,
|
|
child: _cell(
|
|
key: ValueKey('v-$_rev-$r-$c'),
|
|
value: c < _values[r].length ? _values[r][c] : '',
|
|
enabled: enabled,
|
|
number: true,
|
|
muted: _type == ChartType.pie && c >= 2,
|
|
onChanged: (v) {
|
|
while (_values[r].length <= c) {
|
|
_values[r].add('');
|
|
}
|
|
_values[r][c] = v;
|
|
},
|
|
),
|
|
),
|
|
if (enabled && _xLabels.length > 1)
|
|
_iconBtn(Icons.close, () => _removeRow(r)),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _boundField({
|
|
required Key key,
|
|
required TextEditingController controller,
|
|
required String label,
|
|
}) => TextField(
|
|
key: key,
|
|
controller: controller,
|
|
keyboardType: const TextInputType.numberWithOptions(
|
|
decimal: true,
|
|
signed: true,
|
|
),
|
|
inputFormatters: [
|
|
FilteringTextInputFormatter.allow(RegExp(r'[0-9.,\-]')),
|
|
],
|
|
style: const TextStyle(fontSize: 12),
|
|
decoration: InputDecoration(
|
|
labelText: label,
|
|
labelStyle: const TextStyle(fontSize: 12, color: Color(0xFF64748B)),
|
|
hintText: context.l10n.d('geen'),
|
|
isDense: true,
|
|
contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
|
|
border: const OutlineInputBorder(),
|
|
),
|
|
);
|
|
|
|
Widget _headerHint(String text) => Padding(
|
|
padding: const EdgeInsets.only(left: 4, bottom: 4),
|
|
child: Text(
|
|
text,
|
|
style: const TextStyle(
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w600,
|
|
color: Color(0xFF94A3B8),
|
|
),
|
|
),
|
|
);
|
|
|
|
Widget _sortButton({required int? column, required bool enabled}) {
|
|
return PopupMenuButton<bool>(
|
|
key: ValueKey('chart-sort-${column ?? 'label'}'),
|
|
enabled: enabled,
|
|
tooltip: context.l10n.d('Sorteren'),
|
|
icon: const Icon(Icons.sort, size: 15, color: Color(0xFF64748B)),
|
|
padding: EdgeInsets.zero,
|
|
constraints: const BoxConstraints(minWidth: 24, minHeight: 28),
|
|
itemBuilder: (context) => [
|
|
PopupMenuItem(
|
|
value: true,
|
|
child: Text(context.l10n.d('Oplopend sorteren')),
|
|
),
|
|
PopupMenuItem(
|
|
value: false,
|
|
child: Text(context.l10n.d('Aflopend sorteren')),
|
|
),
|
|
],
|
|
onSelected: (ascending) =>
|
|
_sortRows(column: column, ascending: ascending),
|
|
);
|
|
}
|
|
|
|
Widget _colorDot(String hex) => Container(
|
|
width: 16,
|
|
height: 16,
|
|
decoration: BoxDecoration(
|
|
color: Color(int.parse(hex.substring(1), radix: 16) | 0xFF000000),
|
|
shape: BoxShape.circle,
|
|
border: Border.all(color: Colors.white, width: 1.5),
|
|
boxShadow: const [BoxShadow(color: Color(0x330F172A), blurRadius: 2)],
|
|
),
|
|
);
|
|
|
|
Widget _cell({
|
|
required Key key,
|
|
required String value,
|
|
required bool enabled,
|
|
required ValueChanged<String> onChanged,
|
|
bool number = false,
|
|
bool bold = false,
|
|
bool muted = false,
|
|
}) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 2),
|
|
child: TextFormField(
|
|
key: key,
|
|
initialValue: value,
|
|
enabled: enabled,
|
|
onChanged: (v) {
|
|
onChanged(v);
|
|
_emit();
|
|
},
|
|
keyboardType: number
|
|
? const TextInputType.numberWithOptions(decimal: true, signed: true)
|
|
: TextInputType.text,
|
|
inputFormatters: number
|
|
? [FilteringTextInputFormatter.allow(RegExp(r'[0-9.,\-]'))]
|
|
: null,
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: bold ? FontWeight.w600 : FontWeight.normal,
|
|
color: muted ? const Color(0xFF64748B) : null,
|
|
),
|
|
decoration: InputDecoration(
|
|
isDense: true,
|
|
filled: muted,
|
|
fillColor: muted ? const Color(0xFFF1F5F9) : null,
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
horizontal: 8,
|
|
vertical: 8,
|
|
),
|
|
border: const OutlineInputBorder(),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _iconBtn(IconData icon, VoidCallback? onTap, {Key? key}) => IconButton(
|
|
key: key,
|
|
onPressed: onTap,
|
|
icon: Icon(icon, size: 14),
|
|
color: const Color(0xFF94A3B8),
|
|
visualDensity: VisualDensity.compact,
|
|
padding: EdgeInsets.zero,
|
|
constraints: const BoxConstraints(minWidth: 24, minHeight: 24),
|
|
);
|
|
}
|