Compare commits
5 Commits
v0.1.4-dev
...
v0.2.0-dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 4a6aee8a15 | |||
| 411e82807b | |||
| 2b4d2623fc | |||
| 80c315866f | |||
| da70dce369 |
@@ -19,6 +19,7 @@ jobs:
|
|||||||
- mileograph
|
- mileograph
|
||||||
outputs:
|
outputs:
|
||||||
base_version: ${{ steps.meta.outputs.base }}
|
base_version: ${{ steps.meta.outputs.base }}
|
||||||
|
release_tag: ${{ steps.meta.outputs.release_tag }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -29,6 +30,7 @@ jobs:
|
|||||||
RAW_VERSION=$(awk '/^version:/{print $2}' pubspec.yaml)
|
RAW_VERSION=$(awk '/^version:/{print $2}' pubspec.yaml)
|
||||||
BASE_VERSION=${RAW_VERSION%%+*}
|
BASE_VERSION=${RAW_VERSION%%+*}
|
||||||
echo "base=${BASE_VERSION}" >> "$GITHUB_OUTPUT"
|
echo "base=${BASE_VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "release_tag=v${BASE_VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
android-build:
|
android-build:
|
||||||
runs-on:
|
runs-on:
|
||||||
@@ -308,7 +310,7 @@ jobs:
|
|||||||
id: bundle
|
id: bundle
|
||||||
run: |
|
run: |
|
||||||
BASE="${{ needs.meta.outputs.base_version }}"
|
BASE="${{ needs.meta.outputs.base_version }}"
|
||||||
TAG="v${BASE}"
|
TAG="${{ needs.meta.outputs.release_tag }}"
|
||||||
|
|
||||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||||
echo "apk=artifacts/mileograph-${BASE}.apk" >> "$GITHUB_OUTPUT"
|
echo "apk=artifacts/mileograph-${BASE}.apk" >> "$GITHUB_OUTPUT"
|
||||||
|
|||||||
@@ -97,10 +97,16 @@ class _StationAutocompleteState extends State<StationAutocomplete> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class RouteCalculator extends StatefulWidget {
|
class RouteCalculator extends StatefulWidget {
|
||||||
const RouteCalculator({super.key, this.onDistanceComputed, this.onApplyRoute});
|
const RouteCalculator({
|
||||||
|
super.key,
|
||||||
|
this.onDistanceComputed,
|
||||||
|
this.onApplyRoute,
|
||||||
|
this.initialStations,
|
||||||
|
});
|
||||||
|
|
||||||
final ValueChanged<double>? onDistanceComputed;
|
final ValueChanged<double>? onDistanceComputed;
|
||||||
final ValueChanged<RouteResult>? onApplyRoute;
|
final ValueChanged<RouteResult>? onApplyRoute;
|
||||||
|
final List<String>? initialStations;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<RouteCalculator> createState() => _RouteCalculatorState();
|
State<RouteCalculator> createState() => _RouteCalculatorState();
|
||||||
@@ -122,6 +128,9 @@ class _RouteCalculatorState extends State<RouteCalculator> {
|
|||||||
super.didChangeDependencies();
|
super.didChangeDependencies();
|
||||||
if (!_fetched) {
|
if (!_fetched) {
|
||||||
_fetched = true;
|
_fetched = true;
|
||||||
|
if (widget.initialStations != null && widget.initialStations!.isNotEmpty) {
|
||||||
|
context.read<DataService>().stations = List.from(widget.initialStations!);
|
||||||
|
}
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||||
final data = context.read<DataService>();
|
final data = context.read<DataService>();
|
||||||
final result = await data.fetchStations();
|
final result = await data.fetchStations();
|
||||||
|
|||||||
1263
lib/components/pages/loco_timeline.dart
Normal file
1263
lib/components/pages/loco_timeline.dart
Normal file
File diff suppressed because it is too large
Load Diff
@@ -51,7 +51,8 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
String? _activeDraftId;
|
String? _activeDraftId;
|
||||||
|
|
||||||
bool get _isEditing => widget.editLegId != null;
|
bool get _isEditing => widget.editLegId != null;
|
||||||
bool get _draftPersistenceEnabled => false; // legacy single draft disabled in favor of draft list
|
bool get _draftPersistenceEnabled =>
|
||||||
|
false; // legacy single draft disabled in favor of draft list
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -266,8 +267,8 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
if (json is! Map<String, dynamic>) {
|
if (json is! Map<String, dynamic>) {
|
||||||
throw Exception('Unexpected response for leg $legId');
|
throw Exception('Unexpected response for leg $legId');
|
||||||
}
|
}
|
||||||
final beginTime = DateTime.tryParse(json['leg_begin_time'] ?? '') ??
|
final beginTime =
|
||||||
_selectedDate;
|
DateTime.tryParse(json['leg_begin_time'] ?? '') ?? _selectedDate;
|
||||||
final routeStations = _parseRouteStations(json['leg_route']);
|
final routeStations = _parseRouteStations(json['leg_route']);
|
||||||
final mileageVal = (json['leg_mileage'] as num?)?.toDouble() ?? 0.0;
|
final mileageVal = (json['leg_mileage'] as num?)?.toDouble() ?? 0.0;
|
||||||
final useManual = routeStations.isEmpty;
|
final useManual = routeStations.isEmpty;
|
||||||
@@ -297,13 +298,14 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
_routeResult = routeResult;
|
_routeResult = routeResult;
|
||||||
_startController.text = json['leg_start'] ?? '';
|
_startController.text = json['leg_start'] ?? '';
|
||||||
_endController.text = json['leg_end'] ?? '';
|
_endController.text = json['leg_end'] ?? '';
|
||||||
_headcodeController.text =
|
_headcodeController.text = (json['leg_headcode'] as String? ?? '')
|
||||||
(json['leg_headcode'] as String? ?? '').toUpperCase();
|
.toUpperCase();
|
||||||
_notesController.text = json['leg_notes'] ?? '';
|
_notesController.text = json['leg_notes'] ?? '';
|
||||||
_networkController.text =
|
_networkController.text = (json['leg_network'] as String? ?? '')
|
||||||
(json['leg_network'] as String? ?? '').toUpperCase();
|
.toUpperCase();
|
||||||
_mileageController.text =
|
_mileageController.text = mileageVal == 0
|
||||||
mileageVal == 0 ? '' : mileageVal.toStringAsFixed(2);
|
? ''
|
||||||
|
: mileageVal.toStringAsFixed(2);
|
||||||
_tractionItems
|
_tractionItems
|
||||||
..clear()
|
..clear()
|
||||||
..addAll(tractionItems);
|
..addAll(tractionItems);
|
||||||
@@ -317,9 +319,9 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_loadError = 'Failed to load entry: $e';
|
_loadError = 'Failed to load entry: $e';
|
||||||
});
|
});
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(
|
||||||
SnackBar(content: Text('Failed to load entry: $e')),
|
context,
|
||||||
);
|
).showSnackBar(SnackBar(content: Text('Failed to load entry: $e')));
|
||||||
} finally {
|
} finally {
|
||||||
_restoringDraft = false;
|
_restoringDraft = false;
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@@ -394,10 +396,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
_TractionItem _mapLocoToTractionItem(Map<String, dynamic> loco) {
|
_TractionItem _mapLocoToTractionItem(Map<String, dynamic> loco) {
|
||||||
final poweringRaw = loco['alloc_powering'];
|
final poweringRaw = loco['alloc_powering'];
|
||||||
final powering = poweringRaw == true || poweringRaw == 1;
|
final powering = poweringRaw == true || poweringRaw == 1;
|
||||||
return _TractionItem(
|
return _TractionItem(loco: LocoSummary.fromJson(loco), powering: powering);
|
||||||
loco: LocoSummary.fromJson(loco),
|
|
||||||
powering: powering,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
DateTime get _legDateTime => DateTime(
|
DateTime get _legDateTime => DateTime(
|
||||||
@@ -452,20 +451,11 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
|
|
||||||
bool _draftChangedFromBaseline() {
|
bool _draftChangedFromBaseline() {
|
||||||
if (_loadedDraftSnapshot == null) return true;
|
if (_loadedDraftSnapshot == null) return true;
|
||||||
final current = _normalizeDraftSnapshot(
|
final current = _buildDraftSnapshot(
|
||||||
_buildDraftSnapshot(
|
|
||||||
id: _activeDraftId ?? 'temp',
|
id: _activeDraftId ?? 'temp',
|
||||||
includeTimestamp: false,
|
includeTimestamp: false,
|
||||||
),
|
|
||||||
);
|
);
|
||||||
final baseline = _normalizeDraftSnapshot(_loadedDraftSnapshot!);
|
return !_snapshotEquality.equals(_loadedDraftSnapshot, current);
|
||||||
return !_snapshotEquality.equals(baseline, current);
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, dynamic> _normalizeDraftSnapshot(Map<String, dynamic> snapshot) {
|
|
||||||
final normalized = Map<String, dynamic>.from(snapshot);
|
|
||||||
normalized.remove('saved_at');
|
|
||||||
return normalized;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _formIsEmpty() {
|
bool _formIsEmpty() {
|
||||||
@@ -487,8 +477,9 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
useRootNavigator: false,
|
useRootNavigator: false,
|
||||||
builder: (_) => AlertDialog(
|
builder: (_) => AlertDialog(
|
||||||
title: const Text('Save draft?'),
|
title: const Text('Save draft?'),
|
||||||
content:
|
content: const Text(
|
||||||
const Text('Do you want to save this entry as a draft before leaving?'),
|
'Do you want to save this entry as a draft before leaving?',
|
||||||
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.of(context).pop(_ExitChoice.discard),
|
onPressed: () => Navigator.of(context).pop(_ExitChoice.discard),
|
||||||
@@ -637,11 +628,11 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
context.read<DataService>().refreshLegs();
|
context.read<DataService>().refreshLegs();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
context,
|
|
||||||
).showSnackBar(
|
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(isEditingExisting ? 'Entry updated' : 'Entry submitted'),
|
content: Text(
|
||||||
|
isEditingExisting ? 'Entry updated' : 'Entry submitted',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
_lastSubmittedSnapshot = snapshot;
|
_lastSubmittedSnapshot = snapshot;
|
||||||
@@ -816,8 +807,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
jsonEncode(drafts.map((e) => e.toJson()).toList()),
|
jsonEncode(drafts.map((e) => e.toJson()).toList()),
|
||||||
);
|
);
|
||||||
_activeDraftId = id;
|
_activeDraftId = id;
|
||||||
_loadedDraftSnapshot =
|
_loadedDraftSnapshot = _buildDraftSnapshot(id: id, includeTimestamp: false);
|
||||||
_normalizeDraftSnapshot(_buildDraftSnapshot(id: id, includeTimestamp: false));
|
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -882,11 +872,14 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
if (payloadRaw is! Map) return;
|
if (payloadRaw is! Map) return;
|
||||||
final payload = Map<String, dynamic>.from(payloadRaw);
|
final payload = Map<String, dynamic>.from(payloadRaw);
|
||||||
final mode = data['mode'] as String?;
|
final mode = data['mode'] as String?;
|
||||||
final useManual = mode == 'manual' ||
|
final useManual =
|
||||||
(payload.containsKey('leg_distance') && !payload.containsKey('leg_route'));
|
mode == 'manual' ||
|
||||||
|
(payload.containsKey('leg_distance') &&
|
||||||
|
!payload.containsKey('leg_route'));
|
||||||
final beginStr = payload['leg_begin_time'] as String?;
|
final beginStr = payload['leg_begin_time'] as String?;
|
||||||
final beginTime =
|
final beginTime = beginStr == null
|
||||||
beginStr == null ? DateTime.now() : DateTime.tryParse(beginStr) ?? DateTime.now();
|
? DateTime.now()
|
||||||
|
: DateTime.tryParse(beginStr) ?? DateTime.now();
|
||||||
final tripRaw = payload['leg_trip'];
|
final tripRaw = payload['leg_trip'];
|
||||||
final tripId = tripRaw is num ? tripRaw.toInt() : null;
|
final tripId = tripRaw is num ? tripRaw.toInt() : null;
|
||||||
|
|
||||||
@@ -894,7 +887,9 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
RouteResult? restoredRouteResult;
|
RouteResult? restoredRouteResult;
|
||||||
if (!useManual) {
|
if (!useManual) {
|
||||||
if (payload['leg_route'] is List) {
|
if (payload['leg_route'] is List) {
|
||||||
routeStations = (payload['leg_route'] as List).map((e) => e.toString()).toList();
|
routeStations = (payload['leg_route'] as List)
|
||||||
|
.map((e) => e.toString())
|
||||||
|
.toList();
|
||||||
}
|
}
|
||||||
final rr = data['routeResult'];
|
final rr = data['routeResult'];
|
||||||
if (rr is Map<String, dynamic>) {
|
if (rr is Map<String, dynamic>) {
|
||||||
@@ -903,10 +898,17 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
(rr['input_route'] as List?)?.map((e) => e.toString()).toList() ??
|
(rr['input_route'] as List?)?.map((e) => e.toString()).toList() ??
|
||||||
routeStations,
|
routeStations,
|
||||||
calculatedRoute:
|
calculatedRoute:
|
||||||
(rr['calculated_route'] as List?)?.map((e) => e.toString()).toList() ??
|
(rr['calculated_route'] as List?)
|
||||||
|
?.map((e) => e.toString())
|
||||||
|
.toList() ??
|
||||||
routeStations,
|
routeStations,
|
||||||
costs: (rr['costs'] as List?)?.map((e) => (e as num).toDouble()).toList() ?? [],
|
costs:
|
||||||
distance: (rr['distance'] as num?)?.toDouble() ??
|
(rr['costs'] as List?)
|
||||||
|
?.map((e) => (e as num).toDouble())
|
||||||
|
.toList() ??
|
||||||
|
[],
|
||||||
|
distance:
|
||||||
|
(rr['distance'] as num?)?.toDouble() ??
|
||||||
(payload['leg_mileage'] as num?)?.toDouble() ??
|
(payload['leg_mileage'] as num?)?.toDouble() ??
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
@@ -927,21 +929,26 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
_selectedTime = TimeOfDay.fromDateTime(beginTime);
|
_selectedTime = TimeOfDay.fromDateTime(beginTime);
|
||||||
_selectedTripId = tripId == null || tripId == 0 ? null : tripId;
|
_selectedTripId = tripId == null || tripId == 0 ? null : tripId;
|
||||||
_routeResult = restoredRouteResult;
|
_routeResult = restoredRouteResult;
|
||||||
_headcodeController.text =
|
_headcodeController.text = (payload['leg_headcode'] as String? ?? '')
|
||||||
(payload['leg_headcode'] as String? ?? '').toUpperCase();
|
.toUpperCase();
|
||||||
_networkController.text =
|
_networkController.text = (payload['leg_network'] as String? ?? '')
|
||||||
(payload['leg_network'] as String? ?? '').toUpperCase();
|
.toUpperCase();
|
||||||
_notesController.text = payload['leg_notes'] ?? '';
|
_notesController.text = payload['leg_notes'] ?? '';
|
||||||
|
|
||||||
if (useManual) {
|
if (useManual) {
|
||||||
_startController.text = payload['leg_start'] ?? '';
|
_startController.text = payload['leg_start'] ?? '';
|
||||||
_endController.text = payload['leg_end'] ?? '';
|
_endController.text = payload['leg_end'] ?? '';
|
||||||
final miles = (payload['leg_distance'] as num?)?.toDouble();
|
final miles = (payload['leg_distance'] as num?)?.toDouble();
|
||||||
_mileageController.text =
|
_mileageController.text = miles == null || miles == 0
|
||||||
miles == null || miles == 0 ? '' : miles.toStringAsFixed(2);
|
? ''
|
||||||
|
: miles.toStringAsFixed(2);
|
||||||
} else {
|
} else {
|
||||||
_startController.text = routeStations.isNotEmpty ? routeStations.first : '';
|
_startController.text = routeStations.isNotEmpty
|
||||||
_endController.text = routeStations.isNotEmpty ? routeStations.last : '';
|
? routeStations.first
|
||||||
|
: '';
|
||||||
|
_endController.text = routeStations.isNotEmpty
|
||||||
|
? routeStations.last
|
||||||
|
: '';
|
||||||
final dist = _routeResult?.distance ?? 0;
|
final dist = _routeResult?.distance ?? 0;
|
||||||
_mileageController.text = dist == 0 ? '' : dist.toStringAsFixed(2);
|
_mileageController.text = dist == 0 ? '' : dist.toStringAsFixed(2);
|
||||||
}
|
}
|
||||||
@@ -964,11 +971,9 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
});
|
});
|
||||||
final baselineId =
|
final baselineId =
|
||||||
_activeDraftId ?? data['id']?.toString() ?? DateTime.now().toString();
|
_activeDraftId ?? data['id']?.toString() ?? DateTime.now().toString();
|
||||||
_loadedDraftSnapshot = _normalizeDraftSnapshot(
|
_loadedDraftSnapshot = _buildDraftSnapshot(
|
||||||
_buildDraftSnapshot(
|
|
||||||
id: baselineId,
|
id: baselineId,
|
||||||
includeTimestamp: false,
|
includeTimestamp: false,
|
||||||
),
|
|
||||||
);
|
);
|
||||||
_restoringDraft = false;
|
_restoringDraft = false;
|
||||||
}
|
}
|
||||||
@@ -1072,8 +1077,9 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
minimumSize: const Size(0, 36),
|
minimumSize: const Size(0, 36),
|
||||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
),
|
),
|
||||||
onPressed:
|
onPressed: _submitting
|
||||||
_submitting ? null : () => _resetFormState(clearDraft: true),
|
? null
|
||||||
|
: () => _resetFormState(clearDraft: true),
|
||||||
icon: const Icon(Icons.clear, size: 16),
|
icon: const Icon(Icons.clear, size: 16),
|
||||||
label: const Text('Clear form'),
|
label: const Text('Clear form'),
|
||||||
),
|
),
|
||||||
@@ -1444,18 +1450,15 @@ class _StoredDraft {
|
|||||||
final DateTime savedAt;
|
final DateTime savedAt;
|
||||||
final Map<String, dynamic> data;
|
final Map<String, dynamic> data;
|
||||||
|
|
||||||
_StoredDraft({
|
_StoredDraft({required this.id, required this.savedAt, required this.data});
|
||||||
required this.id,
|
|
||||||
required this.savedAt,
|
|
||||||
required this.data,
|
|
||||||
});
|
|
||||||
|
|
||||||
factory _StoredDraft.fromJson(Map<String, dynamic> json) {
|
factory _StoredDraft.fromJson(Map<String, dynamic> json) {
|
||||||
final savedAt = DateTime.tryParse(json['saved_at'] ?? '') ?? DateTime.now();
|
final savedAt = DateTime.tryParse(json['saved_at'] ?? '') ?? DateTime.now();
|
||||||
final data = Map<String, dynamic>.from(json['data'] as Map? ?? {});
|
final data = Map<String, dynamic>.from(json['data'] as Map? ?? {});
|
||||||
final embeddedId = data['id']?.toString();
|
final embeddedId = data['id']?.toString();
|
||||||
return _StoredDraft(
|
return _StoredDraft(
|
||||||
id: json['id']?.toString() ??
|
id:
|
||||||
|
json['id']?.toString() ??
|
||||||
embeddedId ??
|
embeddedId ??
|
||||||
savedAt.microsecondsSinceEpoch.toString(),
|
savedAt.microsecondsSinceEpoch.toString(),
|
||||||
savedAt: savedAt,
|
savedAt: savedAt,
|
||||||
@@ -1464,11 +1467,7 @@ class _StoredDraft {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
return {
|
return {"id": id, "saved_at": savedAt.toIso8601String(), "data": data};
|
||||||
"id": id,
|
|
||||||
"saved_at": savedAt.toIso8601String(),
|
|
||||||
"data": data,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1491,10 +1490,7 @@ class _DraftListPage extends StatelessWidget {
|
|||||||
if (drafts.isEmpty) {
|
if (drafts.isEmpty) {
|
||||||
return const Center(child: Text('No drafts saved yet.'));
|
return const Center(child: Text('No drafts saved yet.'));
|
||||||
}
|
}
|
||||||
return _DraftListBody(
|
return _DraftListBody(drafts: drafts, onDelete: onDeleteDraft);
|
||||||
drafts: drafts,
|
|
||||||
onDelete: onDeleteDraft,
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -1551,16 +1547,16 @@ class _DraftListBodyState extends State<_DraftListBody> {
|
|||||||
Future<void> _confirmDelete(BuildContext context, _StoredDraft draft) async {
|
Future<void> _confirmDelete(BuildContext context, _StoredDraft draft) async {
|
||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (_) => AlertDialog(
|
builder: (dialogCtx) => AlertDialog(
|
||||||
title: const Text('Delete draft?'),
|
title: const Text('Delete draft?'),
|
||||||
content: const Text('This draft will be removed permanently.'),
|
content: const Text('This draft will be removed permanently.'),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.of(context).pop(false),
|
onPressed: () => Navigator.of(dialogCtx).pop(false),
|
||||||
child: const Text('Cancel'),
|
child: const Text('Cancel'),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.of(context).pop(true),
|
onPressed: () => Navigator.of(dialogCtx).pop(true),
|
||||||
child: const Text('Delete'),
|
child: const Text('Delete'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -1607,11 +1603,13 @@ class _DraftListBodyState extends State<_DraftListBody> {
|
|||||||
if (network.isNotEmpty) parts.add('Network $network');
|
if (network.isNotEmpty) parts.add('Network $network');
|
||||||
final notes = (map['leg_notes'] as String? ?? '').trim();
|
final notes = (map['leg_notes'] as String? ?? '').trim();
|
||||||
if (notes.isNotEmpty) parts.add('Notes');
|
if (notes.isNotEmpty) parts.add('Notes');
|
||||||
final mileage = (map['leg_distance'] as num?)?.toDouble() ??
|
final mileage =
|
||||||
|
(map['leg_distance'] as num?)?.toDouble() ??
|
||||||
(map['leg_mileage'] as num?)?.toDouble();
|
(map['leg_mileage'] as num?)?.toDouble();
|
||||||
if (mileage != null && mileage > 0) {
|
if (mileage != null && mileage > 0) {
|
||||||
parts.add('${mileage.toStringAsFixed(1)} mi');
|
parts.add('${mileage.toStringAsFixed(1)} mi');
|
||||||
} else if (map['leg_route'] is List && (map['leg_route'] as List).isNotEmpty) {
|
} else if (map['leg_route'] is List &&
|
||||||
|
(map['leg_route'] as List).isNotEmpty) {
|
||||||
parts.add('Route ${(map['leg_route'] as List).length} stops');
|
parts.add('Route ${(map['leg_route'] as List).length} stops');
|
||||||
}
|
}
|
||||||
final locos = map['locos'];
|
final locos = map['locos'];
|
||||||
|
|||||||
@@ -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()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -574,6 +585,12 @@ class _TractionPageState extends State<TractionPage> {
|
|||||||
icon: const Icon(Icons.info_outline),
|
icon: const Icon(Icons.info_outline),
|
||||||
label: const Text('Details'),
|
label: const Text('Details'),
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: () => _openTimeline(loco),
|
||||||
|
icon: const Icon(Icons.timeline),
|
||||||
|
label: const Text('Timeline'),
|
||||||
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
if (widget.selectionMode)
|
if (widget.selectionMode)
|
||||||
TextButton.icon(
|
TextButton.icon(
|
||||||
@@ -692,6 +709,14 @@ class _TractionPageState extends State<TractionPage> {
|
|||||||
return (background, foreground);
|
return (background, foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _openTimeline(LocoSummary loco) {
|
||||||
|
final label = '${loco.locoClass} ${loco.number}'.trim();
|
||||||
|
context.push(
|
||||||
|
'/traction/${loco.id}/timeline',
|
||||||
|
extra: {'label': label},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _showLocoInfo(LocoSummary loco) async {
|
Future<void> _showLocoInfo(LocoSummary loco) async {
|
||||||
await showModalBottomSheet(
|
await showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
|
|||||||
@@ -301,6 +301,25 @@ class _TripsPageState extends State<TripsPage> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
|
if (!loading && items.isNotEmpty) ...[
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
Chip(
|
||||||
|
avatar: const Icon(Icons.train, size: 16),
|
||||||
|
label: Text('Total had: ${items.length}'),
|
||||||
|
),
|
||||||
|
Chip(
|
||||||
|
avatar: const Icon(Icons.star, size: 16),
|
||||||
|
label: Text(
|
||||||
|
'Winners: ${items.where((e) => e.won == true).length}',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
if (loading)
|
if (loading)
|
||||||
const Center(
|
const Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:dynamic_color/dynamic_color.dart';
|
import 'package:dynamic_color/dynamic_color.dart';
|
||||||
import 'package:mileograph_flutter/components/pages/calculator.dart';
|
import 'package:mileograph_flutter/components/pages/calculator.dart';
|
||||||
|
import 'package:mileograph_flutter/components/pages/loco_timeline.dart';
|
||||||
import 'package:mileograph_flutter/components/pages/new_entry.dart';
|
import 'package:mileograph_flutter/components/pages/new_entry.dart';
|
||||||
import 'package:mileograph_flutter/components/pages/new_traction.dart';
|
import 'package:mileograph_flutter/components/pages/new_traction.dart';
|
||||||
import 'package:mileograph_flutter/components/pages/traction.dart';
|
import 'package:mileograph_flutter/components/pages/traction.dart';
|
||||||
@@ -95,6 +96,27 @@ class MyApp extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
GoRoute(path: '/legs', builder: (_, __) => LegsPage()),
|
GoRoute(path: '/legs', builder: (_, __) => LegsPage()),
|
||||||
GoRoute(path: '/traction', builder: (_, __) => TractionPage()),
|
GoRoute(path: '/traction', builder: (_, __) => TractionPage()),
|
||||||
|
GoRoute(
|
||||||
|
path: '/traction/:id/timeline',
|
||||||
|
builder: (_, state) {
|
||||||
|
final idParam = state.pathParameters['id'];
|
||||||
|
final locoId = int.tryParse(idParam ?? '') ?? 0;
|
||||||
|
final extra = state.extra;
|
||||||
|
String label = state.uri.queryParameters['label'] ?? '';
|
||||||
|
if (extra is Map && extra['label'] is String) {
|
||||||
|
label = extra['label'] as String;
|
||||||
|
} else if (extra is String && extra.isNotEmpty) {
|
||||||
|
label = extra;
|
||||||
|
}
|
||||||
|
if (label.trim().isEmpty) {
|
||||||
|
label = 'Loco $locoId';
|
||||||
|
}
|
||||||
|
return LocoTimelinePage(
|
||||||
|
locoId: locoId,
|
||||||
|
locoLabel: label,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/traction/new',
|
path: '/traction/new',
|
||||||
builder: (_, __) => const NewTractionPage(),
|
builder: (_, __) => const NewTractionPage(),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
class DestinationObject {
|
class DestinationObject {
|
||||||
const DestinationObject(
|
const DestinationObject(
|
||||||
@@ -190,6 +191,143 @@ class LocoSummary extends Loco {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class LocoAttrVersion {
|
||||||
|
final String attrCode;
|
||||||
|
final int? versionId;
|
||||||
|
final int locoId;
|
||||||
|
final int? attrTypeId;
|
||||||
|
final String? valueStr;
|
||||||
|
final int? valueInt;
|
||||||
|
final DateTime? valueDate;
|
||||||
|
final bool? valueBool;
|
||||||
|
final String? valueEnum;
|
||||||
|
final DateTime? validFrom;
|
||||||
|
final DateTime? validTo;
|
||||||
|
final DateTime? txnFrom;
|
||||||
|
final DateTime? txnTo;
|
||||||
|
final String? suggestedBy;
|
||||||
|
final String? approvedBy;
|
||||||
|
final DateTime? approvedAt;
|
||||||
|
final int? sourceEventId;
|
||||||
|
final String? precisionLevel;
|
||||||
|
final String? maskedValidFrom;
|
||||||
|
final dynamic valueNorm;
|
||||||
|
|
||||||
|
const LocoAttrVersion({
|
||||||
|
required this.attrCode,
|
||||||
|
required this.locoId,
|
||||||
|
this.versionId,
|
||||||
|
this.attrTypeId,
|
||||||
|
this.valueStr,
|
||||||
|
this.valueInt,
|
||||||
|
this.valueDate,
|
||||||
|
this.valueBool,
|
||||||
|
this.valueEnum,
|
||||||
|
this.validFrom,
|
||||||
|
this.validTo,
|
||||||
|
this.txnFrom,
|
||||||
|
this.txnTo,
|
||||||
|
this.suggestedBy,
|
||||||
|
this.approvedBy,
|
||||||
|
this.approvedAt,
|
||||||
|
this.sourceEventId,
|
||||||
|
this.precisionLevel,
|
||||||
|
this.maskedValidFrom,
|
||||||
|
this.valueNorm,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory LocoAttrVersion.fromJson(Map<String, dynamic> json) {
|
||||||
|
return LocoAttrVersion(
|
||||||
|
attrCode: json['attr_code']?.toString() ?? '',
|
||||||
|
locoId: (json['loco_id'] as num?)?.toInt() ?? 0,
|
||||||
|
versionId: (json['loco_attr_v_id'] as num?)?.toInt(),
|
||||||
|
attrTypeId: (json['attr_type_id'] as num?)?.toInt(),
|
||||||
|
valueStr: json['value_str']?.toString(),
|
||||||
|
valueInt: (json['value_int'] as num?)?.toInt(),
|
||||||
|
valueDate: _parseDate(json['value_date']),
|
||||||
|
valueBool: _parseBool(json['value_bool']),
|
||||||
|
valueEnum: json['value_enum']?.toString(),
|
||||||
|
validFrom: _parseDate(json['valid_from']),
|
||||||
|
validTo: _parseDate(json['valid_to']),
|
||||||
|
txnFrom: _parseDate(json['txn_from']),
|
||||||
|
txnTo: _parseDate(json['txn_to']),
|
||||||
|
suggestedBy: json['suggested_by']?.toString(),
|
||||||
|
approvedBy: json['approved_by']?.toString(),
|
||||||
|
approvedAt: _parseDate(json['approved_at']),
|
||||||
|
sourceEventId: (json['source_event_id'] as num?)?.toInt(),
|
||||||
|
precisionLevel: json['precision_level']?.toString(),
|
||||||
|
maskedValidFrom: json['masked_valid_from']?.toString(),
|
||||||
|
valueNorm: json['value_norm'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static DateTime? _parseDate(dynamic value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
if (value is DateTime) return value;
|
||||||
|
return DateTime.tryParse(value.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool? _parseBool(dynamic value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
if (value is bool) return value;
|
||||||
|
if (value is num) return value != 0;
|
||||||
|
final str = value.toString().toLowerCase();
|
||||||
|
if (['true', '1', 'yes'].contains(str)) return true;
|
||||||
|
if (['false', '0', 'no'].contains(str)) return false;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<LocoAttrVersion> fromGroupedJson(dynamic json) {
|
||||||
|
final List<LocoAttrVersion> items = [];
|
||||||
|
if (json is Map) {
|
||||||
|
json.forEach((key, value) {
|
||||||
|
if (value is List) {
|
||||||
|
for (final entry in value) {
|
||||||
|
if (entry is Map<String, dynamic>) {
|
||||||
|
final merged = Map<String, dynamic>.from(entry);
|
||||||
|
merged.putIfAbsent('attr_code', () => key);
|
||||||
|
items.add(LocoAttrVersion.fromJson(merged));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
items.sort(
|
||||||
|
(a, b) {
|
||||||
|
final aDate = a.validFrom ?? a.txnFrom ?? DateTime.fromMillisecondsSinceEpoch(0);
|
||||||
|
final bDate = b.validFrom ?? b.txnFrom ?? DateTime.fromMillisecondsSinceEpoch(0);
|
||||||
|
final dateCompare = aDate.compareTo(bDate);
|
||||||
|
if (dateCompare != 0) return dateCompare;
|
||||||
|
return a.attrCode.compareTo(b.attrCode);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
String get valueLabel {
|
||||||
|
if (valueStr != null && valueStr!.isNotEmpty) return valueStr!;
|
||||||
|
if (valueEnum != null && valueEnum!.isNotEmpty) return valueEnum!;
|
||||||
|
if (valueInt != null) return valueInt!.toString();
|
||||||
|
if (valueBool != null) return valueBool! ? 'Yes' : 'No';
|
||||||
|
if (valueDate != null) return DateFormat('yyyy-MM-dd').format(valueDate!);
|
||||||
|
if (valueNorm != null && valueNorm.toString().isNotEmpty) {
|
||||||
|
return valueNorm.toString();
|
||||||
|
}
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
String get validityRange {
|
||||||
|
final start = maskedValidFrom ?? _formatDate(validFrom) ?? 'Unknown';
|
||||||
|
final end = _formatDate(validTo, fallback: 'Present') ?? 'Present';
|
||||||
|
return '$start → $end';
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _formatDate(DateTime? value, {String? fallback}) {
|
||||||
|
if (value == null) return fallback;
|
||||||
|
return DateFormat('yyyy-MM-dd').format(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class LeaderboardEntry {
|
class LeaderboardEntry {
|
||||||
final String userId, username, userFullName;
|
final String userId, username, userFullName;
|
||||||
final double mileage;
|
final double mileage;
|
||||||
|
|||||||
@@ -97,10 +97,7 @@ class ApiService {
|
|||||||
return body;
|
return body;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (res.statusCode == 401 &&
|
if (res.statusCode == 401 && _onUnauthorized != null) {
|
||||||
body is Map<String, dynamic> &&
|
|
||||||
body['detail'] == 'Not authenticated' &&
|
|
||||||
_onUnauthorized != null) {
|
|
||||||
await _onUnauthorized!();
|
await _onUnauthorized!();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/scheduler.dart';
|
import 'package:flutter/scheduler.dart';
|
||||||
@@ -49,6 +50,12 @@ class DataService extends ChangeNotifier {
|
|||||||
bool get isTractionLoading => _isTractionLoading;
|
bool get isTractionLoading => _isTractionLoading;
|
||||||
bool _tractionHasMore = false;
|
bool _tractionHasMore = false;
|
||||||
bool get tractionHasMore => _tractionHasMore;
|
bool get tractionHasMore => _tractionHasMore;
|
||||||
|
final Map<int, List<LocoAttrVersion>> _locoTimelines = {};
|
||||||
|
final Map<int, bool> _isLocoTimelineLoading = {};
|
||||||
|
List<LocoAttrVersion> timelineForLoco(int locoId) =>
|
||||||
|
_locoTimelines[locoId] ?? [];
|
||||||
|
bool isLocoTimelineLoading(int locoId) =>
|
||||||
|
_isLocoTimelineLoading[locoId] ?? false;
|
||||||
|
|
||||||
// Trips
|
// Trips
|
||||||
List<TripSummary> _trips = [];
|
List<TripSummary> _trips = [];
|
||||||
@@ -235,6 +242,24 @@ class DataService extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<List<LocoAttrVersion>> fetchLocoTimeline(int locoId) async {
|
||||||
|
_isLocoTimelineLoading[locoId] = true;
|
||||||
|
_notifyAsync();
|
||||||
|
try {
|
||||||
|
final json = await api.get('/loco/get-timeline/$locoId');
|
||||||
|
final timeline = LocoAttrVersion.fromGroupedJson(json);
|
||||||
|
_locoTimelines[locoId] = timeline;
|
||||||
|
return timeline;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Failed to fetch loco timeline for $locoId: $e');
|
||||||
|
_locoTimelines[locoId] = [];
|
||||||
|
return [];
|
||||||
|
} finally {
|
||||||
|
_isLocoTimelineLoading[locoId] = false;
|
||||||
|
_notifyAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<dynamic> createLoco(Map<String, dynamic> payload) async {
|
Future<dynamic> createLoco(Map<String, dynamic> payload) async {
|
||||||
try {
|
try {
|
||||||
final response = await api.put('/loco/new', payload);
|
final response = await api.put('/loco/new', payload);
|
||||||
@@ -417,6 +442,30 @@ class DataService extends ChangeNotifier {
|
|||||||
return _locoClasses;
|
return _locoClasses;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> createLocoEvent({
|
||||||
|
required int locoId,
|
||||||
|
required String eventDate,
|
||||||
|
required Map<String, dynamic> values,
|
||||||
|
required String details,
|
||||||
|
String eventType = 'other',
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
await api.put(
|
||||||
|
'/event/new',
|
||||||
|
{
|
||||||
|
'loco_id': locoId,
|
||||||
|
'loco_event_type': eventType,
|
||||||
|
'loco_event_date': eventDate,
|
||||||
|
'loco_event_value': jsonEncode(values),
|
||||||
|
'loco_event_details': details,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Failed to create loco event: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void clear() {
|
void clear() {
|
||||||
_homepageStats = null;
|
_homepageStats = null;
|
||||||
_legs = [];
|
_legs = [];
|
||||||
@@ -424,6 +473,8 @@ class DataService extends ChangeNotifier {
|
|||||||
_trips = [];
|
_trips = [];
|
||||||
_tripDetails = [];
|
_tripDetails = [];
|
||||||
_eventFields = [];
|
_eventFields = [];
|
||||||
|
_locoTimelines.clear();
|
||||||
|
_isLocoTimelineLoading.clear();
|
||||||
_notifyAsync();
|
_notifyAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
# 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
|
# 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.
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
version: 0.1.4+1
|
version: 0.2.0+1
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.8.1
|
sdk: ^3.8.1
|
||||||
|
|||||||
Reference in New Issue
Block a user