Compare commits

..

1 Commits

Author SHA1 Message Date
8ab3f53c0d Add accepted leg edit notification and class leaderboard
All checks were successful
Release / meta (push) Successful in 12s
Release / linux-build (push) Successful in 1m0s
Release / web-build (push) Successful in 2m6s
Release / android-build (push) Successful in 6m8s
Release / release-master (push) Successful in 16s
Release / release-dev (push) Successful in 19s
2026-01-05 01:09:43 +00:00
11 changed files with 1114 additions and 132 deletions

View File

@@ -55,26 +55,19 @@ class _StationAutocompleteState extends State<StationAutocomplete> {
},
fieldViewBuilder:
(context, textEditingController, focusNode, onFieldSubmitted) {
textEditingController.value = _controller.value;
textEditingController.value = _controller.value;
return TextField(
controller: textEditingController,
focusNode: focusNode,
textInputAction: TextInputAction.done,
onSubmitted: (_) {
final matches = _findTopMatches(textEditingController.text);
final firstMatch = matches.isEmpty ? null : matches.first;
if (firstMatch == null) return;
_controller.text = firstMatch;
widget.onChanged(firstMatch);
focusNode.unfocus(); // optionally close keyboard
},
decoration: const InputDecoration(
labelText: 'Select station',
border: OutlineInputBorder(),
),
);
},
return TextField(
controller: textEditingController,
focusNode: focusNode,
textInputAction: TextInputAction.done,
onSubmitted: (_) => onFieldSubmitted(),
decoration: const InputDecoration(
labelText: 'Select station',
border: OutlineInputBorder(),
),
);
},
);
}
@@ -181,6 +174,14 @@ class _RouteCalculatorState extends State<RouteCalculator> {
}
Future<void> _calculateRoute(List<String> stations) async {
final cleaned = stations.where((s) => s.trim().isNotEmpty).toList();
if (cleaned.length < 2) {
setState(() {
_routeResult = null;
_errorMessage = 'Add at least two stations before calculating.';
});
return;
}
setState(() {
_errorMessage = null;
_routeResult = null;
@@ -188,7 +189,7 @@ class _RouteCalculatorState extends State<RouteCalculator> {
final api = context.read<ApiService>(); // context is valid here
try {
final res = await api.post('/route/distance2', {
'route': stations.where((s) => s.trim().isNotEmpty).toList(),
'route': cleaned,
});
if (res is Map && res['error'] == false) {
@@ -232,11 +233,28 @@ class _RouteCalculatorState extends State<RouteCalculator> {
});
}
void _clearCalculator() {
final data = context.read<DataService>();
setState(() {
data.stations = [''];
_routeResult = null;
_errorMessage = null;
});
}
@override
Widget build(BuildContext context) {
final data = context.watch<DataService>();
return Column(
children: [
Align(
alignment: Alignment.centerRight,
child: IconButton(
tooltip: 'Clear calculator',
icon: const Icon(Icons.clear_all),
onPressed: _clearCalculator,
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
child: Wrap(
@@ -366,28 +384,34 @@ class _RouteCalculatorState extends State<RouteCalculator> {
spacing: 12,
runSpacing: 8,
children: [
ElevatedButton.icon(
icon: const Icon(Icons.swap_horiz),
label: const Text('Reverse route'),
onPressed: () async {
setState(() {
data.stations = data.stations.reversed.toList();
});
await _calculateRoute(data.stations);
},
),
ElevatedButton.icon(
icon: const Icon(Icons.add),
label: const Text('Add Station'),
onPressed: _addStation,
),
ElevatedButton.icon(
icon: const Icon(Icons.route),
label: const Text('Calculate Route'),
onPressed: () async {
await _calculateRoute(data.stations);
},
),
...(() {
final reverseButton = ElevatedButton.icon(
icon: const Icon(Icons.swap_horiz),
label: const Text('Reverse route'),
onPressed: () async {
setState(() {
data.stations = data.stations.reversed.toList();
});
await _calculateRoute(data.stations);
},
);
final addButton = ElevatedButton.icon(
icon: const Icon(Icons.add),
label: const Text('Add Station'),
onPressed: _addStation,
);
final calculateButton = ElevatedButton.icon(
icon: const Icon(Icons.route),
label: const Text('Calculate Route'),
onPressed: () async {
await _calculateRoute(data.stations);
},
);
final isMobile = MediaQuery.of(context).size.width < 600;
return isMobile
? [addButton, reverseButton, calculateButton]
: [reverseButton, addButton, calculateButton];
})(),
],
),
),

View File

@@ -1,4 +1,6 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.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:provider/provider.dart';
@@ -130,14 +132,38 @@ class _LeaderboardPanelState extends State<LeaderboardPanel> {
fontWeight: FontWeight.w700,
),
),
trailing: Text(
distanceUnits.format(
leaderboard[index].mileage,
decimals: 1,
),
style: textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w700,
),
trailing: Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 8,
children: [
Text(
distanceUnits.format(
leaderboard[index].mileage,
decimals: 1,
),
style: textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
Builder(
builder: (ctx) => IconButton(
tooltip: 'View profile',
icon: const Icon(Icons.open_in_new, size: 20),
onPressed: () {
final auth = ctx.read<AuthService>();
final userId = leaderboard[index].userId;
if (auth.userId == userId) {
ctx.go('/more/profile');
} else {
ctx.pushNamed(
'user-profile',
queryParameters: {'user_id': userId},
);
}
},
),
),
],
),
),
if (index != leaderboard.length - 1) const Divider(height: 12),

View File

@@ -17,11 +17,13 @@ class UserProfilePage extends StatefulWidget {
}
class _UserProfilePageState extends State<UserProfilePage> {
static const int _pageSize = 22;
UserProfileDetail? _profile;
List<Leg> _legs = const [];
bool _loading = false;
bool _loadingMore = false;
bool _hasMore = false;
bool _lastFetchReturnedData = true;
Friendship? _friendship;
bool _actionsLoading = false;
@@ -39,9 +41,9 @@ class _UserProfilePageState extends State<UserProfilePage> {
final userId = _userId;
if (userId == null || userId.isEmpty) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('No user selected.')),
);
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('No user selected.')));
context.pop();
}
return;
@@ -66,7 +68,8 @@ class _UserProfilePageState extends State<UserProfilePage> {
setState(() {
_profile = profile;
_legs = legs;
_hasMore = legs.length >= 25;
_lastFetchReturnedData = legs.isNotEmpty;
_hasMore = _lastFetchReturnedData && _legs.length >= _pageSize;
_friendship = friendship;
});
} finally {
@@ -83,11 +86,13 @@ class _UserProfilePageState extends State<UserProfilePage> {
final more = await data.fetchUserLegs(
userId: userId,
offset: _legs.length,
limit: _pageSize,
);
if (!mounted) return;
setState(() {
_legs = [..._legs, ...more];
_hasMore = more.length >= 25;
_lastFetchReturnedData = more.isNotEmpty;
_hasMore = _lastFetchReturnedData && _legs.length >= _pageSize;
});
} finally {
if (mounted) setState(() => _loadingMore = false);
@@ -110,7 +115,9 @@ class _UserProfilePageState extends State<UserProfilePage> {
final mileage = profile?.mileage;
final privacy = profile?.privacyInfo;
final mileageHidden =
(mileage == null || mileage == 0) && privacy != null && privacy.isNotEmpty;
(mileage == null || mileage == 0) &&
privacy != null &&
privacy.isNotEmpty;
return Card(
child: Padding(
padding: const EdgeInsets.all(12.0),
@@ -152,9 +159,7 @@ class _UserProfilePageState extends State<UserProfilePage> {
return const SizedBox.shrink();
}
final topTen = [...profile.topLocos]
..sort(
(a, b) => (b.mileage ?? 0).compareTo(a.mileage ?? 0),
);
..sort((a, b) => (b.mileage ?? 0).compareTo(a.mileage ?? 0));
final displayLocos = topTen.take(10).toList();
return Card(
child: Padding(
@@ -177,15 +182,14 @@ class _UserProfilePageState extends State<UserProfilePage> {
final mileage = loco.mileage ?? 0;
return ListTile(
leading: CircleAvatar(
backgroundColor: Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.1),
backgroundColor: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.1),
child: Text(
'${index + 1}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.bold,
),
fontWeight: FontWeight.bold,
),
),
),
title: Text(
@@ -195,10 +199,9 @@ class _UserProfilePageState extends State<UserProfilePage> {
subtitle: Text(loco.locoClass),
trailing: Text(
'${mileage.toStringAsFixed(1)} mi',
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(fontWeight: FontWeight.w700),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
);
},
@@ -211,8 +214,9 @@ class _UserProfilePageState extends State<UserProfilePage> {
List<Widget> _buildLegsWithDividers(
BuildContext context,
List<Leg> legs,
) {
List<Leg> legs, {
required bool showEditButton,
}) {
final widgets = <Widget>[];
String? currentDate;
final dayLegs = <Leg>[];
@@ -225,15 +229,21 @@ class _UserProfilePageState extends State<UserProfilePage> {
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Text(
date,
style: Theme.of(context).textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w700,
),
style: Theme.of(
context,
).textTheme.labelMedium?.copyWith(fontWeight: FontWeight.w700),
),
),
);
widgets.add(const Divider());
widgets.addAll(
dayLegs.map((leg) => LegCard(leg: leg, showDate: false)),
dayLegs.map(
(leg) => LegCard(
leg: leg,
showDate: false,
showEditButton: showEditButton,
),
),
);
dayLegs.clear();
}
@@ -295,7 +305,9 @@ class _UserProfilePageState extends State<UserProfilePage> {
break;
case 'pending':
final isRequester = status.requesterId == auth.userId;
label = isRequester ? 'Pending (you sent)' : 'Pending (needs your reply)';
label = isRequester
? 'Pending (you sent)'
: 'Pending (needs your reply)';
color = Colors.orange;
break;
case 'blocked':
@@ -325,10 +337,9 @@ class _UserProfilePageState extends State<UserProfilePage> {
}
Widget _buildActions(Friendship status, AuthService auth) {
final isSelf = status.addresseeId == auth.userId || status.requesterId == auth.userId;
if (isSelf) {
return const Text('This is you.');
}
final targetUserId = _userId;
final isSelf = targetUserId != null && targetUserId == auth.userId;
if (isSelf) return const Text('This is you.');
final isRequester = status.requesterId == auth.userId;
final id = status.id;
final buttons = <Widget>[];
@@ -350,10 +361,12 @@ class _UserProfilePageState extends State<UserProfilePage> {
onPressed: _actionsLoading
? null
: () => run(() async {
final updated = await data.requestFriendship(status.addresseeId);
if (!mounted) return;
setState(() => _friendship = updated);
}),
final updated = await data.requestFriendship(
status.addresseeId,
);
if (!mounted) return;
setState(() => _friendship = updated);
}),
icon: const Icon(Icons.person_add),
label: const Text('Send friend request'),
),
@@ -365,10 +378,12 @@ class _UserProfilePageState extends State<UserProfilePage> {
onPressed: _actionsLoading || id == null || id.isEmpty
? null
: () => run(() async {
await data.cancelFriendship(id);
if (!mounted) return;
setState(() => _friendship = status.copyWith(status: 'none'));
}),
await data.cancelFriendship(id);
if (!mounted) return;
setState(
() => _friendship = status.copyWith(status: 'none'),
);
}),
child: const Text('Cancel request'),
),
);
@@ -378,10 +393,10 @@ class _UserProfilePageState extends State<UserProfilePage> {
onPressed: _actionsLoading || id == null || id.isEmpty
? null
: () => run(() async {
final updated = await data.acceptFriendship(id);
if (!mounted) return;
setState(() => _friendship = updated);
}),
final updated = await data.acceptFriendship(id);
if (!mounted) return;
setState(() => _friendship = updated);
}),
child: const Text('Accept'),
),
);
@@ -390,10 +405,10 @@ class _UserProfilePageState extends State<UserProfilePage> {
onPressed: _actionsLoading || id == null || id.isEmpty
? null
: () => run(() async {
final updated = await data.rejectFriendship(id);
if (!mounted) return;
setState(() => _friendship = updated);
}),
final updated = await data.rejectFriendship(id);
if (!mounted) return;
setState(() => _friendship = updated);
}),
child: const Text('Reject'),
),
);
@@ -404,10 +419,10 @@ class _UserProfilePageState extends State<UserProfilePage> {
onPressed: _actionsLoading || id == null || id.isEmpty
? null
: () => run(() async {
await data.deleteFriendship(id);
if (!mounted) return;
setState(() => _friendship = status.copyWith(status: 'none'));
}),
await data.deleteFriendship(id);
if (!mounted) return;
setState(() => _friendship = status.copyWith(status: 'none'));
}),
icon: const Icon(Icons.person_remove),
label: const Text('Unfriend'),
),
@@ -416,17 +431,14 @@ class _UserProfilePageState extends State<UserProfilePage> {
if (buttons.isEmpty) return const SizedBox.shrink();
return Wrap(
spacing: 8,
runSpacing: 8,
children: buttons,
);
return Wrap(spacing: 8, runSpacing: 8, children: buttons);
}
@override
Widget build(BuildContext context) {
final auth = context.watch<AuthService>();
final theme = Theme.of(context);
final canEdit = auth.userId != null && auth.userId == _userId;
return Scaffold(
appBar: AppBar(
title: const Text('User profile'),
@@ -455,10 +467,7 @@ class _UserProfilePageState extends State<UserProfilePage> {
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Entries',
style: theme.textTheme.titleMedium,
),
Text('Entries', style: theme.textTheme.titleMedium),
if (_loading)
const SizedBox(
width: 16,
@@ -478,13 +487,17 @@ class _UserProfilePageState extends State<UserProfilePage> {
else if (_legs.isEmpty)
Text(
(_profile?.privacyInfo.isNotEmpty ?? false)
? 'Legs hidden due to privacy settings.'
? 'Hidden due to privacy settings.'
: 'No entries found.',
)
else ...[
..._buildLegsWithDividers(context, _legs),
..._buildLegsWithDividers(
context,
_legs,
showEditButton: canEdit,
),
const SizedBox(height: 8),
if (_hasMore || _loadingMore)
if ((_hasMore || _loadingMore) && _legs.isNotEmpty)
Align(
alignment: Alignment.center,
child: OutlinedButton.icon(
@@ -493,8 +506,9 @@ class _UserProfilePageState extends State<UserProfilePage> {
? const SizedBox(
height: 14,
width: 14,
child:
CircularProgressIndicator(strokeWidth: 2),
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.expand_more),
label: Text(

View File

@@ -465,7 +465,7 @@ class _ProfilePageState extends State<ProfilePage> {
subtitle:
user.username.isNotEmpty ? Text('@${user.username}') : null,
trailing: TextButton(
onPressed: () => context.goNamed(
onPressed: () => context.pushNamed(
'user-profile',
extra: user,
queryParameters: {'user_id': user.userId},
@@ -745,16 +745,26 @@ class _ProfilePageState extends State<ProfilePage> {
subtitle: otherUser?.username.isNotEmpty == true
? Text('@${otherUser!.username}')
: null,
trailing: TextButton(
onPressed: () {
final user = otherUser;
if (user != null) {
_loadStatus(user);
}
},
child: const Text('Manage'),
),
),
trailing: TextButton(
onPressed: () {
final user = otherUser;
if (user != null) {
final auth = context.read<AuthService>();
final isSelf = auth.userId == user.userId;
if (isSelf) {
context.go('/more/profile');
} else {
context.pushNamed(
'user-profile',
extra: user,
queryParameters: {'user_id': user.userId},
);
}
}
},
child: const Text('View'),
),
),
);
}),
],

View File

@@ -33,6 +33,14 @@ class _TractionPageState extends State<TractionPage> {
String? _classStatsError;
String? _classStatsForClass;
Map<String, dynamic>? _classStats;
bool _showClassLeaderboardPanel = false;
bool _classLeaderboardLoading = false;
String? _classLeaderboardError;
String? _classLeaderboardForClass;
String? _classFriendsLeaderboardForClass;
List<LeaderboardEntry> _classLeaderboard = [];
List<LeaderboardEntry> _classFriendsLeaderboard = [];
_ClassLeaderboardScope _classLeaderboardScope = _ClassLeaderboardScope.global;
final Map<String, TextEditingController> _dynamicControllers = {};
final Map<String, String?> _enumSelections = {};
@@ -211,6 +219,13 @@ class _TractionPageState extends State<TractionPage> {
_classStats = null;
_classStatsError = null;
_classStatsForClass = null;
_showClassLeaderboardPanel = false;
_classLeaderboard = [];
_classFriendsLeaderboard = [];
_classLeaderboardError = null;
_classLeaderboardForClass = null;
_classFriendsLeaderboardForClass = null;
_classLeaderboardScope = _ClassLeaderboardScope.global;
});
_refreshTraction();
}
@@ -223,6 +238,7 @@ class _TractionPageState extends State<TractionPage> {
});
}
_refreshClassStatsIfOpen();
_refreshClassLeaderboardIfOpen();
}
List<EventField> _activeEventFields(List<EventField> fields) {
@@ -405,6 +421,7 @@ class _TractionPageState extends State<TractionPage> {
});
_refreshTraction();
_refreshClassStatsIfOpen(immediate: true);
_refreshClassLeaderboardIfOpen(immediate: true);
},
),
),
@@ -518,6 +535,19 @@ class _TractionPageState extends State<TractionPage> {
),
),
),
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
sliver: SliverToBoxAdapter(
child: AnimatedCrossFade(
crossFadeState: (_showClassLeaderboardPanel && _hasClassQuery)
? CrossFadeState.showFirst
: CrossFadeState.showSecond,
duration: const Duration(milliseconds: 200),
firstChild: _buildClassLeaderboardCard(context),
secondChild: const SizedBox.shrink(),
),
),
),
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
sliver: _buildTractionSliver(context, data, traction),
@@ -587,6 +617,17 @@ class _TractionPageState extends State<TractionPage> {
_showClassStatsPanel ? 'Hide class stats' : 'Class stats',
),
);
final classLeaderboardButton = !_hasClassQuery
? null
: FilledButton.tonalIcon(
onPressed: _toggleClassLeaderboardPanel,
icon: Icon(
_showClassLeaderboardPanel ? Icons.emoji_events : Icons.leaderboard,
),
label: Text(
_showClassLeaderboardPanel ? 'Hide class leaderboard' : 'Class leaderboard',
),
);
final newTractionButton = !isElevated
? null
@@ -611,12 +652,14 @@ class _TractionPageState extends State<TractionPage> {
final desktopActions = [
refreshButton,
if (classStatsButton != null) classStatsButton,
if (classLeaderboardButton != null) classLeaderboardButton,
if (newTractionButton != null) newTractionButton,
];
final mobileActions = [
if (newTractionButton != null) newTractionButton,
if (classStatsButton != null) classStatsButton,
if (classLeaderboardButton != null) classLeaderboardButton,
refreshButton,
];
@@ -944,6 +987,42 @@ class _TractionPageState extends State<TractionPage> {
return total;
}
Widget _placementBadge(BuildContext context, int index) {
const size = 32.0;
const iconSize = 18.0;
if (index == 0) {
return CircleAvatar(
radius: size / 2,
backgroundColor: Colors.amber.shade400,
child: const Icon(Icons.emoji_events, color: Colors.white, size: iconSize),
);
}
if (index == 1) {
return CircleAvatar(
radius: size / 2,
backgroundColor: Colors.blueGrey.shade200,
child: const Icon(Icons.emoji_events, color: Colors.white, size: iconSize),
);
}
if (index == 2) {
return CircleAvatar(
radius: size / 2,
backgroundColor: Colors.brown.shade300,
child: const Icon(Icons.emoji_events, color: Colors.white, size: iconSize),
);
}
return CircleAvatar(
radius: size / 2,
backgroundColor: Theme.of(context).colorScheme.secondaryContainer,
child: Text(
'${index + 1}',
style: Theme.of(context).textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w800,
),
),
);
}
Color _statusColor(String status, ColorScheme scheme) {
final key = status.toLowerCase();
if (key.contains('scrap')) return Colors.red.shade600;
@@ -1150,4 +1229,212 @@ class _TractionPageState extends State<TractionPage> {
),
);
}
Future<void> _toggleClassLeaderboardPanel() async {
if (!_hasClassQuery) return;
final targetState = !_showClassLeaderboardPanel;
setState(() {
_showClassLeaderboardPanel = targetState;
});
if (targetState) {
await _loadClassLeaderboard(friends: _classLeaderboardScope == _ClassLeaderboardScope.friends);
}
}
void _refreshClassLeaderboardIfOpen({bool immediate = false}) {
if (!_showClassLeaderboardPanel || !_hasClassQuery) return;
final query = (_selectedClass ?? _classController.text).trim();
final scope = _classLeaderboardScope;
final currentData = scope == _ClassLeaderboardScope.global
? _classLeaderboard
: _classFriendsLeaderboard;
final currentClass = scope == _ClassLeaderboardScope.global
? _classLeaderboardForClass
: _classFriendsLeaderboardForClass;
if (!immediate && currentClass == query && currentData.isNotEmpty) {
return;
}
_loadClassLeaderboard(
friends: scope == _ClassLeaderboardScope.friends,
);
}
Future<void> _loadClassLeaderboard({required bool friends}) async {
final query = (_selectedClass ?? _classController.text).trim();
if (query.isEmpty) return;
final currentClass = friends ? _classFriendsLeaderboardForClass : _classLeaderboardForClass;
final currentData = friends ? _classFriendsLeaderboard : _classLeaderboard;
if (currentClass == query && currentData.isNotEmpty) return;
setState(() {
_classLeaderboardLoading = true;
_classLeaderboardError = null;
if (friends && _classFriendsLeaderboardForClass != query) {
_classFriendsLeaderboard = [];
} else if (!friends && _classLeaderboardForClass != query) {
_classLeaderboard = [];
}
});
try {
final data = context.read<DataService>();
final leaderboard = await data.fetchClassLeaderboard(
query,
friends: friends,
);
if (!mounted) return;
setState(() {
if (friends) {
_classFriendsLeaderboard = leaderboard;
_classFriendsLeaderboardForClass = query;
} else {
_classLeaderboard = leaderboard;
_classLeaderboardForClass = query;
}
_classLeaderboardError = null;
});
} catch (e) {
if (!mounted) return;
setState(() {
_classLeaderboardError = 'Failed to load class leaderboard: $e';
});
} finally {
if (mounted) {
setState(() {
_classLeaderboardLoading = false;
});
}
}
}
Widget _buildClassLeaderboardCard(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final distanceUnits = context.watch<DistanceUnitService>();
final leaderboard = _classLeaderboardScope == _ClassLeaderboardScope.global
? _classLeaderboard
: _classFriendsLeaderboard;
final loading = _classLeaderboardLoading;
final error = _classLeaderboardError;
return Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
(_selectedClass ?? _classController.text).trim().isEmpty
? 'Class leaderboard'
: '${(_selectedClass ?? _classController.text).trim()} leaderboard',
style: Theme.of(context).textTheme.titleMedium,
),
),
SegmentedButton<_ClassLeaderboardScope>(
segments: const [
ButtonSegment(
value: _ClassLeaderboardScope.global,
label: Text('Global'),
),
ButtonSegment(
value: _ClassLeaderboardScope.friends,
label: Text('Friends'),
),
],
selected: {_classLeaderboardScope},
onSelectionChanged: (vals) async {
if (vals.isEmpty) return;
final selected = vals.first;
setState(() => _classLeaderboardScope = selected);
if (selected == _ClassLeaderboardScope.friends &&
_classFriendsLeaderboard.isEmpty &&
!_classLeaderboardLoading) {
await _loadClassLeaderboard(friends: true);
} else if (selected == _ClassLeaderboardScope.global &&
_classLeaderboard.isEmpty &&
!_classLeaderboardLoading) {
await _loadClassLeaderboard(friends: false);
}
},
style: SegmentedButton.styleFrom(
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.symmetric(horizontal: 8),
),
),
const SizedBox(width: 8),
IconButton(
tooltip: 'Refresh leaderboard',
icon: const Icon(Icons.refresh),
onPressed: () => _loadClassLeaderboard(
friends: _classLeaderboardScope == _ClassLeaderboardScope.friends,
),
),
],
),
const SizedBox(height: 12),
if (loading)
Row(
children: const [
SizedBox(
height: 16,
width: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
SizedBox(width: 12),
Text('Loading leaderboard...'),
],
)
else if (error != null)
Text(
error,
style: TextStyle(color: scheme.error),
)
else if (leaderboard.isEmpty)
const Text('No leaderboard data yet.')
else
Column(
children: [
for (int i = 0; i < leaderboard.length; i++) ...[
ListTile(
contentPadding: EdgeInsets.zero,
dense: true,
leading: _placementBadge(context, i),
title: Text(
leaderboard[i].userFullName,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
trailing: Text(
distanceUnits.format(
leaderboard[i].mileage,
decimals: 1,
),
style: Theme.of(context).textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
onTap: () {
final auth = context.read<AuthService>();
final userId = leaderboard[i].userId;
if (auth.userId == userId) {
context.go('/more/profile');
} else {
context.pushNamed(
'user-profile',
queryParameters: {'user_id': userId},
);
}
},
),
if (i != leaderboard.length - 1) const Divider(height: 12),
],
],
),
],
),
),
);
}
}
enum _ClassLeaderboardScope { global, friends }

View File

@@ -0,0 +1,532 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/data_service.dart';
import 'package:provider/provider.dart';
class LegShareEditNotificationCard extends StatefulWidget {
const LegShareEditNotificationCard({super.key, required this.notification});
final UserNotification notification;
@override
State<LegShareEditNotificationCard> createState() => _LegShareEditNotificationCardState();
}
class _LegShareEditNotificationCardState extends State<LegShareEditNotificationCard> {
Map<String, dynamic>? _changes;
int? _legId;
int? _shareId;
Leg? _currentLeg;
bool _loading = false;
static const int _summaryLimit = 3;
@override
void initState() {
super.initState();
_parseNotification();
}
@override
void didUpdateWidget(covariant LegShareEditNotificationCard oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.notification != widget.notification) {
_parseNotification();
}
}
void _parseNotification() {
final rawBody = widget.notification.body.trim();
// Reset
_shareId = null;
_legId = null;
_currentLeg = null;
_changes = null;
final parsed = _decodeBody(rawBody);
if (parsed != null) {
_shareId = _parseInt(parsed['share_id']);
_legId = _parseInt(parsed['leg_id']);
final accepted = _asStringKeyedMap(parsed['accepted_changes']);
if (accepted != null) {
_changes = accepted;
}
}
// Fallback: extract share_id from raw string if still missing.
_shareId ??= _extractShareId(rawBody);
}
int? _parseInt(dynamic value) {
if (value is int) return value;
if (value is num) return value.toInt();
if (value is String) return int.tryParse(value.trim());
return null;
}
Map<String, dynamic>? _decodeBody(String rawBody) {
final attempts = <String>[
rawBody,
_stripWrappingQuotes(rawBody),
_replaceSingleQuotes(rawBody),
].where((s) => s.trim().isNotEmpty).toSet();
for (final attempt in attempts) {
final parsed = _decodeJsonToMap(attempt);
if (parsed != null) return parsed;
}
return null;
}
Map<String, dynamic>? _decodeJsonToMap(String source) {
dynamic parsed = source;
for (int i = 0; i < 3 && parsed is String; i++) {
try {
parsed = jsonDecode(parsed);
} catch (e) {
parsed = null;
break;
}
}
if (parsed is Map) {
final map = parsed.map((k, v) => MapEntry(k.toString(), v));
return map;
}
return null;
}
String _stripWrappingQuotes(String input) {
final trimmed = input.trim();
if (trimmed.length >= 2 &&
((trimmed.startsWith('"') && trimmed.endsWith('"')) ||
(trimmed.startsWith("'") && trimmed.endsWith("'")))) {
return trimmed.substring(1, trimmed.length - 1);
}
return input;
}
String _replaceSingleQuotes(String input) {
if (!input.contains("'")) return input;
return input.replaceAll(RegExp(r"(?<!\\)'"), '"');
}
Map<String, dynamic>? _asStringKeyedMap(dynamic value) {
if (value is Map) {
return value.map((k, v) => MapEntry(k.toString(), v));
}
if (value is String && value.trim().isNotEmpty) {
for (final attempt in [value, _replaceSingleQuotes(value)]) {
try {
final decoded = jsonDecode(attempt);
if (decoded is Map) {
return decoded.map((k, v) => MapEntry(k.toString(), v));
}
} catch (_) {
// Ignore; handled by caller.
}
}
}
return null;
}
int? _extractShareId(String raw) {
final patterns = [
RegExp(r'"share_id"\s*:\s*(\d+)'),
RegExp(r"'share_id'\s*:\s*(\d+)"),
RegExp(r'share_id\s*:\s*(\d+)'),
RegExp(r'"share_id"\s*:\s*"(\d+)"'),
];
for (final pattern in patterns) {
final match = pattern.firstMatch(raw);
if (match != null) {
final parsed = int.tryParse(match.group(1)!);
return parsed;
}
}
return null;
}
Future<void> _loadLegIdIfNeeded() async {
if (_legId != null) return;
if (_shareId == null) {
return;
}
try {
final share = await context.read<DataService>().fetchLegShare(_shareId!.toString());
if (!mounted) return;
_legId = share?.entry.id;
_currentLeg ??= _findCurrentLeg(_legId);
} catch (e) {
}
}
Leg? _findCurrentLeg(int? legId) {
if (legId == null) return null;
final data = context.read<DataService>();
try {
return data.legs.firstWhere((l) => l.id == legId);
} catch (_) {
return null;
}
}
@override
Widget build(BuildContext context) {
final changes = _changes;
if (changes == null || changes.isEmpty) {
return const Text('No changes supplied.');
}
final entries = changes.entries.toList();
final shown = entries.take(_summaryLimit).toList();
final remaining = entries.length - shown.length;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
...shown.map((e) => _changePreview(context, e)),
if (remaining > 0)
Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Text('+$remaining others…', style: Theme.of(context).textTheme.bodySmall),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
ElevatedButton(
onPressed: _loading ? null : () => _openDrawer(changes),
child: const Text('View changes'),
),
TextButton(
onPressed: _loading ? null : _dismiss,
child: const Text('Dismiss changes'),
),
],
),
],
);
}
Widget _changePreview(BuildContext context, MapEntry<String, dynamic> change) {
final key = _prettyField(change.key);
final value = change.value;
String display;
if (change.key == 'locos' && value is List) {
display = '${value.length} traction update${value.length == 1 ? '' : 's'}';
} else {
display = _stringify(value);
}
return Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Text('$key: $display'),
);
}
String _prettyField(String raw) {
switch (raw) {
case 'leg_notes':
return 'Notes';
case 'locos':
return 'Traction';
default:
return raw.replaceAll('_', ' ');
}
}
dynamic _currentValueForField(Leg leg, String key) {
switch (key) {
case 'leg_begin_time':
return leg.beginTime;
case 'leg_end_time':
return leg.endTime;
case 'leg_origin_time':
return leg.originTime;
case 'leg_destination_time':
return leg.destinationTime;
case 'leg_notes':
return leg.notes;
case 'leg_headcode':
return leg.headcode;
case 'leg_network':
return leg.network;
case 'leg_start':
return leg.start;
case 'leg_end':
return leg.end;
case 'leg_origin':
return leg.origin;
case 'leg_destination':
return leg.destination;
case 'leg_route':
return leg.route;
case 'leg_mileage':
return leg.mileage;
case 'leg_begin_delay':
return leg.beginDelayMinutes;
case 'leg_end_delay':
return leg.endDelayMinutes;
case 'locos':
return leg.locos;
default:
return null;
}
}
Widget _buildChangeValueWidget(
String key,
dynamic newValue,
Leg? currentLeg,
Widget Function(List<dynamic>) buildLocos,
) {
final currentValue = currentLeg == null ? null : _currentValueForField(currentLeg, key);
if (key == 'locos' && newValue is List) {
final currentCount = (currentValue is List) ? currentValue.length : 0;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Current: $currentCount locos'),
const SizedBox(height: 4),
const Text('New:'),
buildLocos(newValue),
],
);
}
final currentStr = _stringify(currentValue);
final newStr = _stringify(newValue);
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: Text(currentStr, maxLines: 3, overflow: TextOverflow.ellipsis)),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 6.0),
child: Icon(Icons.arrow_right_alt, size: 18),
),
Expanded(child: Text(newStr, maxLines: 3, overflow: TextOverflow.ellipsis)),
],
);
}
String _stringify(dynamic value) {
if (value is DateTime) return value.toIso8601String();
if (value == null) return '';
if (value is List || value is Map) {
return jsonEncode(value);
}
return value.toString();
}
Future<void> _openDrawer(Map<String, dynamic> changes) async {
setState(() => _loading = true);
await _loadLegIdIfNeeded();
_currentLeg ??= _findCurrentLeg(_legId);
if (!mounted) return;
setState(() => _loading = false);
final legId = _legId;
if (legId == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Unable to load shared leg.')),
);
return;
}
final selected = Map<String, bool>.fromEntries(
changes.keys.map((k) => MapEntry(k, false)),
);
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (ctx) {
return StatefulBuilder(
builder: (context, setSheetState) {
Future<void> apply() async {
final payload = <String, dynamic>{};
for (final entry in changes.entries) {
if (selected[entry.key] == true) {
payload[entry.key] = entry.value;
}
}
if (payload.isEmpty) {
Navigator.of(context).pop();
return;
}
final messenger = ScaffoldMessenger.of(context);
setSheetState(() => _loading = true);
try {
final data = context.read<DataService>();
await data.applyLegPartialUpdates(
legId: legId,
updates: payload,
);
if (!context.mounted) return;
await data.dismissNotifications([widget.notification.id]);
if (!context.mounted) return;
messenger.showSnackBar(
const SnackBar(content: Text('Changes applied.')),
);
Navigator.of(context).pop();
} catch (e) {
messenger.showSnackBar(
SnackBar(content: Text('Failed to apply changes: $e')),
);
} finally {
setSheetState(() => _loading = false);
}
}
Widget buildLocos(List<dynamic> locos) {
final parsed = locos
.whereType<Map>()
.map((e) => e.map((k, v) => MapEntry(k.toString(), v)))
.toList();
parsed.sort((a, b) => (b['alloc_pos'] ?? 0).compareTo(a['alloc_pos'] ?? 0));
final leading = parsed.where((e) => (e['alloc_pos'] ?? 0) > 0).toList();
final trailing = parsed.where((e) => (e['alloc_pos'] ?? 0) <= 0).toList();
List<Widget> chipsFor(List<Map<String, dynamic>> list) {
return list
.map(
(loco) => Chip(
backgroundColor:
(loco['alloc_powering'] == 1 || loco['alloc_powering'] == true)
? Theme.of(context).colorScheme.primary.withValues(alpha: 0.12)
: Theme.of(context).colorScheme.surfaceContainerHighest,
label: Text('Loco ${loco['loco_id'] ?? '?'} (pos ${loco['alloc_pos'] ?? '?'}'),
avatar: Icon(
Icons.train,
size: 16,
color: (loco['alloc_powering'] == 1 || loco['alloc_powering'] == true)
? Theme.of(context).colorScheme.primary
: Theme.of(context).hintColor,
),
),
)
.toList();
}
return Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
...chipsFor(leading),
if (leading.isNotEmpty && trailing.isNotEmpty)
const SizedBox(
width: 24,
child: Center(child: Divider(height: 16)),
),
...chipsFor(trailing),
],
);
}
return SafeArea(
child: Padding(
padding: EdgeInsets.only(
left: 16,
right: 16,
bottom: MediaQuery.of(context).viewInsets.bottom + 16,
top: 16,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Review changes', style: Theme.of(context).textTheme.titleMedium),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
),
],
),
const SizedBox(height: 12),
Row(
children: [
TextButton(
onPressed: () => setSheetState(() {
for (final key in selected.keys) {
selected[key] = true;
}
}),
child: const Text('Select all'),
),
const Spacer(),
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
],
),
const SizedBox(height: 8),
...changes.entries.map((entry) {
final key = entry.key;
final prettyKey = _prettyField(key);
final value = entry.value;
final currentLeg = _currentLeg ?? _findCurrentLeg(_legId);
final valueWidget = _buildChangeValueWidget(
key,
value,
currentLeg,
buildLocos,
);
return Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Checkbox(
value: selected[key] ?? false,
onChanged: (v) => setSheetState(() {
selected[key] = v ?? false;
}),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(prettyKey, style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 4),
valueWidget,
],
),
),
],
),
);
}),
Align(
alignment: Alignment.centerRight,
child: FilledButton(
onPressed: _loading ? null : apply,
child: _loading
? const SizedBox(
height: 18,
width: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Apply changes'),
),
),
],
),
),
);
},
);
},
);
}
Future<void> _dismiss() async {
await context.read<DataService>().dismissNotifications([widget.notification.id]);
}
}

View File

@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
@@ -14,8 +15,8 @@ class LegShareNotificationCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final data = context.read<DataService>();
final legShareId = notification.body.trim();
if (legShareId.isEmpty) {
final legShareId = _extractLegShareId(notification.body);
if (legShareId == null) {
return const Text('Invalid leg share notification.');
}
final future = data.fetchLegShare(legShareId);
@@ -144,4 +145,19 @@ class LegShareNotificationCard extends StatelessWidget {
final path = '/add?share=${Uri.encodeComponent(share.id)}&ts=$ts';
router.go(path, extra: target);
}
String? _extractLegShareId(String rawBody) {
final trimmed = rawBody.trim();
if (trimmed.isEmpty) return null;
if (RegExp(r'^[0-9]+$').hasMatch(trimmed)) return trimmed;
try {
final decoded = jsonDecode(trimmed);
if (decoded is Map) {
final id = decoded['share_id'] ?? decoded['leg_share_id'];
final str = id?.toString() ?? '';
if (RegExp(r'^[0-9]+$').hasMatch(str)) return str;
}
} catch (_) {}
return null;
}
}

View File

@@ -88,6 +88,18 @@ extension DataServiceLegShare on DataService {
return null;
}
Future<void> applyLegPartialUpdates({
required int legId,
required Map<String, dynamic> updates,
}) async {
try {
await api.post('/leg/update/$legId/partial', updates);
} catch (e) {
debugPrint('Failed to apply partial updates for leg $legId: $e');
rethrow;
}
}
int? _parseNullableInt(dynamic value) {
if (value is int) return value;
if (value is num) return value.toInt();

View File

@@ -179,4 +179,39 @@ extension DataServiceTraction on DataService {
}
return null;
}
Future<List<LeaderboardEntry>> fetchClassLeaderboard(
String locoClass, {
bool friends = false,
}) async {
try {
final path = Uri.encodeComponent(locoClass);
final suffix = friends ? '/friends' : '';
final json = await api.get('/stats/class/$path/leaderboard$suffix');
List<dynamic>? list;
if (json is List) {
list = json;
} else if (json is Map) {
for (final key in ['leaderboard', 'data', 'items', 'results']) {
final value = json[key];
if (value is List) {
list = value;
break;
}
}
}
return list
?.whereType<Map>()
.map((e) => LeaderboardEntry.fromJson(
e.map((k, v) => MapEntry(k.toString(), v)),
))
.toList() ??
const [];
} catch (e) {
debugPrint(
'Failed to fetch class leaderboard for $locoClass (friends=$friends): $e',
);
return const [];
}
}
}

View File

@@ -22,6 +22,7 @@ import 'package:mileograph_flutter/components/pages/stats.dart';
import 'package:mileograph_flutter/components/pages/traction.dart';
import 'package:mileograph_flutter/components/pages/more/user_profile_page.dart';
import 'package:mileograph_flutter/components/widgets/friend_request_notification_card.dart';
import 'package:mileograph_flutter/components/widgets/leg_share_edit_notification_card.dart';
import 'package:mileograph_flutter/components/widgets/leg_share_notification_card.dart';
import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/authservice.dart';
@@ -731,7 +732,8 @@ class _MyHomePageState extends State<MyHomePage> {
final item = notifications[index];
final isFriendRequest = _isFriendRequestNotification(item);
final isLegShare = _isLegShareNotification(item);
final isSpecial = isFriendRequest || isLegShare;
final isLegShareEdit = _isLegShareEditNotification(item);
final isSpecial = isFriendRequest || isLegShare || isLegShareEdit;
return Card(
child: Padding(
padding: const EdgeInsets.all(12.0),
@@ -754,10 +756,12 @@ class _MyHomePageState extends State<MyHomePage> {
),
const SizedBox(height: 4),
Text(
isFriendRequest || isLegShare
isSpecial
? isFriendRequest
? 'Accept to share entries'
: 'Shared entry details below.'
: isLegShareEdit
? 'Shared leg edits below.'
: 'Shared entry details below.'
: item.body,
style: Theme.of(context).textTheme.bodyMedium,
),
@@ -801,6 +805,13 @@ class _MyHomePageState extends State<MyHomePage> {
notification: item,
),
),
if (_isLegShareEditNotification(item))
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: LegShareEditNotificationCard(
notification: item,
),
),
if (isLegShare)
Padding(
padding: const EdgeInsets.only(top: 8.0),
@@ -890,7 +901,22 @@ class _MyHomePageState extends State<MyHomePage> {
bool _isLegShareNotification(UserNotification notification) {
final channel = notification.channel.trim().toLowerCase();
final type = notification.type.trim().toLowerCase();
return channel.contains('leg_share') || type.contains('leg_share');
final isAcceptEdits =
_isLegShareAcceptEdits(channel) || _isLegShareAcceptEdits(type);
return (channel.contains('leg_share') || type.contains('leg_share')) &&
!isAcceptEdits;
}
bool _isLegShareEditNotification(UserNotification notification) {
final channel = notification.channel.trim().toLowerCase();
final type = notification.type.trim().toLowerCase();
return _isLegShareAcceptEdits(channel) || _isLegShareAcceptEdits(type);
}
bool _isLegShareAcceptEdits(String value) {
final normalized = value.trim().toLowerCase();
// Match both singular/plural: leg_share_accept_edit / leg_share_accept_edits
return normalized.contains('leg_share_accept_edit');
}
Widget _buildBadge(String label) {

View File

@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 0.6.4+7
version: 0.6.5+7
environment:
sdk: ^3.8.1