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:
@@ -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