Compare commits
1 Commits
v0.3.3-dev
...
v0.3.4-dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 44d79e7c28 |
@@ -36,16 +36,20 @@ class RouteDetailsView extends StatelessWidget {
|
||||
final List<String> route;
|
||||
final List<double> costs;
|
||||
final VoidCallback onBack;
|
||||
final Set<String> routingPoints;
|
||||
|
||||
const RouteDetailsView({
|
||||
super.key,
|
||||
required this.route,
|
||||
required this.costs,
|
||||
required this.onBack,
|
||||
this.routingPoints = const {},
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final highlightColor = Theme.of(context).colorScheme.primary;
|
||||
final mutedColor = Theme.of(context).colorScheme.outlineVariant;
|
||||
return Column(
|
||||
children: [
|
||||
Align(
|
||||
@@ -60,8 +64,20 @@ class RouteDetailsView extends StatelessWidget {
|
||||
child: ListView.builder(
|
||||
itemCount: route.length,
|
||||
itemBuilder: (context, index) {
|
||||
final label = route[index];
|
||||
final isRoutingPoint = routingPoints.contains(label);
|
||||
return ListTile(
|
||||
title: Text(route[index]),
|
||||
leading: Icon(
|
||||
Icons.circle,
|
||||
size: 12,
|
||||
color: isRoutingPoint ? highlightColor : mutedColor,
|
||||
),
|
||||
title: Text(
|
||||
label,
|
||||
style: isRoutingPoint
|
||||
? TextStyle(color: highlightColor, fontWeight: FontWeight.w600)
|
||||
: null,
|
||||
),
|
||||
trailing: Text("${costs[index].toStringAsFixed(2)} mi"),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:mileograph_flutter/objects/objects.dart';
|
||||
import 'package:mileograph_flutter/services/data_service.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class LatestLocoChangesPanel extends StatefulWidget {
|
||||
const LatestLocoChangesPanel({super.key});
|
||||
const LatestLocoChangesPanel({super.key, this.expanded = false});
|
||||
|
||||
final bool expanded;
|
||||
|
||||
@override
|
||||
State<LatestLocoChangesPanel> createState() => _LatestLocoChangesPanelState();
|
||||
@@ -11,6 +15,9 @@ class LatestLocoChangesPanel extends StatefulWidget {
|
||||
|
||||
class _LatestLocoChangesPanelState extends State<LatestLocoChangesPanel> {
|
||||
late final ScrollController _controller;
|
||||
final Set<String> _collapsedDates = {};
|
||||
final Set<String> _collapsedClasses = {};
|
||||
final Set<String> _collapsedLocos = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -29,17 +36,18 @@ class _LatestLocoChangesPanelState extends State<LatestLocoChangesPanel> {
|
||||
final data = context.watch<DataService>();
|
||||
final changes = data.latestLocoChanges;
|
||||
final isLoading = data.isLatestLocoChangesLoading;
|
||||
final hasMore = data.latestLocoChangesHasMore;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
return Card(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.bolt, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
@@ -73,52 +81,419 @@ class _LatestLocoChangesPanelState extends State<LatestLocoChangesPanel> {
|
||||
),
|
||||
)
|
||||
else
|
||||
SizedBox(
|
||||
height: 260,
|
||||
child: Scrollbar(
|
||||
controller: _controller,
|
||||
child: ListView.separated(
|
||||
controller: _controller,
|
||||
itemCount: changes.length,
|
||||
separatorBuilder: (context, index) =>
|
||||
const Divider(height: 1),
|
||||
itemBuilder: (context, index) {
|
||||
final change = changes[index];
|
||||
return ListTile(
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(
|
||||
change.locoLabel,
|
||||
style: textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('${change.changeLabel}: ${change.valueLabel}'),
|
||||
Text(
|
||||
change.approvedDateLabel,
|
||||
style: textTheme.labelSmall?.copyWith(
|
||||
color: textTheme.bodySmall?.color?.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: change.approvedBy.isEmpty
|
||||
? null
|
||||
: Text(
|
||||
change.approvedBy,
|
||||
style: textTheme.labelSmall,
|
||||
),
|
||||
);
|
||||
},
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildChangesList(changes, textTheme),
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: isLoading ? null : _loadMore,
|
||||
icon: isLoading
|
||||
? const SizedBox(
|
||||
height: 14,
|
||||
width: 14,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.expand_more),
|
||||
label: Text(isLoading ? 'Loading...' : 'Show more'),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildChangesList(List<LocoChange> changes, TextTheme textTheme) {
|
||||
final grouped = _groupChanges(changes);
|
||||
// Start with all locos collapsed by default.
|
||||
if (_collapsedLocos.isEmpty) {
|
||||
for (final group in grouped) {
|
||||
for (final classGroup in group.classGroups) {
|
||||
for (final locoGroup in classGroup.locoGroups) {
|
||||
_collapsedLocos.add(
|
||||
_locoKey(group.dateLabel, classGroup.classLabel, locoGroup.locoLabel),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final listView = ListView.separated(
|
||||
controller: null,
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemBuilder: (context, groupIndex) {
|
||||
final group = grouped[groupIndex];
|
||||
final dateCollapsed = _collapsedDates.contains(group.dateLabel);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6.0),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
iconSize: 18,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
onPressed: () => _toggleDate(group.dateLabel),
|
||||
icon: Icon(
|
||||
dateCollapsed ? Icons.chevron_right : Icons.expand_more,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
group.dateLabel,
|
||||
style: textTheme.labelLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => _collapseDateChildren(
|
||||
group.dateLabel,
|
||||
group.classGroups,
|
||||
collapse: !_isDateFullyCollapsed(group),
|
||||
),
|
||||
child: Text(
|
||||
_isDateFullyCollapsed(group) ? 'Expand all' : 'Collapse all',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (!dateCollapsed)
|
||||
...group.classGroups.map(
|
||||
(classGroup) {
|
||||
final classKey = _classKey(group.dateLabel, classGroup.classLabel);
|
||||
final classCollapsed = _collapsedClasses.contains(classKey);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8.0, left: 12.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
iconSize: 18,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
onPressed: () => _toggleClass(classKey),
|
||||
icon: Icon(
|
||||
classCollapsed
|
||||
? Icons.chevron_right
|
||||
: Icons.expand_more,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
classGroup.classLabel,
|
||||
style: textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => _collapseClassChildren(
|
||||
group.dateLabel,
|
||||
classGroup.classLabel,
|
||||
classGroup.locoGroups,
|
||||
collapse:
|
||||
!_isClassFullyCollapsed(classKey, classGroup, group.dateLabel),
|
||||
),
|
||||
child: Text(
|
||||
_isClassFullyCollapsed(classKey, classGroup, group.dateLabel)
|
||||
? 'Expand all'
|
||||
: 'Collapse all',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (!classCollapsed)
|
||||
...classGroup.locoGroups.map(
|
||||
(locoGroup) {
|
||||
final locoKey =
|
||||
_locoKey(group.dateLabel, classGroup.classLabel, locoGroup.locoLabel);
|
||||
final locoCollapsed = _collapsedLocos.contains(locoKey);
|
||||
return Padding(
|
||||
padding:
|
||||
const EdgeInsets.only(bottom: 4.0, left: 22.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
iconSize: 18,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
onPressed: () => _toggleLoco(locoKey),
|
||||
icon: Icon(
|
||||
locoCollapsed
|
||||
? Icons.chevron_right
|
||||
: Icons.expand_more,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
locoGroup.locoLabel,
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (!locoCollapsed) ...[
|
||||
const SizedBox(height: 2),
|
||||
...locoGroup.changes.map(
|
||||
(change) => ListTile(
|
||||
dense: true,
|
||||
visualDensity: const VisualDensity(
|
||||
horizontal: 0,
|
||||
vertical: -3,
|
||||
),
|
||||
minVerticalPadding: 0,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(
|
||||
'${change.changeLabel}: ${change.valueLabel}',
|
||||
style: textTheme.bodyMedium,
|
||||
),
|
||||
trailing: change.approvedBy.isEmpty
|
||||
? null
|
||||
: Text(
|
||||
change.approvedBy,
|
||||
style: textTheme.labelSmall,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
separatorBuilder: (_, __) => const Divider(height: 8),
|
||||
itemCount: grouped.length,
|
||||
);
|
||||
|
||||
if (widget.expanded) {
|
||||
return listView;
|
||||
}
|
||||
|
||||
return listView;
|
||||
}
|
||||
|
||||
void _toggleDate(String date) {
|
||||
setState(() {
|
||||
if (_collapsedDates.contains(date)) {
|
||||
_collapsedDates.remove(date);
|
||||
} else {
|
||||
_collapsedDates.add(date);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _toggleClass(String key) {
|
||||
setState(() {
|
||||
if (_collapsedClasses.contains(key)) {
|
||||
_collapsedClasses.remove(key);
|
||||
} else {
|
||||
_collapsedClasses.add(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _toggleLoco(String key) {
|
||||
setState(() {
|
||||
if (_collapsedLocos.contains(key)) {
|
||||
_collapsedLocos.remove(key);
|
||||
} else {
|
||||
_collapsedLocos.add(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _collapseDateChildren(
|
||||
String date,
|
||||
List<_ClassGroup> classGroups, {
|
||||
required bool collapse,
|
||||
}) {
|
||||
setState(() {
|
||||
for (final classGroup in classGroups) {
|
||||
final classKey = _classKey(date, classGroup.classLabel);
|
||||
if (collapse) {
|
||||
_collapsedClasses.add(classKey);
|
||||
} else {
|
||||
_collapsedClasses.remove(classKey);
|
||||
}
|
||||
for (final locoGroup in classGroup.locoGroups) {
|
||||
final locoKey = _locoKey(date, classGroup.classLabel, locoGroup.locoLabel);
|
||||
if (collapse) {
|
||||
_collapsedLocos.add(locoKey);
|
||||
} else {
|
||||
_collapsedLocos.remove(locoKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _collapseClassChildren(
|
||||
String date,
|
||||
String classLabel,
|
||||
List<_LocoGroup> locos, {
|
||||
required bool collapse,
|
||||
}) {
|
||||
setState(() {
|
||||
final classKey = _classKey(date, classLabel);
|
||||
if (collapse) {
|
||||
_collapsedClasses.add(classKey);
|
||||
} else {
|
||||
_collapsedClasses.remove(classKey);
|
||||
}
|
||||
for (final locoGroup in locos) {
|
||||
final locoKey = _locoKey(date, classLabel, locoGroup.locoLabel);
|
||||
if (collapse) {
|
||||
_collapsedLocos.add(locoKey);
|
||||
} else {
|
||||
_collapsedLocos.remove(locoKey);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
bool _isDateFullyCollapsed(_ChangeGroup group) {
|
||||
for (final classGroup in group.classGroups) {
|
||||
final classKey = _classKey(group.dateLabel, classGroup.classLabel);
|
||||
if (!_collapsedClasses.contains(classKey)) return false;
|
||||
for (final loco in classGroup.locoGroups) {
|
||||
final locoKey = _locoKey(group.dateLabel, classGroup.classLabel, loco.locoLabel);
|
||||
if (!_collapsedLocos.contains(locoKey)) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool _isClassFullyCollapsed(String classKey, _ClassGroup classGroup, String date) {
|
||||
if (!_collapsedClasses.contains(classKey)) return false;
|
||||
for (final loco in classGroup.locoGroups) {
|
||||
final locoKey = _locoKey(date, classGroup.classLabel, loco.locoLabel);
|
||||
if (!_collapsedLocos.contains(locoKey)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
String _classKey(String date, String classLabel) => '$date|$classLabel';
|
||||
String _locoKey(String date, String classLabel, String locoLabel) =>
|
||||
'$date|$classLabel|$locoLabel';
|
||||
|
||||
List<_ChangeGroup> _groupChanges(List<LocoChange> changes) {
|
||||
final dateFormat = DateFormat('yyyy-MM-dd');
|
||||
final Map<String, Map<String, Map<String, List<LocoChange>>>> grouped = {};
|
||||
|
||||
final filtered = changes.where((change) {
|
||||
final code = change.attrCode.toLowerCase();
|
||||
return code != 'build_prec' && code != 'operational' && code != 'gettable';
|
||||
});
|
||||
|
||||
for (final change in filtered) {
|
||||
final date = change.approvedAt ?? change.validFrom;
|
||||
final dateKey = date != null ? dateFormat.format(date) : 'Unknown date';
|
||||
final classKey = change.locoClass.isNotEmpty
|
||||
? change.locoClass
|
||||
: 'Unknown class';
|
||||
final locoKey = _locoLabel(change);
|
||||
grouped.putIfAbsent(dateKey, () => {});
|
||||
grouped[dateKey]!.putIfAbsent(classKey, () => {});
|
||||
grouped[dateKey]![classKey]!.putIfAbsent(locoKey, () => []);
|
||||
grouped[dateKey]![classKey]![locoKey]!.add(change);
|
||||
}
|
||||
|
||||
final sortedDates = grouped.keys.toList()
|
||||
..sort((a, b) {
|
||||
if (a == 'Unknown date') return 1;
|
||||
if (b == 'Unknown date') return -1;
|
||||
return b.compareTo(a); // newest first
|
||||
});
|
||||
|
||||
return sortedDates
|
||||
.map(
|
||||
(dateKey) => _ChangeGroup(
|
||||
dateLabel: dateKey,
|
||||
classGroups: grouped[dateKey]!.entries
|
||||
.map(
|
||||
(classEntry) => _ClassGroup(
|
||||
classLabel: classEntry.key,
|
||||
locoGroups: classEntry.value.entries
|
||||
.map(
|
||||
(locoEntry) => _LocoGroup(
|
||||
locoLabel: locoEntry.key,
|
||||
changes: locoEntry.value
|
||||
..sort(
|
||||
(a, b) => (b.approvedAt ?? b.validFrom ?? DateTime(0))
|
||||
.compareTo(a.approvedAt ?? a.validFrom ?? DateTime(0)),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<void> _loadMore() async {
|
||||
final data = context.read<DataService>();
|
||||
await data.fetchLatestLocoChanges(
|
||||
offset: data.latestLocoChanges.length,
|
||||
append: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ChangeGroup {
|
||||
final String dateLabel;
|
||||
final List<_ClassGroup> classGroups;
|
||||
|
||||
_ChangeGroup({required this.dateLabel, required this.classGroups});
|
||||
}
|
||||
|
||||
class _LocoGroup {
|
||||
final String locoLabel;
|
||||
final List<LocoChange> changes;
|
||||
|
||||
_LocoGroup({required this.locoLabel, required this.changes});
|
||||
}
|
||||
|
||||
class _ClassGroup {
|
||||
final String classLabel;
|
||||
final List<_LocoGroup> locoGroups;
|
||||
|
||||
_ClassGroup({required this.classLabel, required this.locoGroups});
|
||||
}
|
||||
|
||||
String _locoLabel(LocoChange change) {
|
||||
final number = change.locoNumber.trim();
|
||||
final name = change.locoName.trim();
|
||||
if (number.isNotEmpty && name.isNotEmpty) return '$number — $name';
|
||||
if (number.isNotEmpty) return number;
|
||||
if (name.isNotEmpty) return name;
|
||||
return 'Loco ${change.locoId}';
|
||||
}
|
||||
|
||||
@@ -3,8 +3,10 @@ import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:mileograph_flutter/objects/objects.dart';
|
||||
import 'package:mileograph_flutter/services/data_service.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class LegCard extends StatelessWidget {
|
||||
class LegCard extends StatefulWidget {
|
||||
const LegCard({
|
||||
super.key,
|
||||
required this.leg,
|
||||
@@ -16,30 +18,106 @@ class LegCard extends StatelessWidget {
|
||||
final bool showEditButton;
|
||||
final bool showDate;
|
||||
|
||||
@override
|
||||
State<LegCard> createState() => _LegCardState();
|
||||
}
|
||||
|
||||
class _LegCardState extends State<LegCard> {
|
||||
bool _expanded = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final leg = widget.leg;
|
||||
final routeSegments = _parseRouteSegments(leg.route);
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
return Card(
|
||||
child: ExpansionTile(
|
||||
onExpansionChanged: (v) => setState(() => _expanded = v),
|
||||
tilePadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
leading: const Icon(Icons.train),
|
||||
title: Text('${leg.start} → ${leg.end}'),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (showDate) Text(_formatDateTime(leg.beginTime)),
|
||||
if (leg.headcode.isNotEmpty)
|
||||
Text(
|
||||
'Headcode: ${leg.headcode}',
|
||||
style: textTheme.labelSmall,
|
||||
),
|
||||
if (leg.network.isNotEmpty)
|
||||
Text(
|
||||
leg.network,
|
||||
style: textTheme.labelSmall,
|
||||
),
|
||||
],
|
||||
title: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final isWide = constraints.maxWidth > 520;
|
||||
final routeText = Text('${leg.start} → ${leg.end}');
|
||||
final timeText =
|
||||
Text(_formatDateTime(leg.beginTime, includeDate: widget.showDate));
|
||||
if (!isWide) {
|
||||
return routeText;
|
||||
}
|
||||
return Row(
|
||||
children: [
|
||||
timeText,
|
||||
const SizedBox(width: 6),
|
||||
const Text('·'),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(child: routeText),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
subtitle: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final isWide = constraints.maxWidth > 520;
|
||||
final timeWidget =
|
||||
Text(_formatDateTime(leg.beginTime, includeDate: widget.showDate));
|
||||
final tractionWrap = !_expanded && leg.locos.isNotEmpty
|
||||
? Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 4,
|
||||
children: leg.locos.map((loco) {
|
||||
final iconColor = loco.powering
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).hintColor;
|
||||
final label = '${loco.locoClass} ${loco.number}'.trim();
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.train, size: 14, color: iconColor),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
label.isEmpty ? 'Loco ${loco.id}' : label,
|
||||
style: textTheme.labelSmall,
|
||||
),
|
||||
],
|
||||
);
|
||||
}).toList(),
|
||||
)
|
||||
: null;
|
||||
|
||||
final children = <Widget>[];
|
||||
if (isWide) {
|
||||
if (tractionWrap != null) {
|
||||
children.add(tractionWrap);
|
||||
}
|
||||
} else {
|
||||
children.add(timeWidget);
|
||||
if (tractionWrap != null) {
|
||||
children.add(const SizedBox(height: 4));
|
||||
children.add(tractionWrap);
|
||||
}
|
||||
}
|
||||
if (leg.headcode.isNotEmpty) {
|
||||
children.add(
|
||||
Text(
|
||||
'Headcode: ${leg.headcode}',
|
||||
style: textTheme.labelSmall,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (leg.network.isNotEmpty) {
|
||||
children.add(
|
||||
Text(
|
||||
leg.network,
|
||||
style: textTheme.labelSmall,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: children,
|
||||
);
|
||||
},
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -66,7 +144,7 @@ class LegCard extends StatelessWidget {
|
||||
],
|
||||
],
|
||||
),
|
||||
if (showEditButton) ...[
|
||||
if (widget.showEditButton) ...[
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
tooltip: 'Edit entry',
|
||||
@@ -76,6 +154,18 @@ class LegCard extends StatelessWidget {
|
||||
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
|
||||
onPressed: () => context.push('/legs/edit/${leg.id}'),
|
||||
),
|
||||
if (_expanded) ...[
|
||||
const SizedBox(width: 4),
|
||||
IconButton(
|
||||
tooltip: 'Delete entry',
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
|
||||
onPressed: () => _confirmDelete(context, leg.id),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
@@ -114,15 +204,52 @@ class LegCard extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _confirmDelete(BuildContext context, int legId) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Delete entry?'),
|
||||
content: const Text('Are you sure you want to delete this entry?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
child: const Text('Delete'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
|
||||
final data = context.read<DataService>();
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
try {
|
||||
await data.api.delete('/legs/delete?leg_id=$legId');
|
||||
await data.refreshLegs();
|
||||
messenger.showSnackBar(const SnackBar(content: Text('Entry deleted')));
|
||||
} catch (e) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('Failed to delete entry: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _formatDate(DateTime? date) {
|
||||
if (date == null) return '';
|
||||
return '${date.year.toString().padLeft(4, '0')}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
String _formatDateTime(DateTime date) {
|
||||
String _formatTime(DateTime date) {
|
||||
return '${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
String _formatDateTime(DateTime date, {bool includeDate = true}) {
|
||||
final timeStr = _formatTime(date);
|
||||
if (!includeDate) return timeStr;
|
||||
final dateStr = _formatDate(date);
|
||||
final timeStr =
|
||||
'${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
|
||||
return '$dateStr · $timeStr';
|
||||
}
|
||||
|
||||
|
||||
@@ -39,9 +39,9 @@ class CalculatorDetailsPage extends StatelessWidget {
|
||||
child: RouteDetailsView(
|
||||
route: parsed.calculatedRoute,
|
||||
costs: parsed.costs,
|
||||
routingPoints: parsed.inputRoute.toSet(),
|
||||
onBack: () => context.pop(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -232,6 +232,8 @@ class _DashboardState extends State<Dashboard> {
|
||||
_buildOnThisDayCard(context, data),
|
||||
const SizedBox(height: 16),
|
||||
_buildTripsCard(context, data),
|
||||
const SizedBox(height: 16),
|
||||
const LatestLocoChangesPanel(expanded: true),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -244,8 +246,6 @@ class _DashboardState extends State<Dashboard> {
|
||||
TopTractionPanel(),
|
||||
SizedBox(height: 16),
|
||||
LeaderboardPanel(),
|
||||
SizedBox(height: 16),
|
||||
LatestLocoChangesPanel(),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -36,6 +36,21 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _load());
|
||||
}
|
||||
|
||||
dynamic _normalizeFieldValue(_FieldEntry field) {
|
||||
final name = field.field.name.toLowerCase();
|
||||
final val = field.value;
|
||||
if (name == 'max_speed') {
|
||||
final numVal = val is num ? val.toDouble() : double.tryParse('$val');
|
||||
if (numVal == null) return val;
|
||||
final unit = (field.unit ?? 'kph').toLowerCase();
|
||||
if (unit == 'mph') {
|
||||
return numVal * 1.60934;
|
||||
}
|
||||
return numVal;
|
||||
}
|
||||
return val;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposeDrafts(_draftEvents);
|
||||
@@ -57,7 +72,7 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
|
||||
String? _eventDateForEntry(LocoAttrVersion entry) {
|
||||
final masked = entry.maskedValidFrom?.trim();
|
||||
if (masked != null && masked.isNotEmpty) return masked;
|
||||
final from = entry.validFrom ?? entry.txnFrom;
|
||||
final from = entry.validFrom;
|
||||
if (from == null) return null;
|
||||
return DateFormat('yyyy-MM-dd').format(from);
|
||||
}
|
||||
@@ -115,7 +130,8 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
|
||||
draft.details = '';
|
||||
draft.fields.add(
|
||||
_FieldEntry(field: field)
|
||||
..value = _valueForEntry(entry),
|
||||
..value = _valueForEntry(entry)
|
||||
..unit = _guessUnit(field, entry.valueLabel),
|
||||
);
|
||||
|
||||
setState(() {
|
||||
@@ -123,6 +139,16 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
|
||||
});
|
||||
}
|
||||
|
||||
String? _guessUnit(EventField field, String valueLabel) {
|
||||
final name = field.name.toLowerCase();
|
||||
if (name == 'max_speed') {
|
||||
final val = valueLabel.toLowerCase();
|
||||
if (val.contains('mph')) return 'mph';
|
||||
return 'kph';
|
||||
}
|
||||
return _defaultUnitForField(field);
|
||||
}
|
||||
|
||||
Future<void> _deleteEntry(LocoAttrVersion entry) async {
|
||||
if (_isDeleting) return;
|
||||
final blockId = entry.versionId;
|
||||
@@ -241,7 +267,7 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
|
||||
invalid.add('Field ${field.field.display} is empty');
|
||||
break;
|
||||
}
|
||||
values[field.field.name] = val;
|
||||
values[field.field.name] = _normalizeFieldValue(field);
|
||||
}
|
||||
if (invalid.isNotEmpty) continue;
|
||||
if (values.isEmpty) {
|
||||
|
||||
@@ -184,7 +184,9 @@ class _FieldList extends StatelessWidget {
|
||||
value: null,
|
||||
onChanged: (field) {
|
||||
if (field == null) return;
|
||||
draft.fields.add(_FieldEntry(field: field));
|
||||
draft.fields.add(
|
||||
_FieldEntry(field: field)..unit = _defaultUnitForField(field),
|
||||
);
|
||||
onChange();
|
||||
},
|
||||
items: availableFields
|
||||
@@ -224,10 +226,10 @@ class _FieldList extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
_FieldInput(
|
||||
field: field.field,
|
||||
value: field.value,
|
||||
onChanged: (val) {
|
||||
entry: field,
|
||||
onChanged: (val, {String? unit}) {
|
||||
field.value = val;
|
||||
if (unit != null) field.unit = unit;
|
||||
onChange();
|
||||
},
|
||||
),
|
||||
@@ -253,17 +255,18 @@ class _FieldList extends StatelessWidget {
|
||||
|
||||
class _FieldInput extends StatelessWidget {
|
||||
const _FieldInput({
|
||||
required this.field,
|
||||
required this.value,
|
||||
required this.entry,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
final EventField field;
|
||||
final dynamic value;
|
||||
final ValueChanged<dynamic> onChanged;
|
||||
final _FieldEntry entry;
|
||||
final void Function(dynamic value, {String? unit}) onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final field = entry.field;
|
||||
final value = entry.value;
|
||||
|
||||
if (field.enumValues != null && field.enumValues!.isNotEmpty) {
|
||||
final options = field.enumValues!;
|
||||
return DropdownButtonFormField<String>(
|
||||
@@ -293,6 +296,119 @@ class _FieldInput extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
final name = field.name.toLowerCase();
|
||||
if (name == 'max_speed') {
|
||||
final unit = entry.unit ?? 'kph';
|
||||
final isNumber = true;
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
initialValue: value?.toString(),
|
||||
onChanged: (val) {
|
||||
final parsed = double.tryParse(val);
|
||||
onChanged(isNumber ? parsed : val, unit: unit);
|
||||
},
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
hintText: 'Enter value',
|
||||
suffixText: 'kph/mph',
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
width: 88,
|
||||
child: DropdownButtonFormField<String>(
|
||||
value: unit,
|
||||
items: const [
|
||||
DropdownMenuItem(value: 'kph', child: Text('kph')),
|
||||
DropdownMenuItem(value: 'mph', child: Text('mph')),
|
||||
],
|
||||
onChanged: (val) {
|
||||
if (val == null) return;
|
||||
onChanged(value, unit: val);
|
||||
},
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
labelText: 'Unit',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
if ({
|
||||
'height',
|
||||
'length',
|
||||
'width',
|
||||
'track_gauge',
|
||||
}.contains(name)) {
|
||||
return TextFormField(
|
||||
initialValue: value?.toString(),
|
||||
onChanged: (val) {
|
||||
final parsed = double.tryParse(val);
|
||||
onChanged(parsed ?? val);
|
||||
},
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
hintText: 'Enter value',
|
||||
suffixText: 'mm',
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
);
|
||||
}
|
||||
|
||||
if (name == 'weight') {
|
||||
return TextFormField(
|
||||
initialValue: value?.toString(),
|
||||
onChanged: (val) {
|
||||
final parsed = double.tryParse(val);
|
||||
onChanged(parsed ?? val);
|
||||
},
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
hintText: 'Enter value',
|
||||
suffixText: 'tonnes',
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
);
|
||||
}
|
||||
|
||||
if (name == 'power') {
|
||||
return TextFormField(
|
||||
initialValue: value?.toString(),
|
||||
onChanged: (val) {
|
||||
final parsed = double.tryParse(val);
|
||||
onChanged(parsed ?? val);
|
||||
},
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
hintText: 'Enter value',
|
||||
suffixText: 'kW',
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
);
|
||||
}
|
||||
|
||||
if (name == 'tractive_effort') {
|
||||
return TextFormField(
|
||||
initialValue: value?.toString(),
|
||||
onChanged: (val) {
|
||||
final parsed = double.tryParse(val);
|
||||
onChanged(parsed ?? val);
|
||||
},
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
hintText: 'Enter value',
|
||||
suffixText: 'kN',
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
);
|
||||
}
|
||||
|
||||
final isNumber = type == 'int' || type == 'integer';
|
||||
return TextFormField(
|
||||
initialValue: value?.toString(),
|
||||
@@ -326,6 +442,13 @@ class _EventDraft {
|
||||
class _FieldEntry {
|
||||
final EventField field;
|
||||
dynamic value;
|
||||
String? unit;
|
||||
|
||||
_FieldEntry({required this.field});
|
||||
}
|
||||
|
||||
String? _defaultUnitForField(EventField field) {
|
||||
final name = field.name.toLowerCase();
|
||||
if (name == 'max_speed') return 'kph';
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -577,7 +577,7 @@ class _TimelineModel {
|
||||
_ValueSegment(
|
||||
start: start,
|
||||
end: end,
|
||||
value: entry.valueLabel,
|
||||
value: _formatValueWithUnits(entry),
|
||||
entry: entry,
|
||||
),
|
||||
);
|
||||
@@ -680,6 +680,53 @@ class _TimelineModel {
|
||||
}
|
||||
}
|
||||
|
||||
String _formatValueWithUnits(LocoAttrVersion entry) {
|
||||
final raw = entry.valueLabel;
|
||||
final code = entry.attrCode.toLowerCase();
|
||||
final lowerRaw = raw.toLowerCase();
|
||||
// Avoid double-appending if units already present.
|
||||
final hasUnits = lowerRaw.contains('mm') ||
|
||||
lowerRaw.contains('tonne') ||
|
||||
lowerRaw.contains('kph') ||
|
||||
lowerRaw.contains('mph');
|
||||
|
||||
double? asNumber = double.tryParse(raw);
|
||||
String formatNumber(double value) {
|
||||
if (value % 1 == 0) return value.toStringAsFixed(0);
|
||||
return value.toStringAsFixed(2);
|
||||
}
|
||||
|
||||
switch (code) {
|
||||
case 'height':
|
||||
case 'length':
|
||||
case 'width':
|
||||
case 'track_gauge':
|
||||
if (hasUnits) return raw;
|
||||
return asNumber != null ? '${formatNumber(asNumber)} mm' : '$raw mm';
|
||||
case 'weight':
|
||||
if (hasUnits) return raw;
|
||||
return asNumber != null ? '${formatNumber(asNumber)} tonnes' : '$raw tonnes';
|
||||
case 'power':
|
||||
if (hasUnits) return raw;
|
||||
return asNumber != null ? '${formatNumber(asNumber)} kW' : '$raw kW';
|
||||
case 'tractive_effort':
|
||||
if (hasUnits) return raw;
|
||||
return asNumber != null ? '${formatNumber(asNumber)} kN' : '$raw kN';
|
||||
case 'max_speed':
|
||||
if (hasUnits) return raw;
|
||||
if (asNumber != null) {
|
||||
// Stored as kph.
|
||||
final formatted = asNumber % 1 == 0
|
||||
? asNumber.toStringAsFixed(0)
|
||||
: asNumber.toStringAsFixed(1);
|
||||
return '$formatted kph';
|
||||
}
|
||||
return '$raw kph';
|
||||
default:
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
class _AxisSegment {
|
||||
final DateTime start;
|
||||
final DateTime end;
|
||||
@@ -742,7 +789,15 @@ class _RowCell {
|
||||
color: Colors.transparent,
|
||||
);
|
||||
}
|
||||
final displayStart = _formatDate(seg.start) ?? '';
|
||||
final entry = seg.entry;
|
||||
String displayStart = '';
|
||||
if (entry != null) {
|
||||
if ((entry.maskedValidFrom ?? '').trim().isNotEmpty) {
|
||||
displayStart = entry.maskedValidFrom!.trim();
|
||||
} else if (entry.validFrom != null) {
|
||||
displayStart = _formatDate(entry.validFrom) ?? '';
|
||||
}
|
||||
}
|
||||
return _RowCell(
|
||||
value: seg.value,
|
||||
rangeLabel: displayStart,
|
||||
|
||||
@@ -128,7 +128,9 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
|
||||
);
|
||||
_lastSubmittedSnapshot = snapshot;
|
||||
_activeDraftId = null;
|
||||
} catch (e) {
|
||||
} catch (e, st) {
|
||||
debugPrint('Leg submit/update failed: $e');
|
||||
debugPrintStack(stackTrace: st);
|
||||
if (!mounted) return;
|
||||
messenger?.showSnackBar(
|
||||
SnackBar(content: Text('Failed to submit: $e')),
|
||||
|
||||
@@ -73,6 +73,8 @@ extension _NewEntryTractionLogic on _NewEntryPageState {
|
||||
for (var i = 0; i < _tractionItems.length; i++) {
|
||||
final item = _tractionItems[i];
|
||||
if (item.isMarker || item.loco == null) continue;
|
||||
final locoId = item.loco!.id;
|
||||
if (locoId == 0) continue;
|
||||
int allocPos;
|
||||
if (i > markerIndex) {
|
||||
allocPos = -(i - markerIndex);
|
||||
@@ -80,8 +82,7 @@ extension _NewEntryTractionLogic on _NewEntryPageState {
|
||||
allocPos = (markerIndex - 1) - i;
|
||||
}
|
||||
payload.add({
|
||||
"loco_type": item.loco!.type,
|
||||
"loco_number": item.loco!.number,
|
||||
"loco_id": locoId,
|
||||
"alloc_pos": allocPos,
|
||||
"alloc_powering": item.powering ? 1 : 0,
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -48,6 +48,9 @@ class DataService extends ChangeNotifier {
|
||||
List<LocoChange> get latestLocoChanges => _latestLocoChanges;
|
||||
bool _isLatestLocoChangesLoading = false;
|
||||
bool get isLatestLocoChangesLoading => _isLatestLocoChangesLoading;
|
||||
bool _latestLocoChangesHasMore = false;
|
||||
bool get latestLocoChangesHasMore => _latestLocoChangesHasMore;
|
||||
int _latestLocoChangesFetched = 0;
|
||||
final Map<int, List<LocoAttrVersion>> _locoTimelines = {};
|
||||
final Map<int, bool> _isLocoTimelineLoading = {};
|
||||
List<LocoAttrVersion> timelineForLoco(int locoId) =>
|
||||
|
||||
@@ -115,7 +115,11 @@ extension DataServiceTraction on DataService {
|
||||
return _locoClasses;
|
||||
}
|
||||
|
||||
Future<void> fetchLatestLocoChanges({int limit = 25, int offset = 0}) async {
|
||||
Future<void> fetchLatestLocoChanges({
|
||||
int limit = 100,
|
||||
int offset = 0,
|
||||
bool append = false,
|
||||
}) async {
|
||||
_isLatestLocoChangesLoading = true;
|
||||
_notifyAsync();
|
||||
try {
|
||||
@@ -138,16 +142,41 @@ extension DataServiceTraction on DataService {
|
||||
);
|
||||
}
|
||||
}
|
||||
_latestLocoChanges = parsed;
|
||||
if (append) {
|
||||
_latestLocoChanges = [..._latestLocoChanges, ...parsed];
|
||||
} else {
|
||||
_latestLocoChanges = parsed;
|
||||
}
|
||||
final fetchedCount = parsed.length;
|
||||
_latestLocoChangesFetched = append
|
||||
? offset + fetchedCount
|
||||
: fetchedCount;
|
||||
_latestLocoChangesHasMore = _latestLocoChangesFetched < 5000;
|
||||
} else {
|
||||
throw Exception('Unexpected latest loco changes response: $json');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Failed to fetch latest loco changes: $e');
|
||||
_latestLocoChanges = [];
|
||||
_latestLocoChangesHasMore = false;
|
||||
_latestLocoChangesFetched = 0;
|
||||
} finally {
|
||||
_isLatestLocoChangesLoading = false;
|
||||
_notifyAsync();
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?> fetchClassStats(String locoClass) async {
|
||||
try {
|
||||
final path = Uri.encodeComponent(locoClass);
|
||||
final json = await api.get('/loco/class/stats/$path/user');
|
||||
if (json is Map) {
|
||||
return Map<String, dynamic>.from(json);
|
||||
}
|
||||
debugPrint('Unexpected class stats response for $locoClass: $json');
|
||||
} catch (e) {
|
||||
debugPrint('Failed to fetch class stats for $locoClass: $e');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:dynamic_color/dynamic_color.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
@@ -189,6 +190,14 @@ class _MyAppState extends State<MyApp> {
|
||||
}
|
||||
}
|
||||
|
||||
class _BackIntent extends Intent {
|
||||
const _BackIntent();
|
||||
}
|
||||
|
||||
class _ForwardIntent extends Intent {
|
||||
const _ForwardIntent();
|
||||
}
|
||||
|
||||
class MyHomePage extends StatefulWidget {
|
||||
final Widget child;
|
||||
const MyHomePage({super.key, required this.child});
|
||||
@@ -206,13 +215,14 @@ class _MyHomePageState extends State<MyHomePage> {
|
||||
}
|
||||
await NavigationGuard.attemptNavigation(() async {
|
||||
if (!mounted) return;
|
||||
context.go(contentPages[index]);
|
||||
_navigateToIndex(index);
|
||||
});
|
||||
}
|
||||
|
||||
int? _lastTabIndex;
|
||||
final List<int> _tabHistory = [];
|
||||
bool _handlingBackNavigation = false;
|
||||
final List<int> _history = [];
|
||||
int _historyPosition = -1;
|
||||
final List<int> _forwardHistory = [];
|
||||
bool _suppressRecord = false;
|
||||
|
||||
bool _fetched = false;
|
||||
|
||||
@@ -256,7 +266,7 @@ class _MyHomePageState extends State<MyHomePage> {
|
||||
Widget build(BuildContext context) {
|
||||
final uri = GoRouterState.of(context).uri;
|
||||
final pageIndex = tabIndexForPath(uri.path);
|
||||
_recordTabChange(pageIndex);
|
||||
_syncHistory(pageIndex);
|
||||
if (pageIndex != _addTabIndex) {
|
||||
NavigationGuard.unregister();
|
||||
}
|
||||
@@ -269,136 +279,227 @@ class _MyHomePageState extends State<MyHomePage> {
|
||||
? widget.child
|
||||
: const Center(child: CircularProgressIndicator());
|
||||
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, _) async {
|
||||
if (didPop) return;
|
||||
final scaffold = LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final isWide = constraints.maxWidth >= 900;
|
||||
final railExtended = constraints.maxWidth >= 1400;
|
||||
final navRailDestinations = _navItems
|
||||
.map(
|
||||
(item) => NavigationRailDestination(
|
||||
icon: Icon(item.icon),
|
||||
label: Text(item.label),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
final navBarDestinations = _navItems
|
||||
.map(
|
||||
(item) => NavigationDestination(
|
||||
icon: Icon(item.icon),
|
||||
label: item.label,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
final shellNav = _shellNavigatorKey.currentState;
|
||||
if (shellNav != null && shellNav.canPop()) {
|
||||
shellNav.pop();
|
||||
return;
|
||||
}
|
||||
|
||||
if (_tabHistory.isNotEmpty) {
|
||||
final previousTab = _tabHistory.removeLast();
|
||||
if (!mounted) return;
|
||||
_handlingBackNavigation = true;
|
||||
context.go(contentPages[previousTab]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pageIndex != 0) {
|
||||
if (!mounted) return;
|
||||
_handlingBackNavigation = true;
|
||||
context.go(contentPages[0]);
|
||||
return;
|
||||
}
|
||||
|
||||
SystemNavigator.pop();
|
||||
},
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final isWide = constraints.maxWidth >= 900;
|
||||
final railExtended = constraints.maxWidth >= 1400;
|
||||
final navRailDestinations = _navItems
|
||||
.map(
|
||||
(item) => NavigationRailDestination(
|
||||
icon: Icon(item.icon),
|
||||
label: Text(item.label),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
final navBarDestinations = _navItems
|
||||
.map(
|
||||
(item) => NavigationDestination(
|
||||
icon: Icon(item.icon),
|
||||
label: item.label,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
|
||||
title: Text.rich(
|
||||
TextSpan(
|
||||
children: const [
|
||||
TextSpan(text: "Mile"),
|
||||
TextSpan(text: "O", style: TextStyle(color: Colors.red)),
|
||||
TextSpan(text: "graph"),
|
||||
],
|
||||
style: const TextStyle(
|
||||
decoration: TextDecoration.none,
|
||||
color: Colors.white,
|
||||
fontFamily: "Tomatoes",
|
||||
),
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
|
||||
title: Text.rich(
|
||||
TextSpan(
|
||||
children: const [
|
||||
TextSpan(text: "Mile"),
|
||||
TextSpan(text: "O", style: TextStyle(color: Colors.red)),
|
||||
TextSpan(text: "graph"),
|
||||
],
|
||||
style: const TextStyle(
|
||||
decoration: TextDecoration.none,
|
||||
color: Colors.white,
|
||||
fontFamily: "Tomatoes",
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
const IconButton(
|
||||
onPressed: null,
|
||||
icon: Icon(Icons.account_circle),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Settings',
|
||||
onPressed: () => context.go('/settings'),
|
||||
icon: const Icon(Icons.settings),
|
||||
),
|
||||
IconButton(onPressed: auth.logout, icon: const Icon(Icons.logout)),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: isWide
|
||||
? null
|
||||
: NavigationBar(
|
||||
selectedIndex: pageIndex,
|
||||
onDestinationSelected: (int index) =>
|
||||
_onItemTapped(index, pageIndex),
|
||||
destinations: navBarDestinations,
|
||||
),
|
||||
body: isWide
|
||||
? Row(
|
||||
children: [
|
||||
SafeArea(
|
||||
child: NavigationRail(
|
||||
selectedIndex: pageIndex,
|
||||
extended: railExtended,
|
||||
labelType: railExtended
|
||||
? NavigationRailLabelType.none
|
||||
: NavigationRailLabelType.selected,
|
||||
onDestinationSelected: (int index) =>
|
||||
_onItemTapped(index, pageIndex),
|
||||
destinations: navRailDestinations,
|
||||
),
|
||||
actions: [
|
||||
const IconButton(
|
||||
onPressed: null,
|
||||
icon: Icon(Icons.account_circle),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Settings',
|
||||
onPressed: () => context.go('/settings'),
|
||||
icon: const Icon(Icons.settings),
|
||||
),
|
||||
IconButton(onPressed: auth.logout, icon: const Icon(Icons.logout)),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: isWide
|
||||
? null
|
||||
: NavigationBar(
|
||||
selectedIndex: pageIndex,
|
||||
onDestinationSelected: (int index) =>
|
||||
_onItemTapped(index, pageIndex),
|
||||
destinations: navBarDestinations,
|
||||
),
|
||||
body: isWide
|
||||
? Row(
|
||||
children: [
|
||||
SafeArea(
|
||||
child: NavigationRail(
|
||||
selectedIndex: pageIndex,
|
||||
extended: railExtended,
|
||||
labelType: railExtended
|
||||
? NavigationRailLabelType.none
|
||||
: NavigationRailLabelType.selected,
|
||||
onDestinationSelected: (int index) =>
|
||||
_onItemTapped(index, pageIndex),
|
||||
destinations: navRailDestinations,
|
||||
),
|
||||
const VerticalDivider(width: 1),
|
||||
Expanded(child: currentPage),
|
||||
],
|
||||
)
|
||||
: currentPage,
|
||||
);
|
||||
),
|
||||
const VerticalDivider(width: 1),
|
||||
Expanded(child: currentPage),
|
||||
],
|
||||
)
|
||||
: currentPage,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
return Shortcuts(
|
||||
shortcuts: <LogicalKeySet, Intent>{
|
||||
LogicalKeySet(LogicalKeyboardKey.browserBack): const _BackIntent(),
|
||||
LogicalKeySet(LogicalKeyboardKey.browserForward): const _ForwardIntent(),
|
||||
},
|
||||
child: Actions(
|
||||
actions: {
|
||||
_BackIntent: CallbackAction<_BackIntent>(
|
||||
onInvoke: (_) {
|
||||
_handleBackNavigation(allowExit: false, recordForward: true);
|
||||
return null;
|
||||
},
|
||||
),
|
||||
_ForwardIntent: CallbackAction<_ForwardIntent>(
|
||||
onInvoke: (_) {
|
||||
_handleForwardNavigation();
|
||||
return null;
|
||||
},
|
||||
),
|
||||
},
|
||||
child: Focus(
|
||||
autofocus: true,
|
||||
child: Listener(
|
||||
onPointerDown: _handlePointerButtons,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: PopScope(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, _) async {
|
||||
if (didPop) return;
|
||||
await _handleBackNavigation(allowExit: true, recordForward: false);
|
||||
},
|
||||
child: scaffold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _recordTabChange(int pageIndex) {
|
||||
final last = _lastTabIndex;
|
||||
if (last == null) {
|
||||
_lastTabIndex = pageIndex;
|
||||
return;
|
||||
void _handlePointerButtons(PointerDownEvent event) {
|
||||
// Support mouse back/forward buttons.
|
||||
if (event.buttons == kBackMouseButton) {
|
||||
_handleBackNavigation(allowExit: false, recordForward: true);
|
||||
} else if (event.buttons == kForwardMouseButton) {
|
||||
_handleForwardNavigation();
|
||||
}
|
||||
if (last == pageIndex) return;
|
||||
}
|
||||
|
||||
if (_handlingBackNavigation) {
|
||||
_handlingBackNavigation = false;
|
||||
_lastTabIndex = pageIndex;
|
||||
return;
|
||||
int get _currentPageIndex => tabIndexForPath(GoRouterState.of(context).uri.path);
|
||||
|
||||
Future<bool> _handleBackNavigation({
|
||||
bool allowExit = false,
|
||||
bool recordForward = false,
|
||||
}) async {
|
||||
final pageIndex = _currentPageIndex;
|
||||
final shellNav = _shellNavigatorKey.currentState;
|
||||
if (shellNav != null && shellNav.canPop()) {
|
||||
shellNav.pop();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (_tabHistory.isEmpty || _tabHistory.last != last) {
|
||||
_tabHistory.add(last);
|
||||
if (_historyPosition > 0) {
|
||||
if (recordForward) _pushForward(pageIndex);
|
||||
_historyPosition -= 1;
|
||||
_suppressRecord = true;
|
||||
context.go(contentPages[_history[_historyPosition]]);
|
||||
return true;
|
||||
}
|
||||
_lastTabIndex = pageIndex;
|
||||
|
||||
if (pageIndex != 0) {
|
||||
if (recordForward) _pushForward(pageIndex);
|
||||
_suppressRecord = true;
|
||||
context.go(contentPages[0]);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (allowExit) {
|
||||
SystemNavigator.pop();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<bool> _handleForwardNavigation() async {
|
||||
if (_forwardHistory.isEmpty) return false;
|
||||
final nextTab = _forwardHistory.removeLast();
|
||||
|
||||
// Move cursor forward, keeping history in sync.
|
||||
if (_historyPosition < _history.length - 1) {
|
||||
_historyPosition += 1;
|
||||
_history[_historyPosition] = nextTab;
|
||||
if (_historyPosition < _history.length - 1) {
|
||||
_history.removeRange(_historyPosition + 1, _history.length);
|
||||
}
|
||||
} else {
|
||||
_history.add(nextTab);
|
||||
_historyPosition = _history.length - 1;
|
||||
}
|
||||
|
||||
_suppressRecord = true;
|
||||
if (!mounted) return false;
|
||||
context.go(contentPages[nextTab]);
|
||||
return true;
|
||||
}
|
||||
|
||||
void _pushForward(int pageIndex) {
|
||||
if (_forwardHistory.isEmpty || _forwardHistory.last != pageIndex) {
|
||||
_forwardHistory.add(pageIndex);
|
||||
}
|
||||
}
|
||||
|
||||
void _syncHistory(int pageIndex) {
|
||||
if (_history.isEmpty) {
|
||||
_history.add(pageIndex);
|
||||
_historyPosition = 0;
|
||||
return;
|
||||
}
|
||||
if (_suppressRecord) {
|
||||
_suppressRecord = false;
|
||||
return;
|
||||
}
|
||||
if (_historyPosition >= 0 &&
|
||||
_historyPosition < _history.length &&
|
||||
_history[_historyPosition] == pageIndex) {
|
||||
return;
|
||||
}
|
||||
if (_historyPosition < _history.length - 1) {
|
||||
_history.removeRange(_historyPosition + 1, _history.length);
|
||||
}
|
||||
_history.add(pageIndex);
|
||||
_historyPosition = _history.length - 1;
|
||||
_forwardHistory.clear();
|
||||
}
|
||||
|
||||
void _navigateToIndex(int index) {
|
||||
_suppressRecord = false;
|
||||
_forwardHistory.clear();
|
||||
context.go(contentPages[index]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 0.3.3+1
|
||||
version: 0.3.4+1
|
||||
|
||||
environment:
|
||||
sdk: ^3.8.1
|
||||
|
||||
Reference in New Issue
Block a user