Files
mileograph_flutter/lib/components/pages/loco_timeline/timeline_grid.dart
Pete Gregory 950978b021
All checks were successful
Release / meta (push) Successful in 12s
Release / linux-build (push) Successful in 7m42s
Release / android-build (push) Successful in 16m34s
Release / release-dev (push) Successful in 38s
Release / release-master (push) Successful in 37s
new settings panel for url pickup
2025-12-22 22:45:33 +00:00

789 lines
24 KiB
Dart

part of 'package:mileograph_flutter/components/pages/loco_timeline.dart';
final DateFormat _dateFormat = DateFormat('yyyy-MM-dd');
class _TimelineGrid extends StatefulWidget {
const _TimelineGrid({
required this.entries,
this.onEditEntry,
this.onDeleteEntry,
});
final List<LocoAttrVersion> entries;
final void Function(LocoAttrVersion entry)? onEditEntry;
final void Function(LocoAttrVersion entry)? onDeleteEntry;
@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,
onEditEntry: widget.onEditEntry,
onDeleteEntry: widget.onDeleteEntry,
),
);
},
),
),
],
),
),
),
),
),
],
),
),
],
);
}
}
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,
this.onEditEntry,
this.onDeleteEntry,
});
final double rowHeight;
final List<_ValueBlock> blocks;
final _TimelineModel model;
final double scrollOffset;
final double viewportWidth;
final void Function(LocoAttrVersion entry)? onEditEntry;
final void Function(LocoAttrVersion entry)? onDeleteEntry;
@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: _ValueBlockMenu(
block: block,
onEditEntry: onEditEntry,
onDeleteEntry: onDeleteEntry,
),
),
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.withValues(alpha: 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.withValues(alpha: 0.9),
) ??
TextStyle(color: textColor.withValues(alpha: 0.9)),
),
],
),
),
),
),
);
}
}
enum _TimelineBlockAction { edit, delete }
class _ValueBlockMenu extends StatelessWidget {
const _ValueBlockMenu({
required this.block,
this.onEditEntry,
this.onDeleteEntry,
});
final _ValueBlock block;
final void Function(LocoAttrVersion entry)? onEditEntry;
final void Function(LocoAttrVersion entry)? onDeleteEntry;
bool get _hasActions => onEditEntry != null || onDeleteEntry != null;
@override
Widget build(BuildContext context) {
if (!_hasActions || block.entry == null) {
return _ValueBlockView(block: block);
}
Future<void> showContextMenuAt(Offset globalPosition) async {
final overlay = Overlay.of(context);
final renderBox = overlay.context.findRenderObject() as RenderBox?;
if (renderBox == null) return;
// Translate from global screen coordinates into the overlay's local space
// so the menu appears where the gesture happened.
final localPosition = renderBox.globalToLocal(globalPosition);
final position = RelativeRect.fromRect(
Rect.fromLTWH(localPosition.dx, localPosition.dy, 1, 1),
Offset.zero & renderBox.size,
);
final action = await showMenu<_TimelineBlockAction>(
context: context,
position: position,
items: [
if (onEditEntry != null)
const PopupMenuItem(
value: _TimelineBlockAction.edit,
child: Text('Edit'),
),
if (onDeleteEntry != null)
const PopupMenuItem(
value: _TimelineBlockAction.delete,
child: Text('Delete'),
),
],
);
final entry = block.entry;
if (action == null || entry == null) return;
switch (action) {
case _TimelineBlockAction.edit:
onEditEntry?.call(entry);
break;
case _TimelineBlockAction.delete:
onDeleteEntry?.call(entry);
break;
}
}
return GestureDetector(
behavior: HitTestBehavior.opaque,
onLongPressStart: (details) async {
if (defaultTargetPlatform == TargetPlatform.android) {
HapticFeedback.lightImpact();
}
await showContextMenuAt(details.globalPosition);
},
onSecondaryTapDown: (details) async {
await showContextMenuAt(details.globalPosition);
},
child: _ValueBlockView(block: block),
);
}
}
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<String, List<_ValueBlock>> attrRows;
final String endLabel;
final List<DateTime> boundaries;
final double axisTotalWidth;
_TimelineModel({
required this.axisSegments,
required this.attrRows,
required this.endLabel,
required this.boundaries,
required this.axisTotalWidth,
});
factory _TimelineModel.fromEntries(List<LocoAttrVersion> entries) {
final grouped = <String, List<LocoAttrVersion>>{};
for (final entry in entries) {
grouped.putIfAbsent(entry.attrCode, () => []).add(entry);
}
final now = DateTime.now();
DateTime? minStart;
DateTime? maxEnd;
final attrSegments = <String, List<_ValueSegment>>{};
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);
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 = <DateTime>{};
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];
const 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<double>(0, (sum, seg) => sum + seg.width);
final attrRows = <String, List<_ValueBlock>>{};
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),
entry: seg.entry,
),
);
}
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<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 {
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;
final LocoAttrVersion? entry;
const _ValueBlock({
required this.left,
required this.width,
required this.cell,
required this.entry,
});
double get right => left + width;
_ValueBlock copyWith({
double? left,
double? width,
_RowCell? cell,
LocoAttrVersion? entry,
}) {
return _ValueBlock(
left: left ?? this.left,
width: width ?? this.width,
cell: cell ?? this.cell,
entry: entry ?? this.entry,
);
}
}
Color _colorForValue(String value) {
final hue = (value.hashCode % 360).toDouble();
final hsl = HSLColor.fromAHSL(1, hue, 0.55, 0.55);
return hsl.toColor();
}