2 Commits

Author SHA1 Message Date
411e82807b attempt to add loco search indicator
All checks were successful
Release / meta (push) Successful in 12s
Release / linux-build (push) Successful in 6m46s
Release / android-build (push) Successful in 15m22s
Release / release-master (push) Successful in 21s
Release / release-dev (push) Successful in 23s
2025-12-16 12:47:52 +00:00
2b4d2623fc add loco timeline view 2025-12-16 12:24:53 +00:00
2 changed files with 545 additions and 277 deletions

View File

@@ -1,3 +1,5 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:mileograph_flutter/objects/objects.dart';
@@ -45,29 +47,15 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
),
body: RefreshIndicator(
onRefresh: _load,
child: ListView(
padding: const EdgeInsets.all(16),
physics: const AlwaysScrollableScrollPhysics(),
children: [
Text(
'Attribute timeline',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 6),
Text(
'Each row is an attribute. Blocks stretch between change dates; scroll sideways for history.',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 16),
if (isLoading && timeline.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: 32.0),
child: Center(child: CircularProgressIndicator()),
)
else if (timeline.isEmpty)
Card(
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(
@@ -92,57 +80,214 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
],
),
),
)
else
_TimelineGrid(entries: timeline),
],
),
);
}
return _TimelineGrid(
entries: timeline,
maxHeight: constraints.maxHeight,
);
},
),
),
);
}
}
class _TimelineGrid extends StatelessWidget {
const _TimelineGrid({required this.entries});
class _TimelineGrid extends StatefulWidget {
const _TimelineGrid({
required this.entries,
required this.maxHeight,
});
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
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;
const labelWidth = 150.0;
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 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(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Scrollbar(
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: IntrinsicWidth(
child: SizedBox(
width: axisWidth,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
_AxisRow(
labelWidth: labelWidth,
segments: axisSegments,
totalWidth: axisWidth,
endLabel: model.endLabel,
),
const SizedBox(height: 8),
...model.attrRows.entries.map(
(entry) => Padding(
padding: const EdgeInsets.symmetric(vertical: 2.0),
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(
labelWidth: labelWidth,
rowHeight: rowHeight,
attrLabel: _formatAttrLabel(entry.key),
cells: entry.value,
segments: axisSegments,
blocks: blocks,
model: model,
scrollOffset: _scrollOffset,
viewportWidth: axisWidth,
),
);
},
),
),
],
@@ -150,10 +295,9 @@ class _TimelineGrid extends StatelessWidget {
),
),
),
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 {
const _AxisRow({
required this.labelWidth,
required this.segments,
required this.endLabel,
required this.totalWidth,
});
final double labelWidth;
final List<_AxisSegment> segments;
final String endLabel;
final double totalWidth;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
const double axisHeight = 48;
return SizedBox(
width: totalWidth,
height: axisHeight,
child: Stack(
children: [
SizedBox(
width: labelWidth,
height: 36,
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(
'Date',
style: theme.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
),
...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,
segments[i].label,
overflow: TextOverflow.ellipsis,
maxLines: 1,
style: theme.textTheme.labelSmall,
),
),
if (isLast)
Align(
),
],
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.labelWidth,
required this.rowHeight,
required this.attrLabel,
required this.cells,
required this.segments,
required this.blocks,
required this.model,
required this.scrollOffset,
required this.viewportWidth,
});
final double labelWidth;
final double rowHeight;
final String attrLabel;
final List<_RowCell> cells;
final List<_AxisSegment> segments;
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);
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: labelWidth,
height: rowHeight,
padding: const EdgeInsets.symmetric(horizontal: 10),
alignment: Alignment.centerLeft,
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(10),
),
child: Text(
attrLabel,
style: theme.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
...segments.asMap().entries.map((entry) {
final idx = entry.key;
final seg = entry.value;
final cell = cells[idx];
final hasValue = cell.value.isNotEmpty;
final textColor = ThemeData.estimateBrightnessForColor(
cell.color.withOpacity(0.9),
) ==
final color = block.cell.color.withOpacity(0.9);
final textColor = ThemeData.estimateBrightnessForColor(color) ==
Brightness.dark
? Colors.white
: Colors.black87;
return Container(
width: seg.width,
height: rowHeight,
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: hasValue
? cell.color.withOpacity(0.9)
: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: theme.colorScheme.outlineVariant,
color: block.cell.value.isEmpty
? theme.colorScheme.surfaceContainerHighest
: color,
borderRadius: BorderRadius.zero,
border: Border.all(color: theme.colorScheme.outlineVariant),
),
),
child: hasValue
? Column(
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(
cell.value,
block.cell.value,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
@@ -308,7 +491,7 @@ class _AttrRow extends StatelessWidget {
),
const SizedBox(height: 4),
Text(
cell.rangeLabel,
block.cell.rangeLabel,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelSmall?.copyWith(
@@ -317,15 +500,13 @@ class _AttrRow extends StatelessWidget {
TextStyle(color: textColor.withOpacity(0.9)),
),
],
)
: const SizedBox.shrink(),
);
}),
],
),
),
),
),
);
}
}
final DateFormat _dateFormat = DateFormat('yyyy-MM-dd');
String? _formatDate(DateTime? date) {
@@ -364,13 +545,17 @@ DateTime _safeEnd(DateTime start, DateTime? end) {
class _TimelineModel {
final List<_AxisSegment> axisSegments;
final Map<String, List<_RowCell>> attrRows;
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) {
@@ -397,6 +582,14 @@ class _TimelineModel {
: 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,
@@ -405,6 +598,7 @@ class _TimelineModel {
entry: entry,
),
);
}
minStart = minStart == null || start.isBefore(minStart!)
? start
: minStart;
@@ -414,70 +608,107 @@ class _TimelineModel {
});
minStart ??= now.subtract(const Duration(days: 1));
maxEnd ??= now;
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(maxEnd);
boundaryDates.add(effectiveMaxEnd);
var boundaries = boundaryDates.toList()..sort();
if (boundaries.length < 2) {
boundaries = [minStart!, maxEnd];
boundaries = [minStart!, effectiveMaxEnd];
}
final axisSegments = <_AxisSegment>[];
const pxPerDay = 4.0;
const double yearWidth = 240.0;
for (int i = 0; i < boundaries.length - 1; i++) {
final start = boundaries[i];
final end = boundaries[i + 1];
final days = (end.difference(start).inDays).clamp(1, 365);
final width = (days * pxPerDay) + 30;
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<double>(0, (sum, seg) => sum + seg.width);
final attrRows = <String, List<_RowCell>>{};
final attrRows = <String, List<_ValueBlock>>{};
for (final entry in attrSegments.entries) {
final cells = <_RowCell>[];
for (final axis in axisSegments) {
final seg = entry.value
.firstWhere(
(s) => s.overlaps(axis.start, axis.end),
orElse: () => _ValueSegment.empty(axis.start, axis.end),
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),
),
);
cells.add(_RowCell.fromSegment(seg));
}
attrRows[entry.key] = cells;
attrRows[entry.key] = blocks;
}
final endLabel = _formatDate(maxEnd) ?? 'Now';
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,
});
}
@@ -495,16 +726,18 @@ class _ValueSegment {
this.entry,
});
factory _ValueSegment.empty(DateTime start, DateTime end) => _ValueSegment(
start: start,
end: end,
value: '',
entry: null,
);
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 {
@@ -527,16 +760,40 @@ class _RowCell {
);
}
final displayStart = _formatDate(seg.start) ?? '';
final displayEnd = _formatDate(seg.entry?.validTo ?? seg.end) ?? 'Now';
final range = '$displayStart$displayEnd';
return _RowCell(
value: seg.value,
rangeLabel: range,
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,
);
}
}
Color _colorForValue(String value) {
final hue = (value.hashCode % 360).toDouble();
final hsl = HSLColor.fromAHSL(1, hue, 0.55, 0.55);

View File

@@ -423,12 +423,12 @@ class _TractionPageState extends State<TractionPage> {
),
),
const SizedBox(height: 12),
Stack(
children: [
if (data.isTractionLoading && traction.isEmpty)
const Center(
child: Padding(
padding: EdgeInsets.symmetric(vertical: 24.0),
child: CircularProgressIndicator(),
),
const Padding(
padding: EdgeInsets.symmetric(vertical: 32.0),
child: Center(child: CircularProgressIndicator()),
)
else if (traction.isEmpty)
Card(
@@ -474,6 +474,17 @@ class _TractionPageState extends State<TractionPage> {
),
],
),
if (data.isTractionLoading)
Positioned.fill(
child: IgnorePointer(
child: Container(
color: Theme.of(context).colorScheme.surface.withOpacity(0.6),
child: const Center(child: CircularProgressIndicator()),
),
),
),
],
),
],
),
);