badge percentage support
This commit is contained in:
@@ -27,8 +27,13 @@ class App extends StatelessWidget {
|
|||||||
ChangeNotifierProvider<AuthService>(
|
ChangeNotifierProvider<AuthService>(
|
||||||
create: (context) => AuthService(api: context.read<ApiService>()),
|
create: (context) => AuthService(api: context.read<ApiService>()),
|
||||||
),
|
),
|
||||||
ChangeNotifierProvider<DataService>(
|
ChangeNotifierProxyProvider<AuthService, DataService>(
|
||||||
create: (context) => DataService(api: context.read<ApiService>()),
|
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(),
|
child: const MyApp(),
|
||||||
|
|||||||
@@ -286,7 +286,7 @@ class _LatestLocoChangesPanelState extends State<LatestLocoChangesPanel> {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
separatorBuilder: (_, __) => const Divider(height: 8),
|
separatorBuilder: (_, index) => const Divider(height: 8),
|
||||||
itemCount: grouped.length,
|
itemCount: grouped.length,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -224,6 +224,7 @@ class _LegCardState extends State<LegCard> {
|
|||||||
);
|
);
|
||||||
if (confirmed != true) return;
|
if (confirmed != true) return;
|
||||||
|
|
||||||
|
if (!context.mounted) return;
|
||||||
final data = context.read<DataService>();
|
final data = context.read<DataService>();
|
||||||
final messenger = ScaffoldMessenger.of(context);
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -299,7 +299,6 @@ class _FieldInput extends StatelessWidget {
|
|||||||
final name = field.name.toLowerCase();
|
final name = field.name.toLowerCase();
|
||||||
if (name == 'max_speed') {
|
if (name == 'max_speed') {
|
||||||
final unit = entry.unit ?? 'kph';
|
final unit = entry.unit ?? 'kph';
|
||||||
final isNumber = true;
|
|
||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -307,7 +306,7 @@ class _FieldInput extends StatelessWidget {
|
|||||||
initialValue: value?.toString(),
|
initialValue: value?.toString(),
|
||||||
onChanged: (val) {
|
onChanged: (val) {
|
||||||
final parsed = double.tryParse(val);
|
final parsed = double.tryParse(val);
|
||||||
onChanged(isNumber ? parsed : val, unit: unit);
|
onChanged(parsed, unit: unit);
|
||||||
},
|
},
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ class ProfilePage extends StatefulWidget {
|
|||||||
|
|
||||||
class _ProfilePageState extends State<ProfilePage> {
|
class _ProfilePageState extends State<ProfilePage> {
|
||||||
bool _initialised = false;
|
bool _initialised = false;
|
||||||
|
final Map<String, bool> _groupExpanded = {};
|
||||||
|
bool _loadingAwards = false;
|
||||||
|
bool _loadingClassProgress = false;
|
||||||
|
bool _loadingLocoProgress = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didChangeDependencies() {
|
void didChangeDependencies() {
|
||||||
@@ -23,7 +27,15 @@ class _ProfilePageState extends State<ProfilePage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _refreshAwards() {
|
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
|
@override
|
||||||
@@ -31,6 +43,14 @@ class _ProfilePageState extends State<ProfilePage> {
|
|||||||
final data = context.watch<DataService>();
|
final data = context.watch<DataService>();
|
||||||
final awards = data.badgeAwards;
|
final awards = data.badgeAwards;
|
||||||
final loading = data.isBadgeAwardsLoading;
|
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(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
@@ -52,27 +72,189 @@ class _ProfilePageState extends State<ProfilePage> {
|
|||||||
child: ListView(
|
child: ListView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
children: [
|
||||||
if (loading && awards.isEmpty)
|
if ((loading || classProgressLoading || locoProgressLoading) &&
|
||||||
|
!hasAnyData)
|
||||||
const Center(
|
const Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.symmetric(vertical: 24.0),
|
padding: EdgeInsets.symmetric(vertical: 24.0),
|
||||||
child: CircularProgressIndicator(),
|
child: CircularProgressIndicator(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else if (awards.isEmpty)
|
else if (!hasAnyData)
|
||||||
const Padding(
|
const Padding(
|
||||||
padding: EdgeInsets.symmetric(vertical: 12.0),
|
padding: EdgeInsets.symmetric(vertical: 12.0),
|
||||||
child: Text('No badges awarded yet.'),
|
child: Text('No badges awarded yet.'),
|
||||||
)
|
)
|
||||||
else
|
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 badgeName = _formatBadgeName(award.badgeCode);
|
||||||
final tier = award.badgeTier.isNotEmpty
|
final tier = award.badgeTier.isNotEmpty
|
||||||
? award.badgeTier[0].toUpperCase() + award.badgeTier.substring(1)
|
? award.badgeTier[0].toUpperCase() + award.badgeTier.substring(1)
|
||||||
@@ -80,46 +262,52 @@ class _ProfilePageState extends State<ProfilePage> {
|
|||||||
final tierIcon = _buildTierIcon(award.badgeTier);
|
final tierIcon = _buildTierIcon(award.badgeTier);
|
||||||
final scope = _scopeToShow(award);
|
final scope = _scopeToShow(award);
|
||||||
|
|
||||||
return Card(
|
final content = Column(
|
||||||
child: Padding(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
padding: const EdgeInsets.all(12.0),
|
children: [
|
||||||
child: Column(
|
Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
children: [
|
||||||
Row(
|
if (tierIcon != null) ...[
|
||||||
children: [
|
tierIcon,
|
||||||
if (tierIcon != null) ...[
|
const SizedBox(width: 8),
|
||||||
tierIcon,
|
],
|
||||||
const SizedBox(width: 8),
|
Expanded(
|
||||||
],
|
child: Text(
|
||||||
Expanded(
|
'$badgeName • $tier',
|
||||||
child: Text(
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
'$badgeName • $tier',
|
fontWeight: FontWeight.w700,
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
),
|
||||||
fontWeight: FontWeight.w700,
|
),
|
||||||
),
|
),
|
||||||
),
|
if (award.awardedAt != null)
|
||||||
),
|
Text(
|
||||||
if (award.awardedAt != null)
|
_formatAwardDate(award.awardedAt!),
|
||||||
Text(
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
_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 (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(' ');
|
.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) {
|
String _formatAwardDate(DateTime date) {
|
||||||
final y = date.year.toString().padLeft(4, '0');
|
final y = date.year.toString().padLeft(4, '0');
|
||||||
final m = date.month.toString().padLeft(2, '0');
|
final m = date.month.toString().padLeft(2, '0');
|
||||||
@@ -176,7 +674,7 @@ class _ProfilePageState extends State<ProfilePage> {
|
|||||||
return '$y-$m-$d';
|
return '$y-$m-$d';
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget? _buildTierIcon(String tier) {
|
Widget? _buildTierIcon(String tier, {double size = 24}) {
|
||||||
final lower = tier.toLowerCase();
|
final lower = tier.toLowerCase();
|
||||||
Color? color;
|
Color? color;
|
||||||
switch (lower) {
|
switch (lower) {
|
||||||
@@ -191,7 +689,7 @@ class _ProfilePageState extends State<ProfilePage> {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (color == null) return null;
|
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) {
|
String? _scopeToShow(BadgeAward award) {
|
||||||
|
|||||||
@@ -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']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
part of 'data_service.dart';
|
part of 'data_service.dart';
|
||||||
|
|
||||||
extension DataServiceBadges on DataService {
|
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;
|
_isBadgeAwardsLoading = true;
|
||||||
|
if (!append) _badgeAwards = [];
|
||||||
try {
|
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;
|
List<dynamic>? list;
|
||||||
if (json is List) {
|
if (json is List) {
|
||||||
list = json;
|
list = json;
|
||||||
@@ -21,22 +29,102 @@ extension DataServiceBadges on DataService {
|
|||||||
?.whereType<Map<String, dynamic>>()
|
?.whereType<Map<String, dynamic>>()
|
||||||
.map(BadgeAward.fromJson)
|
.map(BadgeAward.fromJson)
|
||||||
.toList();
|
.toList();
|
||||||
if (parsed != null) {
|
final items = parsed ?? [];
|
||||||
parsed.sort((a, b) {
|
_badgeAwards =
|
||||||
final aTs = a.awardedAt?.millisecondsSinceEpoch ?? 0;
|
append ? [..._badgeAwards, ...items] : items;
|
||||||
final bTs = b.awardedAt?.millisecondsSinceEpoch ?? 0;
|
_badgeAwards.sort((a, b) {
|
||||||
return bTs.compareTo(aTs);
|
final aTs = a.awardedAt?.millisecondsSinceEpoch ?? 0;
|
||||||
});
|
final bTs = b.awardedAt?.millisecondsSinceEpoch ?? 0;
|
||||||
_badgeAwards = parsed;
|
return bTs.compareTo(aTs);
|
||||||
} else {
|
});
|
||||||
_badgeAwards = [];
|
_badgeAwardsHasMore = items.length >= limit;
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('Failed to fetch badge awards: $e');
|
debugPrint('Failed to fetch badge awards: $e');
|
||||||
_badgeAwards = [];
|
if (!append) _badgeAwards = [];
|
||||||
|
_badgeAwardsHasMore = false;
|
||||||
} finally {
|
} finally {
|
||||||
_isBadgeAwardsLoading = false;
|
_isBadgeAwardsLoading = false;
|
||||||
_notifyAsync();
|
_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();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ class DataService extends ChangeNotifier {
|
|||||||
|
|
||||||
DataService({required this.api});
|
DataService({required this.api});
|
||||||
|
|
||||||
|
String? _currentUserId;
|
||||||
|
|
||||||
_LegFetchOptions _lastLegsFetch = const _LegFetchOptions();
|
_LegFetchOptions _lastLegsFetch = const _LegFetchOptions();
|
||||||
|
|
||||||
// Homepage Data
|
// Homepage Data
|
||||||
@@ -102,6 +104,24 @@ class DataService extends ChangeNotifier {
|
|||||||
List<BadgeAward> get badgeAwards => _badgeAwards;
|
List<BadgeAward> get badgeAwards => _badgeAwards;
|
||||||
bool _isBadgeAwardsLoading = false;
|
bool _isBadgeAwardsLoading = false;
|
||||||
bool get isBadgeAwardsLoading => _isBadgeAwardsLoading;
|
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 = [
|
static const List<EventField> _fallbackEventFields = [
|
||||||
EventField(name: 'operator', display: 'Operator'),
|
EventField(name: 'operator', display: 'Operator'),
|
||||||
@@ -357,6 +377,8 @@ class DataService extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void clear() {
|
void clear() {
|
||||||
|
_currentUserId = null;
|
||||||
|
_lastLegsFetch = const _LegFetchOptions();
|
||||||
_homepageStats = null;
|
_homepageStats = null;
|
||||||
_legs = [];
|
_legs = [];
|
||||||
_onThisDay = [];
|
_onThisDay = [];
|
||||||
@@ -367,9 +389,44 @@ class DataService extends ChangeNotifier {
|
|||||||
_isLocoTimelineLoading.clear();
|
_isLocoTimelineLoading.clear();
|
||||||
_latestLocoChanges = [];
|
_latestLocoChanges = [];
|
||||||
_isLatestLocoChangesLoading = false;
|
_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();
|
_notifyAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void handleAuthChanged(String? userId) {
|
||||||
|
if (_currentUserId == userId) return;
|
||||||
|
_currentUserId = userId;
|
||||||
|
clear();
|
||||||
|
_currentUserId = userId;
|
||||||
|
}
|
||||||
|
|
||||||
double getMileageForCurrentYear() {
|
double getMileageForCurrentYear() {
|
||||||
final currentYear = DateTime.now().year;
|
final currentYear = DateTime.now().year;
|
||||||
return getMileageForYear(currentYear) ?? 0;
|
return getMileageForYear(currentYear) ?? 0;
|
||||||
|
|||||||
@@ -22,6 +22,14 @@ import 'package:provider/provider.dart';
|
|||||||
final GlobalKey<NavigatorState> _shellNavigatorKey = GlobalKey<NavigatorState>();
|
final GlobalKey<NavigatorState> _shellNavigatorKey = GlobalKey<NavigatorState>();
|
||||||
|
|
||||||
const List<String> _contentPages = [
|
const List<String> _contentPages = [
|
||||||
|
"/dashboard",
|
||||||
|
"/logbook",
|
||||||
|
"/traction",
|
||||||
|
"/add",
|
||||||
|
"/more",
|
||||||
|
];
|
||||||
|
|
||||||
|
const List<String> _defaultTabDestinations = [
|
||||||
"/dashboard",
|
"/dashboard",
|
||||||
"/logbook/entries",
|
"/logbook/entries",
|
||||||
"/traction",
|
"/traction",
|
||||||
@@ -29,7 +37,7 @@ const List<String> _contentPages = [
|
|||||||
"/more",
|
"/more",
|
||||||
];
|
];
|
||||||
|
|
||||||
const int _addTabIndex = 5;
|
const int _addTabIndex = 3;
|
||||||
|
|
||||||
class _NavItem {
|
class _NavItem {
|
||||||
final String label;
|
final String label;
|
||||||
@@ -53,10 +61,9 @@ int tabIndexForPath(String path) {
|
|||||||
matchPath = '/logbook/entries';
|
matchPath = '/logbook/entries';
|
||||||
} else if (matchPath.startsWith('/trips')) {
|
} else if (matchPath.startsWith('/trips')) {
|
||||||
matchPath = '/logbook/trips';
|
matchPath = '/logbook/trips';
|
||||||
} else if (matchPath == '/logbook') {
|
}
|
||||||
matchPath = '/logbook/entries';
|
if (matchPath.startsWith('/logbook')) {
|
||||||
} else if (matchPath.startsWith('/logbook/trips')) {
|
matchPath = '/logbook';
|
||||||
matchPath = '/logbook/entries';
|
|
||||||
} else if (matchPath.startsWith('/profile') ||
|
} else if (matchPath.startsWith('/profile') ||
|
||||||
matchPath.startsWith('/settings') ||
|
matchPath.startsWith('/settings') ||
|
||||||
matchPath.startsWith('/more')) {
|
matchPath.startsWith('/more')) {
|
||||||
@@ -107,7 +114,7 @@ class _MyAppState extends State<MyApp> {
|
|||||||
routes: [
|
routes: [
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/',
|
path: '/',
|
||||||
redirect: (_, __) => '/dashboard',
|
redirect: (context, state) => '/dashboard',
|
||||||
),
|
),
|
||||||
ShellRoute(
|
ShellRoute(
|
||||||
navigatorKey: _shellNavigatorKey,
|
navigatorKey: _shellNavigatorKey,
|
||||||
@@ -119,7 +126,7 @@ class _MyAppState extends State<MyApp> {
|
|||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/logbook',
|
path: '/logbook',
|
||||||
builder: (context, state) => const LogbookPage(),
|
redirect: (context, state) => '/logbook/entries',
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/logbook/entries',
|
path: '/logbook/entries',
|
||||||
@@ -132,12 +139,11 @@ class _MyAppState extends State<MyApp> {
|
|||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/trips',
|
path: '/trips',
|
||||||
builder: (context, state) =>
|
redirect: (context, state) => '/logbook/trips',
|
||||||
const LogbookPage(initialTab: LogbookTab.trips),
|
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/legs',
|
path: '/legs',
|
||||||
builder: (context, state) => const LogbookPage(),
|
redirect: (context, state) => '/logbook/entries',
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/traction',
|
path: '/traction',
|
||||||
@@ -254,14 +260,14 @@ class MyHomePage extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _MyHomePageState extends State<MyHomePage> {
|
class _MyHomePageState extends State<MyHomePage> {
|
||||||
List<String> get contentPages => _contentPages;
|
List<String> get tabDestinations => _defaultTabDestinations;
|
||||||
|
|
||||||
Future<void> _onItemTapped(int index, int currentIndex) async {
|
Future<void> _onItemTapped(int index, int currentIndex) async {
|
||||||
if (index < 0 || index >= contentPages.length) {
|
if (index < 0 || index >= tabDestinations.length) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final currentPath = GoRouterState.of(context).uri.path;
|
final currentPath = GoRouterState.of(context).uri.path;
|
||||||
final targetPath = contentPages[index];
|
final targetPath = tabDestinations[index];
|
||||||
final alreadyAtTarget =
|
final alreadyAtTarget =
|
||||||
currentPath == targetPath || currentPath.startsWith('$targetPath/');
|
currentPath == targetPath || currentPath.startsWith('$targetPath/');
|
||||||
if (index == currentIndex && alreadyAtTarget) return;
|
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;
|
int _historyPosition = -1;
|
||||||
final List<int> _forwardHistory = [];
|
final List<String> _forwardHistory = [];
|
||||||
bool _suppressRecord = false;
|
bool _suppressRecord = false;
|
||||||
|
|
||||||
bool _fetched = false;
|
bool _fetched = false;
|
||||||
@@ -323,7 +329,7 @@ class _MyHomePageState extends State<MyHomePage> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final uri = GoRouterState.of(context).uri;
|
final uri = GoRouterState.of(context).uri;
|
||||||
final pageIndex = tabIndexForPath(uri.path);
|
final pageIndex = tabIndexForPath(uri.path);
|
||||||
_syncHistory(pageIndex);
|
_syncHistory(uri.path);
|
||||||
if (pageIndex != _addTabIndex) {
|
if (pageIndex != _addTabIndex) {
|
||||||
NavigationGuard.unregister();
|
NavigationGuard.unregister();
|
||||||
}
|
}
|
||||||
@@ -544,24 +550,21 @@ class _MyHomePageState extends State<MyHomePage> {
|
|||||||
|
|
||||||
Future<void> _openNotificationsPanel(BuildContext context) async {
|
Future<void> _openNotificationsPanel(BuildContext context) async {
|
||||||
final data = context.read<DataService>();
|
final data = context.read<DataService>();
|
||||||
|
final isWide = MediaQuery.sizeOf(context).width >= 900;
|
||||||
|
final sheetHeight = MediaQuery.sizeOf(context).height * 0.9;
|
||||||
try {
|
try {
|
||||||
await data.fetchNotifications();
|
await data.fetchNotifications();
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Already logged inside data service.
|
// Already logged inside data service.
|
||||||
}
|
}
|
||||||
if (!mounted) return;
|
if (!context.mounted) return;
|
||||||
final isWide = MediaQuery.of(context).size.width >= 900;
|
|
||||||
|
|
||||||
final panelBuilder = (BuildContext ctx) {
|
|
||||||
return _buildNotificationsContent(ctx, isWide);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isWide) {
|
if (isWide) {
|
||||||
await showDialog(
|
await showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (dialogCtx) => Dialog(
|
builder: (dialogCtx) => Dialog(
|
||||||
insetPadding: const EdgeInsets.all(16),
|
insetPadding: const EdgeInsets.all(16),
|
||||||
child: panelBuilder(dialogCtx),
|
child: _buildNotificationsContent(dialogCtx, isWide),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@@ -569,11 +572,10 @@ class _MyHomePageState extends State<MyHomePage> {
|
|||||||
context: context,
|
context: context,
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
builder: (sheetCtx) {
|
builder: (sheetCtx) {
|
||||||
final height = MediaQuery.of(context).size.height * 0.9;
|
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
height: height,
|
height: sheetHeight,
|
||||||
child: SafeArea(
|
child: SafeArea(
|
||||||
child: panelBuilder(sheetCtx),
|
child: _buildNotificationsContent(sheetCtx, isWide),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -606,7 +608,7 @@ class _MyHomePageState extends State<MyHomePage> {
|
|||||||
height: listHeight,
|
height: listHeight,
|
||||||
child: ListView.separated(
|
child: ListView.separated(
|
||||||
itemCount: notifications.length,
|
itemCount: notifications.length,
|
||||||
separatorBuilder: (_, __) => const SizedBox(height: 8),
|
separatorBuilder: (_, index) => const SizedBox(height: 8),
|
||||||
itemBuilder: (ctx, index) {
|
itemBuilder: (ctx, index) {
|
||||||
final item = notifications[index];
|
final item = notifications[index];
|
||||||
return Card(
|
return Card(
|
||||||
@@ -644,11 +646,18 @@ class _MyHomePageState extends State<MyHomePage> {
|
|||||||
.textTheme
|
.textTheme
|
||||||
.bodySmall
|
.bodySmall
|
||||||
?.copyWith(
|
?.copyWith(
|
||||||
color: Theme.of(context)
|
color: () {
|
||||||
.textTheme
|
final baseColor = Theme.of(context)
|
||||||
.bodySmall
|
.textTheme
|
||||||
?.color
|
.bodySmall
|
||||||
?.withOpacity(0.7),
|
?.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({
|
Future<bool> _handleBackNavigation({
|
||||||
bool allowExit = false,
|
bool allowExit = false,
|
||||||
bool recordForward = false,
|
bool recordForward = false,
|
||||||
}) async {
|
}) async {
|
||||||
final pageIndex = _currentPageIndex;
|
final currentPath = GoRouterState.of(context).uri.path;
|
||||||
final shellNav = _shellNavigatorKey.currentState;
|
final shellNav = _shellNavigatorKey.currentState;
|
||||||
if (shellNav != null && shellNav.canPop()) {
|
if (shellNav != null && shellNav.canPop()) {
|
||||||
|
if (recordForward) _pushForward(currentPath);
|
||||||
|
_alignHistoryAfterPop(currentPath);
|
||||||
shellNav.pop();
|
shellNav.pop();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_historyPosition > 0) {
|
if (_historyPosition > 0) {
|
||||||
if (recordForward) _pushForward(pageIndex);
|
if (recordForward) _pushForward(currentPath);
|
||||||
_historyPosition -= 1;
|
_historyPosition -= 1;
|
||||||
_suppressRecord = true;
|
_suppressRecord = true;
|
||||||
context.go(contentPages[_history[_historyPosition]]);
|
context.go(_history[_historyPosition]);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pageIndex != 0) {
|
final homePath = tabDestinations.first;
|
||||||
if (recordForward) _pushForward(pageIndex);
|
if (currentPath != homePath) {
|
||||||
|
if (recordForward) _pushForward(currentPath);
|
||||||
_suppressRecord = true;
|
_suppressRecord = true;
|
||||||
context.go(contentPages[0]);
|
context.go(homePath);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -795,35 +805,48 @@ class _MyHomePageState extends State<MyHomePage> {
|
|||||||
|
|
||||||
Future<bool> _handleForwardNavigation() async {
|
Future<bool> _handleForwardNavigation() async {
|
||||||
if (_forwardHistory.isEmpty) return false;
|
if (_forwardHistory.isEmpty) return false;
|
||||||
final nextTab = _forwardHistory.removeLast();
|
final nextPath = _forwardHistory.removeLast();
|
||||||
|
|
||||||
// Move cursor forward, keeping history in sync.
|
// Move cursor forward, keeping history in sync.
|
||||||
if (_historyPosition < _history.length - 1) {
|
if (_historyPosition < _history.length - 1) {
|
||||||
_historyPosition += 1;
|
_historyPosition += 1;
|
||||||
_history[_historyPosition] = nextTab;
|
_history[_historyPosition] = nextPath;
|
||||||
if (_historyPosition < _history.length - 1) {
|
if (_historyPosition < _history.length - 1) {
|
||||||
_history.removeRange(_historyPosition + 1, _history.length);
|
_history.removeRange(_historyPosition + 1, _history.length);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
_history.add(nextTab);
|
_history.add(nextPath);
|
||||||
_historyPosition = _history.length - 1;
|
_historyPosition = _history.length - 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
_suppressRecord = true;
|
_suppressRecord = true;
|
||||||
if (!mounted) return false;
|
if (!mounted) return false;
|
||||||
context.go(contentPages[nextTab]);
|
context.go(nextPath);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _pushForward(int pageIndex) {
|
void _pushForward(String path) {
|
||||||
if (_forwardHistory.isEmpty || _forwardHistory.last != pageIndex) {
|
if (_forwardHistory.isEmpty || _forwardHistory.last != path) {
|
||||||
_forwardHistory.add(pageIndex);
|
_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) {
|
if (_history.isEmpty) {
|
||||||
_history.add(pageIndex);
|
_history.add(path);
|
||||||
_historyPosition = 0;
|
_historyPosition = 0;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -833,13 +856,13 @@ class _MyHomePageState extends State<MyHomePage> {
|
|||||||
}
|
}
|
||||||
if (_historyPosition >= 0 &&
|
if (_historyPosition >= 0 &&
|
||||||
_historyPosition < _history.length &&
|
_historyPosition < _history.length &&
|
||||||
_history[_historyPosition] == pageIndex) {
|
_history[_historyPosition] == path) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (_historyPosition < _history.length - 1) {
|
if (_historyPosition < _history.length - 1) {
|
||||||
_history.removeRange(_historyPosition + 1, _history.length);
|
_history.removeRange(_historyPosition + 1, _history.length);
|
||||||
}
|
}
|
||||||
_history.add(pageIndex);
|
_history.add(path);
|
||||||
_historyPosition = _history.length - 1;
|
_historyPosition = _history.length - 1;
|
||||||
_forwardHistory.clear();
|
_forwardHistory.clear();
|
||||||
}
|
}
|
||||||
@@ -847,6 +870,6 @@ class _MyHomePageState extends State<MyHomePage> {
|
|||||||
void _navigateToIndex(int index) {
|
void _navigateToIndex(int index) {
|
||||||
_suppressRecord = false;
|
_suppressRecord = false;
|
||||||
_forwardHistory.clear();
|
_forwardHistory.clear();
|
||||||
context.go(contentPages[index]);
|
context.go(tabDestinations[index]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
# 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
|
# 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.
|
# 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:
|
environment:
|
||||||
sdk: ^3.8.1
|
sdk: ^3.8.1
|
||||||
|
|||||||
Reference in New Issue
Block a user