Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ jobs:
- uses: actions/checkout@v3
- uses: subosito/flutter-action@v2
with:
flutter-version: '3.10.0'
flutter-version: '3.27.1'
channel: 'stable'
- run: flutter pub get
- run: flutter pub run build_runner build --delete-conflicting-outputs
Expand Down
1 change: 1 addition & 0 deletions curl_output.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions lib/src/constants.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ class AppConstants {
static const String settingsBox = 'settings_box';
static const String teamHistoryBox = 'team_history_box';
static const String eventHistoryBox = 'event_history_box';
static const String leaderboardBox = 'leaderboard_cache_box';

// RobotEvents API
static const String robotEventsBaseUrl = 'https://www.robotevents.com/api/v2';
Expand Down
27 changes: 17 additions & 10 deletions lib/src/models/team_model.dart
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ class Team {
@HiveField(10)
final double? ccwm;
@HiveField(11)
final Map<String, dynamic>? statiq;
final Map? statiq;
@HiveField(12)
final String? grade;

Expand All @@ -50,15 +50,22 @@ class Team {
});

factory Team.fromJson(Map<String, dynamic> json) {
final locationObj = json['location'] as Map<String, dynamic>?;
final city = locationObj?['city'] as String?;
final region = locationObj?['region'] as String?;
final country = locationObj?['country'] as String?;
final locationStr = [city, region, country]
.where((e) => e != null && e.isNotEmpty)
.join(', ');
dynamic loc = json['location'];
String? locationStr;
if (loc is Map) {
final city = loc['city'] as String?;
final region = loc['region'] as String?;
final country = loc['country'] as String?;
locationStr = [city, region, country]
.where((e) => e != null && e.isNotEmpty)
.join(', ');
} else if (loc is String) {
locationStr = loc;
}

final statiq = json['statiq'] as Map<String, dynamic>?;
final statiqRaw = json['statiq'];
final statiq =
statiqRaw is Map ? Map<String, dynamic>.from(statiqRaw) : null;
final perf = statiq?['performance'] as num?;

// Robust ID parsing:
Expand Down Expand Up @@ -103,7 +110,7 @@ class Team {
String? location,
double? trueskill,
double? ccwm,
Map<String, dynamic>? statiq,
Map? statiq,
String? grade,
}) {
return Team(
Expand Down
2 changes: 1 addition & 1 deletion lib/src/models/team_model.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

84 changes: 84 additions & 0 deletions lib/src/repositories/leaderboard_repository.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import 'package:roboscout_iq/src/models/team_model.dart';
import 'package:roboscout_iq/src/services/api_client.dart';
import 'package:roboscout_iq/src/services/local_db_service.dart';

class LeaderboardRepository {
final ApiClient _apiClient;
final LocalDbService _localDb;

LeaderboardRepository(this._apiClient, this._localDb);

Future<List<Map<String, dynamic>>> getGlobalSkills(String gradeLevel,
{bool forceRefresh = false}) async {
final cacheKey = 'skills_$gradeLevel';
final box = _localDb.leaderboardBox;

if (!forceRefresh && box.containsKey(cacheKey)) {
try {
final cachedData = box.get(cacheKey);
if (cachedData is List) {
return cachedData
.map((e) => Map<String, dynamic>.from(e as Map))
.toList();
}
} catch (e) {
print('Error reading skills cache: $e');
}
}

try {
final data = await _apiClient.getGlobalSkills(gradeLevel: gradeLevel);
await box.put(cacheKey, data);
return data;
} catch (e) {
if (box.containsKey(cacheKey)) {
final cachedData = box.get(cacheKey);
if (cachedData is List) {
return cachedData
.map((e) => Map<String, dynamic>.from(e as Map))
.toList();
}
}
rethrow;
}
}

Future<List<Team>> getGlobalTrueSkillRankings(
{bool forceRefresh = false}) async {
const cacheKey = 'trueskill_global';
final box = _localDb.leaderboardBox;

if (!forceRefresh && box.containsKey(cacheKey)) {
try {
final cachedData = box.get(cacheKey);
if (cachedData is List) {
return _deserializeTeams(cachedData);
}
} catch (e) {
print('Error reading trueskill cache: $e');
}
}

try {
final teams = await _apiClient.getGlobalTrueSkillRankings();
final jsonList = teams.map((t) => t.toJson()).toList();
await box.put(cacheKey, jsonList);
return teams;
} catch (e) {
if (box.containsKey(cacheKey)) {
final cachedData = box.get(cacheKey) as List;
return _deserializeTeams(cachedData);
}
rethrow;
}
}

List<Team> _deserializeTeams(List<dynamic> list) {
return list.map((e) {
// Hive returns _Map<dynamic, dynamic>, need to cast to Map<String, dynamic>
// for Team.fromJson
final json = Map<String, dynamic>.from(e as Map);
return Team.fromJson(json);
}).toList();
}
}
35 changes: 35 additions & 0 deletions lib/src/services/api_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,41 @@ class ApiClient {
}
}

Future<List<Team>> getGlobalTrueSkillRankings(
{String gradeLevel = 'Middle School'}) async {
// RoboStem API uses a different base URL and key
final token = _settings.roboStemApiKey ?? AppConstants.roboStemApiKey;
final dio = Dio(BaseOptions(
baseUrl: AppConstants.roboStemBaseUrl, // https://api.robostem-api.org
headers: {
'x-api-key': token,
'accept': 'application/json',
},
connectTimeout: const Duration(seconds: 30),
receiveTimeout: const Duration(seconds: 30),
));
Comment on lines +312 to +320
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Creating a new Dio instance within the getGlobalTrueSkillRankings method for every call is inefficient. This can lead to performance issues, such as socket exhaustion under heavy use, and prevents connection reuse. It's better to create a dedicated Dio instance for the RoboStem API at the class level, similar to how _dio is handled for RobotEvents, and reuse it across calls.

Comment on lines +311 to +320
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This block for creating a Dio instance is nearly identical to the one in the existing getGlobalSkills method. To improve maintainability and reduce code duplication, consider extracting this logic into a private helper method, like _getRoboStemClient(), that returns a configured Dio instance for the RoboStem API. You could then call this helper in both getGlobalTrueSkillRankings and getGlobalSkills.


try {
final response = await dio.get('/api/rankings/statiq', queryParameters: {
'program': 'VIQRC',
'limit': 100, // Reduced from 2500 for performance
'grade_level': gradeLevel,
});

// RoboStem response structure might differ. Assuming standard list or {data: []}
List<Map<String, dynamic>> rawList = [];
if (response.data is List) {
rawList = (response.data as List).cast<Map<String, dynamic>>();
} else if (response.data is Map && response.data['data'] is List) {
rawList = (response.data['data'] as List).cast<Map<String, dynamic>>();
}

return rawList.map((json) => Team.fromJson(json)).toList();
} catch (e) {
return [];
}
Comment on lines +338 to +340
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The catch block currently swallows the error and returns an empty list without any logging. This can make it very difficult to debug API failures. It's a good practice to log the error, similar to how it's done in the getGlobalSkills method, to aid in diagnostics.

    } catch (e) {
      if (e is DioException) {
        print('RoboStem API Error (TrueSkill): ${e.message} ${e.response?.statusCode}');
      } else {
        print('Error fetching TrueSkill rankings: $e');
      }
      return [];
    }

Comment on lines +338 to +340
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

This catch block silently swallows exceptions and returns an empty list. This prevents the UI from displaying a meaningful error message to the user in case of a network failure or other API error. The UI layer is prepared to handle exceptions. To improve error feedback, you should log the error and then rethrow it to be handled by the caller.

    } catch (e) {
      print('Error fetching global TrueSkill rankings: $e');
      rethrow;
    }

}

Future<List<Team>> searchTeams(
{String? number, int? program, int? limit}) async {
// Strategy: Use RobotEvents API for exact team lookup first,
Expand Down
3 changes: 3 additions & 0 deletions lib/src/services/local_db_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ class LocalDbService {
Hive.openBox<ScoreEntry>('saved_scores'),
Hive.openBox<Team>(AppConstants.teamHistoryBox),
Hive.openBox<Event>(AppConstants.eventHistoryBox),
Hive.openBox(AppConstants.leaderboardBox),
]);
}

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

Future<void> clearAllData() async {
await eventsBox.clear();
Expand All @@ -63,5 +65,6 @@ class LocalDbService {
await scoreEntriesBox.clear();
await teamHistoryBox.clear();
await eventHistoryBox.clear();
await leaderboardBox.clear();
}
}
6 changes: 6 additions & 0 deletions lib/src/state/providers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import 'package:roboscout_iq/src/services/rating_service.dart';
import 'package:roboscout_iq/src/services/secure_storage_service.dart';
import 'package:roboscout_iq/src/services/sync_service.dart';
import 'package:roboscout_iq/src/state/settings_provider.dart';
import 'package:roboscout_iq/src/repositories/leaderboard_repository.dart';

// Services
final localDbServiceProvider = Provider((ref) => LocalDbService());
Expand Down Expand Up @@ -62,6 +63,11 @@ final syncServiceProvider = Provider((ref) => SyncService(
ref.read(eventsRepositoryProvider),
));

final leaderboardRepositoryProvider = Provider((ref) => LeaderboardRepository(
ref.read(apiClientProvider),
ref.read(localDbServiceProvider),
));

// Navigation State
final bottomNavIndexProvider =
StateProvider<int>((ref) => 0); // Default to Favorites
Expand Down
2 changes: 1 addition & 1 deletion lib/src/ui/screens/events_list_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -396,7 +396,7 @@ class _EventsListViewState extends ConsumerState<EventsListView> {
const SizedBox(width: 8),
CupertinoButton(
padding: EdgeInsets.zero,
minimumSize: Size.zero,
minSize: 0,
child: Icon(CupertinoIcons.clock, color: primaryColor),
onPressed: () => _showHistory(context),
),
Expand Down
Loading
Loading