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 createState() => _LatestLocoChangesPanelState(); } class _LatestLocoChangesPanelState extends State { late final ScrollController _controller; final Set _collapsedDates = {}; final Set _collapsedClasses = {}; final Set _collapsedLocos = {}; @override void initState() { super.initState(); _controller = ScrollController(); } @override void dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final data = context.watch(); 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 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 changes) { final dateFormat = DateFormat('yyyy-MM-dd'); final Map>>> 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 _loadMore() async { final data = context.read(); 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 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}'; }