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 BadgesPage extends StatefulWidget { const BadgesPage({super.key}); @override State createState() => _BadgesPageState(); } class _BadgesPageState extends State { bool _initialised = false; final Map _groupExpanded = {}; bool _loadingAwards = false; bool _loadingClassProgress = false; bool _loadingLocoProgress = false; @override void didChangeDependencies() { super.didChangeDependencies(); if (_initialised) return; _initialised = true; _refreshAwards(); } Future _refreshAwards() { _loadingAwards = false; _loadingClassProgress = false; _loadingLocoProgress = false; final data = context.read(); return Future.wait([ data.fetchBadgeAwards(limit: 20, badgeCode: 'class_clearance'), data.fetchClassClearanceProgress(), data.fetchLocoClearanceProgress(), ]); } @override Widget build(BuildContext context) { final data = context.watch(); final awards = data.badgeAwards; final loading = data.isBadgeAwardsLoading; final classProgress = data.classClearanceProgress; final classProgressLoading = data.isClassClearanceProgressLoading || _loadingClassProgress; final locoProgress = data.locoClearanceProgress; final locoProgressLoading = data.isLocoClearanceProgressLoading || _loadingLocoProgress; final hasAnyData = awards.isNotEmpty || classProgress.isNotEmpty || locoProgress.isNotEmpty; return Scaffold( appBar: AppBar( title: const Text('Badges'), leading: IconButton( icon: const Icon(Icons.arrow_back), onPressed: () { final navigator = Navigator.of(context); if (navigator.canPop()) { navigator.pop(); } else { context.go('/'); } }, ), ), body: RefreshIndicator( onRefresh: _refreshAwards, child: ListView( padding: const EdgeInsets.all(16), children: [ if ((loading || classProgressLoading || locoProgressLoading) && !hasAnyData) const Center( child: Padding( padding: EdgeInsets.symmetric(vertical: 24.0), child: CircularProgressIndicator(), ), ) else if (!hasAnyData) const Padding( padding: EdgeInsets.symmetric(vertical: 12.0), child: Text('No badges awarded yet.'), ) else ..._buildGroupedAwards( context, awards, classProgress, locoProgress, classProgressLoading, locoProgressLoading, data.classClearanceHasMore, data.locoClearanceHasMore, data.badgeAwardsHasMore, loading, ), ], ), ), ); } List _buildGroupedAwards( BuildContext context, List awards, List classProgress, List locoProgress, bool classProgressLoading, bool locoProgressLoading, bool classProgressHasMore, bool locoProgressHasMore, bool badgeAwardsHasMore, bool badgeAwardsLoading, ) { final grouped = _groupAwards(awards); if ((classProgress.isNotEmpty || classProgressLoading) && !grouped.containsKey('class_clearance')) { grouped['class_clearance'] = []; } if ((locoProgress.isNotEmpty || locoProgressLoading) && !grouped.containsKey('loco_clearance')) { grouped['loco_clearance'] = []; } final codes = _orderedBadgeCodes(grouped.keys.toList()); return codes.map((code) { final items = grouped[code]!; final expanded = _groupExpanded[code] ?? true; final title = _formatBadgeName(code); final isClass = code == 'class_clearance'; final isLoco = code == 'loco_clearance'; final classItems = isClass ? classProgress : []; final locoItems = isLoco ? locoProgress : []; final awardCount = isLoco ? locoItems.where((item) => item.awardedTiers.isNotEmpty).length : items.length; final isLoadingSection = isClass ? (classProgressLoading || badgeAwardsLoading || _loadingAwards) : (isLoco ? locoProgressLoading : false); final children = []; if (isClass && items.isNotEmpty) { children.add(_buildSubheading(context, 'Awarded')); children.addAll( items.map( (award) => Padding( padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0), child: _buildAwardCard(context, award, compact: true), ), ), ); if (badgeAwardsHasMore || badgeAwardsLoading || _loadingAwards) { children.add( Padding( padding: const EdgeInsets.only(top: 4.0, bottom: 8.0), child: _buildLoadMoreButton( context, badgeAwardsLoading || _loadingAwards, () => _loadMoreAwards(), ), ), ); } } else if (!isClass && !isLoco && items.isNotEmpty) { children.add(_buildSubheading(context, 'Awarded')); children.addAll( items.map( (award) => Padding( padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0), child: _buildAwardCard(context, award, compact: true), ), ), ); } if (isClass) { children.addAll( _buildClassProgressSection( context, classItems, classProgressLoading, classProgressHasMore, ), ); } if (isLoco) { children.addAll( _buildLocoProgressSection( context, locoItems, locoProgressLoading, locoProgressHasMore, showHeading: false, ), ); } if (children.isEmpty && !isLoadingSection) { children.add( const Padding( padding: EdgeInsets.symmetric(vertical: 6.0), child: Text('No awards'), ), ); } return Card( margin: const EdgeInsets.symmetric(vertical: 4.0), child: ExpansionTile( key: ValueKey(code), tilePadding: const EdgeInsets.symmetric(horizontal: 12.0), title: Row( children: [ Expanded(child: Text(title)), if (isLoadingSection) ...[ const SizedBox(width: 8), const SizedBox( height: 18, width: 18, child: CircularProgressIndicator(strokeWidth: 2), ), ], const SizedBox(width: 8), _buildCountChip(context, awardCount), ], ), initiallyExpanded: expanded, onExpansionChanged: (isOpen) { setState(() => _groupExpanded[code] = isOpen); }, children: children, ), ); }).toList(); } Map> _groupAwards(List awards) { final Map> grouped = {}; for (final award in awards) { final code = award.badgeCode.toLowerCase(); grouped.putIfAbsent(code, () => []).add(award); } return grouped; } Widget _buildAwardCard( BuildContext context, BadgeAward award, { bool compact = false, }) { final badgeName = _formatBadgeName(award.badgeCode); final tier = award.badgeTier.isNotEmpty ? award.badgeTier[0].toUpperCase() + award.badgeTier.substring(1) : ''; final tierIcon = _buildTierIcon(award.badgeTier); final scope = _scopeToShow(award); final content = Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ if (tierIcon != null) ...[ tierIcon, const SizedBox(width: 8), ], Expanded( child: Text( '$badgeName • $tier', style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w700, ), ), ), if (award.awardedAt != null) Text( _formatAwardDate(award.awardedAt!), style: Theme.of(context).textTheme.bodySmall, ), ], ), if (scope != null && scope.isNotEmpty) ...[ const SizedBox(height: 4), Text( scope, style: Theme.of(context).textTheme.bodyMedium, ), ], if (award.loco != null) ...[ const SizedBox(height: 6), _buildLocoInfo(context, award.loco!), ], ], ); if (compact) { return content; } return Card( child: Padding( padding: const EdgeInsets.all(10.0), child: content, ), ); } Widget _buildLocoInfo(BuildContext context, LocoSummary loco) { final lines = []; final classNum = [ if (loco.locoClass.isNotEmpty) loco.locoClass, if (loco.number.isNotEmpty) loco.number, ].join(' '); if (classNum.isNotEmpty) lines.add(classNum); if ((loco.name ?? '').isNotEmpty) lines.add(loco.name!); if ((loco.livery ?? '').isNotEmpty) lines.add(loco.livery!); if ((loco.location ?? '').isNotEmpty) lines.add(loco.location!); return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Icon(Icons.train, size: 20), const SizedBox(width: 8), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: lines.map((line) { return Text( line, style: Theme.of(context).textTheme.bodyMedium, ); }).toList(), ), ), ], ); } String _formatBadgeName(String code) { if (code.isEmpty) return 'Badge'; const known = { 'class_clearance': 'Class Clearance', 'loco_clearance': 'Loco Clearance', }; final lower = code.toLowerCase(); if (known.containsKey(lower)) return known[lower]!; final parts = code.split(RegExp(r'[_\\s]+')).where((p) => p.isNotEmpty); return parts .map((p) => p[0].toUpperCase() + p.substring(1).toLowerCase()) .join(' '); } List _orderedBadgeCodes(List codes) { final lowerCodes = codes.map((c) => c.toLowerCase()).toSet(); final ordered = []; for (final code in ['loco_clearance', 'class_clearance']) { if (lowerCodes.remove(code)) ordered.add(code); } final remaining = lowerCodes.toList() ..sort((a, b) => _formatBadgeName(a).compareTo(_formatBadgeName(b))); ordered.addAll(remaining); return ordered; } Widget _buildSubheading(BuildContext context, String label) { return Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), child: Text( label, style: Theme.of(context) .textTheme .labelMedium ?.copyWith(fontWeight: FontWeight.w700), ), ); } List _buildClassProgressSection( BuildContext context, List progress, bool isLoading, bool hasMore, ) { if (progress.isEmpty && !isLoading && !hasMore) return const []; return [ _buildSubheading(context, 'In Progress'), ...progress.map( (item) => Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 6.0), child: _buildClassProgressCard(context, item), ), ), if (hasMore || isLoading) Padding( padding: const EdgeInsets.only(top: 4.0, bottom: 8.0), child: _buildLoadMoreButton( context, isLoading, () => _loadMoreClassProgress(), ), ), if (progress.isNotEmpty) const SizedBox(height: 4), ]; } List _buildLocoProgressSection( BuildContext context, List progress, bool isLoading, bool hasMore, {bool showHeading = true} ) { if (progress.isEmpty && !isLoading && !hasMore) return const []; return [ if (showHeading) _buildSubheading(context, 'In Progress'), if (progress.isEmpty && isLoading) Padding( padding: const EdgeInsets.symmetric(vertical: 12.0), child: _buildLoadingIndicator(), ), ...progress.map( (item) => Padding( padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 6.0), child: _buildLocoProgressCard(context, item), ), ), if (hasMore || isLoading) Padding( padding: const EdgeInsets.only(top: 4.0, bottom: 8.0), child: _buildLoadMoreButton( context, isLoading, () => _loadMoreLocoProgress(), ), ), if (progress.isNotEmpty) const SizedBox(height: 4), ]; } Widget _buildClassProgressCard( BuildContext context, ClassClearanceProgress progress, ) { final pct = progress.percentComplete.clamp(0, 100); return Card( margin: const EdgeInsets.symmetric(vertical: 4.0), child: Padding( padding: const EdgeInsets.all(8.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded( child: Text( progress.className, style: Theme.of(context).textTheme.bodyMedium, ), ), Text( '${pct.toStringAsFixed(0)}%', style: Theme.of(context).textTheme.labelMedium, ), ], ), const SizedBox(height: 4), LinearProgressIndicator( value: progress.total == 0 ? 0 : pct / 100, minHeight: 6, ), if (progress.total > 0) Padding( padding: const EdgeInsets.only(top: 2.0), child: Text( '${progress.completed}/${progress.total}', style: Theme.of(context) .textTheme .labelSmall ?.copyWith(color: Theme.of(context).hintColor), ), ), ], ), ), ); } Widget _buildLocoProgressCard( BuildContext context, LocoClearanceProgress progress, ) { final tierIcons = progress.awardedTiers .map((tier) => _buildTierIcon(tier, size: 18)) .whereType() .toList(); final reachedTopTier = progress.nextTier.isEmpty; final pct = progress.percent.clamp(0, 100); final nextTier = progress.nextTier.isNotEmpty ? progress.nextTier[0].toUpperCase() + progress.nextTier.substring(1) : 'Next'; final loco = progress.loco; final title = [ if (loco.number.isNotEmpty) loco.number, if (loco.locoClass.isNotEmpty) loco.locoClass, ].join(' • '); return Card( margin: const EdgeInsets.symmetric(vertical: 4.0), child: Padding( padding: const EdgeInsets.all(10.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded( child: Text( title.isNotEmpty ? title : 'Loco', style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w700, ), ), ), if (tierIcons.isNotEmpty) Row( children: tierIcons .expand((icon) sync* { yield icon; yield const SizedBox(width: 4); }) .toList() ..removeLast(), ), ], ), if ((loco.name ?? '').isNotEmpty) Padding( padding: const EdgeInsets.only(top: 2.0), child: Text( loco.name ?? '', style: Theme.of(context).textTheme.bodySmall, ), ), if (!reachedTopTier) ...[ const SizedBox(height: 4), LinearProgressIndicator( value: progress.required == 0 ? 0 : pct / 100, minHeight: 6, ), Padding( padding: const EdgeInsets.only(top: 2.0), child: Text( '${pct.toStringAsFixed(0)}% to $nextTier award', style: Theme.of(context).textTheme.bodyMedium, ), ), ], ], ), ), ); } Widget _buildLoadMoreButton( BuildContext context, bool isLoading, Future Function() onPressed, ) { return Align( alignment: Alignment.center, child: OutlinedButton.icon( onPressed: isLoading ? null : () { onPressed(); }, icon: isLoading ? const SizedBox( height: 18, width: 18, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.expand_more), label: Text(isLoading ? 'Loading...' : 'Load more'), ), ); } Widget _buildLoadingIndicator() { return const Center( child: SizedBox( height: 24, width: 24, child: CircularProgressIndicator(strokeWidth: 2), ), ); } Widget _buildCountChip(BuildContext context, int count) { return Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(999), ), child: Text( '$count', style: Theme.of(context) .textTheme .labelMedium ?.copyWith(fontWeight: FontWeight.w700), ), ); } Future _loadMoreClassProgress() { final data = context.read(); if (data.isClassClearanceProgressLoading || _loadingClassProgress) { return Future.value(); } setState(() => _loadingClassProgress = true); return data .fetchClassClearanceProgress( offset: data.classClearanceProgress.length, append: true, ) .whenComplete(() { if (mounted) setState(() => _loadingClassProgress = false); }); } Future _loadMoreLocoProgress() { final data = context.read(); if (data.isLocoClearanceProgressLoading || _loadingLocoProgress) { return Future.value(); } setState(() => _loadingLocoProgress = true); return data .fetchLocoClearanceProgress( offset: data.locoClearanceProgress.length, append: true, ) .whenComplete(() { if (mounted) setState(() => _loadingLocoProgress = false); }); } Future _loadMoreAwards() { final data = context.read(); if (data.isBadgeAwardsLoading || _loadingAwards) return Future.value(); setState(() => _loadingAwards = true); return data .fetchBadgeAwards( offset: data.badgeAwards.length, append: true, badgeCode: 'class_clearance', limit: 20, ) .whenComplete(() { if (mounted) setState(() => _loadingAwards = false); }); } String _formatAwardDate(DateTime date) { final y = date.year.toString().padLeft(4, '0'); final m = date.month.toString().padLeft(2, '0'); final d = date.day.toString().padLeft(2, '0'); return '$y-$m-$d'; } Widget? _buildTierIcon(String tier, {double size = 24}) { final lower = tier.toLowerCase(); Color? color; switch (lower) { case 'bronze': color = const Color(0xFFCD7F32); break; case 'silver': color = const Color(0xFFC0C0C0); break; case 'gold': color = const Color(0xFFFFD700); break; } if (color == null) return null; return Icon(Icons.emoji_events, color: color, size: size); } String? _scopeToShow(BadgeAward award) { final scope = award.scopeValue?.trim() ?? ''; if (scope.isEmpty) return null; final code = award.badgeCode.toLowerCase(); if (code == 'loco_clearance') { // Hide numeric loco IDs; loco details are shown separately. if (int.tryParse(scope) != null) return null; } return scope; } }