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

This commit is contained in:
2026-01-05 01:09:43 +00:00
parent 42ac7a97e1
commit 8ab3f53c0d
11 changed files with 1114 additions and 132 deletions

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(