Skip to content

Commit c0e1cd9

Browse files
authored
Merge pull request #2 from axcdeng/add-trueskill-leaderboard-11135300667737851450
feat: Add TrueSkill leaderboard tabs
2 parents 0e4179d + 1aa7eaa commit c0e1cd9

File tree

11 files changed

+453
-72
lines changed

11 files changed

+453
-72
lines changed

.github/workflows/test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ jobs:
1414
- uses: actions/checkout@v3
1515
- uses: subosito/flutter-action@v2
1616
with:
17-
flutter-version: '3.10.0'
17+
flutter-version: '3.27.1'
1818
channel: 'stable'
1919
- run: flutter pub get
2020
- run: flutter pub run build_runner build --delete-conflicting-outputs

curl_output.json

Lines changed: 1 addition & 0 deletions
Large diffs are not rendered by default.

lib/src/constants.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ class AppConstants {
1212
static const String settingsBox = 'settings_box';
1313
static const String teamHistoryBox = 'team_history_box';
1414
static const String eventHistoryBox = 'event_history_box';
15+
static const String leaderboardBox = 'leaderboard_cache_box';
1516

1617
// RobotEvents API
1718
static const String robotEventsBaseUrl = 'https://www.robotevents.com/api/v2';

lib/src/models/team_model.dart

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ class Team {
2929
@HiveField(10)
3030
final double? ccwm;
3131
@HiveField(11)
32-
final Map<String, dynamic>? statiq;
32+
final Map? statiq;
3333
@HiveField(12)
3434
final String? grade;
3535

@@ -50,15 +50,22 @@ class Team {
5050
});
5151

5252
factory Team.fromJson(Map<String, dynamic> json) {
53-
final locationObj = json['location'] as Map<String, dynamic>?;
54-
final city = locationObj?['city'] as String?;
55-
final region = locationObj?['region'] as String?;
56-
final country = locationObj?['country'] as String?;
57-
final locationStr = [city, region, country]
58-
.where((e) => e != null && e.isNotEmpty)
59-
.join(', ');
53+
dynamic loc = json['location'];
54+
String? locationStr;
55+
if (loc is Map) {
56+
final city = loc['city'] as String?;
57+
final region = loc['region'] as String?;
58+
final country = loc['country'] as String?;
59+
locationStr = [city, region, country]
60+
.where((e) => e != null && e.isNotEmpty)
61+
.join(', ');
62+
} else if (loc is String) {
63+
locationStr = loc;
64+
}
6065

61-
final statiq = json['statiq'] as Map<String, dynamic>?;
66+
final statiqRaw = json['statiq'];
67+
final statiq =
68+
statiqRaw is Map ? Map<String, dynamic>.from(statiqRaw) : null;
6269
final perf = statiq?['performance'] as num?;
6370

6471
// Robust ID parsing:
@@ -103,7 +110,7 @@ class Team {
103110
String? location,
104111
double? trueskill,
105112
double? ccwm,
106-
Map<String, dynamic>? statiq,
113+
Map? statiq,
107114
String? grade,
108115
}) {
109116
return Team(

lib/src/models/team_model.g.dart

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import 'package:roboscout_iq/src/models/team_model.dart';
2+
import 'package:roboscout_iq/src/services/api_client.dart';
3+
import 'package:roboscout_iq/src/services/local_db_service.dart';
4+
5+
class LeaderboardRepository {
6+
final ApiClient _apiClient;
7+
final LocalDbService _localDb;
8+
9+
LeaderboardRepository(this._apiClient, this._localDb);
10+
11+
Future<List<Map<String, dynamic>>> getGlobalSkills(String gradeLevel,
12+
{bool forceRefresh = false}) async {
13+
final cacheKey = 'skills_$gradeLevel';
14+
final box = _localDb.leaderboardBox;
15+
16+
if (!forceRefresh && box.containsKey(cacheKey)) {
17+
try {
18+
final cachedData = box.get(cacheKey);
19+
if (cachedData is List) {
20+
return cachedData
21+
.map((e) => Map<String, dynamic>.from(e as Map))
22+
.toList();
23+
}
24+
} catch (e) {
25+
print('Error reading skills cache: $e');
26+
}
27+
}
28+
29+
try {
30+
final data = await _apiClient.getGlobalSkills(gradeLevel: gradeLevel);
31+
await box.put(cacheKey, data);
32+
return data;
33+
} catch (e) {
34+
if (box.containsKey(cacheKey)) {
35+
final cachedData = box.get(cacheKey);
36+
if (cachedData is List) {
37+
return cachedData
38+
.map((e) => Map<String, dynamic>.from(e as Map))
39+
.toList();
40+
}
41+
}
42+
rethrow;
43+
}
44+
}
45+
46+
Future<List<Team>> getGlobalTrueSkillRankings(
47+
{bool forceRefresh = false}) async {
48+
const cacheKey = 'trueskill_global';
49+
final box = _localDb.leaderboardBox;
50+
51+
if (!forceRefresh && box.containsKey(cacheKey)) {
52+
try {
53+
final cachedData = box.get(cacheKey);
54+
if (cachedData is List) {
55+
return _deserializeTeams(cachedData);
56+
}
57+
} catch (e) {
58+
print('Error reading trueskill cache: $e');
59+
}
60+
}
61+
62+
try {
63+
final teams = await _apiClient.getGlobalTrueSkillRankings();
64+
final jsonList = teams.map((t) => t.toJson()).toList();
65+
await box.put(cacheKey, jsonList);
66+
return teams;
67+
} catch (e) {
68+
if (box.containsKey(cacheKey)) {
69+
final cachedData = box.get(cacheKey) as List;
70+
return _deserializeTeams(cachedData);
71+
}
72+
rethrow;
73+
}
74+
}
75+
76+
List<Team> _deserializeTeams(List<dynamic> list) {
77+
return list.map((e) {
78+
// Hive returns _Map<dynamic, dynamic>, need to cast to Map<String, dynamic>
79+
// for Team.fromJson
80+
final json = Map<String, dynamic>.from(e as Map);
81+
return Team.fromJson(json);
82+
}).toList();
83+
}
84+
}

lib/src/services/api_client.dart

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,41 @@ class ApiClient {
305305
}
306306
}
307307

308+
Future<List<Team>> getGlobalTrueSkillRankings(
309+
{String gradeLevel = 'Middle School'}) async {
310+
// RoboStem API uses a different base URL and key
311+
final token = _settings.roboStemApiKey ?? AppConstants.roboStemApiKey;
312+
final dio = Dio(BaseOptions(
313+
baseUrl: AppConstants.roboStemBaseUrl, // https://api.robostem-api.org
314+
headers: {
315+
'x-api-key': token,
316+
'accept': 'application/json',
317+
},
318+
connectTimeout: const Duration(seconds: 30),
319+
receiveTimeout: const Duration(seconds: 30),
320+
));
321+
322+
try {
323+
final response = await dio.get('/api/rankings/statiq', queryParameters: {
324+
'program': 'VIQRC',
325+
'limit': 100, // Reduced from 2500 for performance
326+
'grade_level': gradeLevel,
327+
});
328+
329+
// RoboStem response structure might differ. Assuming standard list or {data: []}
330+
List<Map<String, dynamic>> rawList = [];
331+
if (response.data is List) {
332+
rawList = (response.data as List).cast<Map<String, dynamic>>();
333+
} else if (response.data is Map && response.data['data'] is List) {
334+
rawList = (response.data['data'] as List).cast<Map<String, dynamic>>();
335+
}
336+
337+
return rawList.map((json) => Team.fromJson(json)).toList();
338+
} catch (e) {
339+
return [];
340+
}
341+
}
342+
308343
Future<List<Team>> searchTeams(
309344
{String? number, int? program, int? limit}) async {
310345
// Strategy: Use RobotEvents API for exact team lookup first,

lib/src/services/local_db_service.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ class LocalDbService {
3939
Hive.openBox<ScoreEntry>('saved_scores'),
4040
Hive.openBox<Team>(AppConstants.teamHistoryBox),
4141
Hive.openBox<Event>(AppConstants.eventHistoryBox),
42+
Hive.openBox(AppConstants.leaderboardBox),
4243
]);
4344
}
4445

@@ -53,6 +54,7 @@ class LocalDbService {
5354
Box<Team> get teamHistoryBox => Hive.box<Team>(AppConstants.teamHistoryBox);
5455
Box<Event> get eventHistoryBox =>
5556
Hive.box<Event>(AppConstants.eventHistoryBox);
57+
Box get leaderboardBox => Hive.box(AppConstants.leaderboardBox);
5658

5759
Future<void> clearAllData() async {
5860
await eventsBox.clear();
@@ -63,5 +65,6 @@ class LocalDbService {
6365
await scoreEntriesBox.clear();
6466
await teamHistoryBox.clear();
6567
await eventHistoryBox.clear();
68+
await leaderboardBox.clear();
6669
}
6770
}

lib/src/state/providers.dart

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import 'package:roboscout_iq/src/services/rating_service.dart';
1313
import 'package:roboscout_iq/src/services/secure_storage_service.dart';
1414
import 'package:roboscout_iq/src/services/sync_service.dart';
1515
import 'package:roboscout_iq/src/state/settings_provider.dart';
16+
import 'package:roboscout_iq/src/repositories/leaderboard_repository.dart';
1617

1718
// Services
1819
final localDbServiceProvider = Provider((ref) => LocalDbService());
@@ -62,6 +63,11 @@ final syncServiceProvider = Provider((ref) => SyncService(
6263
ref.read(eventsRepositoryProvider),
6364
));
6465

66+
final leaderboardRepositoryProvider = Provider((ref) => LeaderboardRepository(
67+
ref.read(apiClientProvider),
68+
ref.read(localDbServiceProvider),
69+
));
70+
6571
// Navigation State
6672
final bottomNavIndexProvider =
6773
StateProvider<int>((ref) => 0); // Default to Favorites

lib/src/ui/screens/events_list_screen.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -396,7 +396,7 @@ class _EventsListViewState extends ConsumerState<EventsListView> {
396396
const SizedBox(width: 8),
397397
CupertinoButton(
398398
padding: EdgeInsets.zero,
399-
minimumSize: Size.zero,
399+
minSize: 0,
400400
child: Icon(CupertinoIcons.clock, color: primaryColor),
401401
onPressed: () => _showHistory(context),
402402
),

0 commit comments

Comments
 (0)