Add new friends system, and sharing legs support
All checks were successful
Release / meta (push) Successful in 9s
Release / linux-build (push) Successful in 6m37s
Release / web-build (push) Successful in 5m29s
Release / android-build (push) Successful in 15m58s
Release / release-master (push) Successful in 20s
Release / release-dev (push) Successful in 26s
All checks were successful
Release / meta (push) Successful in 9s
Release / linux-build (push) Successful in 6m37s
Release / web-build (push) Successful in 5m29s
Release / android-build (push) Successful in 15m58s
Release / release-master (push) Successful in 20s
Release / release-dev (push) Successful in 26s
This commit is contained in:
705
lib/components/pages/badges.dart
Normal file
705
lib/components/pages/badges.dart
Normal file
@@ -0,0 +1,705 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:mileograph_flutter/objects/objects.dart';
|
||||
import 'package:mileograph_flutter/services/data_service.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class BadgesPage extends StatefulWidget {
|
||||
const BadgesPage({super.key});
|
||||
|
||||
@override
|
||||
State<BadgesPage> createState() => _BadgesPageState();
|
||||
}
|
||||
|
||||
class _BadgesPageState extends State<BadgesPage> {
|
||||
bool _initialised = false;
|
||||
final Map<String, bool> _groupExpanded = {};
|
||||
bool _loadingAwards = false;
|
||||
bool _loadingClassProgress = false;
|
||||
bool _loadingLocoProgress = false;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_initialised) return;
|
||||
_initialised = true;
|
||||
_refreshAwards();
|
||||
}
|
||||
|
||||
Future<void> _refreshAwards() {
|
||||
_loadingAwards = false;
|
||||
_loadingClassProgress = false;
|
||||
_loadingLocoProgress = false;
|
||||
final data = context.read<DataService>();
|
||||
return Future.wait([
|
||||
data.fetchBadgeAwards(limit: 20, badgeCode: 'class_clearance'),
|
||||
data.fetchClassClearanceProgress(),
|
||||
data.fetchLocoClearanceProgress(),
|
||||
]);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final data = context.watch<DataService>();
|
||||
final awards = data.badgeAwards;
|
||||
final loading = data.isBadgeAwardsLoading;
|
||||
final classProgress = data.classClearanceProgress;
|
||||
final classProgressLoading =
|
||||
data.isClassClearanceProgressLoading || _loadingClassProgress;
|
||||
final locoProgress = data.locoClearanceProgress;
|
||||
final locoProgressLoading =
|
||||
data.isLocoClearanceProgressLoading || _loadingLocoProgress;
|
||||
final hasAnyData =
|
||||
awards.isNotEmpty || classProgress.isNotEmpty || locoProgress.isNotEmpty;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Badges'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () {
|
||||
final navigator = Navigator.of(context);
|
||||
if (navigator.canPop()) {
|
||||
navigator.pop();
|
||||
} else {
|
||||
context.go('/');
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
onRefresh: _refreshAwards,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
if ((loading || classProgressLoading || locoProgressLoading) &&
|
||||
!hasAnyData)
|
||||
const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 24.0),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
)
|
||||
else if (!hasAnyData)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 12.0),
|
||||
child: Text('No badges awarded yet.'),
|
||||
)
|
||||
else
|
||||
..._buildGroupedAwards(
|
||||
context,
|
||||
awards,
|
||||
classProgress,
|
||||
locoProgress,
|
||||
classProgressLoading,
|
||||
locoProgressLoading,
|
||||
data.classClearanceHasMore,
|
||||
data.locoClearanceHasMore,
|
||||
data.badgeAwardsHasMore,
|
||||
loading,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildGroupedAwards(
|
||||
BuildContext context,
|
||||
List<BadgeAward> awards,
|
||||
List<ClassClearanceProgress> classProgress,
|
||||
List<LocoClearanceProgress> locoProgress,
|
||||
bool classProgressLoading,
|
||||
bool locoProgressLoading,
|
||||
bool classProgressHasMore,
|
||||
bool locoProgressHasMore,
|
||||
bool badgeAwardsHasMore,
|
||||
bool badgeAwardsLoading,
|
||||
) {
|
||||
final grouped = _groupAwards(awards);
|
||||
if ((classProgress.isNotEmpty || classProgressLoading) &&
|
||||
!grouped.containsKey('class_clearance')) {
|
||||
grouped['class_clearance'] = [];
|
||||
}
|
||||
if ((locoProgress.isNotEmpty || locoProgressLoading) &&
|
||||
!grouped.containsKey('loco_clearance')) {
|
||||
grouped['loco_clearance'] = [];
|
||||
}
|
||||
final codes = _orderedBadgeCodes(grouped.keys.toList());
|
||||
|
||||
return codes.map((code) {
|
||||
final items = grouped[code]!;
|
||||
final expanded = _groupExpanded[code] ?? true;
|
||||
final title = _formatBadgeName(code);
|
||||
final isClass = code == 'class_clearance';
|
||||
final isLoco = code == 'loco_clearance';
|
||||
final classItems = isClass ? classProgress : <ClassClearanceProgress>[];
|
||||
final locoItems = isLoco ? locoProgress : <LocoClearanceProgress>[];
|
||||
final awardCount = isLoco
|
||||
? locoItems.where((item) => item.awardedTiers.isNotEmpty).length
|
||||
: items.length;
|
||||
final isLoadingSection = isClass
|
||||
? (classProgressLoading || badgeAwardsLoading || _loadingAwards)
|
||||
: (isLoco ? locoProgressLoading : false);
|
||||
|
||||
final children = <Widget>[];
|
||||
|
||||
if (isClass && items.isNotEmpty) {
|
||||
children.add(_buildSubheading(context, 'Awarded'));
|
||||
children.addAll(
|
||||
items.map(
|
||||
(award) => Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0),
|
||||
child: _buildAwardCard(context, award, compact: true),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (badgeAwardsHasMore || badgeAwardsLoading || _loadingAwards) {
|
||||
children.add(
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4.0, bottom: 8.0),
|
||||
child: _buildLoadMoreButton(
|
||||
context,
|
||||
badgeAwardsLoading || _loadingAwards,
|
||||
() => _loadMoreAwards(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
} else if (!isClass && !isLoco && items.isNotEmpty) {
|
||||
children.add(_buildSubheading(context, 'Awarded'));
|
||||
children.addAll(
|
||||
items.map(
|
||||
(award) => Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0),
|
||||
child: _buildAwardCard(context, award, compact: true),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (isClass) {
|
||||
children.addAll(
|
||||
_buildClassProgressSection(
|
||||
context,
|
||||
classItems,
|
||||
classProgressLoading,
|
||||
classProgressHasMore,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (isLoco) {
|
||||
children.addAll(
|
||||
_buildLocoProgressSection(
|
||||
context,
|
||||
locoItems,
|
||||
locoProgressLoading,
|
||||
locoProgressHasMore,
|
||||
showHeading: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (children.isEmpty && !isLoadingSection) {
|
||||
children.add(
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 6.0),
|
||||
child: Text('No awards'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 4.0),
|
||||
child: ExpansionTile(
|
||||
key: ValueKey(code),
|
||||
tilePadding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
title: Row(
|
||||
children: [
|
||||
Expanded(child: Text(title)),
|
||||
if (isLoadingSection) ...[
|
||||
const SizedBox(width: 8),
|
||||
const SizedBox(
|
||||
height: 18,
|
||||
width: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
],
|
||||
const SizedBox(width: 8),
|
||||
_buildCountChip(context, awardCount),
|
||||
],
|
||||
),
|
||||
initiallyExpanded: expanded,
|
||||
onExpansionChanged: (isOpen) {
|
||||
setState(() => _groupExpanded[code] = isOpen);
|
||||
},
|
||||
children: children,
|
||||
),
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
Map<String, List<BadgeAward>> _groupAwards(List<BadgeAward> awards) {
|
||||
final Map<String, List<BadgeAward>> grouped = {};
|
||||
for (final award in awards) {
|
||||
final code = award.badgeCode.toLowerCase();
|
||||
grouped.putIfAbsent(code, () => []).add(award);
|
||||
}
|
||||
return grouped;
|
||||
}
|
||||
|
||||
Widget _buildAwardCard(
|
||||
BuildContext context,
|
||||
BadgeAward award, {
|
||||
bool compact = false,
|
||||
}) {
|
||||
final badgeName = _formatBadgeName(award.badgeCode);
|
||||
final tier = award.badgeTier.isNotEmpty
|
||||
? award.badgeTier[0].toUpperCase() + award.badgeTier.substring(1)
|
||||
: '';
|
||||
final tierIcon = _buildTierIcon(award.badgeTier);
|
||||
final scope = _scopeToShow(award);
|
||||
|
||||
final content = Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
if (tierIcon != null) ...[
|
||||
tierIcon,
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Expanded(
|
||||
child: Text(
|
||||
'$badgeName • $tier',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (award.awardedAt != null)
|
||||
Text(
|
||||
_formatAwardDate(award.awardedAt!),
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (scope != null && scope.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
scope,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
if (award.loco != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
_buildLocoInfo(context, award.loco!),
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
if (compact) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(10.0),
|
||||
child: content,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLocoInfo(BuildContext context, LocoSummary loco) {
|
||||
final lines = <String>[];
|
||||
final classNum = [
|
||||
if (loco.locoClass.isNotEmpty) loco.locoClass,
|
||||
if (loco.number.isNotEmpty) loco.number,
|
||||
].join(' ');
|
||||
if (classNum.isNotEmpty) lines.add(classNum);
|
||||
if ((loco.name ?? '').isNotEmpty) lines.add(loco.name!);
|
||||
if ((loco.livery ?? '').isNotEmpty) lines.add(loco.livery!);
|
||||
if ((loco.location ?? '').isNotEmpty) lines.add(loco.location!);
|
||||
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(Icons.train, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: lines.map((line) {
|
||||
return Text(
|
||||
line,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _formatBadgeName(String code) {
|
||||
if (code.isEmpty) return 'Badge';
|
||||
const known = {
|
||||
'class_clearance': 'Class Clearance',
|
||||
'loco_clearance': 'Loco Clearance',
|
||||
};
|
||||
final lower = code.toLowerCase();
|
||||
if (known.containsKey(lower)) return known[lower]!;
|
||||
final parts = code.split(RegExp(r'[_\\s]+')).where((p) => p.isNotEmpty);
|
||||
return parts
|
||||
.map((p) => p[0].toUpperCase() + p.substring(1).toLowerCase())
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
List<String> _orderedBadgeCodes(List<String> codes) {
|
||||
final lowerCodes = codes.map((c) => c.toLowerCase()).toSet();
|
||||
final ordered = <String>[];
|
||||
for (final code in ['loco_clearance', 'class_clearance']) {
|
||||
if (lowerCodes.remove(code)) ordered.add(code);
|
||||
}
|
||||
final remaining = lowerCodes.toList()
|
||||
..sort((a, b) => _formatBadgeName(a).compareTo(_formatBadgeName(b)));
|
||||
ordered.addAll(remaining);
|
||||
return ordered;
|
||||
}
|
||||
|
||||
Widget _buildSubheading(BuildContext context, String label) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
|
||||
child: Text(
|
||||
label,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.labelMedium
|
||||
?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildClassProgressSection(
|
||||
BuildContext context,
|
||||
List<ClassClearanceProgress> progress,
|
||||
bool isLoading,
|
||||
bool hasMore,
|
||||
) {
|
||||
if (progress.isEmpty && !isLoading && !hasMore) return const [];
|
||||
return [
|
||||
_buildSubheading(context, 'In Progress'),
|
||||
...progress.map(
|
||||
(item) => Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 6.0),
|
||||
child: _buildClassProgressCard(context, item),
|
||||
),
|
||||
),
|
||||
if (hasMore || isLoading)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4.0, bottom: 8.0),
|
||||
child: _buildLoadMoreButton(
|
||||
context,
|
||||
isLoading,
|
||||
() => _loadMoreClassProgress(),
|
||||
),
|
||||
),
|
||||
if (progress.isNotEmpty) const SizedBox(height: 4),
|
||||
];
|
||||
}
|
||||
|
||||
List<Widget> _buildLocoProgressSection(
|
||||
BuildContext context,
|
||||
List<LocoClearanceProgress> progress,
|
||||
bool isLoading,
|
||||
bool hasMore,
|
||||
{bool showHeading = true}
|
||||
) {
|
||||
if (progress.isEmpty && !isLoading && !hasMore) return const [];
|
||||
return [
|
||||
if (showHeading) _buildSubheading(context, 'In Progress'),
|
||||
if (progress.isEmpty && isLoading)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12.0),
|
||||
child: _buildLoadingIndicator(),
|
||||
),
|
||||
...progress.map(
|
||||
(item) => Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 6.0),
|
||||
child: _buildLocoProgressCard(context, item),
|
||||
),
|
||||
),
|
||||
if (hasMore || isLoading)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4.0, bottom: 8.0),
|
||||
child: _buildLoadMoreButton(
|
||||
context,
|
||||
isLoading,
|
||||
() => _loadMoreLocoProgress(),
|
||||
),
|
||||
),
|
||||
if (progress.isNotEmpty) const SizedBox(height: 4),
|
||||
];
|
||||
}
|
||||
|
||||
Widget _buildClassProgressCard(
|
||||
BuildContext context,
|
||||
ClassClearanceProgress progress,
|
||||
) {
|
||||
final pct = progress.percentComplete.clamp(0, 100);
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 4.0),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
progress.className,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${pct.toStringAsFixed(0)}%',
|
||||
style: Theme.of(context).textTheme.labelMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
LinearProgressIndicator(
|
||||
value: progress.total == 0 ? 0 : pct / 100,
|
||||
minHeight: 6,
|
||||
),
|
||||
if (progress.total > 0)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2.0),
|
||||
child: Text(
|
||||
'${progress.completed}/${progress.total}',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.labelSmall
|
||||
?.copyWith(color: Theme.of(context).hintColor),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLocoProgressCard(
|
||||
BuildContext context,
|
||||
LocoClearanceProgress progress,
|
||||
) {
|
||||
final tierIcons = progress.awardedTiers
|
||||
.map((tier) => _buildTierIcon(tier, size: 18))
|
||||
.whereType<Widget>()
|
||||
.toList();
|
||||
final reachedTopTier = progress.nextTier.isEmpty;
|
||||
final pct = progress.percent.clamp(0, 100);
|
||||
final nextTier = progress.nextTier.isNotEmpty
|
||||
? progress.nextTier[0].toUpperCase() + progress.nextTier.substring(1)
|
||||
: 'Next';
|
||||
final loco = progress.loco;
|
||||
final title = [
|
||||
if (loco.number.isNotEmpty) loco.number,
|
||||
if (loco.locoClass.isNotEmpty) loco.locoClass,
|
||||
].join(' • ');
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 4.0),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(10.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title.isNotEmpty ? title : 'Loco',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (tierIcons.isNotEmpty)
|
||||
Row(
|
||||
children: tierIcons
|
||||
.expand((icon) sync* {
|
||||
yield icon;
|
||||
yield const SizedBox(width: 4);
|
||||
})
|
||||
.toList()
|
||||
..removeLast(),
|
||||
),
|
||||
],
|
||||
),
|
||||
if ((loco.name ?? '').isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2.0),
|
||||
child: Text(
|
||||
loco.name ?? '',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
if (!reachedTopTier) ...[
|
||||
const SizedBox(height: 4),
|
||||
LinearProgressIndicator(
|
||||
value: progress.required == 0 ? 0 : pct / 100,
|
||||
minHeight: 6,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2.0),
|
||||
child: Text(
|
||||
'${pct.toStringAsFixed(0)}% to $nextTier award',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLoadMoreButton(
|
||||
BuildContext context,
|
||||
bool isLoading,
|
||||
Future<void> Function() onPressed,
|
||||
) {
|
||||
return Align(
|
||||
alignment: Alignment.center,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: isLoading
|
||||
? null
|
||||
: () {
|
||||
onPressed();
|
||||
},
|
||||
icon: isLoading
|
||||
? const SizedBox(
|
||||
height: 18,
|
||||
width: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.expand_more),
|
||||
label: Text(isLoading ? 'Loading...' : 'Load more'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLoadingIndicator() {
|
||||
return const Center(
|
||||
child: SizedBox(
|
||||
height: 24,
|
||||
width: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCountChip(BuildContext context, int count) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(
|
||||
'$count',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.labelMedium
|
||||
?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _loadMoreClassProgress() {
|
||||
final data = context.read<DataService>();
|
||||
if (data.isClassClearanceProgressLoading || _loadingClassProgress) {
|
||||
return Future.value();
|
||||
}
|
||||
setState(() => _loadingClassProgress = true);
|
||||
return data
|
||||
.fetchClassClearanceProgress(
|
||||
offset: data.classClearanceProgress.length,
|
||||
append: true,
|
||||
)
|
||||
.whenComplete(() {
|
||||
if (mounted) setState(() => _loadingClassProgress = false);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadMoreLocoProgress() {
|
||||
final data = context.read<DataService>();
|
||||
if (data.isLocoClearanceProgressLoading || _loadingLocoProgress) {
|
||||
return Future.value();
|
||||
}
|
||||
setState(() => _loadingLocoProgress = true);
|
||||
return data
|
||||
.fetchLocoClearanceProgress(
|
||||
offset: data.locoClearanceProgress.length,
|
||||
append: true,
|
||||
)
|
||||
.whenComplete(() {
|
||||
if (mounted) setState(() => _loadingLocoProgress = false);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadMoreAwards() {
|
||||
final data = context.read<DataService>();
|
||||
if (data.isBadgeAwardsLoading || _loadingAwards) return Future.value();
|
||||
setState(() => _loadingAwards = true);
|
||||
return data
|
||||
.fetchBadgeAwards(
|
||||
offset: data.badgeAwards.length,
|
||||
append: true,
|
||||
badgeCode: 'class_clearance',
|
||||
limit: 20,
|
||||
)
|
||||
.whenComplete(() {
|
||||
if (mounted) setState(() => _loadingAwards = false);
|
||||
});
|
||||
}
|
||||
|
||||
String _formatAwardDate(DateTime date) {
|
||||
final y = date.year.toString().padLeft(4, '0');
|
||||
final m = date.month.toString().padLeft(2, '0');
|
||||
final d = date.day.toString().padLeft(2, '0');
|
||||
return '$y-$m-$d';
|
||||
}
|
||||
|
||||
Widget? _buildTierIcon(String tier, {double size = 24}) {
|
||||
final lower = tier.toLowerCase();
|
||||
Color? color;
|
||||
switch (lower) {
|
||||
case 'bronze':
|
||||
color = const Color(0xFFCD7F32);
|
||||
break;
|
||||
case 'silver':
|
||||
color = const Color(0xFFC0C0C0);
|
||||
break;
|
||||
case 'gold':
|
||||
color = const Color(0xFFFFD700);
|
||||
break;
|
||||
}
|
||||
if (color == null) return null;
|
||||
return Icon(Icons.emoji_events, color: color, size: size);
|
||||
}
|
||||
|
||||
String? _scopeToShow(BadgeAward award) {
|
||||
final scope = award.scopeValue?.trim() ?? '';
|
||||
if (scope.isEmpty) return null;
|
||||
final code = award.badgeCode.toLowerCase();
|
||||
if (code == 'loco_clearance') {
|
||||
// Hide numeric loco IDs; loco details are shown separately.
|
||||
if (int.tryParse(scope) != null) return null;
|
||||
}
|
||||
return scope;
|
||||
}
|
||||
}
|
||||
@@ -20,10 +20,16 @@ class MoreHomePage extends StatelessWidget {
|
||||
Card(
|
||||
child: Column(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.person),
|
||||
title: const Text('Profile'),
|
||||
onTap: () => context.go('/more/profile'),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.emoji_events),
|
||||
title: const Text('Badges'),
|
||||
onTap: () => context.go('/more/profile'),
|
||||
onTap: () => context.go('/more/badges'),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'package:mileograph_flutter/components/calculator/calculator.dart';
|
||||
import 'package:mileograph_flutter/components/pages/traction.dart';
|
||||
import 'package:mileograph_flutter/objects/objects.dart';
|
||||
import 'package:mileograph_flutter/services/api_service.dart';
|
||||
import 'package:mileograph_flutter/services/authservice.dart';
|
||||
import 'package:mileograph_flutter/services/data_service.dart';
|
||||
import 'package:mileograph_flutter/services/distance_unit_service.dart';
|
||||
import 'package:mileograph_flutter/services/navigation_guard.dart';
|
||||
|
||||
@@ -4,6 +4,7 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
|
||||
Future<bool> _handleExitIntent() async {
|
||||
if (!mounted) return false;
|
||||
if (_isEditing) return true;
|
||||
if (_activeLegShare != null) return true;
|
||||
if (_formIsEmpty()) return true;
|
||||
if (_activeDraftId != null && !_draftChangedFromBaseline()) {
|
||||
return true;
|
||||
@@ -82,6 +83,7 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
|
||||
}
|
||||
|
||||
Future<void> _openDrafts() async {
|
||||
if (_activeLegShare != null) return;
|
||||
final selected = await Navigator.of(context).push<_StoredDraft>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => _DraftListPage(
|
||||
@@ -97,6 +99,7 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
|
||||
}
|
||||
|
||||
Future<void> _saveDraftManually() async {
|
||||
if (_activeLegShare != null) return;
|
||||
if (_savingDraft) return;
|
||||
if (_formIsEmpty()) {
|
||||
ScaffoldMessenger.maybeOf(context)?.showSnackBar(
|
||||
@@ -123,7 +126,9 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
|
||||
}
|
||||
|
||||
Future<void> _saveDraft() async {
|
||||
if (_restoringDraft || !_draftPersistenceEnabled) return;
|
||||
if (_restoringDraft || !_draftPersistenceEnabled || _activeLegShare != null) {
|
||||
return;
|
||||
}
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final draft = {
|
||||
"date": _selectedDate.toIso8601String(),
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
part of 'new_entry.dart';
|
||||
|
||||
class NewEntryPage extends StatefulWidget {
|
||||
const NewEntryPage({super.key, this.editLegId});
|
||||
const NewEntryPage({super.key, this.editLegId, this.legShare});
|
||||
|
||||
final int? editLegId;
|
||||
final LegShareData? legShare;
|
||||
|
||||
@override
|
||||
State<NewEntryPage> createState() => _NewEntryPageState();
|
||||
@@ -52,9 +53,15 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
||||
const DeepCollectionEquality();
|
||||
String? _activeDraftId;
|
||||
DistanceUnit? _lastDistanceUnit;
|
||||
Set<String> _shareUserIds = {};
|
||||
List<UserSummary> _shareUsers = [];
|
||||
LegShareData? _activeLegShare;
|
||||
String? _sharedFromUser;
|
||||
int? _shareNotificationId;
|
||||
|
||||
bool get _isEditing => widget.editLegId != null;
|
||||
bool get _draftPersistenceEnabled =>
|
||||
_activeLegShare == null &&
|
||||
false; // legacy single draft disabled in favor of draft list
|
||||
|
||||
@override
|
||||
@@ -69,16 +76,28 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
||||
data.fetchClassList();
|
||||
data.fetchTripOptions();
|
||||
_loadStations();
|
||||
if (_draftPersistenceEnabled) {
|
||||
if (_draftPersistenceEnabled && widget.legShare == null) {
|
||||
_loadDraft();
|
||||
}
|
||||
_loadStations();
|
||||
if (_isEditing && widget.editLegId != null) {
|
||||
_loadLegForEdit(widget.editLegId!);
|
||||
} else if (widget.legShare != null) {
|
||||
_applyLegShare(widget.legShare!);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant NewEntryPage oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.legShare != null &&
|
||||
(oldWidget.legShare == null ||
|
||||
widget.legShare!.id != oldWidget.legShare!.id)) {
|
||||
_applyLegShare(widget.legShare!);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
NavigationGuard.unregister(_exitGuard);
|
||||
@@ -134,6 +153,282 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
||||
0;
|
||||
}
|
||||
|
||||
List<UserSummary> _friendsFromFriendships(
|
||||
DataService data,
|
||||
String? selfId,
|
||||
) {
|
||||
final friends = <UserSummary>[];
|
||||
for (final f in data.friendships) {
|
||||
final other = _friendFromFriendship(f, selfId);
|
||||
if (other != null &&
|
||||
other.userId.isNotEmpty &&
|
||||
!friends.any((u) => u.userId == other.userId)) {
|
||||
friends.add(other);
|
||||
}
|
||||
}
|
||||
return friends;
|
||||
}
|
||||
|
||||
UserSummary? _friendFromFriendship(Friendship friendship, String? selfId) {
|
||||
final self = selfId ?? '';
|
||||
if (friendship.requesterId == self) return friendship.addressee;
|
||||
if (friendship.addresseeId == self) return friendship.requester;
|
||||
if (friendship.addressee != null) return friendship.addressee;
|
||||
if (friendship.requester != null) return friendship.requester;
|
||||
return null;
|
||||
}
|
||||
|
||||
Widget _buildShareSection(BuildContext context) {
|
||||
final selected = _shareUsers;
|
||||
final label = selected.isEmpty
|
||||
? 'Share with friends'
|
||||
: 'Shared with ${selected.length} friend${selected.length == 1 ? '' : 's'}';
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
onPressed: _submitting ? null : _openShareSheet,
|
||||
icon: const Icon(Icons.share),
|
||||
label: Text(label),
|
||||
),
|
||||
if (selected.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: selected
|
||||
.map(
|
||||
(u) => InputChip(
|
||||
label: Text(u.displayName),
|
||||
avatar: const Icon(Icons.person, size: 16),
|
||||
onDeleted: _submitting
|
||||
? null
|
||||
: () {
|
||||
setState(() {
|
||||
_shareUserIds.remove(u.userId);
|
||||
_shareUsers.removeWhere(
|
||||
(item) => item.userId == u.userId,
|
||||
);
|
||||
});
|
||||
},
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSharedBanner() {
|
||||
final from = _sharedFromUser;
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.orange.withValues(alpha: 0.4)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.share, color: Colors.orange),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
from != null && from.isNotEmpty
|
||||
? 'This entry was shared by $from.'
|
||||
: 'This entry was shared with you.',
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openShareSheet() async {
|
||||
if (_activeLegShare != null) return;
|
||||
final data = context.read<DataService>();
|
||||
final auth = context.read<AuthService>();
|
||||
try {
|
||||
await data.fetchFriendships();
|
||||
} catch (_) {}
|
||||
if (!mounted) return;
|
||||
final baseFriends = _friendsFromFriendships(data, auth.userId);
|
||||
final initialSelectedIds = {..._shareUserIds};
|
||||
final initialSelectedUsers = {
|
||||
for (final u in _shareUsers) u.userId: u,
|
||||
};
|
||||
|
||||
final result = await showModalBottomSheet<List<UserSummary>>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (ctx) {
|
||||
final searchController = TextEditingController();
|
||||
List<UserSummary> searchResults = [];
|
||||
bool searching = false;
|
||||
String searchTerm = '';
|
||||
String? searchError;
|
||||
final selectedIds = {...initialSelectedIds};
|
||||
final selectedUsers = {...initialSelectedUsers};
|
||||
|
||||
return StatefulBuilder(
|
||||
builder: (modalContext, setModalState) {
|
||||
Future<void> runSearch(String term) async {
|
||||
final trimmed = term.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
setModalState(() {
|
||||
searchTerm = '';
|
||||
searchResults = [];
|
||||
searchError = null;
|
||||
});
|
||||
return;
|
||||
}
|
||||
setModalState(() {
|
||||
searchTerm = trimmed;
|
||||
searching = true;
|
||||
searchError = null;
|
||||
});
|
||||
try {
|
||||
final results =
|
||||
await data.searchUsers(trimmed, friendsOnly: true);
|
||||
setModalState(() {
|
||||
searchResults = results;
|
||||
});
|
||||
} catch (e) {
|
||||
setModalState(() {
|
||||
searchError = 'Search failed';
|
||||
});
|
||||
} finally {
|
||||
setModalState(() {
|
||||
searching = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
final list = searchTerm.isNotEmpty ? searchResults : baseFriends;
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(modalContext).viewInsets.bottom + 16,
|
||||
left: 16,
|
||||
right: 16,
|
||||
top: 16,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'Share with friends',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => Navigator.of(modalContext).pop(),
|
||||
icon: const Icon(Icons.close),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: searchController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Search friends',
|
||||
border: const OutlineInputBorder(),
|
||||
suffixIcon: IconButton(
|
||||
icon: const Icon(Icons.search),
|
||||
onPressed: () => runSearch(searchController.text),
|
||||
),
|
||||
),
|
||||
onSubmitted: runSearch,
|
||||
),
|
||||
if (searching)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 8.0),
|
||||
child: LinearProgressIndicator(minHeight: 2),
|
||||
),
|
||||
if (searchError != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: Text(
|
||||
searchError!,
|
||||
style: const TextStyle(color: Colors.red),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
height: 320,
|
||||
child: list.isEmpty
|
||||
? const Center(child: Text('No friends found'))
|
||||
: ListView.builder(
|
||||
itemCount: list.length,
|
||||
itemBuilder: (_, index) {
|
||||
final user = list[index];
|
||||
final isSelected =
|
||||
selectedIds.contains(user.userId);
|
||||
return CheckboxListTile(
|
||||
value: isSelected,
|
||||
title: Text(user.displayName),
|
||||
subtitle: user.username.isNotEmpty
|
||||
? Text('@${user.username}')
|
||||
: null,
|
||||
onChanged: (checked) {
|
||||
setModalState(() {
|
||||
if (checked == true) {
|
||||
selectedIds.add(user.userId);
|
||||
selectedUsers[user.userId] = user;
|
||||
} else {
|
||||
selectedIds.remove(user.userId);
|
||||
selectedUsers.remove(user.userId);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(modalContext).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
const Spacer(),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(modalContext).pop(
|
||||
selectedIds
|
||||
.map((id) => selectedUsers[id])
|
||||
.whereType<UserSummary>()
|
||||
.toList(),
|
||||
),
|
||||
child: const Text('Save'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (result != null && mounted) {
|
||||
setState(() {
|
||||
_shareUsers = result;
|
||||
_shareUserIds = result.map((u) => u.userId).toSet();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _syncManualFieldUnit(DistanceUnit currentUnit) {
|
||||
if (!_useManualMileage) {
|
||||
_lastDistanceUnit = currentUnit;
|
||||
@@ -571,6 +866,96 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
||||
}
|
||||
}
|
||||
|
||||
void _applyLegShare(LegShareData share) {
|
||||
final entry = share.entry;
|
||||
final beginTime = entry.beginTime;
|
||||
final endTime = entry.endTime;
|
||||
final routeStations = entry.route;
|
||||
final mileageVal = entry.mileage;
|
||||
final units = _distanceUnits(context);
|
||||
final useManual = routeStations.isEmpty;
|
||||
final routeResult = useManual
|
||||
? null
|
||||
: RouteResult(
|
||||
inputRoute: routeStations,
|
||||
calculatedRoute: routeStations,
|
||||
costs: const <double>[],
|
||||
distance: mileageVal,
|
||||
);
|
||||
final tractionItems = _buildTractionFromApi(
|
||||
entry.locos
|
||||
.map((l) => {
|
||||
"loco_id": l.id,
|
||||
"type": l.type,
|
||||
"number": l.number,
|
||||
"class": l.locoClass,
|
||||
"name": l.name,
|
||||
"operator": l.operator,
|
||||
"notes": l.notes,
|
||||
"evn": l.evn,
|
||||
"alloc_pos": l.allocPos,
|
||||
"alloc_powering": l.powering ? 1 : 0,
|
||||
})
|
||||
.toList(),
|
||||
);
|
||||
final beginDelay = entry.beginDelayMinutes ?? 0;
|
||||
final endDelay = entry.endDelayMinutes ?? 0;
|
||||
final originTime = entry.originTime;
|
||||
final destinationTime = entry.destinationTime;
|
||||
final hasOriginTime = originTime != null;
|
||||
final hasDestinationTime = destinationTime != null;
|
||||
final hasEndTime = endTime != null || endDelay != 0;
|
||||
|
||||
_restoringDraft = true;
|
||||
setState(() {
|
||||
_activeLegShare = share;
|
||||
_sharedFromUser = share.sharedFromName;
|
||||
_shareNotificationId = share.notificationId;
|
||||
_selectedTripId = null;
|
||||
_selectedDate = beginTime;
|
||||
_selectedTime = TimeOfDay.fromDateTime(beginTime);
|
||||
_selectedEndDate = endTime ?? beginTime;
|
||||
_selectedEndTime = TimeOfDay.fromDateTime(endTime ?? beginTime);
|
||||
_hasEndTime = hasEndTime;
|
||||
_selectedOriginDate = originTime ?? beginTime;
|
||||
_selectedOriginTime = TimeOfDay.fromDateTime(originTime ?? beginTime);
|
||||
_selectedDestinationDate = destinationTime ?? endTime ?? beginTime;
|
||||
_selectedDestinationTime =
|
||||
TimeOfDay.fromDateTime(destinationTime ?? endTime ?? beginTime);
|
||||
_hasOriginTime = hasOriginTime;
|
||||
_hasDestinationTime = hasDestinationTime;
|
||||
_useManualMileage = useManual;
|
||||
_routeResult = routeResult;
|
||||
_startController.text = entry.start;
|
||||
_endController.text = entry.end;
|
||||
_headcodeController.text = entry.headcode.toUpperCase();
|
||||
_notesController.text = entry.notes;
|
||||
_networkController.text = entry.network.toUpperCase();
|
||||
_originController.text = entry.origin;
|
||||
_destinationController.text = entry.destination;
|
||||
_beginDelayController.text = beginDelay.toString();
|
||||
_endDelayController.text = endDelay.toString();
|
||||
_mileageController.text = mileageVal == 0
|
||||
? ''
|
||||
: _formatDistance(
|
||||
units,
|
||||
mileageVal,
|
||||
decimals: 2,
|
||||
includeUnit: false,
|
||||
);
|
||||
_tractionItems
|
||||
..clear()
|
||||
..addAll(tractionItems);
|
||||
if (_tractionItems.where((e) => e.isMarker).isEmpty) {
|
||||
_tractionItems.insert(0, _TractionItem.marker());
|
||||
}
|
||||
_lastSubmittedSnapshot = null;
|
||||
_shareUserIds.clear();
|
||||
_shareUsers.clear();
|
||||
});
|
||||
_restoringDraft = false;
|
||||
}
|
||||
|
||||
List<String> _parseRouteStations(dynamic raw) {
|
||||
if (raw is List) {
|
||||
return raw.map((e) => e.toString()).toList();
|
||||
@@ -809,7 +1194,8 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
||||
minimumSize: const Size(0, 36),
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
onPressed: _isEditing ? null : _openDrafts,
|
||||
onPressed:
|
||||
_isEditing || _activeLegShare != null ? null : _openDrafts,
|
||||
icon: const Icon(Icons.list_alt, size: 16),
|
||||
label: const Text('Drafts'),
|
||||
),
|
||||
@@ -817,23 +1203,26 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
||||
TextButton.icon(
|
||||
style: TextButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
minimumSize: const Size(0, 36),
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
onPressed: _isEditing || _savingDraft || _submitting
|
||||
? null
|
||||
: _saveDraftManually,
|
||||
icon: _savingDraft
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
minimumSize: const Size(0, 36),
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
onPressed: _isEditing ||
|
||||
_savingDraft ||
|
||||
_submitting ||
|
||||
_activeLegShare != null
|
||||
? null
|
||||
: _saveDraftManually,
|
||||
icon: _savingDraft
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.save_alt, size: 16),
|
||||
label: Text(_savingDraft ? 'Saving...' : 'Save to drafts'),
|
||||
),
|
||||
const Spacer(),
|
||||
TextButton.icon(
|
||||
),
|
||||
const Spacer(),
|
||||
TextButton.icon(
|
||||
style: TextButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
minimumSize: const Size(0, 36),
|
||||
@@ -844,11 +1233,17 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
||||
: () => _resetFormState(clearDraft: true),
|
||||
icon: const Icon(Icons.clear, size: 16),
|
||||
label: const Text('Clear form'),
|
||||
),
|
||||
],
|
||||
),
|
||||
_buildTripSelector(context),
|
||||
_dateTimeGroup(
|
||||
],
|
||||
),
|
||||
if (_activeLegShare != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
_buildSharedBanner(),
|
||||
],
|
||||
_buildTripSelector(context),
|
||||
const SizedBox(height: 12),
|
||||
if (_activeLegShare == null) _buildShareSection(context),
|
||||
_dateTimeGroup(
|
||||
context,
|
||||
title: 'Departure time',
|
||||
onDateTap: _pickDate,
|
||||
@@ -1100,11 +1495,15 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.send),
|
||||
label: Text(
|
||||
_submitting
|
||||
? (_isEditing ? 'Saving...' : 'Submitting...')
|
||||
: (_isEditing ? 'Save changes' : 'Submit entry'),
|
||||
),
|
||||
label: () {
|
||||
final shareMode = _activeLegShare != null;
|
||||
if (_submitting) {
|
||||
if (shareMode) return const Text('Accepting...');
|
||||
return Text(_isEditing ? 'Saving...' : 'Submitting...');
|
||||
}
|
||||
if (shareMode) return const Text('Accept entry');
|
||||
return Text(_isEditing ? 'Save changes' : 'Submit entry');
|
||||
}(),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -108,6 +108,8 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
|
||||
"leg_begin_delay": beginDelay,
|
||||
if (_hasEndTime) "leg_end_delay": endDelay,
|
||||
"locos": tractionPayload,
|
||||
if (_activeLegShare != null) "leg_share_id": _activeLegShare!.id,
|
||||
"share_user_ids": _shareUserIds.toList(),
|
||||
};
|
||||
if (_useManualMileage) {
|
||||
final body = {
|
||||
@@ -136,6 +138,9 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
|
||||
if (!mounted) return;
|
||||
dataService.refreshLegs();
|
||||
await dataService.fetchNotifications();
|
||||
if (_shareNotificationId != null) {
|
||||
await dataService.dismissNotifications([_shareNotificationId!]);
|
||||
}
|
||||
if (!mounted) return;
|
||||
messenger?.showSnackBar(
|
||||
SnackBar(
|
||||
@@ -188,6 +193,8 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
|
||||
"headcode": _headcodeController.text.trim(),
|
||||
"beginDelay": beginDelay,
|
||||
"endDelay": endDelay,
|
||||
"legShareId": _activeLegShare?.id,
|
||||
"shareUserIds": _shareUserIds.toList(),
|
||||
"locos": tractionPayload,
|
||||
"routeResult": _routeResult == null
|
||||
? null
|
||||
@@ -223,6 +230,7 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
|
||||
}
|
||||
|
||||
Future<void> _resetFormState({bool clearDraft = false}) async {
|
||||
final hadShare = _activeLegShare != null || widget.legShare != null;
|
||||
_formKey.currentState?.reset();
|
||||
_startController.clear();
|
||||
_endController.clear();
|
||||
@@ -252,6 +260,11 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
|
||||
_matchDestinationToEntry = false;
|
||||
_matchUpdateScheduled = false;
|
||||
_routeResult = null;
|
||||
_activeLegShare = null;
|
||||
_sharedFromUser = null;
|
||||
_shareNotificationId = null;
|
||||
_shareUserIds.clear();
|
||||
_shareUsers.clear();
|
||||
_tractionItems
|
||||
..clear()
|
||||
..add(_TractionItem.marker());
|
||||
@@ -261,6 +274,10 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
|
||||
_savingDraft = false;
|
||||
_loadedDraftSnapshot = null;
|
||||
});
|
||||
if (hadShare && mounted) {
|
||||
// Clear any share params from the URL when resetting.
|
||||
GoRouter.of(context).go('/add');
|
||||
}
|
||||
if (clearDraft) {
|
||||
await _clearDraft();
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user