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
7 changes: 5 additions & 2 deletions src/mobile/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -260,8 +260,9 @@ class _DeciderState extends State<Decider> with WidgetsBindingObserver {
}

void _initLearnGuestSession() {
LearnSyncService.instance.ensureGuestSession().then((_) {
LearnSyncService.instance.syncPendingProgress();
LearnSyncService.instance.ensureGuestSession().then((_) async {
await LearnSyncService.instance.syncPendingProgress();
await LearnSyncService.instance.hydrateLocalProgress();
}).catchError((_) {});
}

Expand Down Expand Up @@ -327,6 +328,8 @@ class _DeciderState extends State<Decider> with WidgetsBindingObserver {
if (token != null) {
LearnSyncService.instance
.linkProgressToAccount(token)
.then((_) => LearnSyncService.instance
.hydrateLocalProgress(authToken: token))
.catchError((_) {});
}
}).catchError((_) {});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@ String learnActivityOrdinal(int activityIndex) {
'SEVEN',
'EIGHT',
];
return ordinals[activityIndex.clamp(0, ordinals.length - 1)];
if (activityIndex >= 0 && activityIndex < ordinals.length) {
return ordinals[activityIndex];
}
return '${activityIndex + 1}';
}

String learnActivityTypeHeader(int activityIndex, String typeKey) {
Expand All @@ -52,10 +55,6 @@ String learnCourseCompletionTime(DateTime completedAt) {
return 'Completed $day $month $year';
}

String learnCourseCompletionRarityLabel({int completionPercent = 17}) {
return 'Only $completionPercent% of AirQo users have completed this course';
}

String _monthName(int month) {
const months = [
'Jan',
Expand Down
73 changes: 65 additions & 8 deletions src/mobile/lib/src/app/learn/models/learn_course_structure.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'package:airqo/src/app/learn/models/learn_lesson_continuation.dart';
import 'package:airqo/src/app/learn/models/learn_v2_catalog.dart';
import 'package:airqo/src/app/learn/services/learn_progress_service.dart';
import 'package:flutter/foundation.dart' show visibleForTesting;

class LearnLessonSlot {
final String catalogId;
Expand Down Expand Up @@ -85,14 +86,70 @@ class LearnStageInfo {

enum LearnUnitStatus { locked, inProgress, completed }

/// Immutable snapshot of server-driven catalog metadata: the stage ladder
/// and the catalog-wide max points. Built from a fetched catalog response;
/// [fallback] carries the built-in defaults for before the first fetch.
class LearnCatalogMeta {
final List<LearnStageInfo> stages;

/// Catalog-wide `max_points` from the server, or null if not provided.
final int? maxPoints;

const LearnCatalogMeta._(this.stages, this.maxPoints);

static const LearnCatalogMeta fallback = LearnCatalogMeta._(
[
LearnStageInfo(name: 'Curious', index: 0),
LearnStageInfo(name: 'Aware', index: 1),
LearnStageInfo(name: 'Observer', index: 2),
LearnStageInfo(name: 'Champion', index: 3),
LearnStageInfo(name: 'Defender', index: 4),
],
null,
);

factory LearnCatalogMeta.fromCatalog(LearnV2CatalogResponse catalog) {
final sorted = [...catalog.stages]
..sort((a, b) => a.index.compareTo(b.index));
final stages = [
for (final s in sorted)
if (s.name.isNotEmpty) LearnStageInfo(name: s.name, index: s.index),
];
return LearnCatalogMeta._(
stages.isNotEmpty ? List.unmodifiable(stages) : fallback.stages,
catalog.maxPoints > 0 ? catalog.maxPoints : null,
);
}
}

class LearnCatalog {
static const stages = [
LearnStageInfo(name: 'Curious', index: 0),
LearnStageInfo(name: 'Aware', index: 1),
LearnStageInfo(name: 'Observer', index: 2),
LearnStageInfo(name: 'Champion', index: 3),
LearnStageInfo(name: 'Defender', index: 4),
];
/// Current metadata snapshot — swapped wholesale by [applyCatalogMeta],
/// never mutated in place. A static because widgets deep in the tree
/// (bottom sheets, lesson experience) consult it without a catalog in
/// scope; the snapshot itself is immutable.
static LearnCatalogMeta meta = LearnCatalogMeta.fallback;

/// Stage ladder — the catalog response's `stages` when available, else the
/// built-in default.
static List<LearnStageInfo> get stages => meta.stages;

/// Adopts server-driven metadata (stage names/order, max points) from a
/// fetched catalog so the app tracks backend changes without a release.
static void applyCatalogMeta(LearnV2CatalogResponse catalog) {
meta = LearnCatalogMeta.fromCatalog(catalog);
}

@visibleForTesting
static void resetMeta() => meta = LearnCatalogMeta.fallback;

/// Max attainable points — the server's catalog-wide value when known,
/// otherwise computed locally from the graded quiz count.
static int maxPoints(
List<LearnCourseViewModel> courses,
LearnProgressService progress,
) {
return meta.maxPoints ?? progress.maxPoints(courses);
}

static List<LearnCourseViewModel> buildFromV2Catalog(
List<LearnV2Course> v2Courses) {
Expand Down Expand Up @@ -209,7 +266,7 @@ class LearnCatalog {
List<LearnCourseViewModel> courses,
LearnProgressService progress,
) {
final maxPts = progress.maxPoints(courses);
final maxPts = maxPoints(courses, progress);
final pts = progress.totalPoints(courses);
if (maxPts > 0 && pts > 0) {
final ratio = pts / maxPts;
Expand Down
11 changes: 11 additions & 0 deletions src/mobile/lib/src/app/learn/models/learn_lesson_activity.dart
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,10 @@ class LearnQuizPayload {

class LearnLessonActivity {
final int index;

/// Backend `_id` of the activity — required by the progress API so the
/// server can verify quiz answers against the catalog.
final String activityId;
final LearnActivityType type;
final String title;
final LearnArticlePayload? article;
Expand All @@ -91,6 +95,7 @@ class LearnLessonActivity {

const LearnLessonActivity({
required this.index,
this.activityId = '',
required this.type,
required this.title,
this.article,
Expand Down Expand Up @@ -119,9 +124,15 @@ class LearnLessonResult {
class LearnQuizGrade {
final bool isCorrect;
final String feedback;
final int? selectedIndex;
final List<int>? selectedIndices;
final List<int>? selectedOrder;

const LearnQuizGrade({
required this.isCorrect,
required this.feedback,
this.selectedIndex,
this.selectedIndices,
this.selectedOrder,
});
}
109 changes: 109 additions & 0 deletions src/mobile/lib/src/app/learn/models/learn_progress_models.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/// JSON numbers may arrive as int or double; never throw on either.
int _asInt(dynamic value, [int fallback = 0]) =>
value is int ? value : (value is num ? value.toInt() : fallback);

/// One quiz activity's attempt, as sent to PUT /learn/progress/lessons/:id.
class QuizAttemptData {
final String activityId;
final String format;
final int? selectedIndex;
final List<int>? selectedIndices;
final List<int>? selectedOrder;
final bool isCorrect;

const QuizAttemptData({
required this.activityId,
required this.format,
this.selectedIndex,
this.selectedIndices,
this.selectedOrder,
required this.isCorrect,
});

Map<String, dynamic> toJson() => {
'activity_id': activityId,
'format': format,
if (selectedIndex != null) 'selected_index': selectedIndex,
if (selectedIndices != null) 'selected_indices': selectedIndices,
if (selectedOrder != null) 'selected_order': selectedOrder,
'is_correct': isCorrect,
};

static List<int>? _intList(dynamic value) => value is List
? value.whereType<int>().toList()
: null;

factory QuizAttemptData.fromJson(Map<String, dynamic> json) =>
QuizAttemptData(
activityId: json['activity_id']?.toString() ?? '',
format: json['format'] as String? ?? '',
selectedIndex:
json['selected_index'] is int ? json['selected_index'] as int : null,
selectedIndices: _intList(json['selected_indices']),
selectedOrder: _intList(json['selected_order']),
isCorrect: json['is_correct'] as bool? ?? false,
);
}

/// One lesson's progress as returned by GET /learn/progress.
class LearnServerLessonProgress {
final bool completed;
final int? furthestActivityIndex;
final int stars;
final int pointsEarned;
final double? quizScoreRatio;
final String? freeTextResponse;

const LearnServerLessonProgress({
required this.completed,
this.furthestActivityIndex,
required this.stars,
required this.pointsEarned,
this.quizScoreRatio,
this.freeTextResponse,
});

factory LearnServerLessonProgress.fromJson(Map<String, dynamic> json) =>
LearnServerLessonProgress(
completed: json['completed'] as bool? ?? false,
furthestActivityIndex: json['furthest_activity_index'] is num
? _asInt(json['furthest_activity_index'])
: null,
stars: _asInt(json['stars']),
pointsEarned: _asInt(json['points_earned']),
quizScoreRatio: json['quiz_score_ratio'] is num
? (json['quiz_score_ratio'] as num).toDouble()
: null,
freeTextResponse: json['free_text_response'] as String?,
);
}

/// Account/guest progress as returned by GET /learn/progress. The API returns
/// `lessons` as an object keyed by lesson id, not an array.
class LearnServerProgress {
final int totalPoints;
final Map<String, LearnServerLessonProgress> lessons;

const LearnServerProgress({
required this.totalPoints,
required this.lessons,
});

factory LearnServerProgress.fromJson(Map<String, dynamic> json) {
final rawLessons = json['lessons'];
final lessons = <String, LearnServerLessonProgress>{};
if (rawLessons is Map) {
for (final entry in rawLessons.entries) {
if (entry.value is Map) {
lessons[entry.key.toString()] = LearnServerLessonProgress.fromJson(
Map<String, dynamic>.from(entry.value as Map),
);
}
}
}
return LearnServerProgress(
totalPoints: _asInt(json['total_points']),
lessons: lessons,
);
}
}
36 changes: 34 additions & 2 deletions src/mobile/lib/src/app/learn/models/learn_v2_catalog.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,40 @@ import 'dart:convert';
LearnV2CatalogResponse learnV2CatalogResponseFromJson(String str) =>
LearnV2CatalogResponse.fromJson(json.decode(str));

/// JSON numbers may arrive as int or double; never throw on either.
int _asInt(dynamic value, [int fallback = 0]) =>
value is int ? value : (value is num ? value.toInt() : fallback);

String learnV2CatalogResponseToJson(LearnV2CatalogResponse data) =>
json.encode(data.toJson());

class LearnV2CatalogResponse {
final bool success;
final String catalogVersion;
final List<LearnV2Stage> stages;
final int maxPoints;
final List<LearnV2Course> courses;

const LearnV2CatalogResponse({
required this.success,
required this.catalogVersion,
this.stages = const [],
this.maxPoints = 0,
required this.courses,
});

factory LearnV2CatalogResponse.fromJson(Map<String, dynamic> json) =>
LearnV2CatalogResponse(
success: json['success'] ?? false,
catalogVersion: json['catalog_version'] ?? '',
stages: json['stages'] is List
? (json['stages'] as List)
.whereType<Map>()
.map((s) =>
LearnV2Stage.fromJson(Map<String, dynamic>.from(s)))
.toList()
: [],
maxPoints: _asInt(json['max_points']),
courses: json['courses'] != null
? (json['courses'] as List)
.map((c) => LearnV2Course.fromJson(c as Map<String, dynamic>))
Expand All @@ -31,10 +47,26 @@ class LearnV2CatalogResponse {
Map<String, dynamic> toJson() => {
'success': success,
'catalog_version': catalogVersion,
'stages': stages.map((s) => s.toJson()).toList(),
'max_points': maxPoints,
'courses': courses.map((c) => c.toJson()).toList(),
};
}

class LearnV2Stage {
final int index;
final String name;

const LearnV2Stage({required this.index, required this.name});

factory LearnV2Stage.fromJson(Map<String, dynamic> json) => LearnV2Stage(
index: _asInt(json['index']),
name: json['name'] as String? ?? '',
);

Map<String, dynamic> toJson() => {'index': index, 'name': name};
}

class LearnV2Course {
final String id;
final int courseNumber;
Expand All @@ -54,7 +86,7 @@ class LearnV2Course {

factory LearnV2Course.fromJson(Map<String, dynamic> json) => LearnV2Course(
id: json['_id'] as String? ?? json['id'] as String? ?? '',
courseNumber: json['course_number'] as int? ?? 0,
courseNumber: _asInt(json['course_number']),
title: json['title'] as String? ?? '',
plainTitleKey:
json['plain_title_key'] as String? ?? json['title'] as String? ?? '',
Expand Down Expand Up @@ -160,7 +192,7 @@ class LearnV2Activity {
LearnV2Activity(
id: json['_id'] as String? ?? json['id'] as String? ?? '',
type: json['type'] as String? ?? '',
order: json['order'] as int? ?? 0,
order: _asInt(json['order']),
payload: Map<String, dynamic>.from(json['payload'] as Map? ?? {}),
);

Expand Down
3 changes: 2 additions & 1 deletion src/mobile/lib/src/app/learn/pages/kya_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ class _KyaPageState extends State<KyaPage> with UiLoggy {
return _buildErrorState(state);
}

if (catalog != null) LearnCatalog.applyCatalogMeta(catalog);
final courses = catalog != null
? LearnCatalog.buildFromV2Catalog(catalog.courses)
: const <LearnCourseViewModel>[];
Expand All @@ -177,7 +178,7 @@ class _KyaPageState extends State<KyaPage> with UiLoggy {
final completed = LearnCatalog.catalogCompletedLessons(courses, _progress);
final total = LearnCatalog.catalogTotalLessons(courses);
final points = _progress.totalPoints(courses);
final maxPoints = _progress.maxPoints(courses);
final maxPoints = LearnCatalog.maxPoints(courses, _progress);

return CustomScrollView(
slivers: [
Expand Down
Loading
Loading