import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/services/dataService.dart'; import 'package:provider/provider.dart'; class LocoTimelinePage extends StatefulWidget { const LocoTimelinePage({ super.key, required this.locoId, required this.locoLabel, }); final int locoId; final String locoLabel; @override State createState() => _LocoTimelinePageState(); } class _LocoTimelinePageState extends State { final List<_EventDraft> _draftEvents = []; bool _isSaving = false; @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) => _load()); } Future _load() { final data = context.read(); data.fetchEventFields(); return data.fetchLocoTimeline(widget.locoId); } void _addDraftEvent() { setState(() { _draftEvents.add(_EventDraft()); }); } Future _saveEvents() async { if (_isSaving) return; if (!_canSaveDrafts()) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Please fix validation issues before saving.')), ); return; } final data = context.read(); setState(() { _isSaving = true; }); try { final invalid = []; for (final draft in _draftEvents) { final dateStr = draft.dateController.text.trim(); if (!_isValidDateString(dateStr)) { invalid.add('Date is invalid (${dateStr.isEmpty ? 'empty' : dateStr})'); continue; } if (draft.fields.isEmpty) { invalid.add('Add at least one field for each event'); continue; } final values = {}; for (final field in draft.fields) { final val = field.value; final isBlankString = val is String && val.trim().isEmpty; if (val == null || isBlankString) { invalid.add('Field ${field.field.display} is empty'); break; } values[field.field.name] = val; } if (invalid.isNotEmpty) continue; if (values.isEmpty) { invalid.add('Add at least one value'); continue; } await data.createLocoEvent( locoId: widget.locoId, eventDate: dateStr, values: values, details: draft.details, ); } if (invalid.isNotEmpty) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(invalid.first)), ); } return; } _draftEvents.clear(); await _load(); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Events saved')), ); } } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Failed to save events: $e')), ); } } finally { if (mounted) { setState(() { _isSaving = false; }); } } } bool _isValidDateString(String input) { final trimmed = input.trim(); final regex = RegExp(r'^\d{4}-(\d{2}|xx|XX)-(\d{2}|xx|XX)$'); if (!regex.hasMatch(trimmed)) return false; final parts = trimmed.split('-'); final monthPart = parts[1]; final dayPart = parts[2]; final monthUnknown = monthPart.toLowerCase() == 'xx'; final dayUnknown = dayPart.toLowerCase() == 'xx'; if (monthUnknown && !dayUnknown) return false; if (!monthUnknown) { final month = int.tryParse(monthPart); if (month == null || month < 1 || month > 12) return false; } if (!dayUnknown) { final day = int.tryParse(dayPart); if (day == null || day < 1 || day > 31) return false; } return true; } bool _draftIsValid(_EventDraft draft) { final dateStr = draft.dateController.text.trim(); if (!_isValidDateString(dateStr)) return false; if (draft.fields.isEmpty) return false; for (final field in draft.fields) { final val = field.value; if (val == null) return false; if (val is String && val.trim().isEmpty) return false; } return true; } bool _canSaveDrafts() { if (_draftEvents.isEmpty) return false; return _draftEvents.every(_draftIsValid); } @override Widget build(BuildContext context) { final data = context.watch(); final timeline = data.timelineForLoco(widget.locoId); final isLoading = data.isLocoTimelineLoading(widget.locoId); return Scaffold( appBar: AppBar( leading: IconButton( icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.of(context).maybePop(), ), title: Text('Timeline ยท ${widget.locoLabel}'), ), body: RefreshIndicator( onRefresh: _load, child: LayoutBuilder( builder: (context, constraints) { if (isLoading && timeline.isEmpty) { return const Center(child: CircularProgressIndicator()); } if (timeline.isEmpty) { return Padding( padding: const EdgeInsets.all(16.0), child: Card( child: Padding( padding: const EdgeInsets.all(16.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'No timeline data yet', style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w700, ), ), const SizedBox(height: 8), const Text( 'This locomotive does not have any attribute history to show right now.', ), const SizedBox(height: 12), OutlinedButton.icon( onPressed: _load, icon: const Icon(Icons.refresh), label: const Text('Try again'), ), ], ), ), ), ); } return ListView( padding: const EdgeInsets.all(16), children: [ _TimelineGrid( entries: timeline, ), const SizedBox(height: 16), _EventEditor( eventFields: data.eventFields, drafts: _draftEvents, onAddEvent: _addDraftEvent, onChange: () => setState(() {}), onSave: _saveEvents, isSaving: _isSaving, canSave: _canSaveDrafts(), ), ], ); }, ), ), ); } } class _TimelineGrid extends StatefulWidget { const _TimelineGrid({ required this.entries, }); final List entries; @override State<_TimelineGrid> createState() => _TimelineGridState(); } class _TimelineGridState extends State<_TimelineGrid> { final ScrollController _horizontalController = ScrollController(); final ScrollController _rightVerticalController = ScrollController(); final ScrollController _leftVerticalController = ScrollController(); bool _isSyncingScroll = false; double _scrollOffset = 0; @override void initState() { super.initState(); _rightVerticalController.addListener(_syncVerticalScroll); _horizontalController.addListener(_onHorizontalScroll); } @override void dispose() { _rightVerticalController.removeListener(_syncVerticalScroll); _horizontalController.removeListener(_onHorizontalScroll); _horizontalController.dispose(); _rightVerticalController.dispose(); _leftVerticalController.dispose(); super.dispose(); } void _syncVerticalScroll() { if (_isSyncingScroll) return; if (!_leftVerticalController.hasClients || !_rightVerticalController.hasClients) { return; } _isSyncingScroll = true; _leftVerticalController.jumpTo( _rightVerticalController.offset.clamp( 0.0, _leftVerticalController.position.maxScrollExtent, ), ); _isSyncingScroll = false; } void _onHorizontalScroll() { if (!mounted) return; setState(() { _scrollOffset = _horizontalController.offset; }); } @override Widget build(BuildContext context) { final filteredEntries = widget.entries.where((e) { final code = e.attrCode.toLowerCase(); return !{ 'operational', 'gettable', 'build_prec', 'build_year', 'build_month', 'build_day', }.contains(code); }).toList(); final model = _TimelineModel.fromEntries(filteredEntries); final axisSegments = model.axisSegments; const labelWidth = 110.0; const rowHeight = 52.0; const double axisHeight = 48; final rows = model.attrRows.entries.toList(); final totalRowsHeight = rows.length * rowHeight; final axisWidth = math.max(model.axisTotalWidth, 120.0); final double viewHeight = totalRowsHeight + axisHeight + 8; return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ SizedBox( height: viewHeight, child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( width: labelWidth, child: Column( children: [ SizedBox( height: axisHeight, child: Align( alignment: Alignment.centerLeft, child: Text( 'Attribute', style: Theme.of(context).textTheme.labelLarge?.copyWith( fontWeight: FontWeight.w700, ), ), ), ), Expanded( child: Scrollbar( controller: _leftVerticalController, thumbVisibility: true, child: ListView.builder( controller: _leftVerticalController, physics: const NeverScrollableScrollPhysics(), itemExtent: rowHeight, itemCount: rows.length, itemBuilder: (_, index) { final label = _formatAttrLabel(rows[index].key); return Container( alignment: Alignment.centerLeft, padding: const EdgeInsets.symmetric( horizontal: 10, ), decoration: BoxDecoration( color: Theme.of(context) .colorScheme .surfaceContainerHighest, border: Border( bottom: BorderSide( color: Theme.of(context).colorScheme.outlineVariant, ), ), ), child: Text( label, style: Theme.of(context) .textTheme .labelLarge ?.copyWith(fontWeight: FontWeight.w700), ), ); }, ), ), ), ], ), ), const SizedBox(width: 8), Expanded( child: Scrollbar( controller: _horizontalController, thumbVisibility: true, child: SingleChildScrollView( controller: _horizontalController, scrollDirection: Axis.horizontal, child: SizedBox( width: axisWidth, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _AxisRow( segments: axisSegments, totalWidth: axisWidth, endLabel: model.endLabel, ), const SizedBox(height: 8), Expanded( child: ListView.builder( controller: _rightVerticalController, itemExtent: rowHeight, itemCount: rows.length, itemBuilder: (_, index) { final blocks = rows[index].value; return Padding( padding: const EdgeInsets.symmetric(vertical: 2.0), child: _AttrRow( rowHeight: rowHeight, blocks: blocks, model: model, scrollOffset: _scrollOffset, viewportWidth: axisWidth, ), ); }, ), ), ], ), ), ), ), ), ], ), ), ], ); } } class _AxisRow extends StatelessWidget { const _AxisRow({ required this.segments, required this.endLabel, required this.totalWidth, }); final List<_AxisSegment> segments; final String endLabel; final double totalWidth; @override Widget build(BuildContext context) { final theme = Theme.of(context); const double axisHeight = 48; return SizedBox( width: totalWidth, height: axisHeight, child: Stack( children: [ for (int i = 0; i < segments.length; i++) ...[ Positioned( left: segments[i].offset, width: segments[i].width, top: 0, bottom: 0, child: Align( alignment: Alignment.centerLeft, child: Text( segments[i].label, overflow: TextOverflow.ellipsis, maxLines: 1, style: theme.textTheme.labelSmall, ), ), ), ], Positioned( right: 0, top: 0, bottom: 0, child: Align( alignment: Alignment.centerRight, child: Text( endLabel, overflow: TextOverflow.ellipsis, maxLines: 1, style: theme.textTheme.labelSmall, ), ), ), ], ), ); } } class _AttrRow extends StatelessWidget { const _AttrRow({ required this.rowHeight, required this.blocks, required this.model, required this.scrollOffset, required this.viewportWidth, }); final double rowHeight; final List<_ValueBlock> blocks; final _TimelineModel model; final double scrollOffset; final double viewportWidth; @override Widget build(BuildContext context) { final width = math.max(model.axisTotalWidth, 120.0); final activeBlock = _activeBlock(blocks, scrollOffset); final double stickyWidth = activeBlock == null ? 0 : (activeBlock.right - scrollOffset).clamp(20.0, viewportWidth); return SizedBox( width: width, height: rowHeight, child: Stack( clipBehavior: Clip.hardEdge, children: [ for (final block in blocks) Positioned( left: block.left, width: block.width, top: 0, bottom: 0, child: _ValueBlockView(block: block), ), if (activeBlock != null) Positioned( left: scrollOffset, width: stickyWidth, top: 0, bottom: 0, child: IgnorePointer( child: ClipRect( child: _ValueBlockView( block: activeBlock.copyWith( left: scrollOffset, width: stickyWidth, ), clipLeftEdge: scrollOffset > activeBlock.left + 0.1, ), ), ), ), ], ), ); } _ValueBlock? _activeBlock(List<_ValueBlock> blocks, double offset) { for (final block in blocks) { if (offset >= block.left && offset < block.right) return block; } return null; } } class _ValueBlockView extends StatelessWidget { const _ValueBlockView({ required this.block, this.clipLeftEdge = false, }); final _ValueBlock block; final bool clipLeftEdge; @override Widget build(BuildContext context) { final theme = Theme.of(context); final color = block.cell.color.withOpacity(0.9); final textColor = ThemeData.estimateBrightnessForColor(color) == Brightness.dark ? Colors.white : Colors.black87; final radius = BorderRadius.only( topLeft: Radius.circular(clipLeftEdge ? 0 : 12), bottomLeft: Radius.circular(clipLeftEdge ? 0 : 12), topRight: const Radius.circular(12), bottomRight: const Radius.circular(12), ); return ClipRRect( borderRadius: radius, child: Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), decoration: BoxDecoration( color: block.cell.value.isEmpty ? theme.colorScheme.surfaceContainerHighest : color, borderRadius: BorderRadius.zero, border: Border.all(color: theme.colorScheme.outlineVariant), ), child: block.cell.value.isEmpty ? const SizedBox.shrink() : FittedBox( alignment: Alignment.topLeft, fit: BoxFit.scaleDown, child: ConstrainedBox( constraints: const BoxConstraints(minWidth: 1, minHeight: 1), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, children: [ Text( block.cell.value, maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodyMedium?.copyWith( fontWeight: FontWeight.w700, color: textColor, ) ?? TextStyle( fontWeight: FontWeight.w700, color: textColor, ), ), const SizedBox(height: 4), Text( block.cell.rangeLabel, maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.labelSmall?.copyWith( color: textColor.withOpacity(0.9), ) ?? TextStyle(color: textColor.withOpacity(0.9)), ), ], ), ), ), ), ); } } final DateFormat _dateFormat = DateFormat('yyyy-MM-dd'); String? _formatDate(DateTime? date) { if (date == null) return null; return _dateFormat.format(date); } String _formatAttrLabel(String code) { if (code.isEmpty) return 'Attribute'; final parts = code.split('_').where((p) => p.isNotEmpty).toList(); if (parts.isEmpty) return code; return parts .map((part) => part.length == 1 ? part.toUpperCase() : part[0].toUpperCase() + part.substring(1)) .join(' '); } DateTime? _parseDateString(String? value) { if (value == null || value.isEmpty) return null; return DateTime.tryParse(value); } DateTime? _effectiveStart(LocoAttrVersion entry) { return entry.validFrom ?? _parseDateString(entry.maskedValidFrom) ?? entry.txnFrom; } DateTime _safeEnd(DateTime start, DateTime? end) { if (end == null || !end.isAfter(start)) { return start.add(const Duration(days: 1)); } return end; } class _TimelineModel { final List<_AxisSegment> axisSegments; final Map> attrRows; final String endLabel; final List boundaries; final double axisTotalWidth; _TimelineModel({ required this.axisSegments, required this.attrRows, required this.endLabel, required this.boundaries, required this.axisTotalWidth, }); factory _TimelineModel.fromEntries(List entries) { final grouped = >{}; for (final entry in entries) { grouped.putIfAbsent(entry.attrCode, () => []).add(entry); } final now = DateTime.now(); DateTime? minStart; DateTime? maxEnd; final attrSegments = >{}; grouped.forEach((attr, items) { items.sort( (a, b) => (_effectiveStart(a) ?? now) .compareTo(_effectiveStart(b) ?? now), ); final segments = <_ValueSegment>[]; for (int i = 0; i < items.length; i++) { final entry = items[i]; final start = _effectiveStart(entry) ?? now; final nextStart = i < items.length - 1 ? _effectiveStart(items[i + 1]) : null; final rawEnd = entry.validTo ?? nextStart ?? now; final end = _safeEnd(start, rawEnd); if (segments.isNotEmpty && segments.last.value == entry.valueLabel) { final last = segments.removeLast(); segments.add( last.copyWith( end: end.isAfter(last.end) ? end : last.end, ), ); } else { segments.add( _ValueSegment( start: start, end: end, value: entry.valueLabel, entry: entry, ), ); } minStart = minStart == null || start.isBefore(minStart!) ? start : minStart; maxEnd = maxEnd == null || end.isAfter(maxEnd!) ? end : maxEnd; } attrSegments[attr] = segments; }); minStart ??= now.subtract(const Duration(days: 1)); final effectiveMaxEnd = maxEnd ?? now; final boundaryDates = {}; for (final segments in attrSegments.values) { for (final seg in segments) { boundaryDates.add(seg.start); boundaryDates.add(seg.end); } } boundaryDates.add(effectiveMaxEnd); var boundaries = boundaryDates.toList()..sort(); if (boundaries.length < 2) { boundaries = [minStart!, effectiveMaxEnd]; } final axisSegments = <_AxisSegment>[]; const double yearWidth = 240.0; for (int i = 0; i < boundaries.length - 1; i++) { final start = boundaries[i]; final end = boundaries[i + 1]; final width = yearWidth; final double offset = axisSegments.isEmpty ? 0.0 : axisSegments.last.offset + axisSegments.last.width; axisSegments.add( _AxisSegment( start: start, end: end, width: width, offset: offset, label: _formatDate(start) ?? '', ), ); } final axisTotalWidth = axisSegments.fold(0, (sum, seg) => sum + seg.width); final attrRows = >{}; for (final entry in attrSegments.entries) { final blocks = <_ValueBlock>[]; for (final seg in entry.value) { final left = _positionForDate(seg.start, boundaries, axisSegments); final right = _positionForDate(seg.end, boundaries, axisSegments); final span = right - left; final width = span < 2.0 ? 2.0 : span; blocks.add( _ValueBlock( left: left, width: width, cell: _RowCell.fromSegment(seg), ), ); } attrRows[entry.key] = blocks; } final endLabel = _formatDate(effectiveMaxEnd) ?? 'Now'; return _TimelineModel( axisSegments: axisSegments, attrRows: attrRows, endLabel: endLabel, boundaries: boundaries, axisTotalWidth: axisTotalWidth, ); } static double _positionForDate( DateTime date, List boundaries, List<_AxisSegment> segments, ) { for (int i = 0; i < boundaries.length - 1; i++) { final start = boundaries[i]; final end = boundaries[i + 1]; if (!date.isAfter(end)) { final seg = segments[i]; final span = end.difference(start).inMilliseconds; final elapsed = date.difference(start).inMilliseconds.clamp(0, span); if (span <= 0) return seg.offset; final fraction = elapsed / span; return seg.offset + (seg.width * fraction); } } return segments.isNotEmpty ? segments.last.offset + segments.last.width : 0.0; } } class _AxisSegment { final DateTime start; final DateTime end; final double width; final double offset; final String label; _AxisSegment({ required this.start, required this.end, required this.width, required this.offset, required this.label, }); } class _ValueSegment { final DateTime start; final DateTime end; final String value; final LocoAttrVersion? entry; _ValueSegment({ required this.start, required this.end, required this.value, this.entry, }); bool overlaps(DateTime s, DateTime e) { return start.isBefore(e) && end.isAfter(s); } _ValueSegment copyWith({DateTime? start, DateTime? end, String? value}) { return _ValueSegment( start: start ?? this.start, end: end ?? this.end, value: value ?? this.value, entry: entry, ); } } class _RowCell { final String value; final String rangeLabel; final Color color; const _RowCell({ required this.value, required this.rangeLabel, required this.color, }); factory _RowCell.fromSegment(_ValueSegment seg) { if (seg.value.isEmpty) { return const _RowCell( value: '', rangeLabel: '', color: Colors.transparent, ); } final displayStart = _formatDate(seg.start) ?? ''; return _RowCell( value: seg.value, rangeLabel: displayStart, color: _colorForValue(seg.value), ); } } class _ValueBlock { final double left; final double width; final _RowCell cell; const _ValueBlock({ required this.left, required this.width, required this.cell, }); double get right => left + width; _ValueBlock copyWith({ double? left, double? width, _RowCell? cell, }) { return _ValueBlock( left: left ?? this.left, width: width ?? this.width, cell: cell ?? this.cell, ); } } class _EventEditor extends StatelessWidget { const _EventEditor({ required this.eventFields, required this.drafts, required this.onAddEvent, required this.onChange, required this.onSave, required this.isSaving, required this.canSave, }); final List eventFields; final List<_EventDraft> drafts; final VoidCallback onAddEvent; final VoidCallback onChange; final Future Function() onSave; final bool isSaving; final bool canSave; @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( 'Add events', style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w700, ), ), Row( children: [ OutlinedButton.icon( onPressed: onAddEvent, icon: const Icon(Icons.add), label: const Text('New event'), ), const SizedBox(width: 8), FilledButton.icon( onPressed: (!canSave || isSaving) ? null : onSave, icon: isSaving ? const SizedBox( height: 14, width: 14, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.save), label: Text(isSaving ? 'Saving...' : 'Save all'), ), ], ), ], ), const SizedBox(height: 12), if (drafts.isEmpty) const Text('No events yet. Add one to propose new values.') else ...drafts.asMap().entries.map( (entry) { final idx = entry.key; final draft = entry.value; return Padding( padding: const EdgeInsets.only(bottom: 12.0), child: Card( child: Padding( padding: const EdgeInsets.all(12.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Text( 'Event ${idx + 1}', style: Theme.of(context).textTheme.titleMedium, ), const Spacer(), IconButton( tooltip: 'Remove', onPressed: () { drafts.removeAt(idx); onChange(); }, icon: const Icon(Icons.delete_outline), ), ], ), const SizedBox(height: 8), Row( children: [ Expanded( child: TextField( controller: draft.dateController, onChanged: (_) => onChange(), decoration: const InputDecoration( labelText: 'Date (YYYY-MM-DD, MM/DD can be XX)', border: OutlineInputBorder(), ), ), ), IconButton( tooltip: 'Pick date', onPressed: () async { final now = DateTime.now(); final picked = await showDatePicker( context: context, initialDate: draft.date ?? now, firstDate: DateTime(1900), lastDate: DateTime(now.year + 10), ); if (picked != null) { draft.date = picked; draft.dateController.text = DateFormat('yyyy-MM-dd').format(picked); onChange(); } }, icon: const Icon(Icons.calendar_month), ), ], ), const SizedBox(height: 8), _FieldList( draft: draft, eventFields: eventFields, onChange: onChange, ), const SizedBox(height: 12), TextField( controller: draft.detailsController, onChanged: (val) { draft.details = val; onChange(); }, decoration: const InputDecoration( labelText: 'Commit message / details', border: OutlineInputBorder(), ), ), ], ), ), ), ); }, ), ], ); } } class _FieldList extends StatelessWidget { const _FieldList({ required this.draft, required this.eventFields, required this.onChange, }); final _EventDraft draft; final List eventFields; final VoidCallback onChange; @override Widget build(BuildContext context) { final usedNames = draft.fields.map((f) => f.field.name).toSet(); final availableFields = eventFields.where((f) => !usedNames.contains(f.name)).toList(); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Text( 'Fields', style: Theme.of(context).textTheme.titleSmall, ), const Spacer(), DropdownButton( hint: const Text('Add field'), value: null, onChanged: (field) { if (field == null) return; draft.fields.add(_FieldEntry(field: field)); onChange(); }, items: availableFields .map( (f) => DropdownMenuItem( value: f, child: Text(f.display), ), ) .toList(), ), ], ), const SizedBox(height: 8), if (draft.fields.isEmpty) const Text('No fields added yet.') else ...draft.fields.asMap().entries.map( (entry) { final idx = entry.key; final field = entry.value; return Padding( padding: const EdgeInsets.symmetric(vertical: 6.0), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( field.field.display, style: Theme.of(context) .textTheme .labelLarge ?.copyWith(fontWeight: FontWeight.w700), ), const SizedBox(height: 4), _FieldInput( field: field.field, value: field.value, onChanged: (val) { field.value = val; onChange(); }, ), ], ), ), IconButton( onPressed: () { draft.fields.removeAt(idx); onChange(); }, icon: const Icon(Icons.close), ), ], ), ); }, ), ], ); } } class _FieldInput extends StatelessWidget { const _FieldInput({ required this.field, required this.value, required this.onChanged, }); final EventField field; final dynamic value; final ValueChanged onChanged; @override Widget build(BuildContext context) { if (field.enumValues != null && field.enumValues!.isNotEmpty) { final options = field.enumValues!; return DropdownButtonFormField( value: value is String && options.contains(value) ? value : null, decoration: const InputDecoration(border: OutlineInputBorder()), items: options .map((v) => DropdownMenuItem(value: v, child: Text(v))) .toList(), onChanged: (val) => onChanged(val), hint: const Text('Select value'), ); } final type = field.type?.toLowerCase(); if (type == 'bool' || type == 'boolean') { final bool? current = value is bool ? value : (value is String ? value == 'true' : null); return DropdownButtonFormField( value: current, decoration: const InputDecoration(border: OutlineInputBorder()), items: const [ DropdownMenuItem(value: true, child: Text('Yes')), DropdownMenuItem(value: false, child: Text('No')), ], onChanged: (val) => onChanged(val), hint: const Text('Select'), ); } final isNumber = type == 'int' || type == 'integer'; return TextFormField( initialValue: value?.toString(), onChanged: (val) { if (isNumber) { final parsed = int.tryParse(val); onChanged(parsed); } else { onChanged(val); } }, decoration: const InputDecoration( border: OutlineInputBorder(), hintText: 'Enter value', ), keyboardType: isNumber ? TextInputType.number : TextInputType.text, ); } } class _EventDraft { DateTime? date; String details = ''; final TextEditingController detailsController = TextEditingController(); final TextEditingController dateController = TextEditingController(); final List<_FieldEntry> fields = []; _EventDraft(); } class _FieldEntry { final EventField field; dynamic value; _FieldEntry({required this.field, this.value}); } Color _colorForValue(String value) { final hue = (value.hashCode % 360).toDouble(); final hsl = HSLColor.fromAHSL(1, hue, 0.55, 0.55); return hsl.toColor(); }