Files
mileograph_flutter/lib/components/pages/profile.dart
Pete Gregory 54026aa93a
All checks were successful
Release / meta (push) Successful in 8s
Release / linux-build (push) Successful in 6m46s
Release / android-build (push) Successful in 14m42s
Release / release-dev (push) Successful in 26s
Release / release-master (push) Successful in 24s
make percentage cleared cards shorter
2025-12-26 22:51:16 +00:00

706 lines
21 KiB
Dart

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 ProfilePage extends StatefulWidget {
const ProfilePage({super.key});
@override
State<ProfilePage> createState() => _ProfilePageState();
}
class _ProfilePageState extends State<ProfilePage> {
bool _initialised = false;
final Map<String, bool> _groupExpanded = {};
bool _loadingAwards = false;
bool _loadingClassProgress = false;
bool _loadingLocoProgress = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_initialised) return;
_initialised = true;
_refreshAwards();
}
Future<void> _refreshAwards() {
_loadingAwards = false;
_loadingClassProgress = false;
_loadingLocoProgress = false;
final data = context.read<DataService>();
return Future.wait([
data.fetchBadgeAwards(limit: 20, badgeCode: 'class_clearance'),
data.fetchClassClearanceProgress(),
data.fetchLocoClearanceProgress(),
]);
}
@override
Widget build(BuildContext context) {
final data = context.watch<DataService>();
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<Widget> _buildGroupedAwards(
BuildContext context,
List<BadgeAward> awards,
List<ClassClearanceProgress> classProgress,
List<LocoClearanceProgress> 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 : <ClassClearanceProgress>[];
final locoItems = isLoco ? locoProgress : <LocoClearanceProgress>[];
final awardCount = isLoco
? locoItems.where((item) => item.awardedTiers.isNotEmpty).length
: items.length;
final isLoadingSection = isClass
? (classProgressLoading || badgeAwardsLoading || _loadingAwards)
: (isLoco ? locoProgressLoading : false);
final children = <Widget>[];
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<String, List<BadgeAward>> _groupAwards(List<BadgeAward> awards) {
final Map<String, List<BadgeAward>> 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 = <String>[];
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<String> _orderedBadgeCodes(List<String> codes) {
final lowerCodes = codes.map((c) => c.toLowerCase()).toSet();
final ordered = <String>[];
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<Widget> _buildClassProgressSection(
BuildContext context,
List<ClassClearanceProgress> 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<Widget> _buildLocoProgressSection(
BuildContext context,
List<LocoClearanceProgress> 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<Widget>()
.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<void> 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<void> _loadMoreClassProgress() {
final data = context.read<DataService>();
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<void> _loadMoreLocoProgress() {
final data = context.read<DataService>();
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<void> _loadMoreAwards() {
final data = context.read<DataService>();
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;
}
}