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 onUpdate; final String? projectPath; const ChartEditor({ super.key, required this.slide, required this.onUpdate, this.projectPath, }); @override State createState() => _ChartEditorState(); } class _ChartEditorState extends State { late final TextEditingController _title; late final TextEditingController _minBound; late final TextEditingController _maxBound; late ChartType _type; String? _source; // Editable grid model (strings while editing). List _xLabels = []; List _rowColors = []; List _seriesNames = []; List _seriesColors = []; List> _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.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 = [ 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.from(_xLabels), rowColors: List.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.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 _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( 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.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 _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 _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 _pickColor({ required String initial, required String title, }) async { final controller = TextEditingController(text: initial); final selected = await showDialog( 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( 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( 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 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), ); }