500 lines
18 KiB
Dart
500 lines
18 KiB
Dart
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, this.expanded = false});
|
|
|
|
final bool expanded;
|
|
|
|
@override
|
|
State<LatestLocoChangesPanel> createState() => _LatestLocoChangesPanelState();
|
|
}
|
|
|
|
class _LatestLocoChangesPanelState extends State<LatestLocoChangesPanel> {
|
|
late final ScrollController _controller;
|
|
final Set<String> _collapsedDates = {};
|
|
final Set<String> _collapsedClasses = {};
|
|
final Set<String> _collapsedLocos = {};
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_controller = ScrollController();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_controller.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
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: [
|
|
const Icon(Icons.bolt, size: 20),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
'Latest loco changes',
|
|
style: textTheme.titleMedium?.copyWith(
|
|
fontWeight: FontWeight.w800,
|
|
),
|
|
),
|
|
),
|
|
if (isLoading && changes.isNotEmpty)
|
|
const SizedBox(
|
|
width: 18,
|
|
height: 18,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
if (isLoading && changes.isEmpty)
|
|
const Padding(
|
|
padding: EdgeInsets.all(12.0),
|
|
child: Center(child: CircularProgressIndicator()),
|
|
)
|
|
else if (changes.isEmpty)
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
|
child: Text(
|
|
'No recent loco changes yet.',
|
|
style: textTheme.bodyMedium,
|
|
),
|
|
)
|
|
else
|
|
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}';
|
|
}
|