Improve entries page and latest changes panel, units on events and timeline
All checks were successful
All checks were successful
This commit is contained in:
@@ -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}';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user