diff --git a/lib/app.dart b/lib/app.dart index 6e345e3..ad9ed53 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -27,8 +27,13 @@ class App extends StatelessWidget { ChangeNotifierProvider( create: (context) => AuthService(api: context.read()), ), - ChangeNotifierProvider( + ChangeNotifierProxyProvider( create: (context) => DataService(api: context.read()), + update: (context, auth, data) { + data ??= DataService(api: context.read()); + data.handleAuthChanged(auth.userId); + return data; + }, ), ], child: const MyApp(), diff --git a/lib/components/dashboard/latest_loco_changes_panel.dart b/lib/components/dashboard/latest_loco_changes_panel.dart index dabc4ba..986755f 100644 --- a/lib/components/dashboard/latest_loco_changes_panel.dart +++ b/lib/components/dashboard/latest_loco_changes_panel.dart @@ -286,7 +286,7 @@ class _LatestLocoChangesPanelState extends State { ], ); }, - separatorBuilder: (_, __) => const Divider(height: 8), + separatorBuilder: (_, index) => const Divider(height: 8), itemCount: grouped.length, ); diff --git a/lib/components/legs/leg_card.dart b/lib/components/legs/leg_card.dart index bdedc91..93d267c 100644 --- a/lib/components/legs/leg_card.dart +++ b/lib/components/legs/leg_card.dart @@ -224,6 +224,7 @@ class _LegCardState extends State { ); if (confirmed != true) return; + if (!context.mounted) return; final data = context.read(); final messenger = ScaffoldMessenger.of(context); try { diff --git a/lib/components/pages/loco_timeline/event_editor.dart b/lib/components/pages/loco_timeline/event_editor.dart index b537730..4374efc 100644 --- a/lib/components/pages/loco_timeline/event_editor.dart +++ b/lib/components/pages/loco_timeline/event_editor.dart @@ -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(), diff --git a/lib/components/pages/profile.dart b/lib/components/pages/profile.dart index 5f2895a..5975f4b 100644 --- a/lib/components/pages/profile.dart +++ b/lib/components/pages/profile.dart @@ -13,6 +13,10 @@ class ProfilePage extends StatefulWidget { class _ProfilePageState extends State { bool _initialised = false; + final Map _groupExpanded = {}; + bool _loadingAwards = false; + bool _loadingClassProgress = false; + bool _loadingLocoProgress = false; @override void didChangeDependencies() { @@ -23,7 +27,15 @@ class _ProfilePageState extends State { } Future _refreshAwards() { - return context.read().fetchBadgeAwards(); + _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 @@ -31,6 +43,14 @@ class _ProfilePageState extends State { 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( @@ -52,27 +72,189 @@ class _ProfilePageState extends State { 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 _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: 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> _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) @@ -80,46 +262,52 @@ class _ProfilePageState extends State { 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 { .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(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() + .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 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'); @@ -176,7 +674,7 @@ class _ProfilePageState extends State { 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 { 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) { diff --git a/lib/objects/objects.dart b/lib/objects/objects.dart index b03e540..8cae07b 100644 --- a/lib/objects/objects.dart +++ b/lib/objects/objects.dart @@ -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 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 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 json) { + final locoJson = json['loco']; + final loco = locoJson is Map + ? LocoSummary.fromJson(Map.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']), + ); + } +} diff --git a/lib/services/data_service/data_service_badges.dart b/lib/services/data_service/data_service_badges.dart index e20cb33..d262700 100644 --- a/lib/services/data_service/data_service_badges.dart +++ b/lib/services/data_service/data_service_badges.dart @@ -1,10 +1,18 @@ part of 'data_service.dart'; extension DataServiceBadges on DataService { - Future fetchBadgeAwards() async { + Future 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? list; if (json is List) { list = json; @@ -21,22 +29,102 @@ extension DataServiceBadges on DataService { ?.whereType>() .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 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? 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(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 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? 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(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(); + } + } } diff --git a/lib/services/data_service/data_service_core.dart b/lib/services/data_service/data_service_core.dart index 717d1ac..9638715 100644 --- a/lib/services/data_service/data_service_core.dart +++ b/lib/services/data_service/data_service_core.dart @@ -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 get badgeAwards => _badgeAwards; bool _isBadgeAwardsLoading = false; bool get isBadgeAwardsLoading => _isBadgeAwardsLoading; + bool _badgeAwardsHasMore = false; + bool get badgeAwardsHasMore => _badgeAwardsHasMore; + List _classClearanceProgress = []; + List get classClearanceProgress => + _classClearanceProgress; + bool _isClassClearanceProgressLoading = false; + bool get isClassClearanceProgressLoading => + _isClassClearanceProgressLoading; + bool _classClearanceHasMore = false; + bool get classClearanceHasMore => _classClearanceHasMore; + List _locoClearanceProgress = []; + List get locoClearanceProgress => + _locoClearanceProgress; + bool _isLocoClearanceProgressLoading = false; + bool get isLocoClearanceProgressLoading => + _isLocoClearanceProgressLoading; + bool _locoClearanceHasMore = false; + bool get locoClearanceHasMore => _locoClearanceHasMore; static const List _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; diff --git a/lib/ui/app_shell.dart b/lib/ui/app_shell.dart index 0fc38ee..a0e8b67 100644 --- a/lib/ui/app_shell.dart +++ b/lib/ui/app_shell.dart @@ -22,6 +22,14 @@ import 'package:provider/provider.dart'; final GlobalKey _shellNavigatorKey = GlobalKey(); const List _contentPages = [ + "/dashboard", + "/logbook", + "/traction", + "/add", + "/more", +]; + +const List _defaultTabDestinations = [ "/dashboard", "/logbook/entries", "/traction", @@ -29,7 +37,7 @@ const List _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 { routes: [ GoRoute( path: '/', - redirect: (_, __) => '/dashboard', + redirect: (context, state) => '/dashboard', ), ShellRoute( navigatorKey: _shellNavigatorKey, @@ -119,7 +126,7 @@ class _MyAppState extends State { ), 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 { ), 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 { - List get contentPages => _contentPages; + List get tabDestinations => _defaultTabDestinations; Future _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 { }); } - final List _history = []; + final List _history = []; int _historyPosition = -1; - final List _forwardHistory = []; + final List _forwardHistory = []; bool _suppressRecord = false; bool _fetched = false; @@ -323,7 +329,7 @@ class _MyHomePageState extends State { 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 { Future _openNotificationsPanel(BuildContext context) async { final data = context.read(); + 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 { 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 { 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 { .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 { ); } - int get _currentPageIndex => tabIndexForPath(GoRouterState.of(context).uri.path); - Future _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 { Future _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 { } 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 { void _navigateToIndex(int index) { _suppressRecord = false; _forwardHistory.clear(); - context.go(contentPages[index]); + context.go(tabDestinations[index]); } } diff --git a/pubspec.yaml b/pubspec.yaml index c83e5a3..7211412 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 0.4.0+1 +version: 0.4.1+1 environment: sdk: ^3.8.1