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
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
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user