badge percentage support

This commit is contained in:
2025-12-26 22:49:43 +00:00
parent 4bd6f0bbed
commit 0971124fd4
10 changed files with 866 additions and 113 deletions

View File

@@ -27,8 +27,13 @@ class App extends StatelessWidget {
ChangeNotifierProvider<AuthService>(
create: (context) => AuthService(api: context.read<ApiService>()),
),
ChangeNotifierProvider<DataService>(
ChangeNotifierProxyProvider<AuthService, DataService>(
create: (context) => DataService(api: context.read<ApiService>()),
update: (context, auth, data) {
data ??= DataService(api: context.read<ApiService>());
data.handleAuthChanged(auth.userId);
return data;
},
),
],
child: const MyApp(),

View File

@@ -286,7 +286,7 @@ class _LatestLocoChangesPanelState extends State<LatestLocoChangesPanel> {
],
);
},
separatorBuilder: (_, __) => const Divider(height: 8),
separatorBuilder: (_, index) => const Divider(height: 8),
itemCount: grouped.length,
);

View File

@@ -224,6 +224,7 @@ class _LegCardState extends State<LegCard> {
);
if (confirmed != true) return;
if (!context.mounted) return;
final data = context.read<DataService>();
final messenger = ScaffoldMessenger.of(context);
try {

View File

@@ -299,7 +299,6 @@ class _FieldInput extends StatelessWidget {
final name = field.name.toLowerCase();
if (name == 'max_speed') {
final unit = entry.unit ?? 'kph';
final isNumber = true;
return Row(
children: [
Expanded(
@@ -307,7 +306,7 @@ class _FieldInput extends StatelessWidget {
initialValue: value?.toString(),
onChanged: (val) {
final parsed = double.tryParse(val);
onChanged(isNumber ? parsed : val, unit: unit);
onChanged(parsed, unit: unit);
},
decoration: const InputDecoration(
border: OutlineInputBorder(),

View File

@@ -13,6 +13,10 @@ class ProfilePage extends StatefulWidget {
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() {
@@ -23,7 +27,15 @@ class _ProfilePageState extends State<ProfilePage> {
}
Future<void> _refreshAwards() {
return context.read<DataService>().fetchBadgeAwards();
_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
@@ -31,6 +43,14 @@ class _ProfilePageState extends State<ProfilePage> {
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(
@@ -52,27 +72,189 @@ class _ProfilePageState extends State<ProfilePage> {
child: ListView(
padding: const EdgeInsets.all(16),
children: [
if (loading && awards.isEmpty)
if ((loading || classProgressLoading || locoProgressLoading) &&
!hasAnyData)
const Center(
child: Padding(
padding: EdgeInsets.symmetric(vertical: 24.0),
child: CircularProgressIndicator(),
),
)
else if (awards.isEmpty)
else if (!hasAnyData)
const Padding(
padding: EdgeInsets.symmetric(vertical: 12.0),
child: Text('No badges awarded yet.'),
)
else
...awards.map((award) => _buildAwardCard(context, award)),
..._buildGroupedAwards(
context,
awards,
classProgress,
locoProgress,
classProgressLoading,
locoProgressLoading,
data.classClearanceHasMore,
data.locoClearanceHasMore,
data.badgeAwardsHasMore,
loading,
),
],
),
),
);
}
Widget _buildAwardCard(BuildContext context, BadgeAward award) {
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: 8.0),
child: Text('No awards'),
),
);
}
return Card(
margin: const EdgeInsets.symmetric(vertical: 6.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)
@@ -80,46 +262,52 @@ class _ProfilePageState extends State<ProfilePage> {
final tierIcon = _buildTierIcon(award.badgeTier);
final scope = _scopeToShow(award);
return Card(
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
final content = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
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: 6),
Text(
scope,
style: Theme.of(context).textTheme.bodyMedium,
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 (award.loco != null) ...[
const SizedBox(height: 8),
_buildLocoInfo(context, award.loco!),
],
],
),
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,
),
);
}
@@ -169,6 +357,316 @@ class _ProfilePageState extends State<ProfilePage> {
.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(10.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: 6),
LinearProgressIndicator(
value: progress.required == 0 ? 0 : pct / 100,
minHeight: 6,
),
Padding(
padding: const EdgeInsets.only(top: 4.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');
@@ -176,7 +674,7 @@ class _ProfilePageState extends State<ProfilePage> {
return '$y-$m-$d';
}
Widget? _buildTierIcon(String tier) {
Widget? _buildTierIcon(String tier, {double size = 24}) {
final lower = tier.toLowerCase();
Color? color;
switch (lower) {
@@ -191,7 +689,7 @@ class _ProfilePageState extends State<ProfilePage> {
break;
}
if (color == null) return null;
return Icon(Icons.emoji_events, color: color);
return Icon(Icons.emoji_events, color: color, size: size);
}
String? _scopeToShow(BadgeAward award) {

View File

@@ -827,3 +827,85 @@ class BadgeAward {
);
}
}
class ClassClearanceProgress {
final String className;
final int completed;
final int total;
final double percentComplete;
ClassClearanceProgress({
required this.className,
required this.completed,
required this.total,
required this.percentComplete,
});
factory ClassClearanceProgress.fromJson(Map<String, dynamic> json) {
final name = _asString(json['class'] ?? json['class_name'] ?? json['name']);
final completed = _asInt(
json['completed'] ?? json['done'] ?? json['count'] ?? json['had'],
);
final total = _asInt(json['total'] ?? json['required'] ?? json['goal']);
double percent = _asDouble(
json['percent_complete'] ??
json['percent'] ??
json['completion'] ??
json['pct'],
);
if (percent == 0 && total > 0) {
percent = (completed / total) * 100;
}
return ClassClearanceProgress(
className: name.isNotEmpty ? name : 'Class',
completed: completed,
total: total,
percentComplete: percent,
);
}
}
class LocoClearanceProgress {
final LocoSummary loco;
final double mileage;
final double required;
final String nextTier;
final List<String> awardedTiers;
final double percent;
LocoClearanceProgress({
required this.loco,
required this.mileage,
required this.required,
required this.nextTier,
required this.awardedTiers,
required this.percent,
});
factory LocoClearanceProgress.fromJson(Map<String, dynamic> json) {
final locoJson = json['loco'];
final loco = locoJson is Map<String, dynamic>
? LocoSummary.fromJson(Map<String, dynamic>.from(locoJson))
: LocoSummary(
locoId: _asInt(json['loco_id']),
locoType: _asString(json['loco_type']),
locoNumber: _asString(json['loco_number']),
locoName: _asString(json['loco_name']),
locoClass: _asString(json['loco_class']),
locoOperator: _asString(json['operator']),
powering: true,
locoNotes: null,
locoEvn: null,
);
return LocoClearanceProgress(
loco: loco,
mileage: _asDouble(json['mileage']),
required: _asDouble(json['required']),
nextTier: _asString(json['next_tier']),
awardedTiers: (json['awarded_tiers'] as List? ?? [])
.map((e) => e.toString())
.toList(),
percent: _asDouble(json['percent']),
);
}
}

View File

@@ -1,10 +1,18 @@
part of 'data_service.dart';
extension DataServiceBadges on DataService {
Future<void> fetchBadgeAwards() async {
Future<void> fetchBadgeAwards({
int offset = 0,
int limit = 50,
bool append = false,
String badgeCode = 'class_clearance',
}) async {
_isBadgeAwardsLoading = true;
if (!append) _badgeAwards = [];
try {
final json = await api.get('/badge/awards/me');
final json = await api.get(
'/badge/awards/me?limit=$limit&offset=$offset&badge_code=$badgeCode',
);
List<dynamic>? list;
if (json is List) {
list = json;
@@ -21,22 +29,102 @@ extension DataServiceBadges on DataService {
?.whereType<Map<String, dynamic>>()
.map(BadgeAward.fromJson)
.toList();
if (parsed != null) {
parsed.sort((a, b) {
final aTs = a.awardedAt?.millisecondsSinceEpoch ?? 0;
final bTs = b.awardedAt?.millisecondsSinceEpoch ?? 0;
return bTs.compareTo(aTs);
});
_badgeAwards = parsed;
} else {
_badgeAwards = [];
}
final items = parsed ?? [];
_badgeAwards =
append ? [..._badgeAwards, ...items] : items;
_badgeAwards.sort((a, b) {
final aTs = a.awardedAt?.millisecondsSinceEpoch ?? 0;
final bTs = b.awardedAt?.millisecondsSinceEpoch ?? 0;
return bTs.compareTo(aTs);
});
_badgeAwardsHasMore = items.length >= limit;
} catch (e) {
debugPrint('Failed to fetch badge awards: $e');
_badgeAwards = [];
if (!append) _badgeAwards = [];
_badgeAwardsHasMore = false;
} finally {
_isBadgeAwardsLoading = false;
_notifyAsync();
}
}
Future<void> fetchClassClearanceProgress({
int offset = 0,
int limit = 20,
bool append = false,
}) async {
_isClassClearanceProgressLoading = true;
if (!append) _classClearanceProgress = [];
try {
final json =
await api.get('/badge/completion/class?limit=$limit&offset=$offset');
List<dynamic>? list;
if (json is List) {
list = json;
} else if (json is Map) {
for (final key in ['progress', 'data', 'items', 'classes']) {
final value = json[key];
if (value is List) {
list = value;
break;
}
}
}
final parsed = list
?.whereType<Map<String, dynamic>>()
.map(ClassClearanceProgress.fromJson)
.toList();
final items = parsed ?? [];
_classClearanceProgress =
append ? [..._classClearanceProgress, ...items] : items;
_classClearanceHasMore = items.length >= limit;
} catch (e) {
debugPrint('Failed to fetch class clearance progress: $e');
if (!append) _classClearanceProgress = [];
_classClearanceHasMore = false;
} finally {
_isClassClearanceProgressLoading = false;
_notifyAsync();
}
}
Future<void> fetchLocoClearanceProgress({
int offset = 0,
int limit = 20,
bool append = false,
}) async {
_isLocoClearanceProgressLoading = true;
if (!append) _locoClearanceProgress = [];
try {
final json =
await api.get('/badge/completion/loco?limit=$limit&offset=$offset');
List<dynamic>? list;
if (json is List) {
list = json;
} else if (json is Map) {
for (final key in ['progress', 'data', 'items', 'locos']) {
final value = json[key];
if (value is List) {
list = value;
break;
}
}
}
final parsed = list
?.whereType<Map<String, dynamic>>()
.map(LocoClearanceProgress.fromJson)
.toList();
final items = parsed ?? [];
_locoClearanceProgress =
append ? [..._locoClearanceProgress, ...items] : items;
_locoClearanceHasMore = items.length >= limit;
} catch (e) {
debugPrint('Failed to fetch loco clearance progress: $e');
if (!append) _locoClearanceProgress = [];
_locoClearanceHasMore = false;
} finally {
_isLocoClearanceProgressLoading = false;
_notifyAsync();
}
}
}

View File

@@ -21,6 +21,8 @@ class DataService extends ChangeNotifier {
DataService({required this.api});
String? _currentUserId;
_LegFetchOptions _lastLegsFetch = const _LegFetchOptions();
// Homepage Data
@@ -102,6 +104,24 @@ class DataService extends ChangeNotifier {
List<BadgeAward> get badgeAwards => _badgeAwards;
bool _isBadgeAwardsLoading = false;
bool get isBadgeAwardsLoading => _isBadgeAwardsLoading;
bool _badgeAwardsHasMore = false;
bool get badgeAwardsHasMore => _badgeAwardsHasMore;
List<ClassClearanceProgress> _classClearanceProgress = [];
List<ClassClearanceProgress> get classClearanceProgress =>
_classClearanceProgress;
bool _isClassClearanceProgressLoading = false;
bool get isClassClearanceProgressLoading =>
_isClassClearanceProgressLoading;
bool _classClearanceHasMore = false;
bool get classClearanceHasMore => _classClearanceHasMore;
List<LocoClearanceProgress> _locoClearanceProgress = [];
List<LocoClearanceProgress> get locoClearanceProgress =>
_locoClearanceProgress;
bool _isLocoClearanceProgressLoading = false;
bool get isLocoClearanceProgressLoading =>
_isLocoClearanceProgressLoading;
bool _locoClearanceHasMore = false;
bool get locoClearanceHasMore => _locoClearanceHasMore;
static const List<EventField> _fallbackEventFields = [
EventField(name: 'operator', display: 'Operator'),
@@ -357,6 +377,8 @@ class DataService extends ChangeNotifier {
}
void clear() {
_currentUserId = null;
_lastLegsFetch = const _LegFetchOptions();
_homepageStats = null;
_legs = [];
_onThisDay = [];
@@ -367,9 +389,44 @@ class DataService extends ChangeNotifier {
_isLocoTimelineLoading.clear();
_latestLocoChanges = [];
_isLatestLocoChangesLoading = false;
_isHomepageLoading = false;
_isOnThisDayLoading = false;
_legsHasMore = false;
_isLegsLoading = false;
_traction = [];
_isTractionLoading = false;
_tractionHasMore = false;
_latestLocoChangesHasMore = false;
_latestLocoChangesFetched = 0;
_isTripDetailsLoading = false;
_locoClasses = [];
_tripList = [];
_stationCache.clear();
_stationInFlightByKey.clear();
_stationNetworks = [];
_stationCountryNetworks = {};
_stationFiltersFetchedAt = null;
_notifications = [];
_isNotificationsLoading = false;
_badgeAwards = [];
_badgeAwardsHasMore = false;
_isBadgeAwardsLoading = false;
_classClearanceProgress = [];
_isClassClearanceProgressLoading = false;
_classClearanceHasMore = false;
_locoClearanceProgress = [];
_isLocoClearanceProgressLoading = false;
_locoClearanceHasMore = false;
_notifyAsync();
}
void handleAuthChanged(String? userId) {
if (_currentUserId == userId) return;
_currentUserId = userId;
clear();
_currentUserId = userId;
}
double getMileageForCurrentYear() {
final currentYear = DateTime.now().year;
return getMileageForYear(currentYear) ?? 0;

View File

@@ -22,6 +22,14 @@ import 'package:provider/provider.dart';
final GlobalKey<NavigatorState> _shellNavigatorKey = GlobalKey<NavigatorState>();
const List<String> _contentPages = [
"/dashboard",
"/logbook",
"/traction",
"/add",
"/more",
];
const List<String> _defaultTabDestinations = [
"/dashboard",
"/logbook/entries",
"/traction",
@@ -29,7 +37,7 @@ const List<String> _contentPages = [
"/more",
];
const int _addTabIndex = 5;
const int _addTabIndex = 3;
class _NavItem {
final String label;
@@ -53,10 +61,9 @@ int tabIndexForPath(String path) {
matchPath = '/logbook/entries';
} else if (matchPath.startsWith('/trips')) {
matchPath = '/logbook/trips';
} else if (matchPath == '/logbook') {
matchPath = '/logbook/entries';
} else if (matchPath.startsWith('/logbook/trips')) {
matchPath = '/logbook/entries';
}
if (matchPath.startsWith('/logbook')) {
matchPath = '/logbook';
} else if (matchPath.startsWith('/profile') ||
matchPath.startsWith('/settings') ||
matchPath.startsWith('/more')) {
@@ -107,7 +114,7 @@ class _MyAppState extends State<MyApp> {
routes: [
GoRoute(
path: '/',
redirect: (_, __) => '/dashboard',
redirect: (context, state) => '/dashboard',
),
ShellRoute(
navigatorKey: _shellNavigatorKey,
@@ -119,7 +126,7 @@ class _MyAppState extends State<MyApp> {
),
GoRoute(
path: '/logbook',
builder: (context, state) => const LogbookPage(),
redirect: (context, state) => '/logbook/entries',
),
GoRoute(
path: '/logbook/entries',
@@ -132,12 +139,11 @@ class _MyAppState extends State<MyApp> {
),
GoRoute(
path: '/trips',
builder: (context, state) =>
const LogbookPage(initialTab: LogbookTab.trips),
redirect: (context, state) => '/logbook/trips',
),
GoRoute(
path: '/legs',
builder: (context, state) => const LogbookPage(),
redirect: (context, state) => '/logbook/entries',
),
GoRoute(
path: '/traction',
@@ -254,14 +260,14 @@ class MyHomePage extends StatefulWidget {
}
class _MyHomePageState extends State<MyHomePage> {
List<String> get contentPages => _contentPages;
List<String> get tabDestinations => _defaultTabDestinations;
Future<void> _onItemTapped(int index, int currentIndex) async {
if (index < 0 || index >= contentPages.length) {
if (index < 0 || index >= tabDestinations.length) {
return;
}
final currentPath = GoRouterState.of(context).uri.path;
final targetPath = contentPages[index];
final targetPath = tabDestinations[index];
final alreadyAtTarget =
currentPath == targetPath || currentPath.startsWith('$targetPath/');
if (index == currentIndex && alreadyAtTarget) return;
@@ -272,9 +278,9 @@ class _MyHomePageState extends State<MyHomePage> {
});
}
final List<int> _history = [];
final List<String> _history = [];
int _historyPosition = -1;
final List<int> _forwardHistory = [];
final List<String> _forwardHistory = [];
bool _suppressRecord = false;
bool _fetched = false;
@@ -323,7 +329,7 @@ class _MyHomePageState extends State<MyHomePage> {
Widget build(BuildContext context) {
final uri = GoRouterState.of(context).uri;
final pageIndex = tabIndexForPath(uri.path);
_syncHistory(pageIndex);
_syncHistory(uri.path);
if (pageIndex != _addTabIndex) {
NavigationGuard.unregister();
}
@@ -544,24 +550,21 @@ class _MyHomePageState extends State<MyHomePage> {
Future<void> _openNotificationsPanel(BuildContext context) async {
final data = context.read<DataService>();
final isWide = MediaQuery.sizeOf(context).width >= 900;
final sheetHeight = MediaQuery.sizeOf(context).height * 0.9;
try {
await data.fetchNotifications();
} catch (_) {
// Already logged inside data service.
}
if (!mounted) return;
final isWide = MediaQuery.of(context).size.width >= 900;
final panelBuilder = (BuildContext ctx) {
return _buildNotificationsContent(ctx, isWide);
};
if (!context.mounted) return;
if (isWide) {
await showDialog(
context: context,
builder: (dialogCtx) => Dialog(
insetPadding: const EdgeInsets.all(16),
child: panelBuilder(dialogCtx),
child: _buildNotificationsContent(dialogCtx, isWide),
),
);
} else {
@@ -569,11 +572,10 @@ class _MyHomePageState extends State<MyHomePage> {
context: context,
isScrollControlled: true,
builder: (sheetCtx) {
final height = MediaQuery.of(context).size.height * 0.9;
return SizedBox(
height: height,
height: sheetHeight,
child: SafeArea(
child: panelBuilder(sheetCtx),
child: _buildNotificationsContent(sheetCtx, isWide),
),
);
},
@@ -606,7 +608,7 @@ class _MyHomePageState extends State<MyHomePage> {
height: listHeight,
child: ListView.separated(
itemCount: notifications.length,
separatorBuilder: (_, __) => const SizedBox(height: 8),
separatorBuilder: (_, index) => const SizedBox(height: 8),
itemBuilder: (ctx, index) {
final item = notifications[index];
return Card(
@@ -644,11 +646,18 @@ class _MyHomePageState extends State<MyHomePage> {
.textTheme
.bodySmall
?.copyWith(
color: Theme.of(context)
.textTheme
.bodySmall
?.color
?.withOpacity(0.7),
color: () {
final baseColor = Theme.of(context)
.textTheme
.bodySmall
?.color;
if (baseColor == null) return null;
final newAlpha =
(baseColor.a * 0.7).clamp(0.0, 1.0);
return baseColor.withValues(
alpha: newAlpha,
);
}(),
),
),
],
@@ -757,31 +766,32 @@ class _MyHomePageState extends State<MyHomePage> {
);
}
int get _currentPageIndex => tabIndexForPath(GoRouterState.of(context).uri.path);
Future<bool> _handleBackNavigation({
bool allowExit = false,
bool recordForward = false,
}) async {
final pageIndex = _currentPageIndex;
final currentPath = GoRouterState.of(context).uri.path;
final shellNav = _shellNavigatorKey.currentState;
if (shellNav != null && shellNav.canPop()) {
if (recordForward) _pushForward(currentPath);
_alignHistoryAfterPop(currentPath);
shellNav.pop();
return true;
}
if (_historyPosition > 0) {
if (recordForward) _pushForward(pageIndex);
if (recordForward) _pushForward(currentPath);
_historyPosition -= 1;
_suppressRecord = true;
context.go(contentPages[_history[_historyPosition]]);
context.go(_history[_historyPosition]);
return true;
}
if (pageIndex != 0) {
if (recordForward) _pushForward(pageIndex);
final homePath = tabDestinations.first;
if (currentPath != homePath) {
if (recordForward) _pushForward(currentPath);
_suppressRecord = true;
context.go(contentPages[0]);
context.go(homePath);
return true;
}
@@ -795,35 +805,48 @@ class _MyHomePageState extends State<MyHomePage> {
Future<bool> _handleForwardNavigation() async {
if (_forwardHistory.isEmpty) return false;
final nextTab = _forwardHistory.removeLast();
final nextPath = _forwardHistory.removeLast();
// Move cursor forward, keeping history in sync.
if (_historyPosition < _history.length - 1) {
_historyPosition += 1;
_history[_historyPosition] = nextTab;
_history[_historyPosition] = nextPath;
if (_historyPosition < _history.length - 1) {
_history.removeRange(_historyPosition + 1, _history.length);
}
} else {
_history.add(nextTab);
_history.add(nextPath);
_historyPosition = _history.length - 1;
}
_suppressRecord = true;
if (!mounted) return false;
context.go(contentPages[nextTab]);
context.go(nextPath);
return true;
}
void _pushForward(int pageIndex) {
if (_forwardHistory.isEmpty || _forwardHistory.last != pageIndex) {
_forwardHistory.add(pageIndex);
void _pushForward(String path) {
if (_forwardHistory.isEmpty || _forwardHistory.last != path) {
_forwardHistory.add(path);
}
}
void _syncHistory(int pageIndex) {
void _alignHistoryAfterPop(String currentPath) {
if (_history.isEmpty) return;
if (_historyPosition >= 0 &&
_historyPosition < _history.length &&
_history[_historyPosition] == currentPath) {
if (_historyPosition > 0) {
_historyPosition -= 1;
}
_history.removeRange(_historyPosition + 1, _history.length);
_suppressRecord = true;
}
}
void _syncHistory(String path) {
if (_history.isEmpty) {
_history.add(pageIndex);
_history.add(path);
_historyPosition = 0;
return;
}
@@ -833,13 +856,13 @@ class _MyHomePageState extends State<MyHomePage> {
}
if (_historyPosition >= 0 &&
_historyPosition < _history.length &&
_history[_historyPosition] == pageIndex) {
_history[_historyPosition] == path) {
return;
}
if (_historyPosition < _history.length - 1) {
_history.removeRange(_historyPosition + 1, _history.length);
}
_history.add(pageIndex);
_history.add(path);
_historyPosition = _history.length - 1;
_forwardHistory.clear();
}
@@ -847,6 +870,6 @@ class _MyHomePageState extends State<MyHomePage> {
void _navigateToIndex(int index) {
_suppressRecord = false;
_forwardHistory.clear();
context.go(contentPages[index]);
context.go(tabDestinations[index]);
}
}