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: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,29 +47,15 @@ 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(
),
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: Padding( child: Padding(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
child: Column( 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 { 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(
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, thumbVisibility: true,
child: SingleChildScrollView( child: SingleChildScrollView(
controller: _horizontalController,
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
child: IntrinsicWidth( child: SizedBox(
width: axisWidth,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [ children: [
_AxisRow( _AxisRow(
labelWidth: labelWidth,
segments: axisSegments, segments: axisSegments,
totalWidth: axisWidth,
endLabel: model.endLabel, endLabel: model.endLabel,
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
...model.attrRows.entries.map( Expanded(
(entry) => Padding( child: ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 2.0), 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( child: _AttrRow(
labelWidth: labelWidth,
rowHeight: rowHeight, rowHeight: rowHeight,
attrLabel: _formatAttrLabel(entry.key), blocks: blocks,
cells: entry.value, model: model,
segments: axisSegments, 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 { 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,
height: axisHeight,
child: Stack(
children: [ children: [
SizedBox( for (int i = 0; i < segments.length; i++) ...[
width: labelWidth, Positioned(
height: 36, left: segments[i].offset,
width: segments[i].width,
top: 0,
bottom: 0,
child: Align( child: Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: Text( child: Text(
'Date', segments[i].label,
style: theme.textTheme.labelLarge?.copyWith( overflow: TextOverflow.ellipsis,
fontWeight: FontWeight.w700, maxLines: 1,
),
),
),
),
...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, style: theme.textTheme.labelSmall,
), ),
), ),
if (isLast) ),
Align( ],
Positioned(
right: 0,
top: 0,
bottom: 0,
child: Align(
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
child: Text( child: Text(
endLabel, endLabel,
overflow: TextOverflow.ellipsis,
maxLines: 1,
style: theme.textTheme.labelSmall, 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,
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),
) ==
Brightness.dark Brightness.dark
? Colors.white ? Colors.white
: Colors.black87; : Colors.black87;
return Container(
width: seg.width, final radius = BorderRadius.only(
height: rowHeight, 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), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: hasValue color: block.cell.value.isEmpty
? cell.color.withOpacity(0.9) ? theme.colorScheme.surfaceContainerHighest
: theme.colorScheme.surfaceContainerHighest, : color,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.zero,
border: Border.all( border: Border.all(color: theme.colorScheme.outlineVariant),
color: theme.colorScheme.outlineVariant,
), ),
), child: block.cell.value.isEmpty
child: hasValue ? const SizedBox.shrink()
? Column( : FittedBox(
alignment: Alignment.topLeft,
fit: BoxFit.scaleDown,
child: ConstrainedBox(
constraints: const BoxConstraints(minWidth: 1, minHeight: 1),
child: 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,6 +582,14 @@ 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);
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( segments.add(
_ValueSegment( _ValueSegment(
start: start, start: start,
@@ -405,6 +598,7 @@ class _TimelineModel {
entry: entry, entry: entry,
), ),
); );
}
minStart = minStart == null || start.isBefore(minStart!) minStart = minStart == null || start.isBefore(minStart!)
? start ? start
: minStart; : minStart;
@@ -414,70 +608,107 @@ 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 width = yearWidth;
final width = (days * pxPerDay) + 30; 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(
_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( 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 +726,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 +760,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);

View File

@@ -423,12 +423,12 @@ class _TractionPageState extends State<TractionPage> {
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Stack(
children: [
if (data.isTractionLoading && traction.isEmpty) if (data.isTractionLoading && traction.isEmpty)
const Center( const Padding(
child: Padding( padding: EdgeInsets.symmetric(vertical: 32.0),
padding: EdgeInsets.symmetric(vertical: 24.0), child: Center(child: CircularProgressIndicator()),
child: CircularProgressIndicator(),
),
) )
else if (traction.isEmpty) else if (traction.isEmpty)
Card( 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()),
),
),
),
],
),
], ],
), ),
); );