add loco timeline view

This commit is contained in:
2025-12-16 12:24:53 +00:00
parent 80c315866f
commit 2b4d2623fc

View File

@@ -1,3 +1,5 @@
import 'dart:math' as math;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/objects/objects.dart';
@@ -45,116 +47,258 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
), ),
body: RefreshIndicator( body: RefreshIndicator(
onRefresh: _load, onRefresh: _load,
child: ListView( child: LayoutBuilder(
padding: const EdgeInsets.all(16), builder: (context, constraints) {
physics: const AlwaysScrollableScrollPhysics(), if (isLoading && timeline.isEmpty) {
children: [ return const Center(child: CircularProgressIndicator());
Text( }
'Attribute timeline', if (timeline.isEmpty) {
style: Theme.of(context).textTheme.titleLarge?.copyWith( return Padding(
fontWeight: FontWeight.w800, padding: const EdgeInsets.all(16.0),
), child: Card(
), child: Padding(
const SizedBox(height: 6), padding: const EdgeInsets.all(16.0),
Text( child: Column(
'Each row is an attribute. Blocks stretch between change dates; scroll sideways for history.', crossAxisAlignment: CrossAxisAlignment.start,
style: Theme.of(context).textTheme.bodyMedium, children: [
), Text(
const SizedBox(height: 16), 'No timeline data yet',
if (isLoading && timeline.isEmpty) style: Theme.of(context).textTheme.titleMedium?.copyWith(
const Padding( fontWeight: FontWeight.w700,
padding: EdgeInsets.symmetric(vertical: 32.0), ),
child: Center(child: CircularProgressIndicator()), ),
) const SizedBox(height: 8),
else if (timeline.isEmpty) const Text(
Card( 'This locomotive does not have any attribute history to show right now.',
child: Padding( ),
padding: const EdgeInsets.all(16.0), const SizedBox(height: 12),
child: Column( OutlinedButton.icon(
crossAxisAlignment: CrossAxisAlignment.start, onPressed: _load,
children: [ icon: const Icon(Icons.refresh),
Text( label: const Text('Try again'),
'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'),
),
],
), ),
), ),
) );
else }
_TimelineGrid(entries: timeline), return _TimelineGrid(
], entries: timeline,
maxHeight: constraints.maxHeight,
);
},
), ),
), ),
); );
} }
} }
class _TimelineGrid extends StatelessWidget { class _TimelineGrid extends StatefulWidget {
const _TimelineGrid({required this.entries}); const _TimelineGrid({
required this.entries,
required this.maxHeight,
});
final List<LocoAttrVersion> entries; final List<LocoAttrVersion> entries;
final double maxHeight;
@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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final model = _TimelineModel.fromEntries(entries); 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; final axisSegments = model.axisSegments;
const labelWidth = 150.0; const labelWidth = 110.0;
const rowHeight = 52.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 paddingTop = MediaQuery.of(context).padding.top;
final double constraintHeight = widget.maxHeight.isFinite
? widget.maxHeight
: MediaQuery.of(context).size.height;
final double availableHeight =
(constraintHeight - paddingTop - 24).clamp(axisHeight + 40, double.infinity);
final double viewHeight = availableHeight;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Scrollbar( SizedBox(
thumbVisibility: true, height: viewHeight,
child: SingleChildScrollView( child: Row(
scrollDirection: Axis.horizontal, crossAxisAlignment: CrossAxisAlignment.start,
child: IntrinsicWidth( children: [
child: Column( SizedBox(
crossAxisAlignment: CrossAxisAlignment.start, width: labelWidth,
mainAxisSize: MainAxisSize.min, child: Column(
children: [ children: [
_AxisRow( SizedBox(
labelWidth: labelWidth, height: axisHeight,
segments: axisSegments, child: Align(
endLabel: model.endLabel, alignment: Alignment.centerLeft,
), child: Text(
const SizedBox(height: 8), 'Attribute',
...model.attrRows.entries.map( style: Theme.of(context).textTheme.labelLarge?.copyWith(
(entry) => Padding( fontWeight: FontWeight.w700,
padding: const EdgeInsets.symmetric(vertical: 2.0), ),
child: _AttrRow( ),
labelWidth: labelWidth, ),
rowHeight: rowHeight, ),
attrLabel: _formatAttrLabel(entry.key), Expanded(
cells: entry.value, child: Scrollbar(
segments: axisSegments, 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,
),
);
},
),
),
],
), ),
), ),
), ),
], ),
), ),
), ],
), ),
), ),
const SizedBox(height: 14),
Text(
'Dates mark when a value changed. The right edge of the last block is “Now” if no end date is present.',
style: Theme.of(context).textTheme.labelMedium,
),
], ],
); );
} }
@@ -162,139 +306,178 @@ class _TimelineGrid extends StatelessWidget {
class _AxisRow extends StatelessWidget { class _AxisRow extends StatelessWidget {
const _AxisRow({ const _AxisRow({
required this.labelWidth,
required this.segments, required this.segments,
required this.endLabel, required this.endLabel,
required this.totalWidth,
}); });
final double labelWidth;
final List<_AxisSegment> segments; final List<_AxisSegment> segments;
final String endLabel; final String endLabel;
final double totalWidth;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
return Row( const double axisHeight = 48;
crossAxisAlignment: CrossAxisAlignment.start, return SizedBox(
mainAxisSize: MainAxisSize.min, width: totalWidth,
children: [ height: axisHeight,
SizedBox( child: Stack(
width: labelWidth, children: [
height: 36, for (int i = 0; i < segments.length; i++) ...[
child: Align( Positioned(
alignment: Alignment.centerLeft, left: segments[i].offset,
child: Text( width: segments[i].width,
'Date', top: 0,
style: theme.textTheme.labelLarge?.copyWith( bottom: 0,
fontWeight: FontWeight.w700, 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,
), ),
), ),
), ),
), ],
...segments.asMap().entries.map( ),
(entry) {
final i = entry.key;
final seg = entry.value;
final isLast = i == segments.length - 1;
return SizedBox(
width: seg.width,
height: 36,
child: Stack(
children: [
Align(
alignment: Alignment.centerLeft,
child: Text(
seg.label,
style: theme.textTheme.labelSmall,
),
),
if (isLast)
Align(
alignment: Alignment.centerRight,
child: Text(
endLabel,
style: theme.textTheme.labelSmall,
),
),
],
),
);
},
),
],
); );
} }
} }
class _AttrRow extends StatelessWidget { class _AttrRow extends StatelessWidget {
const _AttrRow({ const _AttrRow({
required this.labelWidth,
required this.rowHeight, required this.rowHeight,
required this.attrLabel, required this.blocks,
required this.cells, required this.model,
required this.segments, required this.scrollOffset,
required this.viewportWidth,
}); });
final double labelWidth;
final double rowHeight; final double rowHeight;
final String attrLabel; final List<_ValueBlock> blocks;
final List<_RowCell> cells; final _TimelineModel model;
final List<_AxisSegment> segments; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
return Row( final color = block.cell.color.withOpacity(0.9);
crossAxisAlignment: CrossAxisAlignment.center, final textColor = ThemeData.estimateBrightnessForColor(color) ==
mainAxisSize: MainAxisSize.min, Brightness.dark
children: [ ? Colors.white
Container( : Colors.black87;
width: labelWidth,
height: rowHeight, final radius = BorderRadius.only(
padding: const EdgeInsets.symmetric(horizontal: 10), topLeft: Radius.circular(clipLeftEdge ? 0 : 12),
alignment: Alignment.centerLeft, bottomLeft: Radius.circular(clipLeftEdge ? 0 : 12),
decoration: BoxDecoration( topRight: const Radius.circular(12),
color: theme.colorScheme.surfaceContainerHighest, bottomRight: const Radius.circular(12),
borderRadius: BorderRadius.circular(10), );
),
child: Text( return ClipRRect(
attrLabel, borderRadius: radius,
style: theme.textTheme.labelLarge?.copyWith( child: Container(
fontWeight: FontWeight.w700, 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),
), ),
...segments.asMap().entries.map((entry) { child: block.cell.value.isEmpty
final idx = entry.key; ? const SizedBox.shrink()
final seg = entry.value; : FittedBox(
final cell = cells[idx]; alignment: Alignment.topLeft,
final hasValue = cell.value.isNotEmpty; fit: BoxFit.scaleDown,
final textColor = ThemeData.estimateBrightnessForColor( child: ConstrainedBox(
cell.color.withOpacity(0.9), constraints: const BoxConstraints(minWidth: 1, minHeight: 1),
) == child: Column(
Brightness.dark
? Colors.white
: Colors.black87;
return Container(
width: seg.width,
height: rowHeight,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration(
color: hasValue
? cell.color.withOpacity(0.9)
: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: theme.colorScheme.outlineVariant,
),
),
child: hasValue
? Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Text( Text(
cell.value, block.cell.value,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith( style: theme.textTheme.bodyMedium?.copyWith(
@@ -308,7 +491,7 @@ class _AttrRow extends StatelessWidget {
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
cell.rangeLabel, block.cell.rangeLabel,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelSmall?.copyWith( style: theme.textTheme.labelSmall?.copyWith(
@@ -317,15 +500,13 @@ class _AttrRow extends StatelessWidget {
TextStyle(color: textColor.withOpacity(0.9)), TextStyle(color: textColor.withOpacity(0.9)),
), ),
], ],
) ),
: const SizedBox.shrink(), ),
); ),
}), ),
],
); );
} }
} }
final DateFormat _dateFormat = DateFormat('yyyy-MM-dd'); final DateFormat _dateFormat = DateFormat('yyyy-MM-dd');
String? _formatDate(DateTime? date) { String? _formatDate(DateTime? date) {
@@ -364,13 +545,17 @@ DateTime _safeEnd(DateTime start, DateTime? end) {
class _TimelineModel { class _TimelineModel {
final List<_AxisSegment> axisSegments; final List<_AxisSegment> axisSegments;
final Map<String, List<_RowCell>> attrRows; final Map<String, List<_ValueBlock>> attrRows;
final String endLabel; final String endLabel;
final List<DateTime> boundaries;
final double axisTotalWidth;
_TimelineModel({ _TimelineModel({
required this.axisSegments, required this.axisSegments,
required this.attrRows, required this.attrRows,
required this.endLabel, required this.endLabel,
required this.boundaries,
required this.axisTotalWidth,
}); });
factory _TimelineModel.fromEntries(List<LocoAttrVersion> entries) { factory _TimelineModel.fromEntries(List<LocoAttrVersion> entries) {
@@ -397,14 +582,23 @@ class _TimelineModel {
: null; : null;
final rawEnd = entry.validTo ?? nextStart ?? now; final rawEnd = entry.validTo ?? nextStart ?? now;
final end = _safeEnd(start, rawEnd); final end = _safeEnd(start, rawEnd);
segments.add( if (segments.isNotEmpty && segments.last.value == entry.valueLabel) {
_ValueSegment( final last = segments.removeLast();
start: start, segments.add(
end: end, last.copyWith(
value: entry.valueLabel, end: end.isAfter(last.end) ? end : last.end,
entry: entry, ),
), );
); } else {
segments.add(
_ValueSegment(
start: start,
end: end,
value: entry.valueLabel,
entry: entry,
),
);
}
minStart = minStart == null || start.isBefore(minStart!) minStart = minStart == null || start.isBefore(minStart!)
? start ? start
: minStart; : minStart;
@@ -414,70 +608,108 @@ class _TimelineModel {
}); });
minStart ??= now.subtract(const Duration(days: 1)); minStart ??= now.subtract(const Duration(days: 1));
maxEnd ??= now; final effectiveMaxEnd = maxEnd ?? now;
final boundaryDates = <DateTime>{}; final boundaryDates = <DateTime>{};
for (final segments in attrSegments.values) { for (final segments in attrSegments.values) {
for (final seg in segments) { for (final seg in segments) {
boundaryDates.add(seg.start); boundaryDates.add(seg.start);
boundaryDates.add(seg.end);
} }
} }
boundaryDates.add(maxEnd); boundaryDates.add(effectiveMaxEnd);
var boundaries = boundaryDates.toList()..sort(); var boundaries = boundaryDates.toList()..sort();
if (boundaries.length < 2) { if (boundaries.length < 2) {
boundaries = [minStart!, maxEnd]; boundaries = [minStart!, effectiveMaxEnd];
} }
final axisSegments = <_AxisSegment>[]; final axisSegments = <_AxisSegment>[];
const pxPerDay = 4.0; const double yearWidth = 240.0;
for (int i = 0; i < boundaries.length - 1; i++) { for (int i = 0; i < boundaries.length - 1; i++) {
final start = boundaries[i]; final start = boundaries[i];
final end = boundaries[i + 1]; final end = boundaries[i + 1];
final days = (end.difference(start).inDays).clamp(1, 365); final days = end.difference(start).inDays;
final width = (days * pxPerDay) + 30; final width = days >= 365 ? yearWidth : yearWidth / 2;
final double offset = axisSegments.isEmpty
? 0.0
: axisSegments.last.offset + axisSegments.last.width;
axisSegments.add( axisSegments.add(
_AxisSegment( _AxisSegment(
start: start, start: start,
end: end, end: end,
width: width, width: width,
offset: offset,
label: _formatDate(start) ?? '', label: _formatDate(start) ?? '',
), ),
); );
} }
final axisTotalWidth =
axisSegments.fold<double>(0, (sum, seg) => sum + seg.width);
final attrRows = <String, List<_RowCell>>{}; final attrRows = <String, List<_ValueBlock>>{};
for (final entry in attrSegments.entries) { for (final entry in attrSegments.entries) {
final cells = <_RowCell>[]; final blocks = <_ValueBlock>[];
for (final axis in axisSegments) { for (final seg in entry.value) {
final seg = entry.value final left = _positionForDate(seg.start, boundaries, axisSegments);
.firstWhere( final right = _positionForDate(seg.end, boundaries, axisSegments);
(s) => s.overlaps(axis.start, axis.end), final span = right - left;
orElse: () => _ValueSegment.empty(axis.start, axis.end), final width = span < 2.0 ? 2.0 : span;
); blocks.add(
cells.add(_RowCell.fromSegment(seg)); _ValueBlock(
left: left,
width: width,
cell: _RowCell.fromSegment(seg),
),
);
} }
attrRows[entry.key] = cells; attrRows[entry.key] = blocks;
} }
final endLabel = _formatDate(maxEnd) ?? 'Now'; final endLabel = _formatDate(effectiveMaxEnd) ?? 'Now';
return _TimelineModel( return _TimelineModel(
axisSegments: axisSegments, axisSegments: axisSegments,
attrRows: attrRows, attrRows: attrRows,
endLabel: endLabel, endLabel: endLabel,
boundaries: boundaries,
axisTotalWidth: axisTotalWidth,
); );
} }
static double _positionForDate(
DateTime date,
List<DateTime> 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 { class _AxisSegment {
final DateTime start; final DateTime start;
final DateTime end; final DateTime end;
final double width; final double width;
final double offset;
final String label; final String label;
_AxisSegment({ _AxisSegment({
required this.start, required this.start,
required this.end, required this.end,
required this.width, required this.width,
required this.offset,
required this.label, required this.label,
}); });
} }
@@ -495,16 +727,18 @@ class _ValueSegment {
this.entry, this.entry,
}); });
factory _ValueSegment.empty(DateTime start, DateTime end) => _ValueSegment(
start: start,
end: end,
value: '',
entry: null,
);
bool overlaps(DateTime s, DateTime e) { bool overlaps(DateTime s, DateTime e) {
return start.isBefore(e) && end.isAfter(s); 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 { class _RowCell {
@@ -527,16 +761,40 @@ class _RowCell {
); );
} }
final displayStart = _formatDate(seg.start) ?? ''; final displayStart = _formatDate(seg.start) ?? '';
final displayEnd = _formatDate(seg.entry?.validTo ?? seg.end) ?? 'Now';
final range = '$displayStart$displayEnd';
return _RowCell( return _RowCell(
value: seg.value, value: seg.value,
rangeLabel: range, rangeLabel: displayStart,
color: _colorForValue(seg.value), 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,
);
}
}
Color _colorForValue(String value) { Color _colorForValue(String value) {
final hue = (value.hashCode % 360).toDouble(); final hue = (value.hashCode % 360).toDouble();
final hsl = HSLColor.fromAHSL(1, hue, 0.55, 0.55); final hsl = HSLColor.fromAHSL(1, hue, 0.55, 0.55);